From 1a6ebc22c033748e01cb1de094a8c5c813f75335 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 00:56:11 +0900 Subject: [PATCH 001/236] chore(release): open dev at 2.44.0 before releasing 2.43.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b9a96379c5..9242c23306 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.43.0", + "version": "2.44.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From d766c89a4ac16297e1ffd0040912df3ee114ef66 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 01:37:44 +0900 Subject: [PATCH 002/236] docs: plan contributor-preserving A runtime stack --- .../_plan/260906_a_runtime_stack/000_plan.md | 42 + .../003_audit_resolution.md | 8 + .../_plan/260906_a_runtime_stack/010_sse.md | 218 +++++ devlog/_plan/260906_a_runtime_stack/020_ws.md | 886 ++++++++++++++++++ .../260906_a_runtime_stack/030_recovery.md | 208 ++++ .../260906_a_runtime_stack/040_affinity.md | 210 +++++ .../050_capabilities.md | 684 ++++++++++++++ .../260906_a_runtime_stack/060_landing.md | 23 + 8 files changed, 2279 insertions(+) create mode 100644 devlog/_plan/260906_a_runtime_stack/000_plan.md create mode 100644 devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md create mode 100644 devlog/_plan/260906_a_runtime_stack/010_sse.md create mode 100644 devlog/_plan/260906_a_runtime_stack/020_ws.md create mode 100644 devlog/_plan/260906_a_runtime_stack/030_recovery.md create mode 100644 devlog/_plan/260906_a_runtime_stack/040_affinity.md create mode 100644 devlog/_plan/260906_a_runtime_stack/050_capabilities.md create mode 100644 devlog/_plan/260906_a_runtime_stack/060_landing.md diff --git a/devlog/_plan/260906_a_runtime_stack/000_plan.md b/devlog/_plan/260906_a_runtime_stack/000_plan.md new file mode 100644 index 0000000000..521e47c38a --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/000_plan.md @@ -0,0 +1,42 @@ +# A runtime integration roadmap + +## Loop specification + +- Archetype: spec-satisfaction repair; C3 runtime, C4 proxy credential/recovery boundaries. +- Trigger: owner assigned A (#3672, #3679, #3568, #3581, #3671), authorized inherited parallel subagents, contributor-preserving stacked PRs, no-verify pushes, dev integration and immediate resolved-work closure. +- Goal: preserve transport termination, configured WS egress, native subagent MESSAGE recovery, conversation affinity and effective policy capabilities. +- Non-goals: B/C/D implementation, release promotion/publication, production service/config/credential changes. #3661 remains open unless its complete residual scope is independently proven solved. +- Verification: remote focused activation checks during each implementation cycle; required current-head hosted CI before readiness/merge; final dev ancestry and CI. No local tests, typecheck or builds. Git diff checks and prose validation only locally. +- Stop: all five changes or proven equivalents on dev; original PRs closed with attribution and landing references; fully solved linked issues closed; unresolved issue scope documented. +- Memory: this unit, the session-bound goalplan/ledger, and ignored `.tmp/a-runtime-stack/` evidence. +- Outcomes: DONE / proven NOOP; external blockers recorded, never inferred from ordinary conflicts or pending CI. +- Delegation: main owns FSM, branches, commits, pushes and merges. Plan/review lanes have disjoint file scope. Two distinct failed dispatches return ownership to main; new worker scope is added at P. +- Resource scope: existing git/gh identity, owned `codex/a-*` branches, public contributor PR reads, and existing the isolated remote verification host SSH for isolated verification. No new account credentials or provider requests. User imposed no subagent/model-inheritance budget cap; no model override. Two-hour checkpoint per work phase triggers evidence/reliability reassessment; pending CI is monitored with bounded waits, not abandoned. + +## Phase map + +| Cycle | Artifact | Consumes | Delivers | +|---|---|---|---| +| roadmap | 000 + 010..060 | live dev and public contributor changes | audited full integration plan; docs only | +| sse | 010_sse.md | existing SSE relay boundary | failure notification independent of tee cancellation | +| ws | 020_ws.md | prior transport baseline | WS outbound policy and pool identity | +| recovery | 030_recovery.md | validated transport stack | MESSAGE recovery + reparse/cache semantics | +| affinity | 040_affinity.md | recovery/reparse fields | stable Command Code conversation identity | +| capabilities | 050_capabilities.md | final effective dispatch behavior | policy selection congruent with dispatch | +| landing | 060_landing.md | independently verified stack layers | current dev inclusion and closeout | + +The owner explicitly requested a stack. Independent transport fixes are retained as separate cumulative layers to expose interaction at each head; this publication order is not a claim of a hard dependency between SSE and WS. The actual code dependency is recovery before affinity. Each layer has its own PR diff, regression proof and CI. Bottom-up merge only; retarget before deleting parent branches. Keep stacks short by landing verified lower layers while subsequent cycles continue when possible. + +## Ownership + +A owns shared `src/server/responses/core.ts` integration for #3568 then #3581. C owns #3576 and may land its separate OAuth replay region first; both lanes refresh dev and preserve each other's changes. B owns `src/config.ts` final field reconciliation with #3679. Source snapshots use `refs/codex/a-original/N`, not remote-tracking scratch refs that concurrent fetch-prune can remove. + +## Evidence and provenance + +CI entry `.github/workflows/ci.yml` has unrestricted pull_request bases for stacks. `src/**`, `tests/**`, `scripts/**` are observed by its changes job; Linux test shards invoke `scripts/ci/run-bun-test-batches.sh`, gates run tsc/privacy, and macOS/Windows jobs validate platform behavior. These definitions were inspected without executing local suites. Remote-check scripts and real run IDs will be captured at C, not invented at P. Original source changes and review histories are public; any newly discovered security reasoning stays in ignored scratch. + +- #3672: `077dd61f66ac80678d071ae8fe516507f43a4264` +- #3679: `b05cccf264b4ab61db5d8dee8232c2f89bb1b541` +- #3568: `036a9321788464fdf33a387c9f44a834a844bdc1` +- #3581: `f60397d3408e0339ffc66acdcaca8133e40866c2` +- #3671: `7b1beb9c5eacd8dde22681a5df26804be52380b8` diff --git a/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md b/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md new file mode 100644 index 0000000000..e772aeb294 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md @@ -0,0 +1,8 @@ +# Roadmap audit resolution + +Independent reviewer returned GO-WITH-FIXES (2). Both findings accepted and folded before B: + +1. Implementation-cycle D previously implied full CI/dev landing, inconsistent with prepared stack layers. 010..050 now explicitly distinguish exact-head remote focused/type verified draft preparation from 060 full-gate landing. Final objective and full-CI-before-merge criteria remain unchanged. +2. Affinity reparse tests required a cohort option the shared post helper did not accept. 040 now names tests/helpers/agent-task-recovery.ts option extension, internal handler forwarding, and true/false/undefined observation in real initial/cache-only adapter calls. + +Private remote host/user paths were replaced with placeholders; exact machine setup remains ignored scratch. No product edits or local suites in roadmap cycle. diff --git a/devlog/_plan/260906_a_runtime_stack/010_sse.md b/devlog/_plan/260906_a_runtime_stack/010_sse.md new file mode 100644 index 0000000000..e70fc2c223 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/010_sse.md @@ -0,0 +1,218 @@ +# 010 — Surface SSE rewrite failure before tee cancellation (#3672) + +Status: candidate plan, docs-only; implementation class C3 (stream lifecycle). Evidence refreshed 2026-09-06 KST through GitHub API and local persistent refs. + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Source, authorship and drift + +- Public PR: https://github.com/lidge-jun/opencodex/pull/3672 +- Exact original head/commit: `077dd61f66ac80678d071ae8fe516507f43a4264`, persistent ref `refs/codex/a-original/3672`. +- Original parent: `6585e6a70f42be8b6c81ff20d4fa0f39f7da03db`. +- Original author: Hako, GitHub `devswha`; trailer: `Co-authored-by: Hako <25837994+devswha@users.noreply.github.com>`. +- Planning dev/working HEAD: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`, also confirmed by live dev API. Although commits differ, comparing original parent to dev restricted to the three original touched files returns no changed paths. Original patch applies to the same source blobs; later P must repeat this check. +- Live reviewThreads: zero. PR body reports focused/affected passes but explicitly does not claim a green full suite. No outstanding published code-review fix is presently known; exact carried-head remote tests and maintainer review remain acceptance gates. + +## Behavior and necessity + +Current `src/server/sse-payload-rewrite.ts:249-253` releases budget/disposes the rewrite, then awaits `reader.cancel(error)` before `controller.error(error)`. With a real tee and an open inspection sibling, cancellation waits for that sibling; the outer relay cannot observe the failure and abort the work that releases it. Reuse the existing failed-tail owner at `src/server/relay.ts:259`; no new error wrapper, retry mechanism, stream type or configuration is needed. Doing nothing leaves the wait cycle; deleting cancellation loses cleanup; configuration cannot fix the ordering. + +After the change, release/dispose remain synchronous, cancellation rejection is handled asynchronously, and `controller.error(error)` runs immediately. The outer failed-tail relay emits one `response.failed` then `[DONE]` and aborts upstream while inspection remains open. Budget overflow keeps `translation_buffer_limit`. Normal EOF, explicit client cancellation, rewriting, and disposal idempotence remain unchanged. + +## Exact file manifest and diff contract + +| Operation | Path | Required change | +|---|---|---| +| MODIFY | `src/server/sse-payload-rewrite.ts` | At catch line 252 replace awaited cancellation with `void reader.cancel(error).catch(() => {});` and explain the tee dependency. Keep release/dispose/error ordering. | +| MODIFY | `tests/responses/sse-payload-rewrite.test.ts` | Append the original parameterized real-tee regression after the current last test (line 153); cover source cancel resolve and reject, bounded completion and cleanup. | +| MODIFY | `docs-site/src/content/docs/reference/proxy-formats.md` | After line 83 add the five-line native rewrite failure/terminal/budget contract. | + +NEW: none. DELETE: none. Existing test file is already registered; no layout manifest edit. The appendix contains the exact original patch for all three paths, not an outline. No production implementation has been performed by this planning task. + +## Regression activation and independent acceptance + +1. Remote RED: place the original two added tests on the layer's current parent without the one-line production change in an isolated remote verification checkout. Hold a real tee sibling open, exhaust a 64-byte test budget with `data: partial` plus 80 bytes, and require both cases to reject with the one-second inspection-wait deadline. Record that failure, then restore the candidate patch remotely. +2. Remote GREEN: for resolve and reject cancellation, terminal arrives before inspection settles; exactly one `response.failed`, `translation_buffer_limit`, final `data: [DONE]`, abort signal true, zero source cancel calls before sibling release, one dispose, zero current budget bytes and one overflow. +3. Release inspection afterwards: underlying source cancel executes once; late cancellation rejection is observed/handled; no unhandled asynchronous error; disposal stays once. Test `finally` releases locks and budgets even on RED timeout. +4. Run adjacent failed-tail tests remotely to preserve disconnect, terminal and cancellation behavior. Existing Windows-sensitive composition must remain covered by an actual Windows run. +5. A reviewer confirms no awaited sibling-dependent cancellation remains on this exception path, no cancellation errors escape, and no downstream terminal duplication. This layer does not depend on #3679 or recovery/cache work. + +Remote focused command, after verifying remote checkout SHA and installing its pinned runtime/dependencies: + +```sh +bun test tests/responses/sse-payload-rewrite.test.ts tests/responses/sse-failed-tail.test.ts +``` + +Static anchors: `sse-payload-rewrite.ts:145` disposal guard, `:192` budget release, `:249` exception path, `:256` consumer cancellation; `relay.ts:259` failed-tail entry. The original regression fixture itself is the activation instrument; contributor-reported previous RED is context, not this layer's proof. + +#3679 shares only `docs-site/src/content/docs/reference/proxy-formats.md` with this layer. Preserve both paragraphs when the child lands. No release promotion or linked issue is bundled. + +## Execution boundary and resource scope + +This document is candidate planning for a later implementation P, authored during the first docs-only cycle. Main owns roadmap, FSM, goal, implementation and stack integration. This delegated task writes only this document and its sibling `010_sse.md`/`020_ws.md`; it does not run tests, typecheck, builds, Git mutations, GitHub mutations, FSM transitions or goal commands. + +Later implementation scope uses existing `gh` credentials and writes only the assigned own stack branches. Inherited parallel reviewers are authorized. There is no explicit user token/cost cap; a two-hour checkpoint triggers reassessment, not automatic success or abandonment. No production account probes, deployment or release actions belong to this layer. User explicitly forbids local suites; every executable verification below is for a remote isolated checkout or GitHub Actions later. No local typecheck/build is permitted here either. Security investigation material stays in `.tmp`; this public plan records only already-public PR behavior and general integration requirements. + +At the later P, refresh live dev and original PR head through main, compare touched-path blobs and parent changes, and amend this plan before implementation. A changed original SHA invalidates the carried-patch assumption. Preserve unrelated workers' changes. Main may carry the original commit with author identity preserved; every carry/superseding PR and squash message must include the exact `Co-authored-by` trailer below. Publish with the user's authorized `--no-verify` push, never a direct push to dev. Local hook bypass does not supply CI evidence. + +## Main-confirmed remote execution handoff + +Main reports the existing remote repository at `REMOTE_HOST:REMOTE_SOURCE_CHECKOUT` and Bun `1.3.14` have been verified. These are main-provided environment facts, not a local execution claim by this planner. Implementation C uses an isolated remote clone at the exact carried SHA; do not alter the existing remote checkout or its service. Record `git rev-parse HEAD` and `bun --version` from that isolated remote clone with focused activation-test and typecheck receipts. If the carried tree requires a different pinned Bun version, reconcile and record that runtime difference remotely before treating results as representative. + +Carry PRs remain draft until full current-head GitHub CI is green. Focused remote tests/typecheck are implementation evidence, not permission to skip full gates. The final landing cycle requires every full gate described below, including an actually executed Windows lane where Windows behavior is claimed, current-head review, and dev ancestry proof. No local project command execution is allowed at any point. Deeper implementation review belongs to the next cycle; this handoff completes only the concrete candidate plan. + +## Static workflow coverage and later remote evidence + +Inspected at `dev@81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`: + +- `.github/workflows/ci.yml:7` uses `pull_request: {}` without a base branch filter: an open stacked child gets the same workflow. Push trigger at line 27 covers integration branches only; pushing an own feature branch without opening its PR does not establish CI coverage. +- Runtime/test changes activate the `changes` gate and four Linux test shards (`ci.yml:255`), two macOS shards (`ci.yml:451`), and gates (`ci.yml:392`, typecheck at 422, privacy at 430). Linux test discovery is `scripts/ci/run-bun-test-batches.sh:197`; these layer tests are not the storage/API-usage exclusions at line 52. +- Windows full test shards are **dispatch-only**, `ci.yml:658-686`; ordinary PR CI cannot prove Windows behavior. `workflow_dispatch` has only `lane` (`ci.yml:46`), so use the own branch as `--ref`, not a nonexistent SHA input. `lane=all` runs Windows plus the unsharded macOS control (`ci.yml:549`). +- The aggregate `ci` accepts intentional skips (`ci.yml:927`); a green aggregate alone cannot prove a Windows run, regression activation, or even runtime tests on a docs-only PR. Check producer job conclusions and logs. +- `.github/actions/setup-project-bun/action.yml:18` resolves the runtime from `package.json.dependencies.bun`. Record actual Bun version rather than substituting contributor-reported Bun 1.4.0 results. + +Later main-owned CI commands (not executed by this planning task): + +```sh +# Freeze/read own branch head first; then dispatch its checked-in workflow. +gh workflow run ci.yml --repo lidge-jun/opencodex --ref "$A_LAYER_BRANCH" -f lane=all +gh run list --repo lidge-jun/opencodex --workflow ci.yml --branch "$A_LAYER_BRANCH" --limit 10 --json databaseId,headSha,event,status,conclusion +gh run view "$A_RUN_ID" --repo lidge-jun/opencodex --json headSha,event,conclusion,jobs +gh run view "$A_RUN_ID" --repo lidge-jun/opencodex --log +``` + +Assert dispatch `headSha` equals the frozen layer head. For PR merge-ref runs record actual checkout SHA and its head/base parents. A refresh/restack/new commit requires evidence for that resulting tree. Capture URLs, SHA, OS, runtime, command, exit code, failed/skipped test counts and any baseline comparison in main's evidence receipt. `action_required`, pending/cancelled checks, hygiene-only success and author attestations are not green test evidence. Do not check a contributor's local-CI attestation when no such local execution occurred. + +Full relevant suite coverage, typecheck, privacy and docs build must run remotely before readiness. For separately authorized remote checkout verification, install pinned dependencies there, run `bun run typecheck`, `bun run privacy:scan`, `bun run test`, and `(cd docs-site && bun run build)` there. Do not run those commands in the local managed workspace. Failures require a named current-base comparison and repair/reassessment; historic Windows failures do not automatically excuse a new failure. + +## Integration and close-out + +Each layer must be reviewable and independently acceptable against its immediate parent. No acceptance depends on a later A layer fixing its behavior. Main merges bottom-up with current-head CI and review evidence, retargets/restacks children before parent branch deletion, and preserves author trailers in squash/carry history. After main verifies the resulting merge commit is an ancestor of freshly fetched dev, immediately close the superseded original PR with the carry PR/commit reference. Close a linked issue only when its full acceptance scope is satisfied; do not infer an issue from a similar title. This planning task performs none of those actions. + +## Original patch appendix (candidate implementation) + +The following is source material already published in the linked PR. Revalidate context at the later P; do not apply during the docs-only cycle. + +```diff +diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md +index 77a67147a..19049e87f 100644 +--- a/docs-site/src/content/docs/reference/proxy-formats.md ++++ b/docs-site/src/content/docs/reference/proxy-formats.md +@@ -83,6 +83,11 @@ This applies to both tee inspection and eager relay, including Windows rewrite t + even when the upstream read rejects before the response-body cancellation hook runs. + A terminal captured during the bounded post-disconnect drain retains its actual outcome. + ++If native passthrough rewriting fails, including when it exceeds the translation ++buffer budget, the relay reports the failure without waiting for upstream inspection ++to finish. It cancels the upstream work and emits `response.failed` followed by ++`data: [DONE]`; a budget overflow uses the `translation_buffer_limit` error code. ++ + Client-facing Responses SSE frames are limited to 4 MiB per frame, measured in raw bytes before the + SSE block delimiter. On HTTP, an unterminated upstream frame that exceeds the limit fails closed + with a synthetic `response.failed` event followed by `data: [DONE]`. On the Responses WebSocket +diff --git a/src/server/sse-payload-rewrite.ts b/src/server/sse-payload-rewrite.ts +index 3c6d825e6..f9fb62065 100644 +--- a/src/server/sse-payload-rewrite.ts ++++ b/src/server/sse-payload-rewrite.ts +@@ -249,7 +249,9 @@ export function relaySseWithBlockRewrite( + } catch (error) { + releaseBuffer(); + disposeRewrite(); +- try { await reader.cancel(error); } catch { /* already closed */ } ++ // Cancelling one tee branch waits for its sibling. Surface the failure ++ // now so downstream can abort upstream and release the inspection branch. ++ void reader.cancel(error).catch(() => {}); + controller.error(error); + } + }, +diff --git a/tests/responses/sse-payload-rewrite.test.ts b/tests/responses/sse-payload-rewrite.test.ts +index 34dae59e0..773665a05 100644 +--- a/tests/responses/sse-payload-rewrite.test.ts ++++ b/tests/responses/sse-payload-rewrite.test.ts +@@ -153,4 +153,82 @@ describe("SSE payload rewrite composition", () => { + expect(budget.snapshot().currentBytes).toBe(0); + budget.dispose(); + }); ++ ++ test.each(["resolve", "reject"] as const)( ++ "surfaces a rewrite failure before tee cancellation can %s", ++ async cancellationOutcome => { ++ const budget = createTestTranslatorBudget({ maxTurnBytes: 64 }); ++ const upstream = new AbortController(); ++ const cancellation = Promise.withResolvers(); ++ const cancellationError = new Error("upstream cancellation failed"); ++ let cancelCalls = 0; ++ let disposeCalls = 0; ++ const source = new ReadableStream({ ++ start(controller) { ++ controller.enqueue(new TextEncoder().encode("data: partial")); ++ controller.enqueue(new TextEncoder().encode("x".repeat(80))); ++ // Keep the source open after exhausting the rewrite budget. ++ }, ++ cancel() { ++ cancelCalls += 1; ++ return cancellation.promise; ++ }, ++ }); ++ const [native, inspection] = source.tee(); ++ const inspectionReader = inspection.getReader(); ++ await inspectionReader.read(); ++ await inspectionReader.read(); ++ let inspectionSettled = false; ++ const pendingInspection = inspectionReader.read().then(() => { inspectionSettled = true; }); ++ const rewrite = Object.assign((block: string) => [block], { ++ dispose() { disposeCalls += 1; }, ++ }); ++ const rewritten = relaySseWithBlockRewrite(native, rewrite, budget); ++ const client = relaySseWithFailedTail(rewritten, upstream); ++ const completion = readAll(client); ++ let deadline: ReturnType | undefined; ++ ++ try { ++ const out = await Promise.race([ ++ completion, ++ new Promise((_, reject) => { ++ deadline = setTimeout(() => reject(new Error("rewrite failure waited for the inspection tee")), 1_000); ++ }), ++ ]); ++ expect(out.match(/event: response.failed/g)).toHaveLength(1); ++ expect(out).toContain('"code":"translation_buffer_limit"'); ++ expect(out).toEndWith("data: [DONE]\n\n"); ++ expect(upstream.signal.aborted).toBe(true); ++ expect(inspectionSettled).toBe(false); ++ expect(cancelCalls).toBe(0); ++ expect(disposeCalls).toBe(1); ++ expect(budget.snapshot().currentBytes).toBe(0); ++ expect(budget.snapshot().overflows).toBe(1); ++ ++ // Releasing inspection settles both tee cancellation promises. A late ++ // rejection must be handled by the rewriter as well as this reader. ++ const siblingCancellation = inspectionReader.cancel("inspection cleanup"); ++ expect(cancelCalls).toBe(1); ++ if (cancellationOutcome === "reject") { ++ cancellation.reject(cancellationError); ++ await expect(siblingCancellation).rejects.toBe(cancellationError); ++ } else { ++ cancellation.resolve(); ++ await siblingCancellation; ++ } ++ await pendingInspection; ++ await Bun.sleep(0); // Let the runner observe any unhandled cancellation rejection. ++ expect(disposeCalls).toBe(1); ++ } finally { ++ clearTimeout(deadline); ++ const cleanup = inspectionReader.cancel().catch(() => {}); ++ cancellation.resolve(); ++ await cleanup; ++ await pendingInspection; ++ await completion.catch(() => {}); ++ inspectionReader.releaseLock(); ++ budget.dispose(); ++ } ++ }, ++ ); + }); +``` diff --git a/devlog/_plan/260906_a_runtime_stack/020_ws.md b/devlog/_plan/260906_a_runtime_stack/020_ws.md new file mode 100644 index 0000000000..766c662986 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/020_ws.md @@ -0,0 +1,886 @@ +# 020 — Honor upstream WebSocket proxy routing (#3679) + +Status: candidate plan after layer 010, docs-only; implementation class C4 for the outbound routing boundary. Evidence refreshed 2026-09-06 KST; the 01:28 update supersedes the earlier triage snapshot. + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Source, authorship and drift + +- Public PR: https://github.com/lidge-jun/opencodex/pull/3679 +- Exact current original head/commit: `b05cccf264b4ab61db5d8dee8232c2f89bb1b541`, persistent ref `refs/codex/a-original/3679`. +- Original parent and current live dev: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. +- Original author: Clive Rosfield, GitHub `S0RYUASUKA`; trailer: `Co-authored-by: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com>`. +- Ref/head equality verified. All 13 original touched files have identical parent/dev blobs. Layer 010 will additionally change `proxy-formats.md`; preserve its failure paragraph. `src/config.ts` overlaps lane B ownership, so main must recheck fresh dev and coordinate its comment hunk at later P. +- The body still names earlier tested head `182006615c484756012f2d0c1ba72f47c4e5cf5b`. Its counts are author-reported evidence for that head, not proof of this updated head or a later carry. Full suite is explicitly incomplete/non-green in the body. + +## Current review resolution + +All three live review threads are now resolved, not outstanding: + +- Companion documentation request was addressed by this head: https://github.com/lidge-jun/opencodex/pull/3679#discussion_r3941233811 . Provider guide and adapter reference now distinguish adapter selection from transport selection. +- Proxy precedence request was withdrawn; preserve scheme-specific environment precedence and `config.proxy` filling absent scheme variables. The resulting HTTPS proxy intentionally precedes ALL_PROXY. Current patch adds uppercase/lowercase ALL_PROXY regression coverage: https://github.com/lidge-jun/opencodex/pull/3679#discussion_r3941252968 . Do not reintroduce the withdrawn behavior change. +- The separate proxy policy request was withdrawn; retain established operator-selected HTTP/HTTPS proxy support in this routing-only layer: https://github.com/lidge-jun/opencodex/pull/3679#discussion_r3941252966 . Any new investigation belongs in scratch, not this document. + +Remaining gates: independent current-head routing/security review under MAINTAINERS.md and remote executed verification. A resolved bot discussion does not substitute for that review. + +## Behavior and reuse decision + +Current `src/server/responses/codex-ws-session.ts:12-14` constructs WebSocket with headers only. `ws-upstream.ts:167-169` does not resolve or pass a proxy, and pool identity at `codex-ws-pool.ts:55` does not distinguish routes. `src/lib/proxy-env.ts:28` already owns HTTP fetch proxy matching; `src/lib/provider-outbound.ts:79` owns NO_PROXY matching. Reuse and move that matcher rather than adding a second implementation or altering Bun HTTP fetch rules. + +After the patch, choose a route once before dialing. NO_PROXY wins (WSS default 443, WS default 80). Otherwise choose first nonempty HTTPS_PROXY/https_proxy/ALL_PROXY/all_proxy for WSS; HTTP_PROXY alone is not a WSS proxy. An unusable selected value returns immediate HTTP/SSE fallback without dialing WebSocket or trying a lower-priority proxy. HTTP/SSE continues its existing scheme-specific behavior; ALL_PROXY does not become an HTTP fetch input. One-shot and retained sessions receive the same selected route. Pool reuse key includes the route while scope still identifies account/thread/turn; changed route retires the old session. Existing dispatch refusal, abort, headers, quota handling and post-send no-replay behavior remain intact. + +No-code/config-only options do not cover Bun WebSocket construction or retained-session route affinity; no new transport, package dependency, proxy discovery method or routing flag is necessary. + +## Exact file manifest and diff contract + +All operations are MODIFY; NEW and DELETE are none. The appendix is the complete diff against the pinned original parent. No new test file means no layout registration additions. + +| Path | Before → after / exact change | +|---|---| +| `src/lib/proxy-env.ts` | After ProxyEnvMap (line 5), add ProxyRoute direct/proxy/fallback union; exported normalizeProxyHostname and noProxyMatches moved from provider-outbound; matcher accepts an env map and WSS default port. Add resolveProxyRoute with first-nonempty selection, HTTP/HTTPS scheme acceptance and fallback on parse/unsupported value. Keep effectiveProxyFor semantics unchanged. | +| `src/lib/provider-outbound.ts` | Import the shared matcher/normalizer, delete private copies and configuredProxyFor wrapper, call outboundProxyConfigured directly. Keep DNS/destination admission and effective HTTP proxy snapshot logic unchanged. | +| `src/config.ts` | Update only the applyProxyEnv comment at line 3739 to explain transport use and scheme-versus-ALL precedence. No executable ALL_PROXY guard is added. | +| `src/server/responses/ws-upstream.ts` | Import resolver; after frame-size guard at line 151 compute wsUrl, route and optional proxy; fallback before creating socket on route fallback; pass same proxy to identity, pool acquire and one-shot constructor. Existing admission hooks remain effective through HTTP fallback and WS exchange. | +| `src/server/responses/codex-ws-pool.ts` | Add optional proxy to identity/acquire signatures at lines 28/78, include proxy-or-null in hashed key at 55 and forward it into retained constructor at 97. Do not change scope, bounds or eviction. | +| `src/server/responses/codex-ws-session.ts` | Add optional fifth constructor argument; append proxy option only when selected. Preserve headers and all listener/lease lifecycle behavior. | +| `tests/server/proxy-env.test.ts` | Add ALL_PROXY spellings to saved/restored fixture env. Add resolver precedence/bypass/fallback cases, direct Bun WebSocket CONNECT fixture, Windows-only NO_PROXY fetch fixture, and both config-versus-ALL precedence cases. | +| `tests/responses/ws-upstream.test.ts` | Capture constructor options, isolate/restore all proxy env values, assert option+header propagation, zero sockets/one fallback for malformed/unsupported selection, existing upgrade fallback through proxy, NO_PROXY header/custom destination behavior. | +| `tests/responses/ws-upstream-reuse.test.ts` | Isolate/restore proxy env, capture options, exercise proxy A→B→NO_PROXY with two requests per route; expect three sockets, two frames each, old two closed and last retained. | +| `docs-site/src/content/docs/reference/proxy-formats.md` | Add canonical WSS routing, invalid-route fallback and scheme/config/ALL precedence paragraphs after line 113; retain 010's earlier SSE failure paragraph. | +| `docs-site/src/content/docs/guides/providers.md` | After line 620 distinguish adapter selection from transport with link to canonical rules. | +| `docs-site/src/content/docs/reference/adapters.md` | After line 95 add companion transport note, link and HTTP-vs-WSS distinction. | +| `structure/04_transports-and-sidecars.md` | At lines 438 and 647 include route in reuse identity and explain WSS route/fallback without changing HTTP rules. | + +Localized pages currently omit the new behavior; public review records no contradiction. Recheck that remains true at later P; do not add unrelated locale rewrites. The 13-file breadth is one route-selection contract with tests/docs, not 13 independent product changes; keep it one independently reviewed layer. + +## Regression activation and independent acceptance + +Remote RED/GREEN must prove each mechanism rather than only compiling the added API: + +1. Resolver: uppercase/lowercase ordering, blank values, fallback priority, invalid selected proxy, unsupported scheme; NO_PROXY exact/suffix/wildcard/port/IPv6/URL entry, uppercase-empty overriding lowercase; retain HTTP fetch behavior through provider-outbound tests. +2. Construction: one-shot and retained constructors get the chosen option with unchanged authorization/beta/originator/header filtering. Before the production change, a constructor-option assertion must fail on the parent. The test-only resolver import must not be mistaken for sufficient behavioral RED. +3. Invalid route: zero created sockets and exactly one fallback. NO_PROXY produces direct option omission; HTTP_PROXY-only does not create a WSS proxy route. Existing dispatch-refusal/aborted/post-send tests must retain no duplicate dispatch or replay. +4. Affinity: two requests on route A reuse, route B causes replacement, NO_PROXY causes another replacement; sockets `[A,B,direct]`, two frames each, states `[closed,closed,open]`. Parent without proxy in key must fail this assertion remotely. +5. Real runtime: original `proxy-env.test.ts` local CONNECT fixture executes **on the remote test runner**, observing `proxy-probe.invalid:443` with a loopback HTTP proxy. This fixture directly constructs Bun WebSocket; it does not by itself prove codexWsUpstreamFetch integration. Combine it with option-propagation tests and capture a separate remote loopback harness through codexWsUpstreamFetch if an end-to-end integration claim is made. No production credentials required. +6. Actual Windows execution must exercise the new Windows-only NO_PROXY fetch fixture; a Linux skip is expected and not Windows proof. Check full CI and privacy independently; validate disposal/no lingering test listener behavior. + +Remote focused command (only inside verified remote checkout, using fixture-specific env cleanup and restoration): + +```sh +bun test tests/server/proxy-env.test.ts tests/providers/provider-outbound.test.ts tests/providers/provider-outbound-private-network.test.ts tests/responses/ws-upstream.test.ts tests/responses/ws-upstream-reuse.test.ts tests/responses/reserve-dispatch-ws.test.ts --timeout 20000 +``` + +Control the eight HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY case variants within the isolated remote test process; never clear the user's global environment. Original contributor observed inherited-environment failures on an older baseline; reproduce any new discrepancy against this layer's exact parent before classifying it. Any skipped runtime probe must be recorded as unproven rather than silently accepted. + +## Execution boundary and resource scope + +This document is candidate planning for a later implementation P, authored during the first docs-only cycle. Main owns roadmap, FSM, goal, implementation and stack integration. This delegated task writes only this document and its sibling `010_sse.md`/`020_ws.md`; it does not run tests, typecheck, builds, Git mutations, GitHub mutations, FSM transitions or goal commands. + +Later implementation scope uses existing `gh` credentials and writes only the assigned own stack branches. Inherited parallel reviewers are authorized. There is no explicit user token/cost cap; a two-hour checkpoint triggers reassessment, not automatic success or abandonment. No production account probes, deployment or release actions belong to this layer. User explicitly forbids local suites; every executable verification below is for a remote isolated checkout or GitHub Actions later. No local typecheck/build is permitted here either. Security investigation material stays in `.tmp`; this public plan records only already-public PR behavior and general integration requirements. + +At the later P, refresh live dev and original PR head through main, compare touched-path blobs and parent changes, and amend this plan before implementation. A changed original SHA invalidates the carried-patch assumption. Preserve unrelated workers' changes. Main may carry the original commit with author identity preserved; every carry/superseding PR and squash message must include the exact `Co-authored-by` trailer below. Publish with the user's authorized `--no-verify` push, never a direct push to dev. Local hook bypass does not supply CI evidence. + +## Main-confirmed remote execution handoff + +Main reports the existing remote repository at `REMOTE_HOST:REMOTE_SOURCE_CHECKOUT` and Bun `1.3.14` have been verified. These are main-provided environment facts, not a local execution claim by this planner. Implementation C uses an isolated remote clone at the exact carried SHA; do not alter the existing remote checkout or its service. Record `git rev-parse HEAD` and `bun --version` from that isolated remote clone with focused activation-test and typecheck receipts. If the carried tree requires a different pinned Bun version, reconcile and record that runtime difference remotely before treating results as representative. + +Carry PRs remain draft until full current-head GitHub CI is green. Focused remote tests/typecheck are implementation evidence, not permission to skip full gates. The final landing cycle requires every full gate described below, including an actually executed Windows lane where Windows behavior is claimed, current-head review, and dev ancestry proof. No local project command execution is allowed at any point. Deeper implementation review belongs to the next cycle; this handoff completes only the concrete candidate plan. + +## Static workflow coverage and later remote evidence + +Inspected at `dev@81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`: + +- `.github/workflows/ci.yml:7` uses `pull_request: {}` without a base branch filter: an open stacked child gets the same workflow. Push trigger at line 27 covers integration branches only; pushing an own feature branch without opening its PR does not establish CI coverage. +- Runtime/test changes activate the `changes` gate and four Linux test shards (`ci.yml:255`), two macOS shards (`ci.yml:451`), and gates (`ci.yml:392`, typecheck at 422, privacy at 430). Linux test discovery is `scripts/ci/run-bun-test-batches.sh:197`; these layer tests are not the storage/API-usage exclusions at line 52. +- Windows full test shards are **dispatch-only**, `ci.yml:658-686`; ordinary PR CI cannot prove Windows behavior. `workflow_dispatch` has only `lane` (`ci.yml:46`), so use the own branch as `--ref`, not a nonexistent SHA input. `lane=all` runs Windows plus the unsharded macOS control (`ci.yml:549`). +- The aggregate `ci` accepts intentional skips (`ci.yml:927`); a green aggregate alone cannot prove a Windows run, regression activation, or even runtime tests on a docs-only PR. Check producer job conclusions and logs. +- `.github/actions/setup-project-bun/action.yml:18` resolves the runtime from `package.json.dependencies.bun`. Record actual Bun version rather than substituting contributor-reported Bun 1.4.0 results. + +Later main-owned CI commands (not executed by this planning task): + +```sh +# Freeze/read own branch head first; then dispatch its checked-in workflow. +gh workflow run ci.yml --repo lidge-jun/opencodex --ref "$A_LAYER_BRANCH" -f lane=all +gh run list --repo lidge-jun/opencodex --workflow ci.yml --branch "$A_LAYER_BRANCH" --limit 10 --json databaseId,headSha,event,status,conclusion +gh run view "$A_RUN_ID" --repo lidge-jun/opencodex --json headSha,event,conclusion,jobs +gh run view "$A_RUN_ID" --repo lidge-jun/opencodex --log +``` + +Assert dispatch `headSha` equals the frozen layer head. For PR merge-ref runs record actual checkout SHA and its head/base parents. A refresh/restack/new commit requires evidence for that resulting tree. Capture URLs, SHA, OS, runtime, command, exit code, failed/skipped test counts and any baseline comparison in main's evidence receipt. `action_required`, pending/cancelled checks, hygiene-only success and author attestations are not green test evidence. Do not check a contributor's local-CI attestation when no such local execution occurred. + +Full relevant suite coverage, typecheck, privacy and docs build must run remotely before readiness. For separately authorized remote checkout verification, install pinned dependencies there, run `bun run typecheck`, `bun run privacy:scan`, `bun run test`, and `(cd docs-site && bun run build)` there. Do not run those commands in the local managed workspace. Failures require a named current-base comparison and repair/reassessment; historic Windows failures do not automatically excuse a new failure. + +## Integration and close-out + +Each layer must be reviewable and independently acceptable against its immediate parent. No acceptance depends on a later A layer fixing its behavior. Main merges bottom-up with current-head CI and review evidence, retargets/restacks children before parent branch deletion, and preserves author trailers in squash/carry history. After main verifies the resulting merge commit is an ancestor of freshly fetched dev, immediately close the superseded original PR with the carry PR/commit reference. Close a linked issue only when its full acceptance scope is satisfied; do not infer an issue from a similar title. This planning task performs none of those actions. + +## Original patch appendix (candidate implementation) + +The following is source material already published in the linked PR. Revalidate context at the later P; do not apply during the docs-only cycle. + +```diff +diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md +index 6a37cf8a7..255c0d8dc 100644 +--- a/docs-site/src/content/docs/guides/providers.md ++++ b/docs-site/src/content/docs/guides/providers.md +@@ -620,6 +620,12 @@ A provider is included when opencodex has a matching wire adapter, **not** based + (AI Studio, Vertex, and Antigravity/Cloud Code Assist modes), `azure` / `azure-openai`, `kiro`, and + `cursor`. A proprietary API without one of these implementations, such as native Amazon Bedrock, + is not supported directly. ++ ++Provider configuration selects the adapter; upstream transport selection is separate. Eligible ++Responses traffic can use WSS with [explicit proxy routing](/reference/proxy-formats/#json-and-sse-output). ++Invalid or unsupported WebSocket proxy settings fall back to HTTP/SSE, which uses Bun's HTTP ++proxy rules rather than the WSS-specific `ALL_PROXY` fallback. ++ + **GitHub Copilot** is an OAuth provider (`ocx login github-copilot`) that exchanges a GitHub + device-flow login for a short-lived Copilot API token — not a pasted API key. **GitLab Duo** remains + a key/subscription-token gateway on its OpenAI-compatible endpoint. **Cloudflare AI +diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md +index 1db98357d..e2a24c67d 100644 +--- a/docs-site/src/content/docs/reference/adapters.md ++++ b/docs-site/src/content/docs/reference/adapters.md +@@ -95,6 +95,11 @@ body and response, with narrow compatibility rewrites for routed gateways. + `forward` uses configured static headers without relaying caller authorization; `key` uses the + configured provider key. + ++Adapter selection does not select the upstream transport. Eligible requests can use the ++[upstream WebSocket proxy route](/reference/proxy-formats/#json-and-sse-output); invalid or unsupported ++WebSocket proxy settings fall back to HTTP/SSE. HTTP fetch-based Responses handling uses Bun's ++HTTP proxy rules and does not inherit the WSS-specific `ALL_PROXY` fallback. ++ + Noncanonical Responses gateways receive Codex's client-executed `tool_search` declaration as a + collision-safe public function tool. Matching request history and JSON/SSE function calls are + translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward +diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md +index 77a67147a..b4d7e5dea 100644 +--- a/docs-site/src/content/docs/reference/proxy-formats.md ++++ b/docs-site/src/content/docs/reference/proxy-formats.md +@@ -113,6 +113,19 @@ the raw JSON frame and its SSE envelope at 4 MiB, and closes the upstream when i + would overflow. That overflow emits a terminal downstream `response.failed` event followed by + `[DONE]`. + ++The upstream WebSocket checks `NO_PROXY`/`no_proxy` first. Otherwise it uses the first non-empty ++`HTTPS_PROXY`, `https_proxy`, `ALL_PROXY`, or `all_proxy` value; `HTTP_PROXY` alone does not proxy a ++WSS connection. HTTP and HTTPS proxy URLs are passed to Bun. If the selected value is invalid or ++uses an unsupported protocol, opencodex skips the WebSocket attempt and uses HTTP/SSE instead of ++dialing the upstream directly. ++ ++These rules belong to the upstream WebSocket transport, independently of the selected provider ++adapter. HTTP fetch-based Responses requests, including SSE fallback, use Bun's HTTP proxy rules ++and do not use `ALL_PROXY`. `config.proxy` fills missing `HTTP_PROXY`/`HTTPS_PROXY` values; the ++resulting scheme-specific value also takes precedence over an existing `ALL_PROXY` for WebSocket. ++For an HTTPS upstream that requires a proxy, set `HTTPS_PROXY` or `config.proxy`; `HTTP_PROXY` ++alone leaves both WSS and its HTTPS fallback without a scheme-matched proxy. ++ + Every terminal Responses usage object includes both detail objects, even when the provider did not + report those details: + +diff --git a/src/config.ts b/src/config.ts +index 5d67275dc..72da45538 100644 +--- a/src/config.ts ++++ b/src/config.ts +@@ -3738,11 +3738,12 @@ function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements + } + + /** +- * Mirror `config.proxy` into HTTP(S)_PROXY env vars so Bun's native fetch routes every outbound +- * provider call through the proxy — no per-callsite changes (verified: Bun honors these plus +- * NO_PROXY). User-set env vars always win; localhost/127.0.0.1 are appended to NO_PROXY so the +- * CLI's own health checks and running-proxy API calls stay direct. Call once per process entry +- * that makes outbound provider requests (server start, catalog sync). ++ * Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports ++ * such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY ++ * variables win; config fills missing scheme proxies, which take precedence over ALL_PROXY for WS. ++ * localhost/127.0.0.1 are appended to NO_PROXY so the CLI's own health checks and ++ * running-proxy API calls stay direct. Call once per process entry that makes outbound provider ++ * requests (server start, catalog sync). + */ + export function applyProxyEnv(config: OcxConfig): void { + applyProxyEnvWith(config); +diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts +index 495fef0b8..02bdbc207 100644 +--- a/src/lib/provider-outbound.ts ++++ b/src/lib/provider-outbound.ts +@@ -7,7 +7,7 @@ import { + resolvePublicAddresses, + } from "./destination-policy"; + import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; +-import { effectiveProxyFor, outboundProxyConfigured } from "./proxy-env"; ++import { effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env"; + import { publicProviderBaseUrl } from "./provider-url"; + + type ProviderGetInit = Omit; +@@ -37,10 +37,6 @@ function pickPinnedAddress(addresses: Array<{ address: string; family: number }> + return addresses.find(address => address.family === 4) ?? addresses[0]!; + } + +-function configuredProxyFor(): boolean { +- return outboundProxyConfigured(); +-} +- + /** + * Registry-owned fake-IP transparency exception (Clash/Surge/Mihomo TUN mode). + * +@@ -76,45 +72,6 @@ function transparentFakeIpException( + return isCanonicalUrl(name, url); + } + +-function normalizeProxyHostname(hostname: string): string { +- const normalized = hostname.trim().toLowerCase().replace(/\.+$/, ""); +- return normalized.startsWith("[") && normalized.endsWith("]") +- ? normalized.slice(1, -1) +- : normalized; +-} +- +-function noProxyMatches(url: URL): boolean { +- const raw = process.env.NO_PROXY ?? process.env.no_proxy ?? ""; +- const hostname = normalizeProxyHostname(url.hostname); +- const port = url.port || (url.protocol === "https:" ? "443" : "80"); +- for (const rawEntry of raw.split(",")) { +- let entry = rawEntry.trim().toLowerCase(); +- if (!entry) continue; +- if (entry === "*") return true; +- entry = entry.replace(/^https?:\/\//, "").split("/", 1)[0]!; +- +- let entryHost = entry; +- let entryPort = ""; +- const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry); +- if (bracketed) { +- entryHost = bracketed[1]!; +- entryPort = bracketed[2] ?? ""; +- } else if ((entry.match(/:/g)?.length ?? 0) === 1) { +- const separator = entry.lastIndexOf(":"); +- const possiblePort = entry.slice(separator + 1); +- if (/^\d+$/.test(possiblePort)) { +- entryHost = entry.slice(0, separator); +- entryPort = possiblePort; +- } +- } +- if (entryPort && entryPort !== port) continue; +- entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, "")); +- if (!entryHost) continue; +- if (hostname === entryHost || hostname.endsWith(`.${entryHost}`)) return true; +- } +- return false; +-} +- + let proxyBoundaryWarned = false; + let proxyDnsDegradationWarned = false; + +@@ -181,7 +138,7 @@ async function providerOutboundRequest( + return provider.fetch(url, { ...init, method, redirect: "manual" }); + } + const parsed = postUrl ?? new URL(url); +- const proxyConfigured = configuredProxyFor(); ++ const proxyConfigured = outboundProxyConfigured(); + // Snapshot the scheme-matched proxy once, before the DNS await, so admission and transport + // below reason about the same value. `null` here means "no proxy fetch would actually use", + // even if some other proxy variable is set. +diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts +index 46df59268..0ac9ed735 100644 +--- a/src/lib/proxy-env.ts ++++ b/src/lib/proxy-env.ts +@@ -3,6 +3,73 @@ export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const; + + export type ProxyEnvKey = typeof PROXY_ENV_KEYS[number]; + export type ProxyEnvMap = Record; ++export type ProxyRoute = ++ | { kind: "direct" } ++ | { kind: "proxy"; proxy: string } ++ | { kind: "fallback" }; ++ ++export function normalizeProxyHostname(hostname: string): string { ++ const normalized = hostname.trim().toLowerCase().replace(/\.+$/, ""); ++ return normalized.startsWith("[") && normalized.endsWith("]") ++ ? normalized.slice(1, -1) ++ : normalized; ++} ++ ++export function noProxyMatches( ++ url: URL, ++ env: ProxyEnvMap = process.env, ++): boolean { ++ const raw = env.NO_PROXY ?? env.no_proxy ?? ""; ++ const hostname = normalizeProxyHostname(url.hostname); ++ const port = url.port || (url.protocol === "https:" || url.protocol === "wss:" ? "443" : "80"); ++ for (const rawEntry of raw.split(",")) { ++ let entry = rawEntry.trim().toLowerCase(); ++ if (!entry) continue; ++ if (entry === "*") return true; ++ entry = entry.replace(/^(?:https?|wss?):\/\//, "").split("/", 1)[0]!; ++ ++ let entryHost = entry; ++ let entryPort = ""; ++ const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry); ++ if (bracketed) { ++ entryHost = bracketed[1]!; ++ entryPort = bracketed[2] ?? ""; ++ } else if ((entry.match(/:/g)?.length ?? 0) === 1) { ++ const separator = entry.lastIndexOf(":"); ++ const possiblePort = entry.slice(separator + 1); ++ if (/^\d+$/.test(possiblePort)) { ++ entryHost = entry.slice(0, separator); ++ entryPort = possiblePort; ++ } ++ } ++ if (entryPort && entryPort !== port) continue; ++ entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, "")); ++ if (entryHost && (hostname === entryHost || hostname.endsWith(`.${entryHost}`))) return true; ++ } ++ return false; ++} ++ ++export function resolveProxyRoute( ++ url: URL, ++ env: ProxyEnvMap = process.env, ++): ProxyRoute { ++ if (noProxyMatches(url, env)) return { kind: "direct" }; ++ const key = url.protocol === "https:" || url.protocol === "wss:" ++ ? "HTTPS_PROXY" ++ : "HTTP_PROXY"; ++ const proxy = [key, key.toLowerCase(), "ALL_PROXY", "all_proxy"] ++ .map(candidate => env[candidate]?.trim()) ++ .find(Boolean); ++ if (!proxy) return { kind: "direct" }; ++ try { ++ const protocol = new URL(proxy).protocol; ++ return protocol === "http:" || protocol === "https:" ++ ? { kind: "proxy", proxy } ++ : { kind: "fallback" }; ++ } catch { ++ return { kind: "fallback" }; ++ } ++} + + export function proxyEnvPresent( + key: ProxyEnvKey, +diff --git a/src/server/responses/codex-ws-pool.ts b/src/server/responses/codex-ws-pool.ts +index 378cf2d4a..5d406bee4 100644 +--- a/src/server/responses/codex-ws-pool.ts ++++ b/src/server/responses/codex-ws-pool.ts +@@ -25,7 +25,7 @@ function digest(input: unknown): string { + } + + /** Identity comes from the selected outgoing request, never a model label or caller hint. */ +-export function codexWsReuseIdentity(url: string, headers: Record, frameText: string): CodexWsReuseIdentity | null { ++export function codexWsReuseIdentity(url: string, headers: Record, frameText: string, proxy?: string): CodexWsReuseIdentity | null { + if (url !== CODEX_RESPONSES_HTTP_URL) return null; + let body: unknown; + try { body = JSON.parse(frameText); } catch { return null; } +@@ -52,7 +52,7 @@ export function codexWsReuseIdentity(url: string, headers: Record): CodexWsSession | null { ++ acquire(identity: CodexWsReuseIdentity, url: string, headers: Record, proxy?: string): CodexWsSession | null { + this.sweep(); + for (const entry of this.entries.values()) { + if (entry.identity.scope !== identity.scope || entry.identity.key === identity.key) continue; +@@ -94,7 +94,7 @@ export class CodexWsPool { + this.remove(oldest); + } + const createdAt = this.now(); +- const session = new CodexWsSession(url, headers, true, () => this.changed(entry)); ++ const session = new CodexWsSession(url, headers, true, () => this.changed(entry), proxy); + const entry: Entry = { identity, session, createdAt, idleAt: createdAt, retired: false }; + session.reserve(); + this.entries.set(identity.key, entry); +diff --git a/src/server/responses/codex-ws-session.ts b/src/server/responses/codex-ws-session.ts +index bbf62f813..32716a529 100644 +--- a/src/server/responses/codex-ws-session.ts ++++ b/src/server/responses/codex-ws-session.ts +@@ -10,8 +10,8 @@ export class CodexWsSession { + private readonly completedIds = new Set(); + + constructor(url: string, headers: Record, readonly retainable = false, +- private readonly changed: () => void = () => {}) { +- this.socket = new WebSocket(url, { headers } as unknown as string[]); ++ private readonly changed: () => void = () => {}, proxy?: string) { ++ this.socket = new WebSocket(url, { headers, ...(proxy ? { proxy } : {}) } as unknown as string[]); + this.socket.addEventListener("open", this.onOpen); + this.socket.addEventListener("message", this.onIdleMessage); + this.socket.addEventListener("close", this.onClose); +diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts +index e9773d02a..87b3767d2 100644 +--- a/src/server/responses/ws-upstream.ts ++++ b/src/server/responses/ws-upstream.ts +@@ -13,6 +13,7 @@ + // (passthrough relay, adapter parsers, usage sniffing) is unchanged. + + import { compareBunVersions } from "../../lib/bun-stream-caps"; ++import { resolveProxyRoute } from "../../lib/proxy-env"; + import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; + import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, prepareCodexHttpInit, prepareCodexWsRequest } from "./codex-ws-request"; + import { codexWsExchange } from "./codex-ws-exchange"; +@@ -150,6 +151,10 @@ export function codexWsUpstreamFetch( + return sseFallback(url, init); + } + ++ const wsUrl = wsUpstreamUrlFor(url); ++ const proxyRoute = resolveProxyRoute(new URL(wsUrl)); ++ if (proxyRoute.kind === "fallback") return sseFallback(url, init); ++ const proxy = proxyRoute.kind === "proxy" ? proxyRoute.proxy : undefined; + // A genuine caller `originator` is already in these headers via the forward + // set. Never fabricate one here: pool/forward traffic must not impersonate + // Codex CLI, per the metadata-integrity contract. (The backend's fast lane +@@ -164,9 +169,9 @@ export function codexWsUpstreamFetch( + } + let session: CodexWsSession; + try { +- const identity = codexWsReuseIdentity(url, headers, frameText); +- session = (identity ? codexWsPool.acquire(identity, wsUpstreamUrlFor(url), headers) : null) +- ?? new CodexWsSession(wsUpstreamUrlFor(url), headers); ++ const identity = codexWsReuseIdentity(url, headers, frameText, proxy); ++ session = (identity ? codexWsPool.acquire(identity, wsUrl, headers, proxy) : null) ++ ?? new CodexWsSession(wsUrl, headers, false, undefined, proxy); + if (!session.busy && !session.reserve()) { + session.dispose(); + return sseFallback(url, init); +diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md +index 4ee22c114..a45a98c87 100644 +--- a/structure/04_transports-and-sidecars.md ++++ b/structure/04_transports-and-sidecars.md +@@ -435,7 +435,7 @@ These are transport-fidelity guarantees, not a provider-billing guarantee. + + Eligible complete-input creates can retain a canonical upstream socket within + one selected account, credential, thread and turn. Model/tier and immutable +-handshake headers must also match. Turn-state and turn-metadata headers are ++handshake headers and the selected outbound proxy must also match. Turn-state and turn-metadata headers are + projected into their same-name per-frame metadata slots; explicit body values win. + The pool retains at most 32 sockets, expires idle sockets after 30 seconds, and + retires a socket after five minutes or 32 successful exchanges (after active work +@@ -644,7 +644,11 @@ the upgrade with 426 so Codex falls back to HTTP cleanly. + + That setting controls the client-facing upgrade only. The transparent upstream + ChatGPT WS optimization described above is selected independently and still +-returns the same downstream SSE contract. ++returns the same downstream SSE contract. Its WSS route checks NO_PROXY first, then selects the ++first non-empty HTTPS_PROXY, https_proxy, ALL_PROXY, or all_proxy value. HTTP_PROXY alone does not ++route WSS. Unsupported or malformed selected proxy values skip the WebSocket attempt and use the ++existing SSE path immediately; they never fall through to a lower-priority proxy or direct WebSocket ++egress. HTTP/SSE fallback retains Bun fetch's own proxy rules, which do not consult ALL_PROXY. + + The endpoint handles `response.create`, ignores `response.processed`, supports warmup + `generate: false`, and feeds the same request pipeline as HTTP/SSE. +diff --git a/tests/responses/ws-upstream-reuse.test.ts b/tests/responses/ws-upstream-reuse.test.ts +index fd0a8fb5a..b957fdb31 100644 +--- a/tests/responses/ws-upstream-reuse.test.ts ++++ b/tests/responses/ws-upstream-reuse.test.ts +@@ -6,6 +6,8 @@ import { prepareCodexWsRequest } from "../../src/server/responses/codex-ws-reque + + const URL = "https://chatgpt.com/backend-api/codex/responses"; + const realWebSocket = globalThis.WebSocket; ++const proxyEnvKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; ++let savedProxyEnv: Record; + let sequence = 0; + + class Socket extends EventTarget { +@@ -13,7 +15,7 @@ class Socket extends EventTarget { + static onSend: (socket: Socket, frame: Record) => void = (socket) => socket.complete(); + readyState = 0; + frames: Record[] = []; +- constructor(readonly url: string) { ++ constructor(readonly url: string, readonly options?: { proxy?: string }) { + super(); + Socket.all.push(this); + queueMicrotask(() => { if (this.readyState === 0) { this.readyState = 1; this.dispatchEvent(new Event("open")); } }); +@@ -58,7 +60,11 @@ function bodyWith(fields: Record) { + options.body = JSON.stringify({ ...JSON.parse(options.body as string), ...fields }); + return options; + } +-beforeEach(() => { globalThis.WebSocket = Socket as unknown as typeof WebSocket; }); ++beforeEach(() => { ++ globalThis.WebSocket = Socket as unknown as typeof WebSocket; ++ savedProxyEnv = Object.fromEntries(proxyEnvKeys.map(key => [key, process.env[key]])); ++ for (const key of proxyEnvKeys) delete process.env[key]; ++}); + + afterEach(() => { + runOptionalShutdownHooks(); +@@ -67,6 +73,25 @@ afterEach(() => { + Socket.onSend = socket => socket.complete(); + sequence = 0; + globalThis.WebSocket = realWebSocket; ++ for (const key of proxyEnvKeys) delete process.env[key]; ++ for (const key of proxyEnvKeys) { ++ if (savedProxyEnv[key] !== undefined) process.env[key] = savedProxyEnv[key]; ++ } ++}); ++ ++test("proxy changes and NO_PROXY retire the old route while unchanged routes reuse", async () => { ++ for (const proxy of ["http://proxy-a.example:8080", "http://proxy-b.example:8080"]) { ++ process.env.HTTPS_PROXY = proxy; ++ await drain(); ++ await drain(); ++ } ++ process.env.NO_PROXY = "chatgpt.com:443"; ++ await drain(); ++ await drain(); ++ expect(Socket.all.map(socket => socket.options?.proxy)) ++ .toEqual(["http://proxy-a.example:8080", "http://proxy-b.example:8080", undefined]); ++ expect(Socket.all.map(socket => socket.frames.length)).toEqual([2, 2, 2]); ++ expect(Socket.all.map(socket => socket.readyState)).toEqual([3, 3, 1]); + }); + + test("same account/thread/turn reuses one socket without trimming either HTTP input", async () => { +diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts +index fd0951307..cfb087a4b 100644 +--- a/tests/responses/ws-upstream.test.ts ++++ b/tests/responses/ws-upstream.test.ts +@@ -1,4 +1,4 @@ +-import { afterEach, describe, expect, jest, test } from "bun:test"; ++import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; + import { providerFetch } from "../../src/server/responses/fetch-helpers"; + import { handleResponses } from "../../src/server/responses"; + import { isEagerRelaySseResponse } from "../../src/server/relay"; +@@ -162,18 +162,24 @@ describe("shouldUseCodexWsUpstream", () => { + }); + + type Listener = (event: unknown) => void; ++type FakeWebSocketOptions = { ++ headers?: Record; ++ proxy?: string; ++}; + + /** Minimal scriptable stand-in for Bun's WebSocket. */ + class FakeWebSocket { + static instances: FakeWebSocket[] = []; + static script: (ws: FakeWebSocket) => void = () => {}; + url: string; ++ options?: FakeWebSocketOptions; + sent: string[] = []; + closed = false; + listeners = new Map(); + +- constructor(url: string) { ++ constructor(url: string, options?: FakeWebSocketOptions) { + this.url = url; ++ this.options = options; + FakeWebSocket.instances.push(this); + queueMicrotask(() => FakeWebSocket.script(this)); + } +@@ -205,12 +211,23 @@ class FakeWebSocket { + + const RealWebSocket = globalThis.WebSocket; + const RealFetch = globalThis.fetch; ++const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"] as const; ++let savedProxyEnv: Record; ++ ++beforeEach(() => { ++ savedProxyEnv = Object.fromEntries(PROXY_ENV_KEYS.map(key => [key, process.env[key]])); ++ for (const key of PROXY_ENV_KEYS) delete process.env[key]; ++}); + + afterEach(() => { + globalThis.WebSocket = RealWebSocket; + globalThis.fetch = RealFetch; + FakeWebSocket.instances = []; + FakeWebSocket.script = () => {}; ++ for (const key of PROXY_ENV_KEYS) delete process.env[key]; ++ for (const key of PROXY_ENV_KEYS) { ++ if (savedProxyEnv[key] !== undefined) process.env[key] = savedProxyEnv[key]; ++ } + }); + + function installFake(script: (ws: FakeWebSocket) => void) { +@@ -525,6 +542,41 @@ describe("codexWsUpstreamFetch", () => { + expect(text).not.toContain("must-not-leak"); + }); + ++ test("passes the selected proxy without changing handshake headers", async () => { ++ process.env.HTTPS_PROXY = "http://proxy.example:8080"; ++ installFake(ws => { ++ ws.emit("open", {}); ++ ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: {} }) }); ++ }); ++ ++ await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (() => { ++ throw new Error("fallback must not run"); ++ }) as unknown as typeof fetch); ++ ++ const options = FakeWebSocket.instances[0]!.options; ++ expect(options?.proxy).toBe("http://proxy.example:8080"); ++ expect(options?.headers?.authorization).toBe("Bearer test"); ++ expect(options?.headers?.["openai-beta"]).toContain("responses_websockets"); ++ expect(options?.headers?.["content-type"]).toBeUndefined(); ++ }); ++ ++ test.each([ ++ ["unsupported protocol", "socks5://proxy.example:1080"], ++ ["invalid URL", "not a proxy URL"], ++ ])("falls back once without dialing for an %s", async (_label, proxy) => { ++ process.env.HTTPS_PROXY = proxy; ++ const sentinel = new Response("sse-fallback"); ++ let fallbackCalls = 0; ++ const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (async () => { ++ fallbackCalls += 1; ++ return sentinel; ++ }) as typeof fetch); ++ ++ expect(response).toBe(sentinel); ++ expect(fallbackCalls).toBe(1); ++ expect(FakeWebSocket.instances).toHaveLength(0); ++ }); ++ + test("relays event frames as an SSE response and sends one response.create frame", async () => { + installFake(ws => { + ws.emit("open", {}); +@@ -654,6 +706,7 @@ describe("codexWsUpstreamFetch", () => { + }); + + test("falls back to the HTTP fetch when the upgrade is rejected before open", async () => { ++ process.env.HTTPS_PROXY = "http://proxy.example:8080"; + installFake(ws => ws.close()); + const sentinel = new Response("sse-fallback", { status: 429 }); + let fallbackCalls = 0; +@@ -666,6 +719,7 @@ describe("codexWsUpstreamFetch", () => { + expect(response).toBe(sentinel); + expect(isCodexWsUpstreamResponse(response)).toBe(false); + expect(fallbackCalls).toBe(1); ++ expect(FakeWebSocket.instances[0]!.options?.proxy).toBe("http://proxy.example:8080"); + }); + + test("falls back to the HTTP fetch when the upgrade deadline elapses without open or close", async () => { +@@ -800,15 +854,17 @@ describe("codexWsUpstreamFetch", () => { + }); + + test("preserves caller headers on the handshake without fabricating an originator", async () => { +- const seen: Record[] = []; ++ process.env.HTTPS_PROXY = "http://proxy.example:8080"; ++ process.env.NO_PROXY = "chatgpt.com:443"; ++ const seen: FakeWebSocketOptions[] = []; + FakeWebSocket.script = ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: {} }) }); + }; + class HeaderCapturingWebSocket extends FakeWebSocket { +- constructor(url: string, options?: { headers?: Record }) { +- super(url); +- seen.push(options?.headers ?? {}); ++ constructor(url: string, options?: FakeWebSocketOptions) { ++ super(url, options); ++ seen.push(options ?? {}); + } + } + globalThis.WebSocket = HeaderCapturingWebSocket as unknown as typeof WebSocket; +@@ -817,18 +873,19 @@ describe("codexWsUpstreamFetch", () => { + await codexWsUpstreamFetch(CODEX_URL, streamingInit(), fallback); + // Without a caller originator none is invented: pool/forward traffic must + // not impersonate Codex CLI (metadata-integrity contract). +- expect(seen[0].originator).toBeUndefined(); +- expect(seen[0]["openai-beta"]).toContain("responses_websockets"); +- expect(seen[0].authorization).toBe("Bearer test"); ++ expect(seen[0].proxy).toBeUndefined(); ++ expect(seen[0].headers?.originator).toBeUndefined(); ++ expect(seen[0].headers?.["openai-beta"]).toContain("responses_websockets"); ++ expect(seen[0].headers?.authorization).toBe("Bearer test"); + // HTTP body-framing headers do not belong on a WS handshake. +- expect(seen[0]["content-type"]).toBeUndefined(); ++ expect(seen[0].headers?.["content-type"]).toBeUndefined(); + + // A genuine caller originator is forwarded verbatim. + await codexWsUpstreamFetch(CODEX_URL, { + ...streamingInit(), + headers: { ...streamingInit().headers as Record, originator: "codex_cli_rs" }, + }, fallback); +- expect(seen[1].originator).toBe("codex_cli_rs"); ++ expect(seen[1].headers?.originator).toBe("codex_cli_rs"); + }); + + test("aborting before open rejects like an aborted fetch", async () => { +@@ -1194,6 +1251,8 @@ describe("oversized Codex create frames", () => { + }); + + test("dials the configured provider's own wss URL for an opt-in upstream", async () => { ++ process.env.HTTPS_PROXY = "http://proxy.example:8080"; ++ process.env.NO_PROXY = "sub2api.example.com:443"; + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { id: "r-ws" } }) }); +@@ -1206,6 +1265,7 @@ describe("oversized Codex create frames", () => { + ); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(FakeWebSocket.instances[0]!.url).toBe("wss://sub2api.example.com/v1/responses"); ++ expect(FakeWebSocket.instances[0]!.options?.proxy).toBeUndefined(); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + expect(await response.text()).toContain("response.completed"); + }); +diff --git a/tests/server/proxy-env.test.ts b/tests/server/proxy-env.test.ts +index e43ad2d9b..c795c6cf2 100644 +--- a/tests/server/proxy-env.test.ts ++++ b/tests/server/proxy-env.test.ts +@@ -1,8 +1,10 @@ + import { afterEach, beforeEach, describe, expect, test } from "bun:test"; ++import { createServer } from "node:http"; + import { applyProxyEnv } from "../../src/config"; ++import { resolveProxyRoute } from "../../src/lib/proxy-env"; + import type { OcxConfig } from "../../src/types"; + +-const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", "OCX_TEST_PROXY_REF", "OCX_TEST_NO_PROXY_REF"] as const; ++const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "OCX_TEST_PROXY_REF", "OCX_TEST_NO_PROXY_REF"] as const; + let saved: Record; + + beforeEach(() => { +@@ -30,6 +32,128 @@ function configWithRawProxy(proxy: unknown, noProxy?: unknown): OcxConfig { + return { proxy, noProxy, providers: {} } as unknown as OcxConfig; + } + ++describe("resolveProxyRoute", () => { ++ test("wss uses HTTPS_PROXY and never HTTP_PROXY", () => { ++ const target = new URL("wss://chatgpt.com/backend-api/codex/responses"); ++ expect(resolveProxyRoute(target, { ++ HTTPS_PROXY: "http://secure-proxy.example:8443", ++ HTTP_PROXY: "http://plain-proxy.example:8080", ++ })).toEqual({ kind: "proxy", proxy: "http://secure-proxy.example:8443" }); ++ expect(resolveProxyRoute(target, { ++ HTTP_PROXY: "http://plain-proxy.example:8080", ++ })).toEqual({ kind: "direct" }); ++ }); ++ ++ test.each([ ++ ["exact host", "wss://chatgpt.com/path", "chatgpt.com", "direct"], ++ ["domain suffix", "wss://api.chatgpt.com/path", ".chatgpt.com", "direct"], ++ ["wildcard suffix", "wss://api.chatgpt.com/path", "*.chatgpt.com", "direct"], ++ ["wss default port", "wss://chatgpt.com/path", "chatgpt.com:443", "direct"], ++ ["ws default port", "ws://chatgpt.com/path", "chatgpt.com:80", "direct"], ++ ["port mismatch", "wss://chatgpt.com/path", "chatgpt.com:80", "proxy"], ++ ["bracketed IPv6", "wss://[2001:db8::1]/path", "[2001:db8::1]:443", "direct"], ++ ["URL-style entry", "wss://chatgpt.com/path", "https://chatgpt.com/ignored", "direct"], ++ ] as const)("honors NO_PROXY for %s", (_label, target, noProxy, expectedKind) => { ++ expect(resolveProxyRoute(new URL(target), { ++ HTTPS_PROXY: "http://secure-proxy.example:8443", ++ NO_PROXY: noProxy, ++ }).kind).toBe(expectedKind); ++ }); ++ ++ test("uses stable proxy precedence and fails closed on the first unusable proxy", () => { ++ const target = new URL("wss://chatgpt.com/backend-api/codex/responses"); ++ const route = (env: Record) => resolveProxyRoute(target, env); ++ expect([ ++ route({ HTTPS_PROXY: "http://upper-https:1", https_proxy: "http://lower-https:2", ALL_PROXY: "http://upper-all:3", all_proxy: "http://lower-all:4" }), ++ route({ HTTPS_PROXY: " ", https_proxy: "http://lower-https:2", ALL_PROXY: "http://upper-all:3" }), ++ route({ ALL_PROXY: "http://upper-all:3", all_proxy: "http://lower-all:4" }), ++ route({ all_proxy: "https://lower-all:4" }), ++ route({ HTTPS_PROXY: "socks5://unsupported:1080", ALL_PROXY: "http://must-not-win:3" }), ++ route({ HTTPS_PROXY: "not a proxy URL", ALL_PROXY: "http://must-not-win:3" }), ++ route({}), ++ ]).toEqual([ ++ { kind: "proxy", proxy: "http://upper-https:1" }, ++ { kind: "proxy", proxy: "http://lower-https:2" }, ++ { kind: "proxy", proxy: "http://upper-all:3" }, ++ { kind: "proxy", proxy: "https://lower-all:4" }, ++ { kind: "fallback" }, ++ { kind: "fallback" }, ++ { kind: "direct" }, ++ ]); ++ }); ++ ++ test("preserves uppercase NO_PROXY precedence when it is explicitly empty", () => { ++ expect(resolveProxyRoute(new URL("wss://chatgpt.com/path"), { ++ HTTPS_PROXY: "http://secure-proxy.example:8443", ++ NO_PROXY: "", ++ no_proxy: "chatgpt.com", ++ })).toEqual({ kind: "proxy", proxy: "http://secure-proxy.example:8443" }); ++ }); ++ ++ test("Bun WebSocket sends WSS through an HTTP CONNECT proxy", async () => { ++ let resolveConnect!: (target: string) => void; ++ const connected = new Promise(resolve => { resolveConnect = resolve; }); ++ const proxy = createServer(); ++ proxy.on("connect", (request, socket) => { ++ resolveConnect(request.url ?? ""); ++ socket.end("HTTP/1.1 502 Probe Complete\r\nContent-Length: 0\r\n\r\n"); ++ }); ++ await new Promise((resolve, reject) => { ++ proxy.once("error", reject); ++ proxy.listen(0, "127.0.0.1", resolve); ++ }); ++ const address = proxy.address(); ++ if (!address || typeof address === "string") throw new Error("proxy did not bind a TCP port"); ++ const socket = new WebSocket("wss://proxy-probe.invalid/backend-api/codex/responses", { ++ proxy: `http://127.0.0.1:${address.port}`, ++ } as unknown as string[]); ++ try { ++ expect(await Promise.race([ ++ connected, ++ new Promise((_, reject) => setTimeout(() => reject(new Error("CONNECT was not observed")), 5_000)), ++ ])).toBe("proxy-probe.invalid:443"); ++ } finally { ++ try { socket.close(); } catch { /* probe is already complete */ } ++ await new Promise(resolve => proxy.close(() => resolve())); ++ } ++ }, 10_000); ++ ++ test.skipIf(process.platform !== "win32")("Bun fetch honors NO_PROXY on Windows", async () => { ++ let providerRequests = 0; ++ let proxyRequests = 0; ++ const provider = createServer((_request, response) => { ++ providerRequests += 1; ++ response.end("direct"); ++ }); ++ const proxy = createServer((_request, response) => { ++ proxyRequests += 1; ++ response.end("proxied"); ++ }); ++ const listen = async (server: typeof provider): Promise => { ++ await new Promise((resolve, reject) => { ++ server.once("error", reject); ++ server.listen(0, "127.0.0.1", resolve); ++ }); ++ const address = server.address(); ++ if (!address || typeof address === "string") throw new Error("server did not bind a TCP port"); ++ return address.port; ++ }; ++ const [providerPort, proxyPort] = await Promise.all([listen(provider), listen(proxy)]); ++ process.env.HTTP_PROXY = `http://127.0.0.1:${proxyPort}`; ++ process.env.NO_PROXY = "127.0.0.1"; ++ try { ++ expect(await (await fetch(`http://127.0.0.1:${providerPort}/models`)).text()).toBe("direct"); ++ expect(providerRequests).toBe(1); ++ expect(proxyRequests).toBe(0); ++ } finally { ++ await Promise.all([ ++ new Promise(resolve => provider.close(() => resolve())), ++ new Promise(resolve => proxy.close(() => resolve())), ++ ]); ++ } ++ }); ++}); ++ + describe("applyProxyEnv with values the schema does not constrain", () => { + test("warns once per discarded proxy setting without exposing its raw value", () => { + const secret = "raw-proxy-credential-sentinel-2947"; +@@ -122,6 +246,14 @@ describe("applyProxyEnv", () => { + expect(process.env.HTTP_PROXY).toBe("http://proxy.corp:8080"); + }); + ++ test.each(["ALL_PROXY", "all_proxy"])("config fills a scheme proxy ahead of %s for WSS", key => { ++ process.env[key] = "http://fallback-proxy.example:8081"; ++ applyProxyEnv(configWithProxy("http://configured-proxy.example:8080")); ++ expect(process.env[key]).toBe("http://fallback-proxy.example:8081"); ++ expect(resolveProxyRoute(new URL("wss://chatgpt.com/backend-api/codex/responses"))) ++ .toEqual({ kind: "proxy", proxy: "http://configured-proxy.example:8080" }); ++ }); ++ + test("appends loopback entries to an existing NO_PROXY without duplicating", () => { + process.env.NO_PROXY = "internal.corp,localhost"; + applyProxyEnv(configWithProxy("http://proxy.corp:8080")); +@@ -217,4 +349,3 @@ describe("applyProxyEnv with proxy: \"auto\" (#1525)", () => { + expect(process.env.HTTP_PROXY).toBeUndefined(); + }); + }); +- +``` diff --git a/devlog/_plan/260906_a_runtime_stack/030_recovery.md b/devlog/_plan/260906_a_runtime_stack/030_recovery.md new file mode 100644 index 0000000000..99ea201b44 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/030_recovery.md @@ -0,0 +1,208 @@ +# 030 — Native MESSAGE recovery and cached replay (#3568) + +Status: candidate implementation plan, researched 2026-09-06 KST. This is a +docs-only deliverable. Revalidate during this layer's P after preceding layers +land; no implementation or verification pass is claimed here. + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Loop specification and scope + +- Class: C4 for the existing recovery admission boundary; C3 for destination + normalization. Archetype: spec-satisfaction repair, one implementation PABCD + cycle for this decade document. +- Trigger: native parent MESSAGE delivery or replayed encrypted task history on + an opted-in routed child. Goal: preserve the admitted plaintext assignment and + deliver supported plaintext Go Responses agent messages. +- Non-goals: #3571 catalog/effort ordering, multipart recovery, native-backend + retry policy, new credential sources, recovery enabled by default, new routing + metadata protocol, deployment/release work, or a general solution to #3661. +- Verifier: exact-layer remote focused regressions, full Cross-platform CI, + privacy and type gates, and independent recovery-boundary review. Commands + below are planned for remote execution only; none ran during planning. +- Stop condition: reviewed prepared draft and exact-head remote focused/type evidence; full CI/dev inclusion are required by 060 before feature completion. Partial #3661 stays open. +- Memory artifact: this file and main-owned `000` roadmap/evidence ledger. +- Outcomes: DONE only with the evidence above; NOOP only if current dev already + contains equivalent behavior and regressions; BLOCKED for external CI/review + dependencies; UNSAFE/NEEDS_HUMAN for a necessary expansion of admission policy. +- Delegation: inherited parallel read-only reviewers authorized. Downward scope + changes require a P amendment; main reclaims a packet after two distinct worker + failures. Main owns FSM, implementation, commits and stack integration. +- Resource scope: existing gh credentials; later writes restricted to own stack + branches and scoped PR administration. This worker writes only this plan and + `040_affinity.md`. No explicit user token/cost cap; a 2-hour checkpoint triggers + reassessment, not an automatic success or exhaustion claim. No local tests, + typecheck, build, Git mutation or GitHub mutation in this planning task. +- Public record rule: this file describes already-public PR behavior and general + integration requirements. Any new security investigation belongs in `.tmp/`. + +## Provenance and current source + +Live GitHub dev and local HEAD both resolve to +`81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. Original PR +[3568](https://github.com/lidge-jun/opencodex/pull/3568) head is +`036a9321788464fdf33a387c9f44a834a844bdc1`, retained as +`refs/codex/a-original/3568`. The earlier `origin/a-original-*` refs were pruned; +do not depend on them. Read the complete feature diff using +`git diff origin/dev...refs/codex/a-original/3568`, not `HEAD^..HEAD` (the last +two commits are documentation corrections). + +Original author: `voiys ` (GitHub `voiys`). Preserve these +commits in order when carrying the work: + +1. `e8f8726040dbc45b1e946d59db6b9c477459b8d7` — recovery implementation. +2. `4464892336c75b8861ee4caeddfd97d6c4e0e6ab` — canonical Go destination docs. +3. `036a9321788464fdf33a387c9f44a834a844bdc1` — forward-auth exception docs. + +A rewritten/squashed carrying commit and final squash body must contain +`Co-authored-by: voiys `; cite the original PR in the new PR. +Do not force-push the contributor branch. + +Source anchors at the inspected dev SHA: + +- `src/server/responses/agent-task-recovery.ts:61`: envelope type; line 72 + accepts only NEW_TASK; line 74 selects the supported tail envelope; line 180 + injects validated plaintext; line 277 performs admission and line 287 creates + the existing cache key, including message type and parent scope. +- `src/server/responses/agent-task-recovery-cache.ts:23`: existing deletion/byte + accounting; line 43 sets original expiry; line 117 owns resolving cache/flight + behavior. Reuse these owners instead of adding another cache. +- `src/server/responses/core.ts:3233`: final-route recovery gate currently also + requires an unreadable current task. Lines 3261–3282 own reparsing, preserved + continuation fields and the existing non-persistable-body handling. +- `src/adapters/openai-responses.ts:2354`: body expansion/previous-response + handling before effort mapping is the original insertion point. +- `structure/10_adapter-registry.md:5`: adapter factory authority remains the + registry. `opencode-go.ts` below is a destination helper, not a new adapter id. + +Owner search used `isOpenCodeGo`, `normalizeOpenCodeGoAgentMessages`, +`recoverEncryptedAgentTask` and recovery-cache exports. No equivalent Go helper +exists in current dev. Doing nothing retains the public regression; configuration +alone cannot admit MESSAGE or restore history. Reuse admission, injection, cache +deletion and Responses construction; do not duplicate them. + +## Exact implementation change map + +| Action / path | Before → planned after | +|---|---| +| MODIFY `src/server/responses/agent-task-recovery.ts` | Widen `AgentEnvelope.messageType` and the local parse variable to `"NEW_TASK" \| "MESSAGE"`; ROUTING_HEADER captures either and assigns the captured value. Add `restoreCachedEncryptedAgentTasks(req,input,config,{parentThreadId})`: scan only agent_message entries, reuse `admittedRecovery` on each singleton, read the existing cache, and call `injectAssignment` only for a valid hit; return restored count. Fresh recovery continues to handle only the supported tail. | +| MODIFY `src/server/responses/agent-task-recovery-cache.ts` | Export `cachedAgentTaskRecovery(key): string \| null`; return null on miss; delete expired entries with existing `deleteRecoveryCacheEntry`; return live assignment without extending TTL, creating a flight or performing network I/O. | +| MODIFY `src/server/responses/core.ts` | Import restoration helper. Retain Responses/spawn/opt-in/final-route/combo/pass-through exclusions, remove only the outer unreadable-tail prerequisite, restore history first, recompute unreadability, and attempt fresh recovery only when still needed. Feed actual successful restoration/recovery into the existing reparse/route-selection path; preserve continuation fields and existing non-persistence handling. | +| NEW `src/adapters/opencode-go.ts` | Add `isOpenCodeGo(baseUrl)` using URL origin `https://opencode.ai` and normalized path `/zen/go/v1`; malformed/other URLs return false. Add `normalizeOpenCodeGoAgentMessages(body)` with unchanged-reference no-op; convert only nonempty agent_message content arrays entirely composed of input_text/input_image/input_file into user messages; preserve original content parts and add readable author/recipient context. No encrypted/unknown-part conversion. | +| MODIFY `src/adapters/openai-responses.ts` | Import helpers; after `stripPreviousResponseId`, apply normalization only for `!forward && isOpenCodeGo(provider.baseUrl)`, before effort mapping. Preserve raw replay body and existing session headers. | +| NEW `tests/providers/opencode-go-agent-messages.test.ts` | Carry original provider tests and add canonical-Go forward-auth, renamed-provider/trailing-slash URL, malformed/other URL and input_file/empty/mixed unknown-part cases. Assert adapter output and source-body identity, not helper existence. | +| NEW `tests/server/server-agent-task-recovery-replay.test.ts` | Carry original replay/MESSAGE/mixed-history tests. Extend real handler coverage for known history plus a fresh tail and for cache-only continued turns. Check outbound body and recovery fetch counts, not just helper return values. | +| MODIFY `tests/server/agent-task-recovery-cache.test.ts` | Exercise the new read-only accessor on hit, miss and exact expiry; assert repeated reads do not extend lifetime or create recovery flights and expiry uses existing byte-accounting deletion. Reuse existing clock isolation. | +| MODIFY `scripts/test-layout/layout.json` | Register `opencode-go-agent-messages.test.ts` under providers and `server-agent-task-recovery-replay.test.ts` under server in `explicit`. Preserve other registrations. | +| MODIFY `tests/fixtures/test-layout-expected.json` | Add the same two basename/domain mappings. | +| MODIFY `docs-site/src/content/docs/reference/adapters.md` | Carry original non-forward canonical-Go conversion paragraph and recovery link. | +| MODIFY `docs-site/src/content/docs/reference/configuration/providers.md` | Carry original Go section specifying URL, adapter, forward exclusion, cached history versus fresh-tail behavior and context-only identities. | + +No DELETE paths. Existing tests/security/fallback/combo helpers are read/reused; +extend an existing test file only by a documented P amendment if its fixture is +the right home for an uncovered acceptance row. No catalog files in this layer. + +The enum chain is complete: creation is ROUTING_HEADER capture in +`findEnvelope`; serialization is `recoveryPayload` at line 303 plus the existing +message-type cache-key hash at line 292; deserialization/unknown handling remains +the strict envelope matcher and assignment validation at line 171; consumers are +admission, fresh recovery, cache restoration and injection. There is no persisted +enum migration. Recipient consistency remains enforced by existing envelope +validation; do not claim a new independent recipient cache-key field. + +## Activation and independent acceptance + +| Trigger | Observable acceptance | +|---|---| +| Opted-in valid MESSAGE on routed spawned Responses | One recovery request containing MESSAGE; provider receives recovered text; response succeeds. NEW_TASK remains equivalent. | +| Previously admitted ciphertext replayed after tool output or user continuation | Restored plaintext reaches actual provider body; recovery-call count does not increase. | +| Cached NEW_TASK + cached MESSAGE + distinct uncached current MESSAGE | Each known entry restores its own payload; only tail creates one fresh recovery; later replay creates no further recoveries. | +| Unknown historical ciphertext and a recoverable tail | Historical entry remains unchanged; do not claim batch history recovery. Keep existing terminal decision behavior when unsupported unreadability remains. | +| Miss, exact expiry, repeated reads before expiry | No replacement/fetch on read miss; unchanged original expiry and bounded accounting. | +| Other parent/caller/account/message type, malformed envelope or unsupported type | No cache restoration; original input remains unchanged. Existing admission negative suite stays green. | +| Recovery absent/disabled, native forward, trusted pass-through, combo attempt | Existing routing/admission behavior remains; opt-out makes no newly introduced recovery request. | +| Canonical Go non-forward plaintext text/image/file message | Public user message with original parts and readable identities; raw replay input not mutated. | +| Go forward, another destination, unknown/encrypted part, empty content | No Go conversion. Test canonical Go forward directly, not only ChatGPT forward. | +| Recovery success followed by reparse | Continuation fields survive; current route/selection and existing non-persistable-body treatment remain correct. | + +This layer must pass without #3581 or #3571. Main integrates this core change +before #3581 and coordinates any C-lane #3576 core edits. Do not use stack order +to invent a dependency on unrelated SSE/WebSocket changes; revalidate shared +core and documentation context after their integration. + +## Reviews, drift and landing handoff + +Live PR is non-draft, MERGEABLE, REVIEW_REQUIRED. GraphQL returned two resolved +threads, zero unresolved. Preserve both corrections: +[canonical destination](https://github.com/lidge-jun/opencodex/pull/3568#discussion_r3939042861) +and [forward exception](https://github.com/lidge-jun/opencodex/pull/3568#discussion_r3939864549). +The earlier four-topic maintainer review was addressed by moving catalog work to +#3571; do not restore those removed hunks. Its mixed-history concern is represented +in current original tests and the acceptance table. Sender/recipient text is +model context only. The original author reports 19,287 full-suite passes and a +live test on an equivalent local release patch; neither proves the new stack head. + +At later P, compare original feature patch against the actual parent tree, +refresh PR head/reviews and identify new exact-path overlap. Carry all three +original commits, preserve authorship and review corrections, then add focused +integration corrections separately. Main may push own stack branches with +`--no-verify` as authorized. Parent merge/squash requires child replay onto the +new dev ancestry and new head evidence; retarget children before deleting parent +branches. Close carried #3568 only after dev contains the result. Reference +#3661 as partial coverage, never `Closes #3661` for this slice. + +## Remote-only verification plan + +Planning exception to PLAN-VERIFIER-REAL-01: user forbids running tests, +typecheck/build locally and requests static workflow inspection now. Every +command here has execution status **NOT RUN**, exit code **N/A**. Later main +records remote command, exact checkout SHA, result and log URL/receipt. + +Remote execution handoff (main verified): `the isolated remote verification host` has +`REMOTE_SOURCE_CHECKOUT` and Bun 1.3.14. Main creates an isolated remote clone +and checks out the exact carried SHA; the existing checkout is a source for +setup, not a shared mutable test directory. Implementation C runs focused +activation tests and typecheck there. Carry PR remains draft until full +current-head GitHub CI is green; final landing cycle requires every full gate. +The local package pins Bun 1.4.0, so the Bun 1.3.14 focused result is supplemental +and cannot replace the workflow's configured-runtime full gates. + +In that isolated remote checkout, focused C commands are: + +```sh +bun test tests/server/server-agent-task-recovery-replay.test.ts tests/providers/opencode-go-agent-messages.test.ts tests/server/agent-task-recovery-cache.test.ts +bun test tests/server/agent-task-recovery.test.ts tests/server/agent-task-recovery-security.test.ts tests/server/agent-task-recovery-fallback.test.ts tests/server/agent-task-recovery-combo.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts +bun run typecheck +``` + +Full landing gates, on remote runners only: + +```sh +bun run test +bun run privacy:scan +bun --cwd docs-site run build +``` + +Direct test arguments observe the named target/imports; layout guards observe +both manifests. `package.json:43` defines the full test script, +`scripts/test.ts:321` adds `./tests/`, and `tsconfig.json:15` includes `src`. +The docs build is a separate remote requirement; ordinary runtime CI does not +prove prose accuracy. Review the two docs against actual adapter conditions. + +Statically verified CI coverage: `.github/workflows/ci.yml:7` has no PR-base +filter, so child PRs qualify; lines 182–186 match `src/**`, `tests/**` and +`scripts/**`. Linux line 316 calls `scripts/ci/run-bun-test-batches.sh`, whose +line 197 enumerates tests recursively and line 58 accepts `.test.ts` files. +macOS line 532 and Windows line 754 run the tests directory in shards. Lines +422–431 run typecheck and privacy. Require actual producer jobs to succeed; +green intake/aggregate checks with skipped tests are insufficient. + +Main's alternative manual CI invocation is +`gh workflow run ci.yml --repo lidge-jun/opencodex --ref OWN_LAYER_BRANCH -f lane=all`. +The workflow supports lane, not an invented expected-SHA input. Capture the run's +headSha and checkout provenance and reject stale results; PR workflows normally +test the synthetic merge ref, so record both PR head and tested merge SHA. +No workflow or runner approval was issued by this planner. diff --git a/devlog/_plan/260906_a_runtime_stack/040_affinity.md b/devlog/_plan/260906_a_runtime_stack/040_affinity.md new file mode 100644 index 0000000000..c35e7c5db2 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/040_affinity.md @@ -0,0 +1,210 @@ +# 040 — Command Code conversation affinity (#3581) + +Status: candidate implementation plan, researched 2026-09-06 KST. Depends on the +verified `030_recovery.md` layer for stack integration into its reparse owner. +This first-cycle artifact is docs only; re-read current source at this layer's P. + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Loop specification and scope + +- Class: C4 for conversation/cohort isolation; archetype: spec-satisfaction + repair. One implementation PABCD cycle owns this document. +- Trigger: repeated Command Code requests from the same identifiable conversation. + Goal: stable opaque session affinity without treating shared cache cohorts as + individual conversations; enable API-key provider cache-key forwarding. +- Non-goals: Hermes #3433 diagnosis, measured cache-hit/cost promises, OAuth + refresh changes, a global session registry, prompt-text-derived identity, + default trust for unclassified cache keys, or extra OAuth cache-key forwarding. +- Verifier: remote identity/forwarding/reparse regressions, full current-head CI, + privacy/type gates and independent boundary review. No local verifier runs. +- Stop: reviewed prepared draft atop recovery, with exact-head remote focused/type evidence. Full current-head gates and dev ancestry remain required in 060. +- Memory artifact: this file plus main-owned roadmap/ledger. Main alone owns + FSM, goal, implementation, Git and stack integration. +- Resources: existing gh credentials and later own-branch writes only. Inherited + parallel reviewers authorized; downward changes are a P amendment and main + reclaims after two distinct worker failures. No explicit user token/cost cap; + 2-hour checkpoint triggers reassessment. This planner writes only the two + assigned documents; no Git/GitHub mutations or tests/typecheck/build. +- Public scope: already-public patch behavior and general integration plan only; + new security investigation notes belong in `.tmp/`. + +## Provenance and source anchors + +Live dev/local HEAD: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. +[Original PR #3581](https://github.com/lidge-jun/opencodex/pull/3581) head and its +single feature commit: `f60397d3408e0339ffc66acdcaca8133e40866c2`, retained at +`refs/codex/a-original/3581`. Author: `SB Yoon +<44089734+yansigit@users.noreply.github.com>` (GitHub `yansigit`), original +authored date 2026-09-05T01:58:50Z. Preserve original author on carry and include +`Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com>` in any +rewritten/squashed landing. Do not rewrite the contributor's branch. + +Current source still uses `randomUUID()` unconditionally at +`src/adapters/command-code.ts:528`. The helper insertion owner is the same file +after `projectSlug` at line 212. Current `src/server/responses/core.ts:2954` +parses the request, line 2987 assigns inbound thread id, lines 2990–3004 classify +the separate replay scope, and line 3262 preserves fields after recovery. +`src/types/request.ts:71` holds `_clientThreadId` without the proposed cohort +field. `src/providers/registry.ts:2169` is API-key `commandcode`; line 1332 is +OAuth `command-code`. They are separate transport contracts. + +Owner searches: `commandCodeSessionId`, `promptCacheKeyIsSharedCohort`, +`prompt_cache_key`, `_clientThreadId`, `_reasoningReplayScope`. Existing +classification, provider derivation and Chat serialization already exist; reuse +them. `src/providers/xai-transport.ts:101` has a different provider's derivation; +do not reuse its namespace/contract for Command Code. Doing nothing retains +random affinity, configuration cannot change the header builder, and no matching +Command Code helper exists. No new cache/service dependency is warranted. + +## Exact implementation change map + +| Action / path | Before → planned after | +|---|---| +| MODIFY `src/adapters/command-code.ts` | Import `createHash` alongside `randomUUID`; add exported `commandCodeSessionId(parsed)`. Select trimmed `_clientThreadId`, else trimmed replay `clientThreadId`, else trimmed `options.promptCacheKey` only when cohort marker is exactly false. With no identity return randomUUID. Hash `command-code:${kind}\0${identity}` with SHA-256 and form the original opaque UUID-shaped value, preserving explicit version/variant nibble comment. Use helper for x-session-id. | +| MODIFY `src/types/request.ts` | Add optional internal `_promptCacheKeyIsSharedCohort?: boolean` beside `_clientThreadId`; document true=shared, false=explicitly conversation-scoped, absent=unclassified. Do not expose it as a client JSON input field. | +| MODIFY `src/server/responses/core.ts` | Immediately after initial `parseRequest(body)`, copy `options.promptCacheKeyIsSharedCohort` onto parsed internal marker. Add marker to the existing `kept` list in recovery reparse, now containing #3568 restoration. Preserve all sibling fields and both true and false values (undefined-only filtering). | +| MODIFY `src/providers/registry.ts` | Add `promptCacheKey: true` only to `commandcode` API-key provider. Leave OAuth `command-code` transport setting unchanged. | +| MODIFY `tests/providers/command-code-provider.test.ts` | Carry stable/opaque identity, precedence, different-identity, UUID shape and random fallback tests; add whitespace-only fallback and same literal under different identity-kind cases. Assert actual built x-session-id as well as helper output. | +| MODIFY `tests/providers/commandcode-provider.test.ts` | Extend registry expectation and construct real Chat request with promptCacheKey, asserting prompt_cache_key body forwarding. Retain explicit disabled-provider override behavior. | +| MODIFY `tests/claude-integration/claude-code-thought-signature-scope.test.ts` | Carry true/false/undefined propagation assertions in existing drive helper; retain the independent replay-scope expectations. | +| MODIFY `tests/server/server-agent-task-recovery-replay.test.ts` | Parent-layer test file exists after 030. Add real handler/adaptor-boundary observation of marker preservation for recovery and cache-only restoration, with true/false/undefined cases. Use the existing fixture/post helper; no source-text assertion as a substitute for executing reparse. | +| MODIFY `docs-site/src/content/docs/reference/adapters.md` | Add a concise Command Code subsection describing OAuth x-session-id priority/random fallback and API-key commandcode prompt_cache_key forwarding separately; no cache-performance promise. This is a docs-sync addition beyond the original seven-file patch. | + +No NEW or DELETE production/test files. The modified replay test is owned by the +parent layer and already registered there. No layout manifest update is needed +for modifying it. Keep the new helper in its existing adapter: no parallel +factory registration or session cache. `structure/10_adapter-registry.md:5` +remains authoritative and needs no factory-policy change; adapters.md is the +user-visible contract sync target. + +## Explicit handler-fixture amendment + +MODIFY `tests/helpers/agent-task-recovery.ts:144-159`: extend the sixth `post` options argument with `promptCacheKeyIsSharedCohort?: boolean`, and forward it to the fourth `handleResponses` options argument alongside abortSignal and translatorBudget. Do not put this internal field in the JSON request body. Existing callers default to undefined and remain unchanged. + +MODIFY `tests/server/server-agent-task-recovery-replay.test.ts`: parameterize true/false/undefined, use the extended `post` helper for an initial admitted recovery and a continued cache-only replay, and observe the parsed request at the real selected adapter buildRequest boundary via a temporary spy restored after each test. Assert the exact internal marker and existing thread/replay metadata on both calls; assert only one recovery backend call. The later P must bind the spy to the actual exported adapter selector in that carried tree. A source-text assertion is not an alternative to the real reparse execution. + +## Complete field and value chain + +1. Creation: `src/server/claude-messages.ts:836` passes + `promptCacheKeyIsSharedCohort: cacheKeySource === "system"` into + `HandleResponsesOptions` (`core.ts:1548`). The new initial-parse assignment + carries true/false/undefined unchanged. `_clientThreadId` and replay scope use + their existing ingress owners; do not infer new trust from request content. +2. Internal transfer: `OcxParsedRequest` optional field and the `kept` list copy + it across `parseRequest` after both fresh and cached recovery. It is process + request metadata, not persisted configuration or continuation data. +3. Serialization/deserialization: the internal marker has no wire representation + and no persisted migration (N/A intentionally). `parseRequest` at + `src/responses/parser.ts:526` already maps public prompt_cache_key into options; + clients cannot supply the internal classification through that mapping. +4. Consumers: `commandCodeSessionId` permits the cache-key fallback only for + `=== false`; true/undefined both fail closed. Existing replay/cohort consumers + at `core.ts:2990`, `core.ts:3560` and + `src/oauth/anthropic-routing.ts:781` keep their distinct semantics; do not + broaden/rewrite those predicates as incidental cleanup. +5. Provider capability chain: registry promptCacheKey → + `src/providers/derive.ts:252` defaults and line 512 reconciliation → routed + provider config → `src/adapters/openai-chat.ts:1573` serialization (and raw + body forwarding at line 156). Original API-key regression observes the wire + body, rather than only asserting registry metadata. + +## Activation and independent acceptance + +| Trigger | Required observation | +|---|---| +| Same trimmed explicit thread, differing replay/cache values | Same opaque x-session-id in actual built requests; thread wins. | +| No explicit thread, same trimmed replay identity | Stable header; changing replay identity changes it. | +| Neither thread nor replay, nonempty key with marker false | Stable cache-derived header; whitespace trimmed. | +| Same literal in thread/replay/cache namespaces | Different opaque values by kind; preserve original hash namespace. | +| Shared=true or unclassified marker, only cache key/prompt text | Fresh UUID each request; no prompt/body-derived identity. | +| Empty/whitespace identity or no identity | Random fallback, no accidental stable empty-string cohort. | +| Explicit thread with shared=true | Explicit thread remains valid; shared classification disqualifies only cache fallback. | +| Initial parse then successful fresh or cache-only recovery reparse | Adapter observes original true/false/undefined marker and original thread/replay metadata; stable affinity semantics survive. | +| API-key commandcode using route-derived config | Chat body carries prompt_cache_key when present/enabled; absent key or explicit disabled capability omits it. | +| OAuth command-code | Uses proprietary x-session-id builder; this patch does not opt its registry entry into Chat cache-key forwarding. | +| Synthetic raw identity strings | Header matches UUID-shaped contract and contains no raw identity. No added identity logging. | + +C must drive both the helper and real adapter/handler paths. This plan claims a +stable request header, not proven provider cache savings or a provider guarantee +that distinct sessions receive distinct workers. Any credentialed live provider +smoke needs main's chosen authorized runtime scope; a synthetic wire test is not +misreported as real upstream acceptance. + +## Review disposition, drift and stack order + +Live PR is non-draft, MERGEABLE, REVIEW_REQUIRED. GraphQL has zero review threads; +there is no current formal approval. The author already incorporated UUID +nibble explanation and retained API-key-only forwarding/unclassified-key +fallback in the original head. Latest +[author update](https://github.com/lidge-jun/opencodex/pull/3581#issuecomment-5549518114) +reports 18,244 passes on `be81013fa` base; those historical results do not validate +the current parent tree. Older draft/failure commentary is superseded. + +The original patch context predates the current core: original initial-parse +line 2896 is now 2954 and original reparse area around 3210 is now 3262. Carry by +function/field ownership; never replace current core with the older file. Refresh +onto the completed 030 layer and preserve both restoration behavior and the new +cohort marker. Coordinate the shared core with C-lane #3576 through main. This +is not a fix for #3433 and must not close that issue. + +Main publishes a child PR targeting the recovery branch if that PR is still +open; after parent squash/merge, replay only this layer onto dev and retarget. +Revalidate exact diff, review and CI for every new head. Original contributor +credit survives cherry-pick/reimplementation/squash. Own-branch `--no-verify` +pushes are authorized; local prepush hooks must not start a suite. Close original +#3581 once the equivalent change is proven on dev; do not close merely because +a carrying child PR exists. No Git/GitHub action is performed by this planner. + +## Remote-only verification and CI coverage + +All commands below: **NOT RUN, exit N/A during planning**, per explicit user +instruction. Later main runs them only in the remote checkout of the exact layer +and records SHA, command result and artifact/CI URL. + +Main verified `REMOTE_HOST:REMOTE_SOURCE_CHECKOUT` and Bun 1.3.14. Use an isolated +remote clone at the exact carried SHA for focused activation tests/typecheck; +do not mutate the existing remote checkout for this layer. Its Bun version +differs from package.json's 1.4.0 pin, so this is supplemental evidence. Carry +PR stays draft until full current-head GitHub CI is green. Final landing cycle +requires every full gate on the configured remote runners. + +Implementation C, in the isolated remote clone: + +```sh +bun test tests/providers/command-code-provider.test.ts tests/providers/commandcode-provider.test.ts tests/claude-integration/claude-code-thought-signature-scope.test.ts tests/server/server-agent-task-recovery-replay.test.ts +bun run typecheck +``` + +Full landing gates, remote only: + +```sh +bun run test +bun run privacy:scan +bun --cwd docs-site run build +``` + +Focused direct arguments cover identity selection, actual request headers/body, +cohort propagation and parent recovery interaction. `tsconfig.json:15` includes +src; `package.json:43` maps full suite to `scripts/test.ts`, whose line 321 adds +`./tests/`. No claim that typecheck covers prose. Docs require remote build plus +manual comparison of actual transport semantics. + +Static workflow proof: `.github/workflows/ci.yml:7` permits child PR bases; +lines 182–186 select runtime/tests, line 316 runs Linux batches, and +`scripts/ci/run-bun-test-batches.sh:197` recursively enumerates tests (accepted +suffixes at line 58). macOS line 532 and Windows line 754 cover tests shards. +Lines 422–431 run typecheck/privacy. Thus this runtime layer should activate +real jobs even though its parent is not dev. Runtime CI does not guarantee the +new documentation subsection's accuracy; review it explicitly. + +Optional later manual dispatch: +`gh workflow run ci.yml --repo lidge-jun/opencodex --ref OWN_LAYER_BRANCH -f lane=all`. +Record run headSha and actual checkout SHA; this workflow exposes only lane, +not expected-SHA pinning. For PR CI record current PR head and synthetic merge +SHA. Require completed successful producer jobs and independent review of the +current patch; author-reported tests, skipped producers, stale green heads and +hygiene checks cannot complete this layer. diff --git a/devlog/_plan/260906_a_runtime_stack/050_capabilities.md b/devlog/_plan/260906_a_runtime_stack/050_capabilities.md new file mode 100644 index 0000000000..b545f21bae --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/050_capabilities.md @@ -0,0 +1,684 @@ +# 050 — Effective provider capabilities (#3671) + +## Implementation-cycle completion versus landing + +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. + + +## Candidate implementation contract + +Status: candidate planning, not implementation or merge approval. Revalidate at this layer's later P after its lower stack layer lands. This document is the delegated docs-only deliverable; the main agent owns roadmap registration, FSM, goal state, branch integration, CI dispatch and closure. + +- Class: C4 for the policy-boundary slice, based on the public PR's requested security review. Archetype: spec-satisfaction repair. +- Trigger: routing policy capability evidence must describe the effective provider dispatch uses, including unavailability. +- Goal: runtime selection and ordinary management dry-run agree on effective transport capabilities and exclude unresolved, missing, or disabled providers before scoring. +- Non-goals: new provider metadata, registry precedence redesign, catalog UI, OAuth refresh, request transport changes, Lab activation changes, release operations, or changing caller-supplied synthetic dry-run evidence semantics. +- Verifier: remote focused routing/API regressions plus exact-head full Cross-platform CI and a remote documentation build. No local tests, typecheck, builds, or verifier execution in this planning assignment. +- Stop: independently working reviewed draft with original authorship and exact-head remote focused/type evidence. Full current-head gates/dev ancestry remain required by 060. +- Memory artifact: this document and the main-owned roadmap/evidence ledger. +- Outcomes: DONE only after verified dev integration; NOOP only if current dev independently contains all behavior and regressions; BLOCKED for unavailable external CI/credentials; NEEDS_HUMAN/UNSAFE for a policy decision outside authorization; a resource checkpoint is reassessment, never fabricated completion. +- Delegation: inherited parallel read-only reviewers are authorized. Main reclaims a packet after two distinct failed workers; further write delegation requires a P amendment with exact ownership. +- Resources: existing gh credentials; future writes confined to the main's own stack branches and explicitly authorized PR/issue integration. This worker writes only this document. No explicit user token/cost cap. A two-hour checkpoint triggers reassessment and an evidence update. No deployment, account-state operation or provider request is necessary. + +## Provenance and refresh gate + +Inspected September 6, 2026 KST using read-only `gh pr view`, `gh api` reviews/workflow runs, `git show`, and `git diff`. + +- Public PR: https://github.com/lidge-jun/opencodex/pull/3671 +- Exact original head: `7b1beb9c5eacd8dde22681a5df26804be52380b8`. +- Stable source ref: `refs/codex/a-original/3671`; old `origin/a-original-*` refs were pruned by parallel workers and must not be relied upon. +- Original base: `6585e6a70f42be8b6c81ff20d4fa0f39f7da03db`. +- Inspected current dev/tree: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. +- Original commits, oldest first: `2b1e0e00c12d7287f9324a4a39ec7e966712affe` (effective capability evidence); `7b1beb9c5eacd8dde22681a5df26804be52380b8` (unresolved transport exclusion). +- Both commits authored by **Hako <25837994+devswha@users.noreply.github.com>**, GitHub `@devswha`. Preserve those authors when carrying commits. Any squash/reimplementation and the carrying PR must retain `Co-authored-by: Hako <25837994+devswha@users.noreply.github.com>` so attribution survives integration. +- A read-only diff of original base against inspected dev shows no drift in the eight original touched files. This is a snapshot, not a promise about the later stack parent. +- #3679's refreshed source head is `b05cccf264b4ab61db5d8dee8232c2f89bb1b541`; it does not replace #3671 provenance. Re-read parent changes and resolve integration ownership at later P. + +At later P, compare live PR head, stable ref, actual stack parent and dev tip. Inspect each named source hunk and public review again. If any changed, amend this document before carrying the patch. Treat stacked ordering as a user-requested integration constraint; #3671 does not need #3568/#3581 runtime code to function and must be independently testable. + +## Source ownership and before/after map + +Reuse the existing `routedProviderConfig` callback seam; no new resolver, registry, server endpoint or config option is required. Doing nothing leaves policy and effective transport divergent; changing configured URLs or deleting capability checks does not fix the contract; duplicating registry logic creates drift. + +| Operation | Exact path | Before → after | +|---|---|---| +| MODIFY | `src/routing/capability.ts` | Lines 153–160 read raw config plus registry by name → optional resolved-provider argument is authoritative; name-only registry fallback applies only to legacy three-argument callers. Add provider-wide reasoning ladder at lines 223–227, retaining no-reasoning precedence. | +| MODIFY | `src/routing/compatibility/assemble.ts` | Lines 52–60 derive capabilities directly → resolve each active configured candidate through the supplied callback, emit bounded unavailability state on missing/disabled/throw, and compute capabilities only from a resolved provider. | +| MODIFY | `src/routing/evaluator.ts` | Evidence type near line 54 and eligibility lines 280–313 lack transport status → optional `routeResolutionFailed`, `route-unavailable` exclusion and hard eligibility gate independent of unknown policy. | +| MODIFY | `tests/routing/routing-capability-model-matching.test.ts` | Existing model-family tests → retain them and add the complete original effective-transport regression group plus missing/disabled selection regressions below. | +| MODIFY | `tests/routing/routing-profile.test.ts` | Existing management dry-run parity fixture at line 446 → add ordinary dry-run missing/disabled candidate matrix without injected `candidates`. | +| MODIFY | `docs-site/src/content/docs/guides/routing-profile-editor.md` | Dry-run section near line 39 lacks effective transport contract → original explanation plus explicit missing/disabled exclusion. | +| MODIFY | `docs-site/src/content/docs/fr/guides/routing-profile-editor.md` | Same change in French near line 38, preserving corrected typographic apostrophe. | +| MODIFY | `docs-site/src/content/docs/tr/guides/routing-profile-editor.md` | Same change in Turkish near line 53. Original trailing blank-line removal is incidental. | +| MODIFY | `docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md` | Same change in Traditional Chinese near line 33. | +| MODIFY | `structure/01_runtime.md` | Router ownership row at line 18 says selection only → describe shared effective-provider evidence and hard unavailable-candidate exclusion. | +| NEW | None in production/tests | Existing test files already have layout entries; do not add layout manifest churn. | + +Only this plan file is created now. The future layer has ten MODIFY paths. General SOT follows `structure/01_runtime.md`; user-facing truth remains the routing guide. No unpublished investigation details belong in this public unit. + +Read-only caller proof: `src/router.ts:299` owns effective registry transport/metadata; `src/router.ts:622` supplies it to assembly and line 625 evaluates; lines 626–635 route the selected provider or throw. `src/server/management/routing-profile-routes.ts:100` supplies the same resolver; lines 384–390 use assembly when `body.candidates` is absent. Preserve the synthetic-evidence branch. `src/routing/capability.ts:130` classifies effective locality; lines 179–193 preserve no-vision precedence. Core/Lab imports remain behind the existing provider slot (`assemble.ts:45`), with no new import of router from assembly. + +## Public review disposition + +Two prior findings are resolved in original head: French typography and thrown route resolution under permissive unknown policy. One remains open: https://github.com/lidge-jun/opencodex/pull/3671#discussion_r3941006079 . At original-head `assemble.ts:57`, missing/disabled providers skip the resolver but leave failure false. Set the initial state to `!provider || provider.disabled === true` and prove both ordinary dry-run and runtime selection. Do not resolve the review on the basis of this plan. + +Current original-head Cross-platform CI run `33973108478` and React Doctor run `33973108496` have conclusion `action_required`; label/hygiene/target success is not product verification. The PR body reports focused successes and a timeout-adjusted affected run, but explicitly does not claim a green default full suite. No such reported run is accepted as this carried layer's verification. Maintainer approval and explicit security review remain pending under `MAINTAINERS.md:57–61`. + +## Exact original carry diff + +Apply this public source patch as one coherent layer, preserving both original commits/author identity. The subsequent corrections below are required in the same layer before review readiness. This is recorded patch text, not an instruction to run local Git mutations during planning. + +````diff +diff --git a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md +index b437c28b3..84b4410f7 100644 +--- a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md ++++ b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md +@@ -37,6 +37,13 @@ résultat du plafond. + + ## Simuler un profil enregistré + ++Les capacités des candidats utilisent la configuration effective du fournisseur, ++après application du registre. Les exigences de localité (`localOnly` et ++`remoteAllowed`) utilisent donc l’adresse amont effective. Si elle ne peut pas être ++classée, `unknownEvidence.capability` détermine l’admissibilité du candidat. ++Une configuration de fournisseur invalide qui ne peut pas être résolue est toujours ++exclue avec `route-unavailable`, même si les capacités inconnues sont autorisées. ++ + Sélectionnez un profil enregistré et utilisez **Évaluation à sec** pour ajouter des éléments propres à la requête, tels que la taille de la fenêtre de contexte, l’utilisation d’outils, l’entrée d’images ou la sortie structurée. La simulation évalue l’admissibilité et la notation, mais n’envoie jamais de requête à un modèle en amont. + + Les modifications non enregistrées ne sont pas prises en compte par la simulation. Enregistrez d’abord le profil afin que la révision et l’évaluation affichées correspondent à la même configuration. +diff --git a/docs-site/src/content/docs/guides/routing-profile-editor.md b/docs-site/src/content/docs/guides/routing-profile-editor.md +index 5cf5fc6d7..7931f29ad 100644 +--- a/docs-site/src/content/docs/guides/routing-profile-editor.md ++++ b/docs-site/src/content/docs/guides/routing-profile-editor.md +@@ -38,6 +38,13 @@ cap outcome. + + ## Dry-run a saved profile + ++Candidate capabilities use the effective provider configuration after registry ++overrides are applied. Locality requirements (`localOnly` and `remoteAllowed`) ++therefore use the effective upstream address. If that address cannot be classified, ++the profile's `unknownEvidence.capability` setting decides eligibility. ++An invalid provider configuration that cannot be resolved is always excluded with ++`route-unavailable`, even when unknown capabilities are allowed. ++ + Select a saved profile and use **Dry-run evaluation** to add request evidence such as context-window size, tool use, image input, or structured output. Dry-run evaluates eligibility and scoring but never sends an upstream model request. + + Unsaved edits are not used by dry-run. Save the profile first so the displayed revision and evaluation refer to the same configuration. +diff --git a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md +index dd7aa50d7..ec75bdd17 100644 +--- a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md ++++ b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md +@@ -52,6 +52,13 @@ ayrıdır. + + ## Kaydedilmiş bir profilde deneme çalıştırması (dry-run) yapma + ++Aday yetenekleri, kayıt defteri kuralları uygulandıktan sonraki etkin sağlayıcı ++yapılandırmasını kullanır. Yerellik gereksinimleri (`localOnly` ve `remoteAllowed`) ++bu nedenle etkin üst sunucu adresine göre değerlendirilir. Adres sınıflandırılamıyorsa, ++adayın uygunluğunu profilin `unknownEvidence.capability` ayarı belirler. ++Çözümlenemeyen geçersiz sağlayıcı yapılandırmaları, bilinmeyen yeteneklere izin ++verilse bile `route-unavailable` ile her zaman dışlanır. ++ + Kaydedilmiş bir profili seçin ve bağlam penceresi boyutu, araç kullanımı, görsel + girişi veya yapılandırılmış çıktı gibi istek kanıtları eklemek için **Deneme + çalıştırması değerlendirmesi (Dry-run evaluation)**'ı kullanın. Deneme +@@ -99,5 +106,3 @@ Düzenleyici şu uç noktaları kullanır: + } + } + ``` +- +- +diff --git a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md +index e6ae93a76..0b54e70d5 100644 +--- a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md ++++ b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md +@@ -32,6 +32,9 @@ OpenCodex 儀表板中的 **Models → Routing** 分頁可以直接管理 `confi + + ## 試跑已儲存的設定檔 + ++候選能力使用套用 registry 覆寫後的有效供應商設定。因此,本地性需求(`localOnly` 與 `remoteAllowed`)會依據實際上游位址判定。若無法分類該位址,則由設定檔的 `unknownEvidence.capability` 決定候選是否合格。 ++無法解析的無效供應商設定一律以 `route-unavailable` 排除,即使原則允許未知能力也是如此。 ++ + 選取一個已儲存的設定檔,使用 **Dry-run evaluation** 加入請求證據,例如 context-window 大小、工具使用、圖片輸入或結構化輸出。試跑會評估資格與評分,但永遠不會送出上游模型請求。 + + 未儲存的編輯不會被試跑使用。請先儲存設定檔,讓顯示的 revision 與評估參照同一份設定。 +diff --git a/src/routing/capability.ts b/src/routing/capability.ts +index 8495951a0..7f26e8bbd 100644 +--- a/src/routing/capability.ts ++++ b/src/routing/capability.ts +@@ -10,7 +10,7 @@ + * how that affects eligibility. + */ + +-import { modelInList, type OcxConfig } from "../types"; ++import { modelInList, type OcxConfig, type OcxProviderConfig } from "../types"; + import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; + import { serviceTierSupportForModel } from "../providers/service-tier"; + import { PROVIDER_REGISTRY } from "../providers/registry"; +@@ -149,14 +149,20 @@ function localRemoteEvidence(baseUrl: string | undefined): Pick entry.id === providerName); ++ const provider = resolvedProvider ?? config.providers[providerName]; ++ const registryEntry = resolvedProvider === undefined ++ ? PROVIDER_REGISTRY.find(entry => entry.id === providerName) ++ : undefined; + const catalogRow = cachedCatalogModels().find(model => model.provider === providerName && model.id === modelId); + const isNative = providerName === OPENAI_CODEX_PROVIDER_ID && !modelId.includes("/"); + +@@ -224,6 +230,7 @@ export function candidateCapabilityEvidence( + ? [] + : modelRecordValue(provider?.modelReasoningEfforts, modelId) + ?? modelRecordValue(registryEntry?.modelReasoningEfforts, modelId) ++ ?? provider?.reasoningEfforts + ?? (isNative ? nativeReasoningEfforts(modelId) : undefined); + + const tierSupport = provider +diff --git a/src/routing/compatibility/assemble.ts b/src/routing/compatibility/assemble.ts +index 1d543690a..d7cebc94b 100644 +--- a/src/routing/compatibility/assemble.ts ++++ b/src/routing/compatibility/assemble.ts +@@ -52,11 +52,26 @@ export function assemblePolicyCandidateEvidence( + return profile.candidates.map(candidate => { + const key = `${candidate.provider}/${candidate.model}`; + const compatibility = compatibilityByCandidate?.get(key); ++ const provider = config.providers[candidate.provider]; ++ let routed: OcxProviderConfig | undefined; ++ let routeResolutionFailed = false; ++ if (provider && provider.disabled !== true) { ++ try { ++ routed = options.routedProviderConfig(candidate.provider, provider); ++ } catch { ++ // This is known unavailability, not unknown capability evidence. Keep ++ // the failure separate so permissive unknown policies cannot select it. ++ routeResolutionFailed = true; ++ } ++ } + + return { + provider: candidate.provider, + model: candidate.model, +- capability: candidateCapabilityEvidence(config, candidate.provider, candidate.model), ++ ...(routeResolutionFailed ? { routeResolutionFailed: true } : {}), ++ capability: routed ++ ? candidateCapabilityEvidence(config, candidate.provider, candidate.model, routed) ++ : undefined, + health: policyCandidateHealthEvidence(config, candidate, now), + quota: quotaEvidenceForCandidate({ + provider: candidate.provider, +diff --git a/src/routing/evaluator.ts b/src/routing/evaluator.ts +index a07b83306..7cf801bfe 100644 +--- a/src/routing/evaluator.ts ++++ b/src/routing/evaluator.ts +@@ -54,6 +54,8 @@ export interface PolicyCandidateEvidence { + accountRef?: string; + /** Codex pool account id (provider "openai"); used to derive account-scoped quota evidence. */ + codexAccountId?: string; ++ /** A failed effective-transport resolution excludes the candidate under every unknown policy. */ ++ routeResolutionFailed?: boolean; + capability?: RouteCapabilityEvidence; + health?: RouteHealthEvidence; + quota?: RouteQuotaEvidence; +@@ -278,6 +280,8 @@ export function evaluatePolicyProfile( + ...requestRequirementFor(requestEvidence, evidence.capability), + ]; + const exclusions: RouteExclusionReason[] = []; ++ const routeUnavailable = evidence.routeResolutionFailed === true; ++ if (routeUnavailable) exclusions.push({ code: "route-unavailable" }); + const bad = unsatisfiedOrUnknown(requirements); + for (const requirement of bad) { + if (requirement.outcome === "unsatisfied") { +@@ -310,7 +314,7 @@ export function evaluatePolicyProfile( + if (unknownCostBlocked) { + exclusions.push({ code: "cost-limit-unknown", detail: "maxEstimatedCostUsd" }); + } +- let eligible = !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked; ++ let eligible = !routeUnavailable && !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked; + + // Trace/dry-run copy only: report the profile cap that was applied and the + // operator-visible outcome. Do not feed this copy into costScore() — that +diff --git a/tests/routing/routing-capability-model-matching.test.ts b/tests/routing/routing-capability-model-matching.test.ts +index bb956c2d8..509eeec2e 100644 +--- a/tests/routing/routing-capability-model-matching.test.ts ++++ b/tests/routing/routing-capability-model-matching.test.ts +@@ -1,10 +1,19 @@ +-import { describe, expect, test } from "bun:test"; ++import { afterEach, beforeEach, describe, expect, test } from "bun:test"; ++import { mkdtempSync } from "node:fs"; ++import { tmpdir } from "node:os"; ++import { join } from "node:path"; ++import { validateConfigCandidate } from "../../src/config"; ++import { NoEligiblePolicyCandidateError, routeModel, routedProviderConfig } from "../../src/router"; + import { candidateCapabilityEvidence } from "../../src/routing/capability"; ++import { assemblePolicyCandidateEvidence } from "../../src/routing/compatibility/assemble"; + import { evaluatePolicyProfile } from "../../src/routing/evaluator"; ++import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; ++import { getRoutingProfile } from "../../src/routing/profile"; + import { PROVIDER_REGISTRY } from "../../src/providers/registry"; + import { modelRecordValue } from "../../src/reasoning-effort"; + import { isModelTextOnly } from "../../src/vision"; +-import type { OcxConfig, OcxProviderConfig } from "../../src/types"; ++import type { OcxConfig, OcxProviderConfig, OcxRoutingProfileConfig } from "../../src/types"; ++import { removeTreeWithRetry } from "../helpers/remove-tree"; + + /** + * `candidateCapabilityEvidence` describes what the resolver will do with a candidate, +@@ -35,6 +44,224 @@ function configFor(provider: OcxProviderConfig): OcxConfig { + return { providers: { custom: provider } } as unknown as OcxConfig; + } + ++describe("policy capability evidence uses the effective provider", () => { ++ let testDir: string; ++ let previousHome: string | undefined; ++ ++ beforeEach(() => { ++ previousHome = process.env.OPENCODEX_HOME; ++ testDir = mkdtempSync(join(tmpdir(), "ocx-effective-capability-")); ++ process.env.OPENCODEX_HOME = testDir; ++ }); ++ ++ afterEach(() => { ++ closeRequestHistoryIndex(); ++ if (previousHome === undefined) delete process.env.OPENCODEX_HOME; ++ else process.env.OPENCODEX_HOME = previousHome; ++ removeTreeWithRetry(testDir); ++ }); ++ ++ function policyConfig( ++ name: string, ++ provider: OcxProviderConfig, ++ model: string, ++ require: OcxRoutingProfileConfig["require"], ++ ): OcxConfig { ++ const result = validateConfigCandidate({ ++ port: 10100, ++ defaultProvider: name, ++ providers: { [name]: provider }, ++ routingProfiles: { guarded: { candidates: [{ provider: name, model }], require } }, ++ }); ++ if (!result.ok) throw new Error(result.error); ++ return result.config; ++ } ++ ++ const localOnly = { localOnly: true, remoteAllowed: false }; ++ const loopback = "http://127.0.0.1:11434/v1"; ++ ++ test("a loopback URL discarded by registry routing cannot satisfy a local-only policy", () => { ++ const config = policyConfig("deepseek", { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "deepseek-v4-flash", localOnly); ++ const before = structuredClone(config); ++ ++ expect(routeModel(config, "deepseek/deepseek-v4-flash").provider.baseUrl) ++ .toBe("https://api.deepseek.com"); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ expect(config).toEqual(before); ++ }); ++ ++ test.each(["custom-local", "ollama"])("a genuine local %s endpoint remains eligible", name => { ++ const config = policyConfig(name, { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "local-model", localOnly); ++ const before = structuredClone(config); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.providerName).toBe(name); ++ expect(route.provider.baseUrl).toBe(loopback); ++ expect(route.routeDecision?.requirements).toEqual([ ++ { id: "local-only", expected: true, actual: true, outcome: "satisfied" }, ++ { id: "remote-allowed", expected: false, actual: false, outcome: "satisfied" }, ++ ]); ++ expect(config).toEqual(before); ++ }); ++ ++ test("an explicitly public endpoint remains ineligible for a local-only policy", () => { ++ const config = policyConfig("deepseek", { ++ adapter: "openai-chat", baseUrl: "https://api.deepseek.com", ++ }, "deepseek-v4-flash", localOnly); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("a local candidate is selected after excluding a registry-pinned remote candidate", () => { ++ const config = policyConfig("deepseek", { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "deepseek-v4-flash", localOnly); ++ config.providers.local = { adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true }; ++ config.routingProfiles!.guarded!.candidates.push({ provider: "local", model: "local-model" }); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.providerName).toBe("local"); ++ expect(route.provider.baseUrl).toBe(loopback); ++ expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); ++ }); ++ ++ test("registry no-vision defaults participate before policy image requirements", () => { ++ const config = policyConfig("deepseek", { ++ adapter: "openai-chat", baseUrl: "https://api.deepseek.com", ++ modelInputModalities: { "deepseek-v4-flash": ["text", "image"] }, ++ }, "deepseek-v4-flash", { imageInput: true }); ++ const routed = routeModel(config, "deepseek/deepseek-v4-flash"); ++ expect(isModelTextOnly(routed.provider, routed.modelId)).toBe(true); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("the effective model context ceiling gates a policy requirement", () => { ++ const config = policyConfig("openai-apikey", { ++ adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", ++ modelContextWindows: { "gpt-6-astra": 2_000_000 }, ++ }, "gpt-6-astra", { minContextWindow: 1_500_000 }); ++ const routed = routeModel(config, "openai-apikey/gpt-6-astra"); ++ expect(routed.provider.modelContextWindows?.["gpt-6-astra"]).toBe(1_050_000); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("canonical forward auth filled by routing satisfies the encrypted-task requirement", () => { ++ const config = policyConfig("openai", { ++ adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", ++ }, "gpt-5.5", { encryptedCodexTasks: true }); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.provider.authMode).toBe("forward"); ++ expect(route.routeDecision?.candidates[0]?.capability?.encryptedCodexTasks).toBe(true); ++ expect(config.providers.openai!.authMode).toBeUndefined(); ++ }); ++ ++ test("the effective provider-wide reasoning ladder participates in policy selection", () => { ++ const config = policyConfig("xiaomi-mimo", { ++ adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1", ++ }, "mimo-v2.5", { reasoningEffort: "high" }); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.provider.reasoningEfforts).toEqual(["low", "medium", "high"]); ++ expect(route.routeDecision?.candidates[0]?.capability?.reasoningEfforts) ++ .toEqual(["low", "medium", "high"]); ++ expect(config.providers["xiaomi-mimo"]!.reasoningEfforts).toBeUndefined(); ++ }); ++ ++ test("a same-named custom transport does not inherit an unrelated registry model map", () => { ++ const config = policyConfig("meta-model", { ++ adapter: "openai-responses", baseUrl: "https://custom.example/v1", ++ }, "muse-spark-1.3", { reasoningEffort: "high" }); ++ const routed = routeModel(config, "meta-model/muse-spark-1.3"); ++ expect(routed.provider.baseUrl).toBe("https://custom.example/v1"); ++ expect(routed.provider.modelReasoningEfforts).toBeUndefined(); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("an invalid unselected transport cannot prevent a healthy sibling from routing", () => { ++ const config = policyConfig("local", { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "local-model", {}); ++ config.providers.ollama = { adapter: "openai-chat", baseUrl: " " }; ++ config.routingProfiles!.guarded!.candidates.push({ provider: "ollama", model: "local-model" }); ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.providerName).toBe("local"); ++ expect(route.provider.baseUrl).toBe(loopback); ++ expect(route.routeDecision?.candidates[1]?.capability).toBeUndefined(); ++ }); ++ ++ test("an unresolved transport contributes no positive capability evidence", () => { ++ const config = policyConfig("ollama", { ++ adapter: "openai-chat", baseUrl: loopback, ++ modelInputModalities: { "local-model": ["text", "image"] }, ++ }, "local-model", { imageInput: true }); ++ config.providers.ollama!.baseUrl = " "; ++ ++ const evidence = assemblePolicyCandidateEvidence(config, getRoutingProfile(config, "guarded")!, Date.now(), { ++ routedProviderConfig, ++ }); ++ expect(evidence[0]?.capability).toBeUndefined(); ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ }); ++ ++ test("missing and disabled providers are not resolved for capability evidence", () => { ++ const config = policyConfig("local", { ++ adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, ++ }, "local-model", { tools: true }); ++ config.providers.disabled = { ...config.providers.local!, disabled: true }; ++ config.routingProfiles!.guarded!.candidates.push( ++ { provider: "missing", model: "model" }, ++ { provider: "disabled", model: "model" }, ++ ); ++ const resolved: string[] = []; ++ const evidence = assemblePolicyCandidateEvidence(config, getRoutingProfile(config, "guarded")!, Date.now(), { ++ routedProviderConfig: (name, provider) => { ++ resolved.push(name); ++ return routedProviderConfig(name, provider); ++ }, ++ }); ++ ++ expect(resolved).toEqual(["local"]); ++ expect(evidence[0]?.capability?.tools).toBe(true); ++ expect(evidence[1]?.capability).toBeUndefined(); ++ expect(evidence[2]?.capability).toBeUndefined(); ++ }); ++ ++ test.each(["allow", "penalize", "exclude"] as const)( ++ "an unresolved first candidate is excluded when unknown capabilities are %s", ++ capability => { ++ const config = policyConfig("ollama", { ++ adapter: "openai-chat", baseUrl: loopback, ++ }, "local-model", {}); ++ config.providers.ollama!.baseUrl = " "; ++ config.providers.local = { adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true }; ++ const profile = config.routingProfiles!.guarded!; ++ profile.candidates.push({ provider: "local", model: "local-model" }); ++ profile.unknownEvidence = { ...profile.unknownEvidence, capability }; ++ ++ const route = routeModel(config, "policy/guarded"); ++ expect(route.providerName).toBe("local"); ++ expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); ++ expect(route.routeDecision?.candidates[0]?.exclusions).toContainEqual({ code: "route-unavailable" }); ++ expect(JSON.stringify(route.routeDecision)).not.toContain("Invalid baseUrl"); ++ }, ++ ); ++ ++ test("all unresolved candidates produce a policy exclusion while explicit routing keeps validation", () => { ++ const config = policyConfig("ollama", { ++ adapter: "openai-chat", baseUrl: loopback, ++ }, "local-model", {}); ++ config.providers.ollama!.baseUrl = " "; ++ ++ expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); ++ expect(() => routeModel(config, "ollama/local-model")).toThrow('Invalid baseUrl for provider "ollama"'); ++ }); ++}); ++ + describe("candidateCapabilityEvidence model matching", () => { + test("a family entry covers its tagged siblings, as the resolver does", () => { + const provider = providerWithFamilyEntries(); + +```` + +## Required correction on top of the original head + +In `src/routing/compatibility/assemble.ts`, change exactly: + +```diff +- let routeResolutionFailed = false; ++ let routeResolutionFailed = !provider || provider.disabled === true; +``` + +Keep the active-provider `if` and catch intact. Missing/disabled providers must never invoke the resolver. Exception messages must not be copied into evidence or traces. + +Append this test inside the original effective-provider describe block in `tests/routing/routing-capability-model-matching.test.ts`, using its `policyConfig`, `loopback` and cleanup fixtures: + +```ts + for (const unavailable of ["missing", "disabled"] as const) { + test.each(["allow", "penalize", "exclude"] as const)( + `${unavailable} first candidate is excluded under %s unknown policy`, + capability => { + const config = policyConfig("local", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", {}); + if (unavailable === "disabled") { + config.providers.disabled = { ...config.providers.local!, disabled: true }; + } + const profile = config.routingProfiles!.guarded!; + profile.candidates.unshift({ provider: unavailable, model: "local-model" }); + profile.unknownEvidence = { ...profile.unknownEvidence, capability }; + const resolved: string[] = []; + const evidence = assemblePolicyCandidateEvidence( + config, getRoutingProfile(config, "guarded")!, Date.now(), { + routedProviderConfig: (name, provider) => { + resolved.push(name); + return routedProviderConfig(name, provider); + }, + }, + ); + expect(resolved).toEqual(["local"]); + expect(evidence[0]?.routeResolutionFailed).toBe(true); + expect(evidence[0]?.capability).toBeUndefined(); + const evaluation = evaluatePolicyProfile(config, "guarded", {}, evidence); + expect(evaluation.selectedIndex).toBe(1); + expect(evaluation.candidates[0]?.eligible).toBe(false); + expect(evaluation.candidates[0]?.exclusions).toContainEqual({ code: "route-unavailable" }); + expect(routeModel(config, "policy/guarded").providerName).toBe("local"); + profile.candidates.pop(); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }, + ); + } +``` + +Append inside the existing describe in `tests/routing/routing-profile.test.ts`. Imports and `baseConfig`/ManagementRequest fixtures already exist: + +```ts + for (const unavailable of ["missing", "disabled"] as const) { + test.each(["allow", "penalize", "exclude"] as const)( + `API dry-run excludes ${unavailable} provider under %s unknown policy`, + async capability => { + const config = baseConfig({ + providers: { + local: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:11434/v1", allowPrivateNetwork: true }, + }, + defaultProvider: "local", + routingProfiles: { + guarded: { + candidates: [ + { provider: unavailable, model: "local-model" }, + { provider: "local", model: "local-model" }, + ], + require: {}, + unknownEvidence: { capability }, + }, + }, + }); + if (unavailable === "disabled") { + config.providers.disabled = { ...config.providers.local!, disabled: true }; + } + for (const withSibling of [true, false]) { + if (!withSibling) config.routingProfiles!.guarded!.candidates.pop(); + const req = new ManagementRequest("http://localhost/api/routing-profiles/dry-run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ profile: "guarded", evidence: {} }), + }); + const response = await handleManagementAPI(req, new URL(req.url), config, { + refreshCodexCatalog: async () => {}, + }); + expect(response!.status).toBe(200); + const body = await response!.json() as { + selectedIndex: number | null; + candidates: Array<{ eligible: boolean; exclusions: Array<{ code: string }> }>; + }; + expect(body.selectedIndex).toBe(withSibling ? 1 : null); + expect(body.candidates[0]?.eligible).toBe(false); + expect(body.candidates[0]?.exclusions).toContainEqual({ code: "route-unavailable" }); + } + }, + ); + } +``` + +The empty `require` is deliberate: it activates the bug even when capability unknown handling has no unsatisfied requirement to mask it. Existing synthetic `candidates` fixtures retain their meaning. No upstream request is needed for these scenarios. + +After each original guide paragraph, add the corresponding exact sentence: + +| Path | Added text | +|---|---| +| `docs-site/src/content/docs/guides/routing-profile-editor.md` | Missing or disabled providers are also excluded with `route-unavailable` before scoring. | +| `docs-site/src/content/docs/fr/guides/routing-profile-editor.md` | Les fournisseurs absents ou désactivés sont également exclus avec `route-unavailable` avant le calcul des scores. | +| `docs-site/src/content/docs/tr/guides/routing-profile-editor.md` | Eksik veya devre dışı sağlayıcılar da puanlama öncesinde `route-unavailable` ile dışlanır. | +| `docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md` | 缺少或停用的供應商也會在評分前以 `route-unavailable` 排除。 | + +SOT edit: + +```diff +-| `src/router.ts` | Provider/model selection before adapter dispatch. | ++| `src/router.ts` | Provider/model selection before adapter dispatch. Policy execution and ordinary management dry-run share effective-provider capability evidence; unresolved, missing, and disabled providers are excluded before scoring. | +``` + +## Activation and independent acceptance + +| Trigger | Observable proof, required remotely | +|---|---| +| Canonical DeepSeek with configured loopback URL overridden by registry | Direct resolution shows canonical remote destination; local-only policy rejects it. Genuine custom-local and Ollama loopback remain eligible; config snapshots are unchanged. | +| Registry no-vision defaults, capped native API context, filled forward auth and provider-wide reasoning ladder | Policy sees exactly the effective dispatch values; no-reasoning empty ladder remains a known negative. Same-name custom transport does not gain unrelated registry metadata. | +| Resolver throws for first candidate under allow/penalize/exclude | First candidate has `route-unavailable`, no positive capability and `eligible=false`; healthy sibling wins. All invalid candidates produce NoEligiblePolicyCandidateError; explicit routing preserves its validation error. | +| Missing/disabled first candidate; empty requirements under each unknown policy | Resolver spy only sees active sibling, unavailable evidence true; evaluator and runtime choose sibling, all-unavailable policy fails. | +| Ordinary management dry-run without supplied candidate evidence | Missing/disabled candidates remain excluded; selectedIndex is sibling index or null with no sibling. This proves the public review fix at the actual API owner. | +| Core-only runtime and compatibility provider slot | Core/Lab boundary tests pass; no new import chain, timer or asynchronous activation is introduced. | +| Existing three-argument capability helper callers and synthetic dry-run fixtures | Existing model-family/catalog/profile tests remain green; optional argument is backward compatible. | + +This layer is independently acceptable only with the original carry plus the review correction and all regression cases together. The predecessor contributes the integration baseline, not deferred tests. Main must independently review the public policy-boundary change; do not rely on the original CodeRabbit status alone. + +## Remote verifiers and static workflow coverage + +Main verified `the isolated remote verification host`, its existing `REMOTE_SOURCE_CHECKOUT` checkout, and Bun `1.3.14`. Implementation C uses a separate isolated remote clone at the exact carried SHA; do not alter or run checks in that existing checkout. Record the clone path and resolved SHA with every receipt. No local project command execution is permitted at any point. + +Implementation C runs focused activation regressions and typecheck remotely. The carrying PR stays draft until full current-head GitHub CI is green; focused success alone does not authorize readiness or landing. The final landing cycle requires every full gate, including the separately verified documentation build and required reviews. Deeper implementation review belongs to the next cycle, after candidate-plan revalidation. + +These commands are plans for an isolated remote checkout of the exact carried commit, **not commands to execute on the local Mac**. Record host, exact SHA, command, exit status and full output artifact. Use fixture homes and no real provider traffic. + +```sh +# REMOTE ONLY: exact-head focused behavior and core/Lab boundaries +bun test tests/routing/routing-capability-model-matching.test.ts tests/routing/routing-capability-catalog.test.ts tests/routing/policy-execution.test.ts tests/routing/routing-profile.test.ts tests/routing/routing-compatibility.test.ts tests/routing/compatibility-provider-equivalence.test.ts tests/lab/core-lab-boundary.test.ts +# REMOTE ONLY: complete source checks; hosted CI may supply this evidence instead +bun run typecheck +bun run test +bun run privacy:scan +# REMOTE ONLY: docs build in an isolated verification checkout, without publication +cd docs-site +bun install --frozen-lockfile +bun run build +``` + +For red/green proof, use the remote original carry head without the one-line correction but with the new regression cases: new missing/disabled cases must fail. Add correction on the remote verification candidate and show the same cases green. Do not run this experiment by rewriting this shared worktree. + +Static inspection at `81871b3fa` confirms: + +- `.github/workflows/ci.yml:7` uses `pull_request: {}` without a base filter, so a child PR targeting a parent branch is covered. Push alone to `codex/*` does not trigger it (`:26–27`); an opened PR or authorized workflow_dispatch is necessary. +- `ci.yml:182–185` includes `src/**` and `tests/**`; this layer must produce `changes.ci=true`. Original workflows awaiting approval provide no test evidence. +- `ci.yml:254–316` runs four Linux shards with `scripts/ci/run-bun-test-batches.sh`. Inspect logs for actual file execution and all shards, including split API/storage jobs, rather than only aggregate status. +- `ci.yml:422–431` covers typecheck and privacy. macOS and Windows suites are configured at `:451`, `:532`, `:625`, and `:754`; inspect the actual selected lane and successful test jobs, not skipped jobs. +- `ci.yml:906` aggregate permits intentionally skipped jobs. An aggregate green with skipped required product jobs is insufficient for this source-changing layer. +- `.github/workflows/deploy-docs.yml:3–9` triggers on main/docs changes or manual dispatch, and contains deployment. It is **not** a PR docs-build verifier. Do not dispatch publication to obtain validation. Use the remote build-only commands above. No CI workflow edits are needed in this layer. + +The roadmap cycle changes documentation only; expensive tests may correctly skip there. That success cannot be reused for the later implementation head. Each restack or review fix requires new exact-head evidence. + +## Main-owned stack delivery and closure + +Carry original commits in order, then commit the targeted review correction and regressions. Publish only own branches with the user's authorized `--no-verify` push policy. Do not rewrite the contributor branch. Populate every repository PR-template section, stack parent link and source attribution. No local gate bypass can substitute for remote product CI. + +Merge bottom-up once each layer's exact current head meets review/CI gates. When a lower PR is squash-merged, restack/retarget descendants before deleting lower branches; verify the new parent ancestry and each child diff. Preserve author trailers in the final squash body. + +After main verifies the carrying merge SHA is an ancestor of freshly fetched `origin/dev`, immediately close original #3671 as superseded if it did not auto-close, linking the carrying PR/merge. If original #3671 itself is merged, verify its merged state. Do not close it on branch push, PR creation, CI success alone, or merge only into a stack parent. No additional issue is identified as fully resolved by this unit. + +## Planning proof + +Only this document was written by this worker. The embedded original patch was read directly from the pinned original objects. Source/caller/workflow checks are static; no local tests, typecheck, build, Git mutation, GitHub mutation, goal or FSM transition was performed. The main-owned later P must revalidate all candidate hunks and review state. diff --git a/devlog/_plan/260906_a_runtime_stack/060_landing.md b/devlog/_plan/260906_a_runtime_stack/060_landing.md new file mode 100644 index 0000000000..760192edb8 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/060_landing.md @@ -0,0 +1,23 @@ +# Verified stacked integration and closeout + +## Changes + +No production changes planned. MODIFY phase records with evidence; move this completed public unit from `devlog/_plan/260906_a_runtime_stack/` to `_fin/` only after all units have a terminal outcome. Private security evidence remains ignored. + +## Procedure + +1. Refresh each original and replacement PR head, base, unresolved reviews, required checks, and live dev. If source author advanced, compare unique new commits before disposition. +2. Require actual current-head full cross-platform tests and typecheck/privacy gates, not intake-only green or old author attestations. Record remote focused triggers as supplementary proof. Repair test failures by cause; never claim retry alone fixed a failure. +3. Verify each child contains its current parent's head and its base points at the parent. Merge the bottom with source author's account-linked Co-authored-by trailer preserved in squash body, or use merge commits to retain ancestry. Push owned branches with --no-verify; any necessary rewrite uses explicit --force-with-lease on owned refs only. +4. Retarget the next child to dev, rebase/merge as needed after a squash, verify its diff and fresh checks. Do not delete a parent branch before child retargeting. +5. Immediately fetch dev and prove `git merge-base --is-ancestor origin/dev`. Close the superseded source PR with the replacement PR and landing SHA. For original PRs directly merged, record merged state. +6. Read linked issue acceptance scope. Close only fully solved issues; #3661 MESSAGE recovery is partial and must retain the residual issue. Do not use automatic Closes for partial work. +7. Check final dev CI on its exact current SHA. Record all original/replacement PR URLs, contributor trailers, merge SHAs and issue dispositions. Complete goal only when every criterion has captured evidence and all FSM cycles closed. + +## Activation and failure cases + +Source-author update: compare the new head, carry any still-needed change and reverify. Concurrent dev movement: integrate it without losing another lane's changes. Squash changes parent identity: cascade child before any merge. Failed CI: inspect exact job/step logs, fix the failing scope, re-run on new head. Unresolved issue scope: leave open with specific residual, never close to improve counts. + +## Verifiers + +Run read-only `gh pr view`, `gh pr checks`, `gh run view` and `git merge-base --is-ancestor` at the real current heads. Local git ancestry and static diff checks are allowed. Runtime/typecheck/build verification occurs on GitHub Actions or isolated the isolated remote verification host checkouts only. Full CI pending means this landing cycle remains active. From f9f662143ebae6125d642201342eb933b983996d Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:37:56 +0900 Subject: [PATCH 003/236] docs: record C-lane integration and verification scope --- devlog/_plan/260906_c_lane/000_plan.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 devlog/_plan/260906_c_lane/000_plan.md diff --git a/devlog/_plan/260906_c_lane/000_plan.md b/devlog/_plan/260906_c_lane/000_plan.md new file mode 100644 index 0000000000..8f7385965e --- /dev/null +++ b/devlog/_plan/260906_c_lane/000_plan.md @@ -0,0 +1,9 @@ +# C-lane integration coordination + +Scope: carry public PRs #3638, #3536, #3631, #3576, #3658 with original-author attribution and user-authorized stacked PR integration into dev. No local tests, typechecks or builds. + +The user explicitly requires security working plans and reviews to stay in gitignored scratch. Full numbered diff-level roadmap and evidence live in `.tmp/c-lane/` of the bound d778 checkout; this neutral index is the PABCD plan-unit anchor. This storage override follows AGENTS.md and does not weaken any implementation or verification criterion. + +Order: roadmap → service scheduler → account persistence → OAuth configuration → Antigravity refresh/replay → quota diagnostics → final stack integration. OAuth refresh consumes the configuration layer; other layers retain the user-requested stack order. Each layer is independently reviewed and tested on a remote host before cycle close. Hosted full CI runs at each PR head and gates final bottom-up merges. + +Original PR and fully solved linked issues close immediately after the matching change is proven on dev. Partial diagnostic work does not close a broader unresolved report. Release branches and live account settings are out of scope. From ed7ca4cf05e1b5333dab178923282847395c485a Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:24:54 +0800 Subject: [PATCH 004/236] fix(service): use normal Windows scheduler priority (cherry picked from commit a93f1b27ef868eaecf8bc5aca5ffcae2040a1c28) --- .../content/docs/reference/cli/lifecycle.md | 6 ++++ src/service.ts | 8 +++-- tests/service/service.test.ts | 32 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 0e91777061..e75a2b6241 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -237,6 +237,12 @@ Run opencodex as a login-managed background service (macOS **launchd**, Linux ** Windows **Task Scheduler**) that auto-starts on login and auto-restarts on crash. Service runs set `OCX_SERVICE=1` so a restart does not churn the Codex config. +Windows Task Scheduler installs use normal process priority (`Priority=4`). The older background +priority (`7`, also the scheduler default when omitted) can delay the proxy's health responses under +CPU contention, making the tray report Offline even while the process is alive. After upgrading, +run `ocx service repair` to migrate that registered priority and restart the service. This migration +may request UAC approval; a priority already set to normal or high does not itself trigger replacement. + The Windows wrapper verifies its baked Bun runtime and CLI entry before every start attempt. If an interrupted package update removed either file, it logs one `installation is incomplete` message and stops instead of retrying the same missing executable every five seconds. Reinstall opencodex, then diff --git a/src/service.ts b/src/service.ts index b37e88c3db..fa8770ec55 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1926,7 +1926,7 @@ export function buildWindowsTaskXml( true false PT0S - 7 + 4 PT1M 3 @@ -2972,6 +2972,10 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise const identityUpgradeNeeded = registrationHealthy && preferredSid !== undefined && !windowsTaskHasSessionRecoveryTriggers(triggers, preferredSid); + // Omitted Priority also defaults to 7; background priority can starve health probes under CPU load. + const priorityUpgradeNeeded = registrationHealthy && taskXmlOptionalValueEquals( + taskXmlSection(taskXmlWithoutCommentsAndCdata(registeredXml), "Settings"), "Priority", "7", + ); const refreshableLegacy = windowsTaskRegistrationRefreshableLegacy( registeredXml, deps.schedulerWscript, @@ -2998,7 +3002,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise // Re-register only when the registered XML is actually stale, so the ordinary repair // stays free of `schtasks /create` and its UAC prompt. let startExpectedXml = registeredXml; - if (!registrationHealthy || identityUpgradeNeeded) { + if (!registrationHealthy || identityUpgradeNeeded || priorityUpgradeNeeded) { // The task was stopped above, so a failed replacement must not exit here: `/create /f` // can be rejected, elevation can be cancelled, and staging or verification can fail. // Any of those would leave a previously runnable proxy stopped and the user worse off diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index e36a2785b6..2eadf7f4fc 100644 --- a/tests/service/service.test.ts +++ b/tests/service/service.test.ts @@ -491,6 +491,7 @@ describe("Windows service task", () => { expect(xml).toContain("false"); expect(xml).toContain("false"); expect(xml).toContain("PT0S"); + expect(xml).toContain("4"); expect(xml).toContain(""); expect(xml).toContain("PT1M"); expect(xml).toContain("3"); @@ -2601,6 +2602,37 @@ describe("service repair", () => { expect(calls).toEqual(["env", "auth", "stop", "assets", "reregister", "start", "state"]); }); + test.each(["7", "omitted", "4", "1"])("repair migrates only the background scheduler priority (%s)", async priority => { + const calls: string[] = []; + const previousXml = buildWindowsTaskXml().replace(/\d<\/Priority>/, + priority === "omitted" ? "" : `${priority}`); + const shouldUpgrade = priority === "7" || priority === "omitted"; + let attemptNonce = ""; + await repairService({ + platform: "win32", + diagnose: () => baseDiag, + assertEnv: () => {}, + assertAuth: () => {}, + resolveExpectedUserId: () => TEST_WINDOWS_TASK_SID, + stopScheduler: () => { calls.push("stop"); }, + writeSchedulerAssets: () => { calls.push("assets"); }, + readSchedulerXml: () => attemptNonce + ? buildWindowsTaskXml(undefined, undefined, attemptNonce) + : previousXml, + reregisterScheduler: async (nonce, registeredXml) => { + expect(registeredXml).toBe(previousXml); + expect(buildWindowsTaskXmlDocument()).toContain("4"); + calls.push("reregister"); + attemptNonce = nonce; + }, + startScheduler: () => { calls.push("start"); }, + writeSchedulerState: () => { calls.push("state"); }, + }); + expect(calls).toEqual(shouldUpgrade + ? ["stop", "assets", "reregister", "start", "state"] + : ["stop", "assets", "start", "state"]); + }); + test("repair migrates an exact legacy account name to the preferred SID", async () => { const calls: string[] = []; const sid = "S-1-5-21-111-222-333-1001"; From f05d0f5f4673407deef4285b0b8137bba4e06af8 Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:31:45 +0800 Subject: [PATCH 005/236] docs(service): sync Windows priority migration across locales (cherry picked from commit 37b822b6d7b16098d15f3f0347f1d987c5d96cea) --- docs-site/src/content/docs/fr/reference/cli/lifecycle.md | 6 ++++++ docs-site/src/content/docs/ja/reference/cli/lifecycle.md | 6 ++++++ docs-site/src/content/docs/ko/reference/cli/lifecycle.md | 6 ++++++ docs-site/src/content/docs/ru/reference/cli/lifecycle.md | 6 ++++++ docs-site/src/content/docs/tr/reference/cli/lifecycle.md | 6 ++++++ docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md | 4 ++++ docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md | 4 ++++ 7 files changed, 38 insertions(+) diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index ac152db316..59652bbaef 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -152,6 +152,12 @@ Invalide le cache local du sélecteur de modèles de Codex afin qu’il soit rec Exécute opencodex comme service d’arrière-plan géré à l’ouverture de session — **launchd** sous macOS, **unité utilisateur systemd** sous Linux et **Task Scheduler** sous Windows — qui démarre automatiquement à la connexion et redémarre après un plantage. Les services définissent `OCX_SERVICE=1` afin qu’un redémarrage ne réécrive pas inutilement la configuration Codex. +Les installations via le Planificateur de tâches Windows utilisent une priorité de processus normale (`Priority=4`). +L’ancienne priorité d’arrière-plan (`7`, également la valeur par défaut si le paramètre est omis) peut retarder les réponses +aux contrôles de santé en cas de contention CPU : la zone de notification affiche alors Offline même si le processus fonctionne. +Après la mise à jour, exécutez `ocx service repair` pour migrer cette priorité enregistrée et redémarrer le service. +Une confirmation UAC peut être nécessaire. Une priorité déjà normale ou haute ne déclenche pas, à elle seule, de réenregistrement. + | Sous-commande | Action | | --- | --- | | aucune | Installe et démarre le service s’il est absent ; sinon, actualise et redémarre le service existant. Une définition Task Scheduler Windows saine est réutilisée ; une définition obsolète peut être réenregistrée et nécessiter une élévation. | diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index d6e9425b52..b187ff7fd3 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -156,6 +156,12 @@ Codex のローカル モデル ピッカー キャッシュを無効にし、 opencodex を、ログイン時に自動起動し、クラッシュ時に自動再起動するログイン管理バックグラウンド サービス (macOS **launchd**、Linux **systemd ユーザー ユニット**、Windows **タスク スケジューラ**) として実行します。サービスは `OCX_SERVICE=1` を設定して実行されるため、再起動によって Codex 設定が変更されることはありません。 +Windows タスク スケジューラでインストールするサービスは、通常のプロセス優先度(`Priority=4`)を使用します。 +以前のバックグラウンド優先度(`7`。省略時もスケジューラの既定値は `7`)では、CPU の競合により +ヘルスチェックへの応答が遅れ、プロセスが動作中でもトレイに Offline と表示されることがあります。 +アップグレード後に `ocx service repair` を実行すると、この登録済み優先度を移行してサービスを再起動します。 +移行時に UAC の承認が必要になる場合があります。すでに通常または高優先度の場合、優先度だけを理由に再登録しません。 + |サブコマンド |アクション | | --- | --- | |なし |未インストールなら作成して開始し、既存なら更新して再起動します。正常な Windows タスク スケジューラ定義は再利用しますが、古い定義は再登録され、昇格が必要になる場合があります。 | diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 081c791bbb..4847614674 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -203,6 +203,12 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 유닛, Windows **Task Scheduler**). 로그인 시 자동 시작하고 충돌 시 자동 재시작합니다. 서비스 실행은 `OCX_SERVICE=1`을 설정하므로 재시작해도 Codex 설정이 흔들리지 않습니다. +Windows 작업 스케줄러로 설치하는 서비스는 보통 프로세스 우선순위(`Priority=4`)를 사용합니다. +이전의 백그라운드 우선순위(`7`, 생략 시에도 스케줄러 기본값은 `7`)에서는 CPU 경합으로 상태 확인 응답이 +늦어져 프로세스가 살아 있어도 트레이에 Offline이 표시될 수 있습니다. 업그레이드 후 `ocx service repair`를 +실행하면 등록된 해당 우선순위를 변경하고 서비스를 재시작합니다. 이 과정에서 UAC 승인이 필요할 수 있습니다. +이미 보통 또는 높음 우선순위인 경우 우선순위만을 이유로 다시 등록하지 않습니다. + | 하위 명령 | 동작 | | --- | --- | | 없음 | 서비스가 없으면 설치하고 시작하며, 이미 있으면 새로 고쳐 재시작합니다. 정상인 Windows 작업 스케줄러 정의는 재사용하지만, 오래된 정의는 다시 등록되어 관리자 권한 승인이 필요할 수 있습니다. | diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 30b4e627a3..1ace7cc10f 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -220,6 +220,12 @@ unit**, Windows **Task Scheduler**), которая автоматически перезапускается при crash. Запуски службы выставляют `OCX_SERVICE=1`, чтобы restart не дёргал конфиг Codex. +При установке через Windows Task Scheduler используется обычный приоритет процесса (`Priority=4`). +Прежний фоновый приоритет (`7`, также значение планировщика по умолчанию при отсутствии параметра) +при конкуренции за CPU может задерживать ответы проверки состояния: трей показывает Offline, хотя процесс работает. +После обновления выполните `ocx service repair`, чтобы изменить этот зарегистрированный приоритет и перезапустить службу. +Может потребоваться подтверждение UAC. Если уже задан обычный или высокий приоритет, сам приоритет не вызывает перерегистрацию. + | Подкоманда | Действие | | --- | --- | | none | Установить и запустить службу, если её нет; иначе обновить и перезапустить существующую службу. Исправная конфигурация Windows Task Scheduler используется повторно; устаревшая может быть перерегистрирована и потребовать повышения прав. | diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index e26f4c8662..6a7a565139 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -244,6 +244,12 @@ kullanıcı birimi**, Windows **Görev Zamanlayıcı**) olarak çalıştırın. çalıştırmaları `OCX_SERVICE=1` ayarlar, böylece bir yeniden başlatma Codex yapılandırmasını dalgalandırmaz. +Windows Görev Zamanlayıcı kurulumları normal işlem önceliğini (`Priority=4`) kullanır. Eski arka plan +önceliği (`7`; değer belirtilmediğinde de zamanlayıcının varsayılanı `7` olur), CPU çekişmesi sırasında +sağlık denetimi yanıtlarını geciktirebilir ve işlem çalışırken bile sistem tepsisinde Offline görünmesine neden olabilir. +Güncellemeden sonra kayıtlı bu önceliği değiştirmek ve servisi yeniden başlatmak için `ocx service repair` komutunu çalıştırın. +UAC onayı gerekebilir. Zaten normal veya yüksek öncelik ayarlanmışsa yalnızca öncelik nedeniyle yeniden kayıt yapılmaz. + | Alt komut | Eylem | | --- | --- | | none | Servis yoksa kurup başlatın; varsa yenileyip yeniden başlatın. Sağlıklı bir Windows Task Scheduler tanımı yeniden kullanılır; eski bir tanım yeniden kaydedilebilir ve yükseltme gerektirebilir. | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index dfae403438..f0c6ee5a59 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -153,6 +153,10 @@ ocx status --json 将 opencodex 作为登录管理的后台服务运行(macOS **launchd**、Linux **systemd user unit**、Windows **Task Scheduler**),在登录时自动启动,在崩溃时自动重启。服务运行会设置 `OCX_SERVICE=1`,因此重启时不会反复改动 Codex 配置。 +Windows 任务计划程序安装使用普通进程优先级(`Priority=4`)。旧的后台优先级(`7`,省略时调度器也默认使用 `7`) +可能在 CPU 竞争时延迟健康检查响应,导致进程仍存活时托盘显示 Offline。升级后运行 `ocx service repair`, +即可迁移该注册优先级并重启服务;过程中可能需要批准 UAC 提示。已设为普通或高优先级时,不会仅因优先级而重新注册。 + | 子命令 | 操作 | | --- | --- | | none | 服务不存在时安装并启动;已存在时刷新并重启。正常的 Windows 任务计划程序定义会复用;过时定义可能会重新注册并需要提升权限。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index bb377466fd..71d575e774 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -147,6 +147,10 @@ ocx status --json 將 opencodex 作為登入管理的背景服務執行(macOS **launchd**、Linux **systemd user unit**、Windows **Task Scheduler**),在登入時自動啟動並在崩潰時自動重啟。服務執行時設定 `OCX_SERVICE=1`,使重啟不會折騰 Codex 設定。 +Windows 工作排程器安裝使用一般處理程序優先順序(`Priority=4`)。舊的背景優先順序(`7`,省略時排程器也預設使用 `7`) +可能在 CPU 競爭時延遲健康檢查回應,導致處理程序仍在執行時系統匣顯示 Offline。升級後執行 `ocx service repair`, +即可遷移該註冊優先順序並重新啟動服務;過程中可能需要核准 UAC 提示。已設為一般或高優先順序時,不會僅因優先順序而重新註冊。 + | 子指令 | 動作 | | --- | --- | | 無 | 服務不存在時安裝並啟動;已存在時重新整理並重啟。正常的 Windows 工作排程器定義會沿用;過時的定義可能會重新註冊並需要提高權限。 | From f8a62ba5e7cb39aa8d3f02da666162e34988cf11 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 01:38:36 +0900 Subject: [PATCH 006/236] docs: refresh SSE stack layer contract --- devlog/_plan/260906_a_runtime_stack/011_sse_refresh.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 devlog/_plan/260906_a_runtime_stack/011_sse_refresh.md diff --git a/devlog/_plan/260906_a_runtime_stack/011_sse_refresh.md b/devlog/_plan/260906_a_runtime_stack/011_sse_refresh.md new file mode 100644 index 0000000000..506c051db2 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/011_sse_refresh.md @@ -0,0 +1,3 @@ +# SSE layer P refresh + +The original #3672 head remains 077dd61f66ac80678d071ae8fe516507f43a4264 and open. Fresh dev fetch and restricted original-parent/dev diff show no drift in all three touched files. Consume 010 unchanged. Implementation branch: codex/a-01-sse; base dev, including audited roadmap commit. Carry original Hako commit with -x and unchanged author. All project verification remote; actual tee failure/late reject regression plus adjacent failed-tail and typecheck on exact carried SHA. Full CI remains mandatory before landing. From 4b34cbb8d3f308cd2b01e8d87784c65afb50a40f Mon Sep 17 00:00:00 2001 From: Hako <25837994+devswha@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:40:54 +0900 Subject: [PATCH 007/236] fix(responses): surface rewrite failures before tee cancellation (cherry picked from commit 077dd61f66ac80678d071ae8fe516507f43a4264) --- .../content/docs/reference/proxy-formats.md | 5 ++ src/server/sse-payload-rewrite.ts | 4 +- tests/responses/sse-payload-rewrite.test.ts | 78 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 77a67147ac..19049e87f5 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -83,6 +83,11 @@ This applies to both tee inspection and eager relay, including Windows rewrite t even when the upstream read rejects before the response-body cancellation hook runs. A terminal captured during the bounded post-disconnect drain retains its actual outcome. +If native passthrough rewriting fails, including when it exceeds the translation +buffer budget, the relay reports the failure without waiting for upstream inspection +to finish. It cancels the upstream work and emits `response.failed` followed by +`data: [DONE]`; a budget overflow uses the `translation_buffer_limit` error code. + Client-facing Responses SSE frames are limited to 4 MiB per frame, measured in raw bytes before the SSE block delimiter. On HTTP, an unterminated upstream frame that exceeds the limit fails closed with a synthetic `response.failed` event followed by `data: [DONE]`. On the Responses WebSocket diff --git a/src/server/sse-payload-rewrite.ts b/src/server/sse-payload-rewrite.ts index 3c6d825e6c..f9fb62065d 100644 --- a/src/server/sse-payload-rewrite.ts +++ b/src/server/sse-payload-rewrite.ts @@ -249,7 +249,9 @@ export function relaySseWithBlockRewrite( } catch (error) { releaseBuffer(); disposeRewrite(); - try { await reader.cancel(error); } catch { /* already closed */ } + // Cancelling one tee branch waits for its sibling. Surface the failure + // now so downstream can abort upstream and release the inspection branch. + void reader.cancel(error).catch(() => {}); controller.error(error); } }, diff --git a/tests/responses/sse-payload-rewrite.test.ts b/tests/responses/sse-payload-rewrite.test.ts index 34dae59e07..773665a054 100644 --- a/tests/responses/sse-payload-rewrite.test.ts +++ b/tests/responses/sse-payload-rewrite.test.ts @@ -153,4 +153,82 @@ describe("SSE payload rewrite composition", () => { expect(budget.snapshot().currentBytes).toBe(0); budget.dispose(); }); + + test.each(["resolve", "reject"] as const)( + "surfaces a rewrite failure before tee cancellation can %s", + async cancellationOutcome => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 64 }); + const upstream = new AbortController(); + const cancellation = Promise.withResolvers(); + const cancellationError = new Error("upstream cancellation failed"); + let cancelCalls = 0; + let disposeCalls = 0; + const source = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: partial")); + controller.enqueue(new TextEncoder().encode("x".repeat(80))); + // Keep the source open after exhausting the rewrite budget. + }, + cancel() { + cancelCalls += 1; + return cancellation.promise; + }, + }); + const [native, inspection] = source.tee(); + const inspectionReader = inspection.getReader(); + await inspectionReader.read(); + await inspectionReader.read(); + let inspectionSettled = false; + const pendingInspection = inspectionReader.read().then(() => { inspectionSettled = true; }); + const rewrite = Object.assign((block: string) => [block], { + dispose() { disposeCalls += 1; }, + }); + const rewritten = relaySseWithBlockRewrite(native, rewrite, budget); + const client = relaySseWithFailedTail(rewritten, upstream); + const completion = readAll(client); + let deadline: ReturnType | undefined; + + try { + const out = await Promise.race([ + completion, + new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error("rewrite failure waited for the inspection tee")), 1_000); + }), + ]); + expect(out.match(/event: response.failed/g)).toHaveLength(1); + expect(out).toContain('"code":"translation_buffer_limit"'); + expect(out).toEndWith("data: [DONE]\n\n"); + expect(upstream.signal.aborted).toBe(true); + expect(inspectionSettled).toBe(false); + expect(cancelCalls).toBe(0); + expect(disposeCalls).toBe(1); + expect(budget.snapshot().currentBytes).toBe(0); + expect(budget.snapshot().overflows).toBe(1); + + // Releasing inspection settles both tee cancellation promises. A late + // rejection must be handled by the rewriter as well as this reader. + const siblingCancellation = inspectionReader.cancel("inspection cleanup"); + expect(cancelCalls).toBe(1); + if (cancellationOutcome === "reject") { + cancellation.reject(cancellationError); + await expect(siblingCancellation).rejects.toBe(cancellationError); + } else { + cancellation.resolve(); + await siblingCancellation; + } + await pendingInspection; + await Bun.sleep(0); // Let the runner observe any unhandled cancellation rejection. + expect(disposeCalls).toBe(1); + } finally { + clearTimeout(deadline); + const cleanup = inspectionReader.cancel().catch(() => {}); + cancellation.resolve(); + await cleanup; + await pendingInspection; + await completion.catch(() => {}); + inspectionReader.releaseLock(); + budget.dispose(); + } + }, + ); }); From 53649bab49fc4b9e1062304efeeaee1c7bf36ace Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 01:38:39 +0900 Subject: [PATCH 008/236] docs(plan): lock attributable lane B catalog stack roadmap --- .../260906_lane_b_catalog_stack/000_plan.md | 59 + .../009_roadmap_lock.md | 5 + .../010_visibility.md | 298 ++++ .../020_context.md | 636 ++++++++ .../030_ordering.md | 665 +++++++++ .../040_management.md | 1297 +++++++++++++++++ .../260906_lane_b_catalog_stack/050_fable.md | 344 +++++ .../060_landing.md | 31 + 8 files changed, 3335 insertions(+) create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/000_plan.md create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/009_roadmap_lock.md create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/010_visibility.md create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/020_context.md create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/040_management.md create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/050_fable.md create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/060_landing.md diff --git a/devlog/_plan/260906_lane_b_catalog_stack/000_plan.md b/devlog/_plan/260906_lane_b_catalog_stack/000_plan.md new file mode 100644 index 0000000000..98a74cce9a --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/000_plan.md @@ -0,0 +1,59 @@ +# Lane B catalog carry roadmap + +## Loop specification + +- Archetype: spec-satisfaction repair and attributable integration. +- Trigger: owner assigned catalog lane B and authorized stacked PRs, no-verify pushes, merges and immediate closure of completed source work. +- Goal: manual OpenAI visibility, persistent context limits, Go effort/ordering, provider model management and Fable 1M selectors work together on dev. +- Non-goals: other lanes, release/main/preview promotion, deployment, global proxy/config changes, new dependencies, broad cleanup. +- Tool/credential scope: git and authenticated GitHub CLI for this repository; inherited-model subagents; read-only local inspection; isolated QA or remote checks only when needed. +- Write scope: this unit, the exact source-PR files named by each decade plan, necessary focused regression/SoT follow-ups, and ignored scratch/evidence. Preserve peer changes. +- Resource policy: user authorized inherited parallel agents without a numeric cap. No imposed token/cost limit. Six-hour work-phase checkpoint; a reached bound is reported honestly, never as success. Context compaction only checkpoints work. +- Verifier: current-head Cross-platform CI, GUI tests/lint/build and privacy checks from repository CI; independent diff review; GUI observation where rendering changed; git ancestry and attribution checks. Local tests, suites, typechecks and builds are prohibited for this run. +- Stop: all five outcomes verified on dev, replacements merged, original PRs closed and fully resolved issues closed. +- Memory artifact: this unit plus the session-bound goalplan; volatile source/review/CI snapshots in `.tmp/lane-b/`. +- Outcomes: DONE after proof; NOOP only with current-code proof; external BLOCKED/UNSAFE/NEEDS_HUMAN requires evidence and no other authorized progress. Pending CI is continuing work. +- Escalation up: main reclaims a packet after two distinct agents fail it. Down: delegate only explicit bounded tasks recorded at P; no speculative implementation of a later phase. + +## Current tree and source anchors + +Initial dev is `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. The managed checkout stays in place and is adopted as `codex/lane-b-01-visibility`. + +| Phase | Source PR/head | Contract | Branch plan | +|---|---|---|---| +| roadmap | current dev | lock these documents only | base visibility branch | +| visibility / 010 | #3653 / `956eedac439922cf7645f130ef8432833e813a9a` | distinguish native and configured manual rows | `codex/lane-b-01-visibility`, base dev | +| context / 020 | #3654 / `8facdb0d8c10109701015c0f6109fc67b1d9dd3c` | preserve selection independently of enabled state | `codex/lane-b-02-context`, base 01 | +| ordering / 030 | #3571 / `0a935c5694229760c8c1cd5a62072107d8ae6696` | separate picker/spawn rank and exact efforts | `codex/lane-b-03-ordering`, base 02 | +| management / 040 | #3659 / `ff4e5cd5352b9c1bd05e3de0091f3483ca130be5` | consume visibility contract for hide/delete and static sync | `codex/lane-b-04-management`, base 03 | +| fable / 050 | #3649 / `95becce94255982667cef10308806770d49cc05b` | preserve 1M selector and canonical upstream route | `codex/lane-b-05-fable`, base 04 | +| landing / 060 | all verified replacement heads | bottom-up dev integration and closure | retain parent refs until child retarget | + +The owner explicitly requests stacked PR delivery. Context and ordering share persisted catalog configuration; model management consumes the visibility and catalog contracts. Fable is functionally independent and placed last only to satisfy the requested stack delivery; no runtime dependency is claimed. One work-phase is one full PABCD cycle; each implementation phase is verified before the next. + +## Existing owners and SoT + +Runtime management lives in `src/server/management/`, catalog publication in `src/codex/catalog/`, persistence in `src/config.ts` and `src/providers/context-cap.ts`, dashboard rows in `gui/src/models-groups.ts`, provider workspace in `gui/src/components/provider-workspace/`. Focused tests remain in domain directories; new files update both test-layout manifests. Read nested AGENTS before changes. + +SoT synchronization targets are `structure/02_config-and-codex-home.md` (context persistence), `structure/03_catalog-and-subagents.md` (efforts and ordering), and `structure/05_gui-and-management-api.md` (visibility and model operations), plus the source PR's public documentation. Add narrow contract notes only when existing text would otherwise be incomplete or contradictory. + +## Verification execution policy + +`.github/workflows/ci.yml:7` accepts all PR bases, including open stack heads. Its `changes` filter controls actual test execution; a green aggregate with skipped test jobs is insufficient. `workflow_dispatch` supports all lanes. Inspect each actual run's head SHA, event, test jobs and conclusions. Author-reported historical test counts do not certify a carry head. + +`git diff --check` and a Python document-completeness checker are documentation/static artifact checks, not repository test execution. These are the only local checks in the docs-only cycle. Implementation C receipts invoke a read-only GitHub evidence verifier that asserts the actual checked-out SHA and successful test jobs; the verifier never starts local tests. Screenshot paths already in original PRs preserve author evidence; rendering changes require an actual observation of the carried state or an explicitly identified outstanding gate. + +## Attribution and publication + +Carry source non-merge commits with `git cherry-pick -x` when compatible; otherwise apply the exact merge-base diff preserving binaries and create a scoped commit with actual source-author `Co-authored-by` trailers. Keep source PR/head references in every replacement description. Do not cherry-pick upstream merge commits as new feature content. Every push uses `git push --no-verify`; no direct dev pushes or contributor-branch rewrites. + +GitHub operations stay sequential. Bottom-up merge commits preserve ancestry; if squash is required, restack the children immediately and revalidate. Before merging, inspect current head, exact-head CI, outstanding reviews and any peer dev drift. Preserve all author trailers. Close source PRs as superseded and issues #3650/#3651 as completed only after their replacement is reachable from dev and solves the full report. + +## Shared surfaces + +- A #3679 and B #3654 share `src/config.ts`; B reconciles both independent field additions. +- D #3625 and B #3659 share locale modules; retain all keys. +- D #3646 and B #3649 share Claude alias routing; D owns hub alias resolution, B owns Fable native selector round-trip. +- A #3568 and B #3571 share layout manifests and provider docs; preserve both additions. + +Independent review findings involving security stay in ignored scratch space. Public plans describe the already-public source changes, never unpublished vulnerability analysis. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/009_roadmap_lock.md b/devlog/_plan/260906_lane_b_catalog_stack/009_roadmap_lock.md new file mode 100644 index 0000000000..f5c1ce2a2b --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/009_roadmap_lock.md @@ -0,0 +1,5 @@ +# Roadmap lock + +The seven numbered roadmap documents passed independent read-only audit. The roadmap cycle produced documentation only. Original final binary diffs are pinned in scratch for attributable carry. GUI and docs observation will use an isolated remote checkout; macmini-cf has Bun 1.3.14. Ordinary PR CI proves Linux/macOS and quality gates, and the final stack receives an explicit Windows all-lane run. No local repository tests, typecheck or builds were run. + +Next cycle: 010 visibility. Carry the final #3653 diff rather than only its early commits, preserving the final Fast-row assertions and original PNG. Add the planned mixed-group and client-export behavior coverage, obtain independent management-boundary review and verify the carry head in CI. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/010_visibility.md b/devlog/_plan/260906_lane_b_catalog_stack/010_visibility.md new file mode 100644 index 0000000000..3577713269 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/010_visibility.md @@ -0,0 +1,298 @@ +# 010 — Manual OpenAI visibility and replacement rows + +Status: planned; source-inspection only. Research captured 2026-09-05T16:34:39.759005+00:00. Local anchor: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. Source: [PR #3653](https://github.com/lidge-jun/opencodex/pull/3653), issue [#3650](https://github.com/lidge-jun/opencodex/issues/3650). Source head `956eedac439922cf7645f130ef8432833e813a9a`, source base `0b7f60ee259bdd0e5c68b62936fe153af151e9dd`. Live GraphQL confirmed this head remains OPEN with zero unresolved threads on 2026-09-05 UTC (2026-09-06 KST). + +## Execution contract + +This is an implementation design for one later PABCD cycle, not an implementation receipt. The main agent owns the FSM, host goal, 000 roadmap, branch stack, publication, and merge. This delegated research made no production edits, ran no local tests/typecheck/build, and changed no refs. + +Loop archetype: spec-satisfaction repair. Trigger: the linked public issue and source PR. Verifier: exact carried-head GitHub CI plus targeted behavior evidence below. Stop: all activation scenarios accounted for, CI producers successful, author attribution retained, and merge commit proven reachable from dev. Expected outcomes: DONE after that evidence, NOOP only if current dev already implements the same behavior; otherwise retain explicit BLOCKED/NEEDS_HUMAN evidence without claiming completion. Upward escalation: main reclaims a slice after two distinct agents fail its packet; downward delegation requires a P-phase amendment. Resource and credential limits inherit the main lane-B 000 plan; this document authorizes no independent goal, workflow dispatch, deployment, or account change. + +The future executor must re-read the nearest src/GUI/docs AGENTS before code changes. No local tests, suites, typecheck, lint, builds, or dependency installation: CI is the execution verifier. Read-only `git apply --check` below checks textual portability only, not correctness. + +## CI evidence contract + +Source inspection at the recorded local HEAD establishes coverage, not passing execution: + +- `.github/workflows/ci.yml:182-201` selects runtime/tests/GUI changes; both source diffs select `ci` and `gui`. +- `ci.yml:255-316`: four Linux shards invoke `bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD"`. `scripts/ci/run-bun-test-batches.sh:46-64,196-204` enumerates test files and excludes only the dedicated storage/API-usage families; the targeted files below are included. +- `ci.yml:422-428`: root TypeScript checks and `cd gui && bun test --isolate tests`; GUI lint/build at 416-420 and 442-446, privacy scan at 430-431. These are CI commands, never instructions to run locally. +- macOS test execution is at `ci.yml:532`; Windows is **dispatch-only** at `ci.yml:661-662` and runs six shards at 754. A normal PR check does not prove Windows test execution. Main must obtain appropriate exact-head dispatch evidence before claiming three-platform coverage. +- `ci.yml:917-933` permits skipped producers. Inspect actual test/gates job results and tested commit (including the PR merge ref and its head parent), not just aggregate `ci=success`. +- `.github/workflows/react-doctor.yml:15-18,46-57` scans PR changes in `gui` and blocks warnings. It is an additional review gate, not a replacement for GUI tests. +- `.github/workflows/deploy-docs.yml:3-10,25-32` builds docs only on main push or dispatch; normal PR CI has no Astro-docs build. Do not dispatch a deploying workflow merely to get validation. Main must arrange non-deploy hosted docs-build evidence or explicitly retain that verification gap. Local builds remain prohibited. + +Before merging a carried layer, refresh source/head/base/review status, inspect exact-head CI jobs and remaining findings, preserve coauthor credit through squash, and verify the resulting merge SHA is an ancestor of fetched dev. Only then close the superseded source PR and its resolved issue; a source PR carried through another PR is not automatically merged/closed. Retarget/rebase the next child onto dev after its parent lands; do not delete a parent branch while an open child still targets it. These are main-owned future actions. + +## Scope and caller proof + +C3 product slice, with the existing management validation boundary retained for independent review. Outcome: manually configured `openai/gpt-5.5` can be toggled without HTTP 400; it replaces the matching bare dashboard row, while account-qualified native rows and native provider controls survive. No route renaming, entitlement change, catalog order redesign, model deletion API, or runtime transport change. + +Current `src/server/management/model-routes.ts:567-570` rejects every non-native OpenAI target. `gui/src/pages/Models.tsx:1463` sends the actual row's `native` flag; group visibility at 1218 sends mixed native/manual targets. `gui/src/model-visibility.ts:58-70` serializes these unchanged. Reuse this caller and existing atomic visibility handler, rather than adding an endpoint. + +Current `src/server/management/model-rows.ts:125-172` deduplicates routed custom rows but concatenates all native rows. Add bare-native filtering after `visibleCustomModels`/`customNamespaced` are known, before Fast-row metadata at 173-184. `loadExportModels` at 214-216 consumes the same rows and removes disabled entries, so client-config export needs explicit regression coverage. `model-routes.ts:357` returns these rows; `model-routes.ts:490-510` serializes client config via the existing export path. `gui/src/models-groups.ts:84` loses native controls when all visible rows are manual; derive `nativeProviderGroup` also from configured canonical OpenAI `authMode: forward`, while leaving `native` false for manual-only groups. + +## Exact source carry map + +| Operation | Path | +|---|---| +| NEW | `docs-site/public/screenshots/manual-openai-model-toggle.png` | +| MODIFY | `docs-site/src/content/docs/reference/management-api.md` | +| MODIFY | `gui/src/models-groups.ts` | +| MODIFY | `gui/tests/models-native-group-controls.test.ts` | +| MODIFY | `src/server/management/model-routes.ts` | +| MODIFY | `src/server/management/model-rows.ts` | +| MODIFY | `tests/codex-integration/model-visibility-management-api.test.ts` | + +All six textual files were reviewed, including all test hunks. The PNG is accounted for as a binary evidence asset: blob identity verified, pixels not inspected in this docs-only pass. Existing modules and test files are reused; no new runtime abstraction or test manifest entry is needed. + +### Carry method and attribution + +Prefer a **base-to-final-head diff port**. The source contains merge commits, and the last one resolved `model-rows.ts` against Fast metadata. Applying only the original feature commit loses the final regression assertions. Actual `git show -s` commit metadata identifies **Robin Bially <7304732+RobinBially@users.noreply.github.com>** on: + +- `7c5b4d918401d086dc633ab941be1ff9f844b13e`: original feature. +- `6c1b8b2d2f0abc4baa6618cea2e485374dda2aeb`: account-qualified and pending-selection regression additions. +- `956eedac439922cf7645f130ef8432833e813a9a`: final merge resolution, parents `e520ee5e437e6fc1d8482f51950722db9b58049a` and the source base above; adds Fast availability assertions in the existing test. + +Use `Co-authored-by: Robin Bially <7304732+RobinBially@users.noreply.github.com>` in the carry commit and squash description. Do not blindly cherry-pick merge commits with `-m`. A selective cherry-pick is possible only if the executor separately ports the final merge resolution and compares resulting source delta to the final PR diff. + +Read-only `git apply --check --exclude='*.png' .tmp/lane-b/3653.patch` returned 0 against the recorded HEAD. Cached patch and `git diff ` match after normalizing Git's abbreviated index-hash lines. The patch has no binary payload; the future executor must retrieve `docs-site/public/screenshots/manual-openai-model-toggle.png` from the pinned source commit, blob `d8a0dab0de58bdfee4764341465eeff6a41b4dec`, or capture a replacement from the carried-head UI. + +## Implementation sequence within this phase + +1. Port the validation hunk without moving existing malformed-request or initial-selection-pending checks (`model-routes.ts:531-542`). Preserve mixed native/routed key handling at 593-635 and catalog convergence. +2. Port bare-native row filtering before current Fast annotations; retain combo precedence, account-qualified IDs and export metadata. +3. Port canonical forward-OpenAI grouping and all source regression tests. +4. Update public API documentation and attach honest UI evidence. Update the translated API error rows listed below so they do not imply that 400 is the only rejection contract. +5. Obtain hosted CI and independent review; then main performs authorized stack merge/issue closure. + +## Activation and regression matrix + +| Scenario / trigger | Observable proof | Owning test/evidence | +|---|---|---| +| Configured manual OpenAI row, `native:false`, enable then disable | 200; namespaced disabled key changes, native key preserved | source-added `tests/codex-integration/model-visibility-management-api.test.ts` | +| Unconfigured routed OpenAI or unsupported native target | 400, no config mutation | same file, retain existing negatives | +| Pending initial selection with configured or unconfigured manual target | 409 `initial_model_selection_pending`, config equal to before | source-added same file; `tests/providers/initial-selection-write-fence.test.ts` | +| Malformed scope while pending | 400 before pending check; no mutation | source-added same file | +| Bare `gpt-5.5` custom/native collision | one manual row with routed selector and 128k metadata | source-added row-list test | +| Exact account-qualified collision `desktop/` | account-qualified native survives custom collision and deletion | source-added row-list test | +| Remove manual entries | bare native row returns, qualified row remains | source-added row-list test | +| Replacement enabled / disabled | `fastRowAvailable` true / false, pending also false | final-head source assertions plus existing pending tests | +| Manual-only canonical forward OpenAI group | `nativeProviderGroup:true`, `native:false`; controls remain | `gui/tests/models-native-group-controls.test.ts` | +| Client-config export after replacement, disable, restoration | uses manual selector once; excludes disabled replacement; restores native selector when manual row removed | extend `tests/server/management-client-config-route.test.ts` using existing export fixture; preserve `tests/config/client-config-export.test.ts` coverage | +| Combined group visibility with bare + custom rows | both target kinds accepted atomically; unrelated provider keys unchanged | add explicit mixed group-scope case to existing visibility test if existing fixtures do not cover OpenAI | + +The source GUI grouping test exercises data grouping, not a rendered switch. Future browser evidence must show one manual row, toggle success, account-qualified row retention and native controls in the carried version. Use synthetic accounts; record build/head, DOM/API result and screenshot. The source PNG is prior evidence, not proof that the carried build works. + +## Docs additions beyond the source diff + +The source English API reference at `docs-site/src/content/docs/reference/management-api.md:194-204` is the public SoT. MODIFY each existing locale's `PUT /api/model-visibility` error cell to append `409 initial_model_selection_pending` and refresh/retry guidance; retain existing translated 400 text. Carry the manual-row paragraph's same semantics without changing API identifiers. Exact existing paths: + +- MODIFY `docs-site/src/content/docs/fr/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/ja/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/ko/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/ru/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/tr/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/zh-cn/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/zh-tw/reference/management-api.md`. + +No GUI copy key is introduced by this patch. Do not alter unrelated locale content or the structure ownership table (`structure/05_gui-and-management-api.md:138`) which already names the correct owner. + +## Interphase dependencies and readiness + +010 establishes manual/native group identity consumed by 020's context controls and the later #3659 hide/delete layer. 020 is not mechanically dependent on this change, but must preserve 010's appended GUI test and API paragraph. Later #3659 shares `src/server/management/model-routes.ts`; port it after this visibility contract. Current dev changes since the source base do not touch any of these seven source paths. + +Live review thread `discussion_r3940553047` is resolved; source final tests include its account-qualified fixture. The out-of-diff 409 documentation request is also carried. No unresolved source review finding was returned. Contributor-reported passes are not our validation. Source Cross-platform CI run **33974042485** and React Doctor run **33974042542** were `action_required`; neither establishes passing product CI. Merge remains blocked on carried-head executed checks, independent review and valid GUI/docs evidence. + +## Pinned public source diff + +The following is the full textual base-to-head source patch (PNG retrieval is described above). Apply against current owners, not by copying entire stale source files. Plan amendments above add focused coverage/docs; keep them in this same phase. + +```diff +diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md +index 784c6e17f5..ae24513a97 100644 +--- a/docs-site/src/content/docs/reference/management-api.md ++++ b/docs-site/src/content/docs/reference/management-api.md +@@ -191,12 +191,20 @@ first and submit the returned digest. Prefer quarantine when recovery may be nee + | `GET /api/models` | Return the dashboard/CLI model rows | `catalog_busy` when gathering is saturated | + | `GET /api/client-config?client=...` | Build a read-only client config for any supported file integration | 400 unsupported client; 503 catalog unavailable | + | `PUT /api/disabled-models` | Replace the shared disabled-model list | 400 invalid JSON | +-| `PUT /api/model-visibility` | Atomically change provider- or model-level visibility | 400 invalid provider, scope, target, or body | ++| `PUT /api/model-visibility` | Atomically change provider- or model-level visibility | 400 invalid provider, scope, target, or body; 409 `initial_model_selection_pending` (refresh the model list and retry) | + | `GET, POST /api/custom-models` | List custom models or add one | 400 invalid fields; 404 provider missing; 409 duplicate model | + | `PUT, DELETE /api/custom-models/{id}` | Edit or delete one custom model | 400 invalid id/fields; 404 not found; 409 duplicate model | + | `GET, PUT /api/selected-models` | Read provider allowlists and availability, or replace one allowlist | 400 missing provider/body; 404 unknown provider; PUT 409 `initial_model_selection_pending` | + | `GET, PUT /api/model-presets` | Read preset summaries or choose preset/all/custom mode | 400 invalid mode or unsupported preset; 404 unknown provider; PUT 409 `initial_model_selection_pending` | + ++A manual model replaces the Models dashboard row with the same provider and model ID. ++For OpenAI, the manual row keeps `openai/` and supports the same visibility controls ++as other routed models; removing it restores the bare native dashboard row. Explicit ++account-qualified native rows stay separate. This does not rename bare native routes or ++change account entitlements. Non-native OpenAI visibility targets must match a configured ++manual model. ++ ++ + Valid PUT requests to `/api/selected-models` and `/api/model-presets` return HTTP 409 with code `initial_model_selection_pending` until a reliable initial model list is available. Refresh model discovery (for example, `GET /api/models`) and retry after it succeeds. + + ### OAuth accounts, provider keys, and data-plane keys +diff --git a/gui/src/models-groups.ts b/gui/src/models-groups.ts +index a8d6ddc69c..3e24aaf459 100644 +--- a/gui/src/models-groups.ts ++++ b/gui/src/models-groups.ts +@@ -81,7 +81,8 @@ export function buildProviderModelGroups 0 && providerRows.every(row => row.native === true), +- nativeProviderGroup: providerRows.some(row => row.native === true), ++ nativeProviderGroup: providerRows.some(row => row.native === true) ++ || (provider === "openai" && configured?.authMode === "forward"), + liveModels: configured?.liveModels !== false, + configuredModels: configured?.models ?? [], + contextWindow: configured?.contextWindow, +diff --git a/gui/tests/models-native-group-controls.test.ts b/gui/tests/models-native-group-controls.test.ts +index ffd27ad17f..14c2f2c6d8 100644 +--- a/gui/tests/models-native-group-controls.test.ts ++++ b/gui/tests/models-native-group-controls.test.ts +@@ -98,3 +98,9 @@ test("the native group exposes the context modal alongside the custom-model and + // The custom-add and cap controls no longer sit behind an isNative guard. + expect(src).not.toMatch(/\{!isNative && { ++ const groups = buildProviderModelGroups([customRow("gpt-5.5")], [{name:"openai",authMode:"forward"}]); ++ expect(groups[0]!.nativeProviderGroup).toBe(true); ++ expect(groups[0]!.native).toBe(false); ++}); +diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts +index e9ea26a90e..c3e9d58cf9 100644 +--- a/src/server/management/model-routes.ts ++++ b/src/server/management/model-routes.ts +@@ -566,7 +566,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise model.provider === provider && model.modelId === id); ++ if (!id || (native && (provider !== "openai" || !supportedNative.has(id))) ++ || (provider === "openai" && !native && !configuredOpenAiCustom)) { + return jsonResponse({ error: "invalid model visibility target" }, 400); + } + const key = `${native ? "native" : "routed"}:${id}`; +diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts +index 4a3fbeaa64..4635a9fbfd 100644 +--- a/src/server/management/model-rows.ts ++++ b/src/server/management/model-rows.ts +@@ -169,7 +169,11 @@ export async function listManagementModelRows( + ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}), + }; + }).filter((row): row is ManagementModelRow => row !== null); +- const rows = [...native, ...dedupedRouted, ...visibleCustomModels]; ++ // Manual OpenAI rows retain their routed selector but replace the bare dashboard row. ++ // Account-qualified rows remain distinct, explicitly selected routes. ++ const visibleNative = native.filter(model => model.id.includes("/") ++ || !customNamespaced.has(routedSlug(model.provider, model.id))); ++ const rows = [...visibleNative, ...dedupedRouted, ...visibleCustomModels]; + // Include disabled rows and configured aliases before the export visibility filter: + // a hidden real `x--fast` must never become a synthetic selector for another model. + const knownIds = config.fastRows === false ? new Set() : knownEffortRowIds(config); +diff --git a/tests/codex-integration/model-visibility-management-api.test.ts b/tests/codex-integration/model-visibility-management-api.test.ts +index 6259818667..15bc17f808 100644 +--- a/tests/codex-integration/model-visibility-management-api.test.ts ++++ b/tests/codex-integration/model-visibility-management-api.test.ts +@@ -1,5 +1,5 @@ + import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +-import { existsSync, mkdirSync} from "node:fs"; ++import { existsSync, mkdirSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + import { nativeModelRows } from "../../src/codex/catalog"; + import { loadConfig, saveConfig } from "../../src/config"; +@@ -7,6 +7,8 @@ import { handleManagementAPI } from "../../src/server/management-api"; + import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; + import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; + import { removeTreeWithRetry } from "../helpers/remove-tree"; ++import { ManagementRequest as Request } from "../helpers/management-auth"; ++import { listManagementModelRows } from "../../src/server/management/model-rows"; + + const TEST_DIR = join(import.meta.dir, `.tmp-model-visibility-management-${process.pid}`); + const previousOpencodexHome = process.env.OPENCODEX_HOME; +@@ -365,4 +367,77 @@ describe("atomic model visibility management", () => { + expect(loadConfig()).toEqual(before); + }); + }); +-import { ManagementRequest as Request } from "../helpers/management-auth"; ++ ++test("configured manual OpenAI rows can be toggled alongside native rows", async () => { ++ const config = loadConfig(); ++ config.providers.openai = {adapter:"openai-responses",authMode:"forward",baseUrl:"https://chatgpt.com/backend-api/codex",liveModels:false}; ++ config.customModels = [{id:"manual-gpt",provider:"openai",modelId:"gpt-5.5",contextWindow:128_000}]; ++ config.disabledModels = ["openai/gpt-5.5", "gpt-5.4"]; ++ expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"gpt-5.5",native:false}],enabled:true},config)).status).toBe(200); ++ expect(config.disabledModels).toEqual(["gpt-5.4"]); ++ expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"gpt-5.5",native:false},{id:"gpt-5.4",native:true}],enabled:false},config)).status).toBe(200); ++ expect(config.disabledModels).toContain("openai/gpt-5.5"); ++ expect(config.disabledModels).toContain("gpt-5.4"); ++ expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"not-configured",native:false}],enabled:true},config)).status).toBe(400); ++}); ++ ++test("manual models replace management rows with the same provider/id and deletion restores natives", async () => { ++ const config = loadConfig(); ++ config.providers.openai = {adapter:"openai-responses",authMode:"forward",baseUrl:"https://chatgpt.com/backend-api/codex",liveModels:false}; ++ config.customModels = [ ++ {id:"manual-gpt",provider:"openai",modelId:"gpt-5.5",contextWindow:128_000}, ++ {id:"manual-google",provider:"google-antigravity",modelId:"gemini-3.1-pro",contextWindow:128_000}, ++ ]; ++ config.codexAccountNamespaces = { desktop: "@main" }; ++ config.codexAccountPickerEnabled = true; ++ const accountModel = "gpt-5.5-account-fixture"; ++ const qualifiedId = `desktop/${accountModel}`; ++ writeFileSync(join(isolatedCodexHome!.path, "models_cache.json"), JSON.stringify({ ++ models: [{ ++ slug: accountModel, supported_in_api: true, visibility: "list", ++ base_instructions: "You are Codex.", comp_hash: null, shell_type: "unified_exec", ++ supported_reasoning_levels: [{ effort: "medium" }], model_messages: {}, ++ }], ++ })); ++ // Even an exact qualified-ID collision must preserve the account-bound native route. ++ config.customModels.push({ id: "manual-qualified", provider: "openai", modelId: qualifiedId }); ++ const rows = await listManagementModelRows(config,{entitlementWaitMs:0}); ++ expect(rows.filter(row=>row.provider==="openai" && row.id==="gpt-5.5")).toEqual([ ++ expect.objectContaining({namespaced:"openai/gpt-5.5",custom:true,customId:"manual-gpt",contextWindow:128_000,fastRowAvailable:true}), ++ ]); ++ expect(rows.filter(row=>row.provider==="google-antigravity" && row.id==="gemini-3.1-pro")).toHaveLength(1); ++ expect(rows.filter(row => row.id === qualifiedId && row.native)).toEqual([ ++ expect.objectContaining({ namespaced: qualifiedId, provider: "openai", native: true }), ++ ]); ++ config.disabledModels = ["openai/gpt-5.5"]; ++ const disabledRows = await listManagementModelRows(config, { entitlementWaitMs: 0 }); ++ expect(disabledRows.find(row => row.namespaced === "openai/gpt-5.5")).toMatchObject({ ++ custom: true, disabled: true, fastRowAvailable: false, ++ }); ++ config.disabledModels = []; ++ config.customModels = []; ++ const restored = await listManagementModelRows(config,{entitlementWaitMs:0}); ++ expect(restored.some(row => row.id === qualifiedId && row.native)).toBe(true); ++ expect(restored.filter(row=>row.provider==="openai" && row.id==="gpt-5.5")).toEqual([ ++ expect.objectContaining({namespaced:"gpt-5.5",native:true}), ++ ]); ++}); ++ ++test("manual OpenAI visibility preserves the pending-selection error contract", async () => { ++ const config = loadConfig(); ++ config.providers.openai = { ++ adapter: "openai-responses", authMode: "forward", liveModels: false, ++ baseUrl: "https://chatgpt.com/backend-api/codex", ++ initialModelSelection: { version: 1, registrationId: "11111111-1111-4111-8111-111111111111", status: "pending" }, ++ }; ++ config.customModels = [{ id: "manual-gpt", provider: "openai", modelId: "gpt-5.5" }]; ++ const before = structuredClone(config); ++ for (const target of [{ id: "gpt-5.5", native: false }, { id: "not-configured", native: false }]) { ++ const response = await putWithConfig({ scope: "models", provider: "openai", targets: [target], enabled: true }, config); ++ expect(response.status).toBe(409); ++ expect(await response.json()).toMatchObject({ code: "initial_model_selection_pending" }); ++ expect(config).toEqual(before); ++ } ++ expect((await putWithConfig({ scope: "invalid", provider: "openai", targets: [], enabled: true }, config)).status).toBe(400); ++ expect(config).toEqual(before); ++}); +``` diff --git a/devlog/_plan/260906_lane_b_catalog_stack/020_context.md b/devlog/_plan/260906_lane_b_catalog_stack/020_context.md new file mode 100644 index 0000000000..91254361d0 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/020_context.md @@ -0,0 +1,636 @@ +# 020 — Preserve selected provider context limits + +Status: planned; source-inspection only. Research captured 2026-09-05T16:34:39.759005+00:00. Local anchor: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. Source: [PR #3654](https://github.com/lidge-jun/opencodex/pull/3654), issue [#3651](https://github.com/lidge-jun/opencodex/issues/3651). Source head `8facdb0d8c10109701015c0f6109fc67b1d9dd3c`, source base `0b7f60ee259bdd0e5c68b62936fe153af151e9dd`. Live GraphQL confirmed this head remains OPEN with zero unresolved threads on 2026-09-05 UTC (2026-09-06 KST). + +## Execution contract + +This is an implementation design for one later PABCD cycle, not an implementation receipt. The main agent owns the FSM, host goal, 000 roadmap, branch stack, publication, and merge. This delegated research made no production edits, ran no local tests/typecheck/build, and changed no refs. + +Loop archetype: spec-satisfaction repair. Trigger: the linked public issue and source PR. Verifier: exact carried-head GitHub CI plus targeted behavior evidence below. Stop: all activation scenarios accounted for, CI producers successful, author attribution retained, and merge commit proven reachable from dev. Expected outcomes: DONE after that evidence, NOOP only if current dev already implements the same behavior; otherwise retain explicit BLOCKED/NEEDS_HUMAN evidence without claiming completion. Upward escalation: main reclaims a slice after two distinct agents fail its packet; downward delegation requires a P-phase amendment. Resource and credential limits inherit the main lane-B 000 plan; this document authorizes no independent goal, workflow dispatch, deployment, or account change. + +The future executor must re-read the nearest src/GUI/docs AGENTS before code changes. No local tests, suites, typecheck, lint, builds, or dependency installation: CI is the execution verifier. Read-only `git apply --check` below checks textual portability only, not correctness. + +## CI evidence contract + +Source inspection at the recorded local HEAD establishes coverage, not passing execution: + +- `.github/workflows/ci.yml:182-201` selects runtime/tests/GUI changes; both source diffs select `ci` and `gui`. +- `ci.yml:255-316`: four Linux shards invoke `bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD"`. `scripts/ci/run-bun-test-batches.sh:46-64,196-204` enumerates test files and excludes only the dedicated storage/API-usage families; the targeted files below are included. +- `ci.yml:422-428`: root TypeScript checks and `cd gui && bun test --isolate tests`; GUI lint/build at 416-420 and 442-446, privacy scan at 430-431. These are CI commands, never instructions to run locally. +- macOS test execution is at `ci.yml:532`; Windows is **dispatch-only** at `ci.yml:661-662` and runs six shards at 754. A normal PR check does not prove Windows test execution. Main must obtain appropriate exact-head dispatch evidence before claiming three-platform coverage. +- `ci.yml:917-933` permits skipped producers. Inspect actual test/gates job results and tested commit (including the PR merge ref and its head parent), not just aggregate `ci=success`. +- `.github/workflows/react-doctor.yml:15-18,46-57` scans PR changes in `gui` and blocks warnings. It is an additional review gate, not a replacement for GUI tests. +- `.github/workflows/deploy-docs.yml:3-10,25-32` builds docs only on main push or dispatch; normal PR CI has no Astro-docs build. Do not dispatch a deploying workflow merely to get validation. Main must arrange non-deploy hosted docs-build evidence or explicitly retain that verification gap. Local builds remain prohibited. + +Before merging a carried layer, refresh source/head/base/review status, inspect exact-head CI jobs and remaining findings, preserve coauthor credit through squash, and verify the resulting merge SHA is an ancestor of fetched dev. Only then close the superseded source PR and its resolved issue; a source PR carried through another PR is not automatically merged/closed. Retarget/rebase the next child onto dev after its parent lands; do not delete a parent branch while an open child still targets it. These are main-owned future actions. + +## Scope and caller proof + +C3 context-state contract; provider-removal integration receives the affected persistence review. Outcome: off → reload → on preserves an explicit cap such as 128,000 and does not force 922,000. Persist selection independently of activation. No new context-cap endpoint, automatic enable on read, changed account entitlement, arbitrary native-window expansion or redesign of the context modal. + +`gui/src/pages/Models.tsx:733-741` currently sends `NATIVE_GPT56_OPT_IN_WINDOW` when a native group is enabled. `src/providers/context-cap.ts:46-54` deletes active state on off and uses global value on every implicit enable. `src/server/management/provider-routes.ts:1399-1503` owns all three public request branches and refreshes live state/catalog after writes. `src/config.ts:1137` and `src/types/config.ts:607` carry only active limits. `src/codex/catalog/metadata.ts:301-304` exempts ordinary native windows from the 922k ceiling. + +Reuse `context-cap.ts` for two maps: `providerContextCaps` is the only active input; new `providerContextCapValues` remembers the last selection. `selectedProviderContextCaps` merges sanitized remembered values first, active values last. Existing `providerContextCap` (line 10) remains unchanged, so disabled selections never activate catalog capping. `nativeContextLimits` at `metadata.ts:242-263` reads only active caps; long-window opt-in at 278-299 retains per-model ceilings. `src/codex/catalog/provider-fetch.ts:626` keys discovery by active caps; remembered-only changes do not need a new runtime cache key. + +Provider removal/rename consumers are not optional: `providerEditorCandidate` at `provider-routes.ts:267-271`, editor adoption at 288-291, persisted editor callback at 849-852, direct deletion at 1381-1385, and `provider-id-rewrite.ts:113-124`. All must move/clear remembered state along with active state, without enabling it. + +## Exact source carry map + +| Operation | Path | +|---|---| +| NEW | `docs-site/public/screenshots/openai-context-cap-off.png` | +| NEW | `docs-site/public/screenshots/openai-context-cap-on.png` | +| MODIFY | `docs-site/src/content/docs/reference/management-api.md` | +| MODIFY | `gui/src/pages/Models.tsx` | +| MODIFY | `gui/src/pages/models-shared.ts` | +| MODIFY | `gui/tests/models-native-group-controls.test.ts` | +| MODIFY | `gui/tests/models-status-toast.test.tsx` | +| MODIFY | `src/codex/catalog/metadata.ts` | +| MODIFY | `src/config.ts` | +| MODIFY | `src/providers/context-cap.ts` | +| MODIFY | `src/providers/provider-id-rewrite.ts` | +| MODIFY | `src/server/management/provider-routes.ts` | +| MODIFY | `src/types/config.ts` | +| MODIFY | `tests/codex-integration/native-model-toggle.test.ts` | +| MODIFY | `tests/providers/provider-id-rewrite.test.ts` | +| MODIFY | `tests/server/management-provider-validation.test.ts` | + +All 14 textual source files were reviewed. Both PNGs are accounted for as evidence assets; blob identity verified but pixels not inspected. Existing tests are extended, so source carry requires no new test-layout registration. + +### Carry method and attribution + +Prefer a **final diff port** atop the 010 child branch. Actual commit metadata names **Robin Bially <7304732+RobinBially@users.noreply.github.com>** on original `216c11a4941e1b00dc8a069de4ab75128c5f8abf` and clarification `202028670b8f3ec8b8b51761a89cccae081b32a7`. Preserve both contributions with `Co-authored-by: Robin Bially <7304732+RobinBially@users.noreply.github.com>` in the carry commit and eventual squash body. Merge commits brought in evolving dev; do not cherry-pick them blindly. The two non-merge commits can be considered for selective cherry-pick, but no cherry-pick was tested and the final delta must be compared to the pinned final PR diff. + +Read-only `git apply --check --exclude='*.png' .tmp/lane-b/3654.patch` returned 0 against the recorded HEAD, before 010 is applied. Cached text equals the local source base-to-head diff after normalizing abbreviated index-hash lines. This is not proof against the future stacked parent. Preserve dev's unrelated xAI changes: `src/config.ts:583-587` and provider creation/patch handling changed since the source base; port hunks rather than replacing those files. + +Binary source assets to carry from the pinned head (the cached patch omits payload): + +- `docs-site/public/screenshots/openai-context-cap-off.png`, blob `092f7377f9781e9ea6b52b04cee5b1069c59f9c6`. +- `docs-site/public/screenshots/openai-context-cap-on.png`, blob `204cbd24a72b0d37efe900cedc02f72d9515fb51`. + +## Implementation sequence within this phase + +1. Add the optional positive-integer map to OcxConfig and Zod config schema; no default map is required. Existing configs lacking it must retain active values via the merged selector. +2. Add selector/forget helpers and modify per-provider/global/all-provider mutations exactly as the source diff. Use existing top-level deletion provenance helper when a map empties. Keep active-only readers unchanged. +3. Extend rename handling to both fields and preserve collision reporting. Switch editor candidate, persisted editor and direct provider removal from disable to forget. Adopt remembered values back into live config after successful editor persistence. +4. Expose `values` on GET and every successful PUT response. Update the route comment at current `provider-routes.ts:1433-1436`: implicit enable restores remembered value, then global default; it no longer always chooses global. Retain existing validation and catalog-refresh branches. +5. Add GUI response/cache/state for `values`; old server/cache fallback is `values ?? caps ?? {}`. Remove the native-only forced enable value and display `active ?? remembered ?? global`. Keep the select present but disabled while cap is off; custom drafts use that same displayed selection. +6. Remove only the special 922k bypass for ordinary native windows in `metadata.ts`; keep `longWindowOptInCeiling`, provider/per-model overlay precedence, and supported ceiling clamps intact. +7. Carry source tests, fill the concrete branch-coverage gaps below, synchronize directly affected documentation, and obtain hosted CI/rendered evidence. Do not implement any later layer here. + +## Activation matrix and exact test changes + +| Scenario / trigger | Required observed result | Test owner | +|---|---|---| +| First enable, no saved selection, global 350k | active and returned selected value 350k | source-added `tests/server/management-provider-validation.test.ts` | +| Explicit 128k, off, reload, implicit on | no active map while off, persisted remembered 128k; returns to active 128k | same source test | +| Remembered 128k while off | `providerContextCap` undefined and native/routed catalog not narrowed by remembered map | extend same test to inspect off-state catalog, not only config | +| Active legacy config with no remembered map | selection response derives active value; first off records it; reload/on restores it | extend same test file with legacy active 128k fixture | +| Global value change without setAll | existing enabled and disabled choices unchanged; first-time provider gets new global | existing test at 4168-4189 plus remembered assertion | +| Enabled A=128k, disabled B remembers 256k, `{value:600000,setAll:true}` | A active/remembered 600k, B still disabled/remembered 256k; B later restores 256k | add explicit two-provider case near existing 4196-4202; source only exercises all-active case | +| Same initial state, `{setAll:true}` without value | every configured provider active and remembered at global; replaces B's 256k | extend existing 4212-4218 and source-added setAll case | +| `{setAll:false}` | all active caps removed, all selections retained | source test plus multi-provider extension | +| Invalid/mixed body, unknown provider, fractional value floors to zero | existing 400/404, no active or remembered mutation | extend existing negatives at 4243-4321 to snapshot both maps | +| Rename while disabled | remembered key moved, no active cap; destination collision preserved/reported | source `tests/providers/provider-id-rewrite.test.ts` + collision case for remembered map | +| Direct removal or editor removal | both maps lose removed ID after persisted reload and live adoption; other provider selections stay | add cases in `tests/server/management-provider-validation.test.ts` near existing delete/editor fixtures | +| GUI native OpenAI 128k off/on | display stays 128k, aria-pressed changes, request body contains only provider/enabled | source `gui/tests/models-status-toast.test.tsx` | +| GUI reload while disabled, old cached/server response without values | remembered 128k restored after reload; old shape falls back to caps/global without crashing | extend the same rendered test with remount and legacy-response fixtures | +| Cap=922k on gpt-5.4 | window becomes 922k, not 1M | source expectation update in `tests/codex-integration/native-model-toggle.test.ts:299` | +| Supported long window ceilings / narrower overlays | gpt-5.6-sol ≤922k, Astra ≤872k; gpt-5.5 remains 272k; smaller cap and model overlay win | retain/extend existing native toggle cases around 289-315, inspect `metadata.ts:289-304` | + +`gui/tests/models-native-group-controls.test.ts` is a source-oracle guard and cannot replace the rendered behavior test. The source GUI test does not remount while disabled, and the source backend test does not exercise a disabled provider through global setAll; those are explicit amendments, not already-proven coverage. Existing `tests/providers/context-cap-unknown-window.test.ts` remains relevant to active-only routed fallback. + +## Public documentation synchronization + +Source updates `docs-site/src/content/docs/reference/management-api.md:237` with both request shapes. Additional MODIFY paths are required because `docs-site/src/content/docs/reference/configuration/providers.md:31-32` currently contradicts that new API text and lacks the stored-selection field. Apply this exact English row contract: + +```diff +-| `providerContextCaps?` | `Record` | `{}` | Per-provider Codex-visible context caps. A cap only lowers a known context window. | ++| `providerContextCaps?` | `Record` | `{}` | Active provider context limits. Ordinary windows are lowered; native models with a supported long window can expand only up to their own supported ceiling. | ++| `providerContextCapValues?` | `Record` | `{}` | Last selected provider limits, retained while disabled. These values do not activate a cap. An enabled value takes precedence over a remembered value. | +-| `contextCapValue?` | `number` | `350000` | Default value used by the dashboard context-cap controls. Changing it applies the value to every routed provider — including providers without an existing `providerContextCaps` entry — only when "apply to every routed provider" is toggled on; otherwise each provider keeps its own cap. | ++| `contextCapValue?` | `number` | `350000` | Default used on first enable. A later enable restores the selected provider value. Updating the global value with `setAll: true` changes enabled caps only; `setAll: true` without a value enables all configured providers at the current global value. | +``` + +MODIFY `docs-site/src/content/docs/guides/model-routing.md:94-97` by adding after the existing active-cap paragraph: “Switching a cap off retains its selection in `providerContextCapValues`; switching it on restores that selection. A remembered selection never applies a limit while disabled.” Keep the current enabled-only global-update wording. Keep the valid explicit 922k opt-in example in `reference/configuration/providers.md:87-91`; eliminating the switch's forced value does not remove explicit native opt-in support. + +Mirror these same key/default/activation semantics in the existing translated references and routing guides below; keep the source English identifiers verbatim and use the established locale prose. For API references append the source `caps`/`values` explanation and two concrete JSON payloads; for providers references replace the stale all-providers global-update claim, insert `providerContextCapValues`, and distinguish supported native ceilings. This is contract synchronization, not unrelated translation cleanup. + +- MODIFY `docs-site/src/content/docs/fr/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/fr/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/fr/guides/model-routing.md`. +- MODIFY `docs-site/src/content/docs/ja/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/ja/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/ja/guides/model-routing.md`. +- MODIFY `docs-site/src/content/docs/ko/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/ko/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/ko/guides/model-routing.md`. +- MODIFY `docs-site/src/content/docs/ru/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/ru/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/ru/guides/model-routing.md`. +- MODIFY `docs-site/src/content/docs/tr/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/tr/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/tr/guides/model-routing.md`. +- MODIFY `docs-site/src/content/docs/zh-cn/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/zh-cn/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/zh-cn/guides/model-routing.md`. +- MODIFY `docs-site/src/content/docs/zh-tw/reference/management-api.md`. +- MODIFY `docs-site/src/content/docs/zh-tw/reference/configuration/providers.md`. +- MODIFY `docs-site/src/content/docs/zh-tw/guides/model-routing.md`. + +No new GUI visible string is required. The API reference remains the public behavior SoT; `structure/05_gui-and-management-api.md:137` already names the owner and endpoint and needs no ownership rewrite. + +## Interphase dependencies and readiness + +Stack this as the next layer after 010. Preserve 010's manual-only OpenAI group identity, appended GUI test and manual-visibility documentation. Shared paths with 010: `gui/tests/models-native-group-controls.test.ts`, API references after translation sync. The #3571 layer later shares `src/types/config.ts`; #3659 later shares provider configuration docs. Lane A's proxy work can touch `src/config.ts`; main must reconcile that file rather than overwrite whole snapshots. + +Source review thread `discussion_r3940577476` is resolved; carry clarification commit `202028670b8f3ec8b8b51761a89cccae081b32a7` and both setAll meanings. Zero unresolved source review threads was returned; this does not waive independent carried-head review. Source Cross-platform CI run **33974043191** and React Doctor run **33974043207** were `action_required`. No product CI execution pass is established. The concrete remaining gates are hosted execution, branch-coverage additions, documentation parity and rendered carried-head off/reload/on evidence. + +Browser evidence must show chosen 128k, disabled 128k after reload, enabled 128k, corresponding request/response payloads, and ordinary-native 922k ceiling behavior. Label the off-state selector as the next-enable choice through the existing context-cap label; do not report the displayed value as an active window. Use the existing browser tooling on a CI/remote-built isolated surface; no local build or service mutation is authorized by this research packet. + +## Pinned public source diff + +Full textual source diff follows. Binary blobs are pinned above. Amend only the paths and behaviors explicitly named in this phase; do not replace entire files with stale source versions. + +```diff +diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md +index 784c6e17f5..133084ba93 100644 +--- a/docs-site/src/content/docs/reference/management-api.md ++++ b/docs-site/src/content/docs/reference/management-api.md +@@ -237,6 +237,18 @@ keys are not returned to dashboard clients. + | `GET, PUT /api/provider-context-caps` | Read or update global, all-provider, or one-provider context caps | 400 invalid request; 404 unknown provider | + | `GET /api/provider-presets` | Return GUI provider presets derived from the runtime registry | — | + ++The provider context-cap response includes `caps` (active limits) and `values` (last selected ++values, retained while disabled). Enabling a provider without `value` restores its selection, ++or uses the global `contextCapValue` on first enable. This also applies to OpenAI: the switch ++does not select a special 922k mode. An active cap bounds every native window; models with a ++supported long-context window may expand only up to their own supported ceiling. ++Updating the global value with `{ "value": 600000, "setAll": true }` changes only enabled ++provider caps; disabled providers keep their remembered selections when later enabled. ++In contrast, `{ "setAll": true }` without `value` enables every configured provider at the ++current global value, replacing their remembered selections. Turning a cap off does not ++activate its remembered value or erase the selection. ++ ++ + `provider_has_dependent_combos` is a safety barrier: remove or edit the dependent combos before + deleting their provider. + +diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx +index 91971cf54d..5b90f29a73 100644 +--- a/gui/src/pages/Models.tsx ++++ b/gui/src/pages/Models.tsx +@@ -51,8 +51,6 @@ import { + fmtK, + NATIVE_CAP_OPTIONS, + NATIVE_CAP_OPTION_SET, +- NATIVE_GPT56_DEFAULT_WINDOW, +- NATIVE_GPT56_OPT_IN_WINDOW, + PAGE, + readCollapsedProviders, + THREAD_OPTION_SET, +@@ -75,6 +73,7 @@ type CachedModelsPage = { + selectedModels: ProviderModelMap; + disabled: string[]; + contextCaps: Record; ++ contextCapValues?: Record; + contextCapValue: number; + }; + +@@ -213,6 +212,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + const [search, setSearch] = useState>({}); + const [limit, setLimit] = useState>({}); + const [contextCaps, setContextCaps] = useState>(() => cached?.contextCaps ?? {}); ++ const [contextCapValues, setContextCapValues] = useState>(() => cached?.contextCapValues ?? {}); + const [contextCapValue, setContextCapValue] = useState(() => cached?.contextCapValue ?? 350_000); + const [customCap, setCustomCap] = useState(""); + const [showCustom, setShowCustom] = useState(false); +@@ -428,6 +428,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + selectedModels: selectionData, + disabled: [...nextDisabled], + contextCaps: capsData.caps ?? {}, ++ contextCapValues: capsData.values ?? capsData.caps ?? {}, + contextCapValue: nextCapValue, + } satisfies CachedModelsPage; + writeSessionListCache(cacheKey, next); +@@ -447,6 +448,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + setSelectedModels(next.selectedModels); + setContextCapValue(next.contextCapValue); + setContextCaps(next.contextCaps); ++ setContextCapValues(next.contextCapValues ?? next.contextCaps); + }, []); + + const catalogResource = useDataSurface( +@@ -722,7 +724,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + } + }; + +- const toggleProviderCap = async (provider: string, nativeGroup = false) => { ++ const toggleProviderCap = async (provider: string) => { + setBusy(true); + busyRef.current = true; + setStatus(""); +@@ -733,13 +735,12 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + const r = await fetch(`${apiBase}/api/provider-context-caps`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, +- body: JSON.stringify(enabled && nativeGroup +- ? { provider, enabled, value: NATIVE_GPT56_OPT_IN_WINDOW } +- : { provider, enabled }), ++ body: JSON.stringify({ provider, enabled }), + }); + try { + const data = await readJsonOrThrow(r, t("models.capSaveFailed")); + setContextCaps(data?.caps ?? {}); ++ setContextCapValues(data?.values ?? data?.caps ?? {}); + setOk(true); + setStatus(t("models.capApplied")); + await load(true); +@@ -784,6 +785,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + const data = await readJsonOrThrow(r, t("models.capSaveFailed")); + if (typeof data?.value === "number" && Number.isFinite(data.value) && data.value > 0) setContextCapValue(data.value); + setContextCaps(data?.caps ?? {}); ++ setContextCapValues(data?.values ?? data?.caps ?? {}); + setOk(true); + setStatus(t("models.capApplied")); + await load(true); +@@ -828,7 +830,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + const onSelectProviderCap = (provider: string, raw: string) => { + if (raw === CUSTOM_OPTION) { + setProviderCapCustomOpen(prev => ({ ...prev, [provider]: true })); +- setProviderCapCustomDraft(prev => ({ ...prev, [provider]: String(contextCaps[provider] ?? contextCapValue) })); ++ setProviderCapCustomDraft(prev => ({ ...prev, [provider]: String(contextCaps[provider] ?? contextCapValues[provider] ?? contextCapValue) })); + return; + } + setProviderCapCustomOpen(prev => ({ ...prev, [provider]: false })); +@@ -1176,18 +1178,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + const recentForProvider = modelDiscovery?.recentArrivals[provider] ?? []; + const recentIds = new Set(recentForProvider.map(row => row.id)); + const capOn = contextCaps[provider] !== undefined; +- const providerCap = contextCaps[provider] ?? contextCapValue; +- // With the cap off, `providerCap` is only the value a future toggle would apply — for the +- // native group that is the 350k default, which says nothing true about what Codex sees. +- // The honest number there is the largest window the rows actually advertise. +- const widestRowWindow = rows.reduce((widest, row) => { +- const window = typeof row.contextWindow === "number" && row.contextWindow > 0 ? row.contextWindow : undefined; +- if (window === undefined) return widest; +- return widest === undefined || window > widest ? window : widest; +- }, undefined); +- const capDisplayValue = capOn +- ? providerCap +- : (nativeProviderGroup ? NATIVE_GPT56_DEFAULT_WINDOW : (widestRowWindow ?? providerCap)); ++ // Show the value the next enable will actually use, including a remembered selection. ++ const capDisplayValue = contextCaps[provider] ?? contextCapValues[provider] ?? contextCapValue; + // The native group offers only the three windows GPT-5.6 actually has contracts for + // (272k live, 372k legacy, 1.05M measured); routed providers keep the generic ladder. + // The set has to follow the list, or a saved value outside it loses its option. +@@ -1348,7 +1340,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; + screen-reader user was not told this governs the context window. + The number belongs to the adjacent Select, which is where a value + goes (020_control_affordances.md). */} +- toggleProviderCap(provider, nativeProviderGroup)} disabled={busy} label={t("models.contextCapLabel")} showLabel /> ++ toggleProviderCap(provider)} disabled={busy} label={t("models.contextCapLabel")} showLabel /> + {/* Always rendered, disabled when the cap is off. A cap-off provider used to + drop this control entirely, which is the defect the user reported: openai + showed 1.05M and anthropic showed nothing, so the two rows started at +diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts +index 1575a52ac9..fdc487301c 100644 +--- a/gui/src/pages/models-shared.ts ++++ b/gui/src/pages/models-shared.ts +@@ -56,6 +56,7 @@ export interface ProviderContextCapsResponse { + cap?: number; + value?: number; + caps?: Record; ++ values?: Record; + } + + export interface V2Status { +diff --git a/gui/tests/models-native-group-controls.test.ts b/gui/tests/models-native-group-controls.test.ts +index ffd27ad17f..dbe0d68a3c 100644 +--- a/gui/tests/models-native-group-controls.test.ts ++++ b/gui/tests/models-native-group-controls.test.ts +@@ -72,14 +72,9 @@ test("every provider keeps its window readable with the cap switched off", async + // slot is occupied on every card (040_cap_cluster_and_occupied_slot.md), which makes the + // property this test protects strictly wider than it was. + expect(src).not.toContain("{(capOn || nativeProviderGroup) && ("); +- // With the cap off the stored value is only what a future toggle would apply — the 350k +- // default — so the display falls back to the widest window the rows actually advertise. +- // Matched as separate fragments because the expression is wrapped across lines now, and +- // it grew a native branch: with the cap off the native group shows its default window +- // rather than the widest advertised row. A single-line literal pinned the formatting +- // instead of the behaviour and broke on the reflow that introduced that branch. +- expect(src).toContain("const capDisplayValue = capOn"); +- expect(src).toContain("nativeProviderGroup ? NATIVE_GPT56_DEFAULT_WINDOW : (widestRowWindow ?? providerCap)"); ++ // The disabled select previews the persisted choice or global default used by enable. ++ expect(src).toContain("contextCaps[provider] ?? contextCapValues[provider] ?? contextCapValue"); ++ expect(src).not.toContain("value: NATIVE_GPT56_OPT_IN_WINDOW"); + // The select is inert until the cap is actually on: showing a number is not the same as + // offering to change one. + expect(src).toContain("disabled={busy || !capOn}"); +diff --git a/gui/tests/models-status-toast.test.tsx b/gui/tests/models-status-toast.test.tsx +index 29c902c2cb..5ecadb078e 100644 +--- a/gui/tests/models-status-toast.test.tsx ++++ b/gui/tests/models-status-toast.test.tsx +@@ -175,3 +175,40 @@ test("success toast expires after 6s and a repeated action re-arms it", async () + await fireTimers(6000); + expect(container.querySelector(".action-toast")).toBeNull(); + }); ++ ++test("OpenAI context switch restores the selected cap instead of forcing 922k", async () => { ++ testWindow.sessionStorage.clear(); ++ let caps: Record = {openai:128_000}; ++ const values = {openai:128_000}; ++ const bodies: unknown[] = []; ++ const fallback = globalThis.fetch; ++ globalThis.fetch = (async (input, init) => { ++ const url = String(input); ++ if (url.endsWith("/api/models")) return Response.json([ ++ {provider:"openai",id:"gpt-5.5",namespaced:"gpt-5.5",native:true,disabled:false,contextWindow:caps.openai??272_000}, ++ ]); ++ if (url.endsWith("/api/providers")) return Response.json([{name:"openai",authMode:"forward",liveModels:false}]); ++ if (url.endsWith("/api/provider-context-caps")) { ++ if (init?.method === "PUT") { ++ const body=JSON.parse(String(init.body)); bodies.push(body); ++ caps=body.enabled ? {openai:values.openai} : {}; ++ } ++ return Response.json({caps,values,value:350_000}); ++ } ++ return fallback(input,init); ++ }) as typeof fetch; ++ const { createRoot } = await import("react-dom/client"); ++ await act(async () => { root=createRoot(container); root.render(); }); ++ const settle=async()=>{await new Promise(resolve=>testWindow.setTimeout(resolve,0));}; ++ await act(settle); ++ const cluster=()=>container.querySelector(".models-cap-cluster")!; ++ const toggle=()=>cluster().querySelector("button.switch")!; ++ expect(cluster().textContent).toContain("128k"); ++ await act(async()=>{toggle().click();await settle();}); ++ expect(toggle().getAttribute("aria-pressed")).toBe("false"); ++ expect(cluster().textContent).toContain("128k"); ++ await act(async()=>{toggle().click();await settle();}); ++ expect(toggle().getAttribute("aria-pressed")).toBe("true"); ++ expect(cluster().textContent).toContain("128k"); ++ expect(bodies).toEqual([{provider:"openai",enabled:false},{provider:"openai",enabled:true}]); ++}); +diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts +index a50dd9469f..f239ce48b1 100644 +--- a/src/codex/catalog/metadata.ts ++++ b/src/codex/catalog/metadata.ts +@@ -299,8 +299,6 @@ function narrowToLimits(raw: number | undefined, slug: string, input: NativeCont + return overlay !== undefined && cap !== undefined ? Math.min(window, cap) : window; + } + const narrowed = overlay === undefined ? raw : Math.min(raw, overlay); +- // 922k is the GPT-5.6 1M opt-in, not a request to shrink gpt-5.4's 1M window. +- if (cap === NATIVE_GPT56_MAX_INPUT_TOKENS) return narrowed; + return applyProviderContextCap(narrowed, cap) ?? narrowed; + } + +diff --git a/src/config.ts b/src/config.ts +index d2b0bb707a..cd14e26b9c 100644 +--- a/src/config.ts ++++ b/src/config.ts +@@ -1134,6 +1134,7 @@ const configSchema = z.object({ + subagentModels: z.array(z.string().min(1)).optional().catch(undefined), + clientIntegrations: clientIntegrationsSchema.optional().catch(undefined), + providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), ++ providerContextCapValues: z.record(z.string(), z.number().int().positive()).optional(), + contextCapValue: z.number().int().positive().optional(), + multiAgentGuidanceEnabled: z.boolean().optional(), + // Invalid optional recovery config must not discard unrelated provider/account state. +diff --git a/src/providers/context-cap.ts b/src/providers/context-cap.ts +index d10807ced2..9dd10126ad 100644 +--- a/src/providers/context-cap.ts ++++ b/src/providers/context-cap.ts +@@ -43,13 +43,21 @@ export function globalContextCapValue(config: Pick + return isValidContextCap(value) ? Math.floor(value) : DEFAULT_PROVIDER_CONTEXT_CAP; + } + ++/** Active caps win over remembered values from an earlier switch-off. */ ++export function selectedProviderContextCaps(config: Pick): Record { ++ return { ...providerContextCaps({ providerContextCaps: config.providerContextCapValues }), ...providerContextCaps(config) }; ++} ++ + export function setProviderContextCap(config: OcxConfig, provider: string, enabled: boolean, value?: number): void { + const next = providerContextCaps(config); ++ const selected = selectedProviderContextCaps(config); + if (enabled) { +- next[provider] = isValidContextCap(value) ? Math.floor(value) : globalContextCapValue(config); ++ next[provider] = isValidContextCap(value) ? Math.floor(value) : (selected[provider] ?? globalContextCapValue(config)); ++ selected[provider] = next[provider]; + } else { + delete next[provider]; + } ++ if (Object.keys(selected).length > 0) config.providerContextCapValues = selected; + if (Object.keys(next).length > 0) config.providerContextCaps = next; + else deleteConfigTopLevelKey(config, "providerContextCaps"); + } +@@ -66,18 +74,33 @@ export function setGlobalContextCapValue(config: OcxConfig, value: number, apply + if (!applyToAll) return; + const caps = providerContextCaps(config); + for (const provider of Object.keys(caps)) caps[provider] = next; +- if (Object.keys(caps).length > 0) config.providerContextCaps = caps; ++ if (Object.keys(caps).length > 0) { ++ config.providerContextCaps = caps; ++ config.providerContextCapValues = { ...selectedProviderContextCaps(config), ...caps }; ++ } + } + + /** Enable the cap for every named provider at the current value, or clear all caps. */ + export function setAllProviderContextCaps(config: OcxConfig, providerNames: string[], enabled: boolean): void { ++ const selected = selectedProviderContextCaps(config); + if (!enabled) { ++ if (Object.keys(selected).length > 0) config.providerContextCapValues = selected; + deleteConfigTopLevelKey(config, "providerContextCaps"); + return; + } + const value = globalContextCapValue(config); + const next: Record = {}; +- for (const name of providerNames) next[name] = value; ++ for (const name of providerNames) { next[name] = value; selected[name] = value; } ++ if (Object.keys(selected).length > 0) config.providerContextCapValues = selected; + if (Object.keys(next).length > 0) config.providerContextCaps = next; + else deleteConfigTopLevelKey(config, "providerContextCaps"); + } ++ ++/** Provider removal clears both the active limit and its remembered selection. */ ++export function forgetProviderContextCap(config: OcxConfig, provider: string): void { ++ setProviderContextCap(config, provider, false); ++ const values = { ...config.providerContextCapValues }; ++ delete values[provider]; ++ if (Object.keys(values).length > 0) config.providerContextCapValues = values; ++ else deleteConfigTopLevelKey(config, "providerContextCapValues"); ++} +diff --git a/src/providers/provider-id-rewrite.ts b/src/providers/provider-id-rewrite.ts +index 10a6f3f211..0111a8674a 100644 +--- a/src/providers/provider-id-rewrite.ts ++++ b/src/providers/provider-id-rewrite.ts +@@ -112,14 +112,16 @@ export function rewriteProviderReferences(config: OcxConfig, from: string, to: s + + // Keys. `providerContextCaps` is KEYED by provider id — a prefix rewrite would + // silently orphan the cap — and a destination key may already be occupied. +- const caps = config.providerContextCaps; +- if (caps && Object.hasOwn(caps, from)) { +- if (Object.hasOwn(caps, to)) { +- collisions.push(`providerContextCaps.${to}`); +- } else { +- caps[to] = caps[from]!; +- delete caps[from]; +- changed += 1; ++ for (const field of ["providerContextCaps", "providerContextCapValues"] as const) { ++ const caps = config[field]; ++ if (caps && Object.hasOwn(caps, from)) { ++ if (Object.hasOwn(caps, to)) { ++ collisions.push(`${field}.${to}`); ++ } else { ++ caps[to] = caps[from]!; ++ delete caps[from]; ++ changed += 1; ++ } + } + } + +diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts +index e26420e003..7b4e10dec6 100644 +--- a/src/server/management/provider-routes.ts ++++ b/src/server/management/provider-routes.ts +@@ -60,7 +60,7 @@ import { clearThreadAccountMap } from "../../codex/routing"; + import { primeCodexPoolQuotas } from "../../codex/auth-api"; + import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache"; + import { getCodexModelEntitlementStatus } from "../../codex/model-entitlements"; +-import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; ++import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, selectedProviderContextCaps, forgetProviderContextCap, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; + import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget"; + import { resolveCodexHomeDir } from "../../codex/home"; + import { readUsageEntries } from "../../usage/log"; +@@ -267,7 +267,7 @@ function providerEditorCandidate( + candidate.providers = providers; + for (const name of removedProviders) { + dropProviderCustomModels(candidate, name); +- setProviderContextCap(candidate, name, false); ++ forgetProviderContextCap(candidate, name); + } + const validated = validateConfigCandidate(candidate); + if (!validated.ok) { +@@ -288,6 +288,8 @@ function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): vo + else live.customModels = structuredClone(persisted.customModels); + if (persisted.providerContextCaps === undefined) delete live.providerContextCaps; + else live.providerContextCaps = structuredClone(persisted.providerContextCaps); ++ if (persisted.providerContextCapValues === undefined) delete live.providerContextCapValues; ++ else live.providerContextCapValues = structuredClone(persisted.providerContextCapValues); + if (persisted.disabledModels === undefined) delete live.disabledModels; + else live.disabledModels = [...persisted.disabledModels]; + if (persisted.modelDiscovery === undefined) delete live.modelDiscovery; +@@ -847,7 +849,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; ++ /** Last selected provider caps; retained while a cap is switched off. Not an active limit. */ ++ providerContextCapValues?: Record; + /** Global Codex-visible context cap value (tokens). Falls back to DEFAULT_PROVIDER_CONTEXT_CAP. */ + contextCapValue?: number; + /** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */ +diff --git a/tests/codex-integration/native-model-toggle.test.ts b/tests/codex-integration/native-model-toggle.test.ts +index 52044cde5a..eac9ec0960 100644 +--- a/tests/codex-integration/native-model-toggle.test.ts ++++ b/tests/codex-integration/native-model-toggle.test.ts +@@ -296,7 +296,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { + const over = nativeModelRows({ providerContextCaps: { openai: 2_000_000 } }); + expect(over.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(922_000); + expect(raised.find(r => r.slug === "gpt-5.5")?.contextWindow).toBe(272_000); +- expect(raised.find(r => r.slug === "gpt-5.4")?.contextWindow).toBe(1_000_000); ++ expect(raised.find(r => r.slug === "gpt-5.4")?.contextWindow).toBe(922_000); + }); + + test("nativeModelRows applies providerContextCaps.openai as a ceiling (#1430)", () => { +diff --git a/tests/providers/provider-id-rewrite.test.ts b/tests/providers/provider-id-rewrite.test.ts +index 1df8753b82..cc87ae55fc 100644 +--- a/tests/providers/provider-id-rewrite.test.ts ++++ b/tests/providers/provider-id-rewrite.test.ts +@@ -209,3 +209,10 @@ test("removal leaves the custom-model ownership marker untouched", () => { + legacyOwnedSlugs: ["agnes-ai/agnes-2.5-flash", "huggingface/DeepSeek-V4-Flash-0731"], + }); + }); ++ ++ test("moves remembered provider caps without activating them", () => { ++ const config = { providerContextCapValues: { [FROM]: 128_000 } } as unknown as OcxConfig; ++ expect(rewriteProviderReferences(config, FROM, TO)).toEqual({ changed: 1, collisions: [] }); ++ expect(config.providerContextCapValues).toEqual({ [TO]: 128_000 }); ++ expect(providerContextCap(config, TO)).toBeUndefined(); ++}); +diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts +index 35a7924ebe..36bf70be57 100644 +--- a/tests/server/management-provider-validation.test.ts ++++ b/tests/server/management-provider-validation.test.ts +@@ -4664,3 +4664,31 @@ describe("provider transport option management contract (#1668, #2816)", () => { + }); + }); + }); ++ ++test("OpenAI provider cap remembers an explicit window across off, reload, and on", async () => { ++ mkdirSync(TEST_DIR, { recursive: true }); ++ process.env.OPENCODEX_HOME = TEST_DIR; ++ let live: OcxConfig = { ++ port: 0, defaultProvider: "openai", contextCapValue: 350_000, ++ providers: { openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", liveModels: false } }, ++ }; ++ saveConfig(live); ++ const put = async (body: unknown) => { ++ const url = new URL("http://localhost/api/provider-context-caps"); ++ const response = await handleManagementAPI(new Request(url, {method:"PUT", headers:{"content-type":"application/json"}, body:JSON.stringify(body)}), url, live, {createManagementConvergeCodex:catalogConvergenceFactory()}); ++ expect(response?.status).toBe(200); ++ return response!.json(); ++ }; ++ expect(await put({provider:"openai",enabled:true})).toMatchObject({caps:{openai:350_000}}); ++ await put({provider:"openai",enabled:true,value:128_000}); ++ expect(await put({provider:"openai",enabled:false})).toMatchObject({caps:{},values:{openai:128_000}}); ++ live = loadConfig(); ++ expect(live.providerContextCaps).toBeUndefined(); ++ expect(await put({provider:"openai",enabled:true})).toMatchObject({caps:{openai:128_000}}); ++ const {nativeModelRows} = await import("../../src/codex/catalog"); ++ expect(nativeModelRows(live).filter(row=>row.contextWindow !== undefined).every(row=>row.contextWindow! <= 128_000)).toBe(true); ++ await put({setAll:false}); ++ expect(loadConfig().providerContextCapValues?.openai).toBe(128_000); ++ await put({setAll:true}); ++ expect(loadConfig().providerContextCaps?.openai).toBe(350_000); ++}); +``` diff --git a/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md b/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md new file mode 100644 index 0000000000..d4eff0de6f --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md @@ -0,0 +1,665 @@ +# 030 — Preserve configured Go efforts and separate complete picker order + +Class: C3 cross-module catalog contract. One future PABCD cycle consumes this document after the model-toggle/context layers (010/020), before management (040). This cycle carries only #3571; message recovery #3568 is explicitly outside it. + +## Outcome and necessity + +Configured canonical `opencode-go` efforts survive generation and retained sync without injected max/ultra. A nonblank bare catalog id in `modelPickerOrder` opts into complete-picker display ordering; exact ids outrank raw/encoded equivalents. Routed-only and empty configurations retain legacy behavior. Display sorting must leave the natural five-candidate subagent roster unchanged. Existing `applyReasoningLevels`, `slugEquivalenceKey`, `SPAWN_PRIORITY_FIELD`, and observed-state merge own these behaviors; reuse them, with no new catalog engine or provider roster. + +## Current owners and amendment anchors + +- `src/codex/catalog/sync.ts:315,358,412`: `deriveEntry` currently preserves exact combo/forward ladders, but Go uses ordinary synthetic tiers. Pass a separate `preserveExactReasoning` predicate to both derive branches; do not alter exact-combo metadata policy. +- `src/codex/catalog/effort.ts:223-247`: `preserveExact` already skips synthetic max/ultra insertion; retain this owner unchanged. +- `src/codex/catalog/sync.ts:518,654-668`: builder currently applies routed display ordering and records natural spawn priority. Reject whitespace-only entries consistently, without trimming significant ids or changing routed-only ordering. +- `src/codex/catalog/sync.ts:781,814`: add `modelPickerRank`, `applyFullModelPickerOrder`, optional order/selectors on `ObservedCatalogMergeInput`; retain the backwards-compatible wrapper at `sync.ts:1215` with empty defaults. +- `src/codex/convergence.ts:344` and `src/codex/catalog/sync.ts:1764`: both production merge callers must pass order and account selectors. A helper-only test is not proof of caller wiring. +- Retained native rows restore natural priority before recomputing featured priority; retained OCX routed rows rebuild featured rank with selector stride and reset obsolete display overrides. Apply full order only after native/routed admission and multi-agent version assignment. +- `src/codex/catalog/sync.ts:177-208`: `effectiveSubagentRoster` actually reads `opencodex_spawn_priority`, then visibility/v2 filters and the five-row cap. Inspect this consumer on every carry amendment, not only emitted priorities. +- `src/types/config.ts` documents `modelPickerOrder`; it has diverged since the source base and the previous context phase also touches this file. Apply only the comment delta, preserving the new context contract. + +## Focused implementation deltas + +The appendix contains the full pinned textual source delta, including complete NEW test files. Key reviewable boundaries are: + +```diff + const preserveExact = isExactComboCatalogModel(model, exactComboSlugs); ++const preserveExactReasoning = preserveExact || model?.provider === "opencode-go"; +-applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); ++applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning); +``` + +Use the same predicate in the native-template branch, preserving `codexForwardNativeCapabilityAlias !== null`. Retained merge must independently exclude `opencode-go/` from mock-max insertion while preserving the existing Reserve and exact-combo exclusions. + +```diff + const mergedModels = mergeCatalogEntriesFromObservedState({ ++ modelPickerOrder, ++ accountSelectors, + catalogModels, +``` + +Repeat at retained sync. Complete ordering preserves `entry[SPAWN_PRIORITY_FIELD] ?? entry.priority ?? 9`, records that natural value, and sets display priority to exact/equivalent rank or `pickerOrder.length + natural`; empty/routed-only input returns before mutation. The retained-row block must execute before final routed filtering/merge, using fresh `featured` and selector stride. Do not transplant the whole 1,800-line sync module. + +## Activation and regression matrix + +| Test owner | Activate | Required observation | +|---|---|---| +| NEW `tests/codex-integration/catalog-go-exact-efforts.test.ts` | Derive Go with null and native template, `[high,max]` and `[high,xhigh]`; merge both disk-only and fresh-only Muse | Exact effort/default ladders, no synthetic max for Muse; other provider still has max/ultra | +| NEW `tests/codex-integration/catalog-full-picker-order.test.ts` | Bare native id + Go routed ids, then apply twice | Specified complete display order; unchanged stored natural ranks and byte-equivalent repeated result | +| Same | Empty, whitespace-only, routed-only, raw slash upstream id plus encoded id | Legacy behavior; no whitespace activation; exact rank wins equivalence and no suffix aliasing | +| Same | Start full order, switch to empty/routed-only during provider outage; change featured order, promote/demote; zero/two selectors and nonzero picker index | Healthy and degraded rows agree on both display and spawn rank; second merge is stable; input snapshot unmutated | +| Same plus existing `codex-v2-gate.test.ts` | Change picker only while retaining configured subagent roster; use v2 eligibility | Same five candidates and valid exact Go effort membership | +| Existing `tests/codex-integration/codex-catalog.test.ts` | Existing normalization/recovery fixtures | Existing native Reserve/exact ladders and account rows retain their contracts; align assertions only for intentional Go tier change | +| Existing `tests/test-layout.test.ts`, `tests/test-layout-tooling.test.ts` | NEW file registration | Both explicit layout map and expected fixture contain both file names in codex-integration | + +Source tests cover most matrix rows. Add a production-entry convergence/retained-sync assertion to the existing catalog tests if source tests only call the helper: configure order, run each entry under fixture IO, then compare displayed ids and `effectiveSubagentRoster` before/after. Use known fixture helpers, no live service. CI commands to select this family for a focused rerun are `bun test tests/codex-integration/catalog-full-picker-order.test.ts tests/codex-integration/catalog-go-exact-efforts.test.ts tests/codex-integration/codex-catalog.test.ts tests/codex-integration/codex-v2-gate.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts` on a CI runner only; full required CI still applies. + +## Docs, dependencies, and unresolved acceptance + +- English and French `guides/model-ordering.md` must explicitly describe opt-in and migration: old lists containing previously ignored bare ids now change complete ordering. Do not introduce a pinned-native allowlist restriction: the public source contract deliberately allows new bare catalog ids. +- English provider reference adds Go efforts/config-key examples and a roster link. The source's dated endpoint claims are not independently provider-validated by this research; carry as configured examples or require fresh primary evidence before describing them as current supported roster. No provider requests are authorized here. +- Proposed SoT amendment, MODIFY `structure/03_catalog-and-subagents.md:35`: add: “Complete picker order is enabled by a nonblank bare id in modelPickerOrder. Display priority is independent of opencodex_spawn_priority; retained rows recompute natural ranks from the current featured roster and account-selector stride. Canonical opencode-go rows preserve configured reasoning ladders in generation and retained merges.” Main owns applying this documented delta in C. +- 020 → 030 shares `src/types/config.ts`; 030 → 040 shares `tests/codex-integration/codex-catalog.test.ts` and English provider reference. Coordinate one sequential integration owner; no recovery dependency on lane A's #3568. +- No dashboard JSX change in #3571. For functional UI evidence obtain isolated CI-produced catalog/model-list output and a Codex picker capture showing native-first order plus unchanged subagent list; an old author's local-release screenshot is not carried-head proof. No local build or live default service mutation. +- No newly established algorithm blocker in this read-only source review. Pending: exact-head CI, caller-level coverage, docs build gap, source-era bare-id warning disposition, and refreshed independent review. Source metadata has no guaranteed complete review-thread list; stale CodeRabbit prose is not an unresolved-thread verdict. + +## Source and baseline + +Read on 2026-09-06 KST in `/Users/jun/.codex/worktrees/f80e/opencodex` at `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. Pinned source: [PR #3571](https://github.com/lidge-jun/opencodex/pull/3571), head `0a935c5694229760c8c1cd5a62072107d8ae6696`, source base `6585e6a70f42be8b6c81ff20d4fa0f39f7da03db`. Inputs are captured `.tmp/lane-b/3571.json` and `.patch`; no claim of a fresh remote status check is made. `git show -s` independently confirmed the head commit author below. + +| Source commit | Actual commit author | Subject | +|---|---|---| +| `e57a57d5f0299fefd37d0f3d661e7b8d81afda1d` | voiys <matej2714@gmail.com> | fix(catalog): preserve Go efforts and support native-first picker order | +| `d745d8a417dc8b56372625b656bde778270ddf41` | voiys <matej2714@gmail.com> | fix(codex): reset retained picker order after provider outages | +| `90eaaddd815f5f70263fbfe90f4968c41856fa2a` | voiys <matej2714@gmail.com> | fix(codex): normalize picker orders and retain slug compatibility | +| `0a935c5694229760c8c1cd5a62072107d8ae6696` | voiys <matej2714@gmail.com> | fix(codex): refresh retained spawn ranks during discovery outages | + +Preserve original commit authors on a clean replay; for reimplementation or squash put `Co-authored-by: voiys ` in each carried logical commit and the final squash body. PR login alone is not an author trailer. + +Safe carry strategy: main revalidates pinned source/head and incoming parent; replay the complete reviewed source series in order or reproduce its exact delta with attribution. Preserve source follow-up commits, not only the initial feature commit. Publish a child against its still-open parent; after parent squash, rebuild the child on the new dev ancestry and re-run exact-head CI. Retarget surviving children before parent branch deletion. Push uses the user's authorized `--no-verify` to avoid local hooks; this does not substitute for CI. Once dev contains the complete carried behavior, close the superseded source PR with a carry reference; do not close it for a partial/default-only slice. No linked issue is invented. + +## Exact change ledger + +Every source changed file is accounted for below. “Same base” means a read-only `git hash-object` of current file bytes matches the patch's old blob prefix; it does not prove future cherry-pick cleanliness. “Drift” requires contextual reconciliation. Source binary is explicitly unreviewed. All textual hunks were inspected as source behavior; appendix preserves exact before/after, including complete NEW test content. No source production file was edited. + +| Operation | Exact path | Baseline / disposition | +|---|---|---| +| MODIFY | `docs-site/src/content/docs/fr/guides/model-ordering.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/guides/model-ordering.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/reference/configuration/providers.md` | Drift; preserve current unrelated edits | +| MODIFY | `scripts/test-layout/layout.json` | Same base; reviewed textual delta | +| MODIFY | `src/codex/catalog/sync.ts` | Same base; reviewed textual delta | +| MODIFY | `src/codex/convergence.ts` | Same base; reviewed textual delta | +| MODIFY | `src/types/config.ts` | Drift; preserve current unrelated edits | +| NEW | `tests/codex-integration/catalog-full-picker-order.test.ts` | New source file, absent locally | +| NEW | `tests/codex-integration/catalog-go-exact-efforts.test.ts` | New source file, absent locally | +| MODIFY | `tests/codex-integration/codex-catalog.test.ts` | Same base; reviewed textual delta | +| MODIFY | `tests/codex-integration/codex-v2-gate.test.ts` | Same base; reviewed textual delta | +| MODIFY | `tests/fixtures/test-layout-expected.json` | Same base; reviewed textual delta | +| MODIFY (planned SoT addition) | `structure/03_catalog-and-subagents.md` | Public contract prose delta specified above; not in source PR | + +## Execution boundary and verifier + +This is a docs-only roadmap deliverable, not an implementation or merge receipt. The main agent owns 000, the goal/FSM, branch operations, integration, and final acceptance. No local tests, suites, typecheck, builds, hooks, provider requests, commits, pushes, or GitHub writes were run by this researcher. The next implementation cycle must re-read this plan against its actual parent tip. + +Loop archetype: spec-satisfaction repair. Trigger: carry the pinned public source PR into lane B. Stop: required behaviors, exact-head CI, reviewer disposition and parent integration all have durable evidence. Expected result: DONE only after verified dev ancestry; NOOP only if equivalent behavior is already landed; unresolved correctness/CI evidence is pending, not DONE. Scope and unattended resource bounds are inherited from main's 000; this document does not arm or amend the goal. Upward escalation: return concrete caller/test evidence to main if the carry contract cannot be satisfied; additional worker dispatch requires main's planned scope. + +CI-only verifier, inspected at the baseline below: + +- `.github/workflows/ci.yml:5` uses unfiltered `pull_request`, so child PR bases are supported. `changes` at lines 181-218 admits `src/**`, `tests/**`, `scripts/**`, `gui/**`; source changes also admit packaging. +- Linux `test 1/4..4/4`, lines 255-316, invokes `scripts/ci/run-bun-test-batches.sh`. That runner enumerates `tests` (line 197), admits `.test.ts` files (lines 46-68), and excludes only storage/API-usage families into dedicated jobs; the catalog/management files in this plan are included. Each actual batch runs `bun test --isolate --timeout 60000` under a process timeout (lines 121-126). Read logs to prove the named files ran; aggregate green alone is insufficient. +- `gates`, lines 390-449, runs root TypeScript (`bun x tsc --noEmit`), GUI tests (`cd gui && bun test --isolate tests`), privacy scan and skill-surface check. GUI changes additionally run `bun run lint` and `bun run build`; `gui/package.json:8` expands build to `tsc -b && vite build`. GUI lint is `oxlint .`, including changed locale/UI inputs; the separately named `lint:i18n` script is not a dedicated CI step. +- macOS runs two full-suite shards (lines 451-547). Windows full-suite six shards (lines 658-769) run only on `workflow_dispatch` with lane `all`; do not infer Windows full-suite coverage from packaging smoke or PR aggregate success. Main must obtain an exact-ref dispatch if Windows full-suite evidence is required, then verify the run head. +- `.github/workflows/deploy-docs.yml:3-10,24-33` builds Astro only on main push or manual dispatch and then deploys. Normal PR CI has no Astro docs build. Do not trigger this deploy workflow merely to obtain a pre-merge check. Main must arrange an approved non-deploy CI verifier on the exact candidate commit or explicitly retain this as a readiness gap; this research does not add workflow code or authorize deployment. +- Save head SHA, parent/base SHA, run URL, executed job conclusions, named test logs, approvals and unresolved-thread disposition. Source-author reported passes are historical claims, not carried-head validation. Never attest local checks that were intentionally prohibited. + + +## Pinned public source delta + +Reconcile only the touched hunks at the implementation parent. The conceptual deltas above and acceptance amendments take precedence over copying this source verbatim. This appendix records public source-PR behavior only. + +````diff +diff --git a/docs-site/src/content/docs/fr/guides/model-ordering.md b/docs-site/src/content/docs/fr/guides/model-ordering.md +index cac2b0667c..64408efa53 100644 +--- a/docs-site/src/content/docs/fr/guides/model-ordering.md ++++ b/docs-site/src/content/docs/fr/guides/model-ordering.md +@@ -23,7 +23,7 @@ priorités `i * N + j`, où `j` est la position du sélecteur en base zéro ; un + sont déplacées hors de ces groupes de sélecteurs. Codex continue de n’annoncer que les cinq premières + lignes visibles dans le sélecteur. + +-Les priorités sans sélecteur pertinentes sont : ++Sans ordre global du sélecteur, les priorités sans sélecteur pertinentes sont : + + | Entrée du catalogue | Priorité | Source | + | --- | --- : | --- | +@@ -134,8 +134,31 @@ au-delà de ce bloc mis en avant : + Les lignes routées indiquées apparaissent dans l’ordre configuré. Une ligne absente du tableau conserve sa + priorité normale et reste donc devant la bande d’affichage de `modelPickerOrder` ; indiquez toutes les + lignes routées dont vous souhaitez contrôler l’ordre relatif. Une ligne également présente dans +-`subagentModels` conserve sa priorité de mise en avant. `modelPickerOrder` ne réorganise ni les lignes +-natives non qualifiées ni celles qualifiées par un compte ; utilisez `subagentModels` pour celles-ci. ++`subagentModels` conserve sa priorité de mise en avant. Une liste contenant uniquement des identifiants ++routés conserve la position normale des lignes natives. ++ ++Pour ordonner tout le sélecteur, incluez un identifiant natif non qualifié : ++ ++```json ++{ ++ "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] ++} ++``` ++ ++Les lignes indiquées apparaissent d’abord dans l’ordre du tableau, puis les lignes absentes ++selon leur priorité naturelle. La correspondance est exacte : `gpt-5.6-sol` et ++`openai/gpt-5.6-sol` désignent deux lignes distinctes. Pour une ligne qualifiée par un compte, ++indiquez son identifiant complet, sélecteur inclus. Les formes brute et encodée du même ++identifiant routé sont acceptées, avec priorité aux correspondances exactes. Les entrées ++vides sont ignorées. ++ ++### Migration : identifiants natifs dans les listes existantes ++ ++Auparavant, les identifiants natifs dans `modelPickerOrder` étaient ignorés. Une liste ++existante contenant un identifiant natif non qualifié ordonne désormais tout le sélecteur, ++y compris les lignes mises en avant. Supprimez ces identifiants pour conserver l’ancien ++comportement limité aux lignes routées. Les listes absentes, vides ou uniquement routées ++conservent leur comportement ; les priorités des candidats sous-agents ne changent pas. + + `modelPickerOrder` ne modifie jamais l’ensemble des candidats de `spawn_agent`. Il change uniquement la + priorité visible par Codex dans le sélecteur, tandis qu’OpenCodex conserve la priorité naturelle de chaque +diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md +index 696f631a58..352c8ddb12 100644 +--- a/docs-site/src/content/docs/guides/model-ordering.md ++++ b/docs-site/src/content/docs/guides/model-ordering.md +@@ -23,7 +23,7 @@ priorities `i * N + j`, where `j` is the selector's zero-based position; a route + rows are moved outside those selector groups. Codex still advertises only the first five + picker-visible rows. + +-The relevant no-selector priorities are: ++Without complete-picker ordering, the relevant no-selector priorities are: + + | Catalog entry | Priority | Source | + | --- | ---: | --- | +@@ -133,8 +133,28 @@ featured block: + Listed routed rows appear in the configured order. A routed row omitted from the array keeps its + normal priority, so it remains ahead of the `modelPickerOrder` display band; list every routed row + whose relative position you want to control. A row also present in `subagentModels` keeps its +-featured priority. Bare native and account-qualified native rows are not reordered by +-`modelPickerOrder`; use `subagentModels` for those rows. ++featured priority. With a routed-only list, native rows keep their normal positions. ++ ++To order the complete picker, include a bare native id: ++ ++```json ++{ ++ "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] ++} ++``` ++ ++Listed rows appear first in array order, followed by unlisted rows in natural priority ++order. Matching uses exact catalog ids: `gpt-5.6-sol` and `openai/gpt-5.6-sol` are separate ++rows. Raw and encoded spellings of the same routed id are also accepted, with exact ++matches taking precedence. Empty entries are ignored. Account-qualified rows need ++their selector-qualified id in the list. ++ ++### Migration note: native ids in existing orders ++ ++Previously, native ids in `modelPickerOrder` were ignored. An existing list containing ++a bare native id now activates complete-picker ordering, including featured rows. ++Remove bare native ids to keep the previous routed-only behavior. Unset, empty and ++routed-only lists retain their behavior; subagent candidate priorities are unchanged. + + `modelPickerOrder` never changes the `spawn_agent` candidate set. It changes only the + Codex-visible picker priority while opencodex retains each moved row's natural priority for +diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md +index ab8a154ecb..24f175a09a 100644 +--- a/docs-site/src/content/docs/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/reference/configuration/providers.md +@@ -810,3 +810,21 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 + "visionSidecar": { "enabled": true } + } + ``` ++ ++ ++## OpenCode Go reasoning efforts ++ ++Go catalog rows preserve their configured reasoning efforts exactly, including during ++catalog sync. OpenCodex does not append synthetic `max` or `ultra` choices to these rows. ++Use `modelReasoningEfforts` and `modelDefaultReasoningEfforts` for each model's accepted ++upstream values. Key these per-provider maps by upstream model ID, not the routed ++`opencode-go/` catalog slug. For example, Omen Alpha (`omen-alpha`) accepts `low`, `high`, ++and `max`; Muse Spark 1.3 Contributor (`muse-spark-1.3-contributor`) accepts `minimal`, `low`, `medium`, `high`, and `xhigh` (Go endpoint validation, 2026-09-05). ++See the [OpenCode Go model list](https://opencode.ai/docs/go/#models) for the current roster. ++A configured subset can exclude the lower tiers. Other providers retain their existing behavior. ++ ++For a native-first picker, include native ids in `modelPickerOrder` followed by the ++routed ids. This orders the complete picker while preserving the separate subagent ++candidate priorities. Routed-only orders keep their previous behavior. See the ++[ordering migration note](/guides/model-ordering/#migration-note-native-ids-in-existing-orders). ++`modelDisplayNames` on a provider controls readable labels without changing wire ids. +diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json +index 29dd2c5f1c..7f3fa69999 100644 +--- a/scripts/test-layout/layout.json ++++ b/scripts/test-layout/layout.json +@@ -255,6 +255,8 @@ + "bun-stream-caps.test.ts": "lib", + "cancel-body-on-abort.test.ts": "server", + "catalog-cursor-search.test.ts": "codex-integration", ++ "catalog-full-picker-order.test.ts": "codex-integration", ++ "catalog-go-exact-efforts.test.ts": "codex-integration", + "catalog-input-modality-enum.test.ts": "codex-integration", + "catalog-llamacpp-capabilities.test.ts": "codex-integration", + "catalog-oauth-observation.test.ts": "codex-integration", +diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts +index 3f5f472baf..7b0be6e19b 100644 +--- a/src/codex/catalog/sync.ts ++++ b/src/codex/catalog/sync.ts +@@ -315,6 +315,8 @@ export function deriveEntry( + contextCap?: NativeContextLimitsInput, + ): RawEntry { + const preserveExact = isExactComboCatalogModel(model, exactComboSlugs); ++ // Go exposes model-specific upstream enums; synthetic tiers mislead subagent overrides. ++ const preserveExactReasoning = preserveExact || model?.provider === "opencode-go"; + const codexForwardNativeCapabilityAlias = model?.codexForwardNativeCapabilityAlias === true + ? upstreamNativeEntry(model.id) + : null; +@@ -359,7 +361,7 @@ export function deriveEntry( + e, + model?.reasoningEfforts, + model?.defaultReasoningEffort, +- preserveExact || codexForwardNativeCapabilityAlias !== null, ++ preserveExactReasoning || codexForwardNativeCapabilityAlias !== null, + ); + // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned + // native tool/search/responses-lite contract while preserving the routed slug and wire id. +@@ -409,7 +411,7 @@ export function deriveEntry( + }; + if (isRouted) { + applyRoutedCodexToolMode(entry, model?.codexToolMode); +- applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); ++ applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning); + } + else { + applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]); +@@ -518,7 +520,7 @@ export function buildCatalogEntriesFromObservedState({ + // before. The spawn_agent candidate window is derived separately from SPAWN_PRIORITY_FIELD, so + // this display reorder cannot change which rows are spawn candidates. + const pickerOrder = Array.isArray(modelPickerOrder) +- ? modelPickerOrder.filter((id): id is string => typeof id === "string" && id.length > 0) ++ ? modelPickerOrder.filter((id): id is string => typeof id === "string" && id.trim().length > 0) + : []; + const pickerOrderRank = new Map(pickerOrder.map((slug, i) => [slug, i] as const)); + const pickerOrderActive = pickerOrder.length > 0; +@@ -779,12 +781,33 @@ export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly< + unsupportedNativeEntries: "drop", + }); + ++/** Preserve exact-id precedence while accepting the existing raw/encoded slug spellings. */ ++function modelPickerRank(order: readonly string[]): (slug: string) => number | undefined { ++ const exact = new Map(order.map((slug, index) => [slug, index])); ++ const equivalent = new Map(order.map((slug, index) => [slugEquivalenceKey(slug), index])); ++ return slug => exact.get(slug) ?? equivalent.get(slugEquivalenceKey(slug)); ++} ++ ++/** A picker order containing native ids orders the whole list, without changing spawn ranks. */ ++export function applyFullModelPickerOrder(entries: RawEntry[], order: readonly string[]): void { ++ const pickerOrder = order.filter(slug => slug.trim().length > 0); ++ if (!pickerOrder.some(slug => !slug.includes("/"))) return; ++ const rankOf = modelPickerRank(pickerOrder); ++ for (const entry of entries) { ++ const natural = entry[SPAWN_PRIORITY_FIELD] ?? entry.priority ?? 9; ++ entry[SPAWN_PRIORITY_FIELD] = natural; ++ entry.priority = rankOf(String(entry.slug)) ?? pickerOrder.length + Number(natural); ++ } ++} ++ + export interface ObservedCatalogMergeInput { + readonly catalogModels: readonly RawEntry[]; + readonly baselineCatalogModels: readonly RawEntry[]; + readonly routedEntries: readonly RawEntry[]; + readonly baseline: ReadonlyMap; + readonly featured: readonly string[]; ++ readonly modelPickerOrder?: readonly string[]; ++ readonly accountSelectors?: readonly string[]; + readonly wsEnabled: boolean; + readonly template: RawEntry | null; + readonly disabledModels: ReadonlySet; +@@ -817,6 +840,8 @@ export function mergeCatalogEntriesFromObservedState({ + routedEntries, + baseline, + featured, ++ modelPickerOrder = [], ++ accountSelectors = [], + wsEnabled, + template, + disabledModels, +@@ -975,7 +1000,9 @@ export function mergeCatalogEntriesFromObservedState({ + finished.priority = nativePriority(slug, upstream.priority); + return finished; + } +- const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m.priority) }); ++ const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m[SPAWN_PRIORITY_FIELD] ?? m.priority) }); ++ // Recompute spawn rank from current featured models, not a prior picker override. ++ delete preserved[SPAWN_PRIORITY_FIELD]; + // Older natives kept from disk still need the mock top tiers (max + ultra always + // for subagent max spawns; wire-clamped to the model's real top rung). + if (!isGpt56NativeSlug(slug) && slug !== NATIVE_RESERVE_MODEL) ensureUltraReasoningLevel(preserved); +@@ -1060,6 +1087,32 @@ export function mergeCatalogEntriesFromObservedState({ + // remain outside provider ownership and survive unless a fresh row replaces their exact slug. + return !isOcxAuthoredRoutedEntry(entry); + }); ++ // Retained rows bypass the builder. Recompute managed spawn ranks from current config ++ // before either display-order mode; a saved display override is not current roster authority. ++ const pickerOrder = modelPickerOrder.filter(slug => slug.trim().length > 0); ++ const fullPickerOrder = pickerOrder.some(slug => !slug.includes("/")); ++ const rankOf = modelPickerRank(pickerOrder); ++ const featuredRankOf = modelPickerRank(featured); ++ const priorityStride = Math.max(accountSelectors.length, 1); ++ for (const entry of preservedRoutedEntries) { ++ const natural = entry[SPAWN_PRIORITY_FIELD]; ++ if (typeof natural === "number") { ++ entry.priority = natural; ++ delete entry[SPAWN_PRIORITY_FIELD]; ++ } ++ const slug = String(entry.slug); ++ if (!isOcxAuthoredRoutedEntry(entry) || isNativeAliasCatalogEntry(entry)) continue; ++ const featuredRank = featuredRankOf(slug); ++ entry.priority = featuredRank !== undefined ++ ? featuredRank * priorityStride ++ : (accountSelectors.length > 0 ? 1_000 : 0) + 5; ++ if (featuredRank !== undefined || fullPickerOrder) continue; ++ const pickerIndex = rankOf(slug); ++ if (pickerIndex !== undefined) { ++ entry[SPAWN_PRIORITY_FIELD] = entry.priority; ++ entry.priority = PICKER_ORDER_PRIORITY_BASE + pickerIndex * priorityStride; ++ } ++ } + let finalRoutedEntries = [...admittedRoutedEntries, ...preservedRoutedEntries]; + finalRoutedEntries = finalRoutedEntries.filter(entry => { + const slug = typeof entry.slug === "string" ? entry.slug : ""; +@@ -1134,7 +1187,7 @@ export function mergeCatalogEntriesFromObservedState({ + // Mock-max universality (260709): preserved routed entries from disk may predate + // the max rung — ensure it here so subagent max spawns validate on every + // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact. +- if (!exactCombo && !reserveProjection) { ++ if (!exactCombo && !reserveProjection && !String(e.slug ?? "").startsWith("opencode-go/")) { + const levels = Array.isArray(e.supported_reasoning_levels) + ? e.supported_reasoning_levels as Array<{ effort?: string }> + : []; +@@ -1161,6 +1214,7 @@ export function mergeCatalogEntriesFromObservedState({ + multiAgentV2Enabled, + { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection }, + ); ++ applyFullModelPickerOrder(versionedEntries, modelPickerOrder); + for (const entry of versionedEntries) { + const kind = entry.opencodex_catalog_kind; + if (trustedAccountBoundNativeCatalogSlug(entry) === undefined +@@ -1762,6 +1816,8 @@ function writeRetainedCatalogSync({ + }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) + : []; + catalog.models = mergeCatalogEntriesFromObservedState({ ++ modelPickerOrder, ++ accountSelectors, + catalogModels: catalogModelsForMerge, + baselineCatalogModels: baselineCatalog?.models ?? [], + routedEntries: goEntries, +diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts +index df765a7853..8b30bb9eb2 100644 +--- a/src/codex/convergence.ts ++++ b/src/codex/convergence.ts +@@ -342,6 +342,8 @@ function prepareCatalog( + )), + ); + const mergedModels = mergeCatalogEntriesFromObservedState({ ++ modelPickerOrder, ++ accountSelectors, + catalogModels, + baselineCatalogModels, + routedEntries, +diff --git a/src/types/config.ts b/src/types/config.ts +index 8cf1246979..425a8e095a 100644 +--- a/src/types/config.ts ++++ b/src/types/config.ts +@@ -418,17 +418,14 @@ export interface OcxConfig { + /** One-time featured-roster upgrade marker; later user ordering is preserved. */ + subagentModelsVersion?: number; + /** +- * Optional full picker ordering for the Codex model catalog, independent of the +- * 5-slot `subagentModels` spawn_agent cap. DISPLAY-ONLY: it controls the visual order of +- * the Codex model picker for large routed catalogs (10-20+ models) that would otherwise sort +- * arbitrarily and reshuffle on every rebuild. Values are routed `/` catalog +- * slugs (matched by exact slug or `provider/id`); native OpenAI passthrough rows and +- * account-qualified native rows are not reordered (order native rows via `subagentModels`). +- * Listed routed rows appear in array order; rows not listed keep their normal display order. +- * `subagentModels`-featured rows keep their top position. When unset or empty, catalog +- * priority is unchanged. This changes ONLY what the user sees in the picker: the spawn_agent +- * candidate set is derived from each row's natural priority and is provably unaffected, even +- * when every routed row is listed (see opencodex_spawn_priority / effectiveSubagentRoster). ++ * Display-only order for the Codex picker, independent of subagentModels. ++ * Routed-only lists order non-featured routed rows; featured and native rows keep ++ * their normal positions. Including a bare native id opts into ordering the complete ++ * picker: listed ids appear first in array order, followed by unlisted rows in their ++ * natural priority order. Exact catalog ids take precedence over equivalent raw/encoded ++ * routed ids; empty entries are ignored. The separate natural spawn ++ * priority is preserved, so display order does not change subagent candidates. ++ * Unset or empty leaves catalog priorities unchanged. + */ + modelPickerOrder?: string[]; + /** +diff --git a/tests/codex-integration/catalog-full-picker-order.test.ts b/tests/codex-integration/catalog-full-picker-order.test.ts +new file mode 100644 +index 0000000000..1dbda27090 +--- /dev/null ++++ b/tests/codex-integration/catalog-full-picker-order.test.ts +@@ -0,0 +1,111 @@ ++import { routedSlug } from "../../src/providers/slug-codec"; ++import { expect, test } from "bun:test"; ++import { buildCatalogEntriesFromObservedState, mergeCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, applyFullModelPickerOrder, deriveEntry, mergeCatalogEntriesForSync, SPAWN_PRIORITY_FIELD } from "../../src/codex/catalog/sync"; ++ ++test("native-first picker order preserves Go subagent ranks and is repeatable", () => { ++ const rows: any[] = [ ++ { slug: "opencode-go/glm-5.3", priority: 0 }, ++ { slug: "gpt-5.6-sol", priority: 9 }, ++ { slug: "gpt-6-astra", priority: 9 }, ++ ]; ++ const order = ["gpt-6-astra", "gpt-5.6-sol", "opencode-go/glm-5.3"]; ++ applyFullModelPickerOrder(rows, order); ++ expect([...rows].sort((a,b) => a.priority-b.priority).map(r => r.slug)).toEqual(order); ++ expect(rows.map(r => r[SPAWN_PRIORITY_FIELD])).toEqual([0,9,9]); ++ const once = structuredClone(rows); ++ applyFullModelPickerOrder(rows, order); ++ expect(rows).toEqual(once); ++}); ++ ++test("existing routed-only ordering retains its behavior", () => { ++ const rows: any[] = [{ slug: "opencode-go/glm-5.3", priority: 1000 }]; ++ applyFullModelPickerOrder(rows, ["opencode-go/glm-5.3"]); ++ expect(rows).toEqual([{ slug: "opencode-go/glm-5.3", priority: 1000 }]); ++}); ++ ++ ++test("sync refreshes native spawn rank when featured models change", () => { ++ const sol = deriveEntry(null, "gpt-5.6-sol", "Sol", 105); ++ const order = ["gpt-5.6-sol"]; ++ applyFullModelPickerOrder([sol], order); ++ expect(sol[SPAWN_PRIORITY_FIELD]).toBe(105); ++ ++ const baseline = new Map([["gpt-5.6-sol", 9]]); ++ const promoted = mergeCatalogEntriesForSync([sol], [], baseline, ["gpt-5.6-sol"], false); ++ applyFullModelPickerOrder(promoted, order); ++ expect(promoted.find(entry => entry.slug === sol.slug)?.[SPAWN_PRIORITY_FIELD]).toBe(0); ++ ++ const demoted = mergeCatalogEntriesForSync(promoted, [], baseline, ["opencode-go/glm-5.3"], false); ++ applyFullModelPickerOrder(demoted, order); ++ expect(demoted.find(entry => entry.slug === sol.slug)?.[SPAWN_PRIORITY_FIELD]).toBe(101); ++}); ++ ++ ++test("bare native ids and routed slugs match exactly, without suffix aliases", () => { ++ const rows: any[] = [ ++ { slug: "openai/gpt-5.6-sol", priority: 2 }, ++ { slug: "gpt-5.6-sol", priority: 9 }, ++ { slug: "other/gpt-5.6-sol", priority: 3 }, ++ ]; ++ applyFullModelPickerOrder(rows, ["gpt-5.6-sol", "openai/gpt-5.6-sol"]); ++ expect(rows.map(row => row.priority)).toEqual([1, 0, 5]); ++ expect(rows.map(row => row[SPAWN_PRIORITY_FIELD])).toEqual([2, 9, 3]); ++}); ++ ++test.each([ ++ { order: [] as string[] }, ++ { order: ["gpt-5.6-sol", "opencode-go/glm-5.3"], after: ["opencode-go/glm-5.3"] }, ++ { order: ["gpt-5.6-sol", "opencode-go/glm-5.3"], before: ["opencode-go/glm-5.3"], after: [] }, ++ { order: ["gpt-5.6-sol", "opencode-go/team/model"], modelId: "team/model", before: ["other/model", "opencode-go/team/model"], after: ["opencode-go/team/model", "other/model"] }, ++ ++ { order: ["", "opencode-go/glm-5.3"] }, ++ { order: [" ", "opencode-go/glm-5.3"] }, ++ { order: [""] }, ++ { order: ["opencode-go/team/model"], modelId: "team/model" }, ++ { order: ["opencode-go/glm-5.3"] }, ++ { order: ["other/model", "opencode-go/glm-5.3"] }, ++])("degraded discovery refreshes ranks and remains stable for %j", ({ order, modelId = "glm-5.3", before = [], after = [] }) => { ++ for (const accountSelectors of [[], ["account-a", "account-b"]]) { ++ const slug = routedSlug("opencode-go", modelId); ++ const fresh = (modelPickerOrder: readonly string[], featured: readonly string[] = []) => buildCatalogEntriesFromObservedState({ ++ template: null, gptSlugs: [], ++ goModels: [{ id: modelId, provider: "opencode-go", displayName: "GLM 5.3", reasoningEfforts: ["high", "max"] }], ++ featured, modelPickerOrder, wsEnabled: false, multiAgentMode: "default", ++ exactComboSlugs: new Set(), accountSelectors, suppressedBareNativeSlugs: new Set(), ++ disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled: false, ++ }); ++ const merge = (catalogModels: Record[], routedEntries: Record[], modelPickerOrder: readonly string[], degraded: boolean, featured: readonly string[] = []) => ++ mergeCatalogEntriesFromObservedState({ ++ catalogModels, routedEntries, modelPickerOrder, accountSelectors, ++ baselineCatalogModels: [], baseline: new Map(), featured, wsEnabled: false, ++ template: null, disabledModels: new Set(), selectedModelsByProvider: new Map(), ++ gatheredProviderNames: new Set(["opencode-go"]), ++ degradedProviderNames: new Set(degraded ? ["opencode-go"] : []), ++ legacyCustomModelSlugs: new Set(), multiAgentMode: "default", multiAgentV2Enabled: false, ++ exactComboSlugs: new Set(), hasPhysicalComboProvider: false, includeNativeOpenAi: true, ++ accountBoundEntries: [], ++ policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "suppress" }, ++ }); ++ const fullOrder = ["gpt-5.6-sol", slug]; ++ const previous = merge([], fresh(fullOrder, before), fullOrder, false, before); ++ const saved = structuredClone(previous); ++ const healthy = merge(previous, fresh(order, after), order, false, after); ++ const degraded = merge(previous, [], order, true, after); ++ const row = (entries: Record[]) => entries.find(entry => entry.slug === slug)!; ++ expect(row(degraded).priority).toBe(row(healthy).priority); ++ expect(row(degraded)[SPAWN_PRIORITY_FIELD]).toBe(row(healthy)[SPAWN_PRIORITY_FIELD]); ++ expect(merge(degraded, [], order, true, after)).toEqual(degraded); ++ expect(previous).toEqual(saved); ++ } ++}); ++ ++ ++test("full ordering ignores empty entries and accepts raw upstream ids with slashes", () => { ++ const slug = routedSlug("vendor", "team/model"); ++ const rows = [{ slug, priority: 1000 }, { slug: "gpt-5.6-sol", priority: 9 }]; ++ applyFullModelPickerOrder(rows, ["", "gpt-5.6-sol", "vendor/team/model"]); ++ expect(rows.map(row => row.priority)).toEqual([1, 0]); ++ const exact = [{ slug, priority: 5 }]; ++ applyFullModelPickerOrder(exact, ["gpt-5.6-sol", slug, "vendor/team/model"]); ++ expect(exact[0]!.priority).toBe(1); ++}); +diff --git a/tests/codex-integration/catalog-go-exact-efforts.test.ts b/tests/codex-integration/catalog-go-exact-efforts.test.ts +new file mode 100644 +index 0000000000..5fa4da816b +--- /dev/null ++++ b/tests/codex-integration/catalog-go-exact-efforts.test.ts +@@ -0,0 +1,39 @@ ++import { expect, test } from "bun:test"; ++import { deriveEntry, mergeCatalogEntriesForSync } from "../../src/codex/catalog/sync"; ++ ++for (const template of [null, { slug: "gpt-5.6-sol", supported_reasoning_levels: [{ effort: "ultra" }] }]) { ++ test(`Go preserves exact configured efforts (${template ? "template" : "fallback"})`, () => { ++ for (const [id, efforts] of [ ++ ["glm-5.3", ["high", "max"]], ++ ["glm-5.3-flash", ["high", "max"]], ++ ["omen-alpha", ["high", "max"]], ++ ["deepseek-v4-flash-vision-exp", ["high", "max"]], ++ ["muse-spark-1.3-contributor", ["high", "xhigh"]], ++ ] as const) { ++ const entry = deriveEntry(template, `opencode-go/${id}`, "Go", 1, { ++ provider: "opencode-go", id, reasoningEfforts: [...efforts], defaultReasoningEffort: efforts[1], ++ }); ++ expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual([...efforts]); ++ expect(entry.default_reasoning_level).toBe(efforts[1]); ++ } ++ }); ++} ++ ++test("other providers retain their existing virtual tiers", () => { ++ const entry = deriveEntry(null, "other/model", "Other", 1, { ++ provider: "other", id: "model", reasoningEfforts: ["high"], ++ }); ++ expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(["high", "max", "ultra"]); ++}); ++ ++test("sync does not reintroduce max for Muse", () => { ++ const muse = deriveEntry(null, "opencode-go/muse-spark-1.3-contributor", "Muse", 1, { ++ provider: "opencode-go", id: "muse-spark-1.3-contributor", ++ reasoningEfforts: ["high", "xhigh"], defaultReasoningEffort: "xhigh", ++ }); ++ for (const [disk, fresh] of [[[muse], []], [[], [muse]]]) { ++ const entries = mergeCatalogEntriesForSync(disk, fresh, new Map(), [], false); ++ const entry = entries.find(e => e.slug === muse.slug)!; ++ expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(["high", "xhigh"]); ++ } ++}); +diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts +index 37d8c69798..cc394a548d 100644 +--- a/tests/codex-integration/codex-catalog.test.ts ++++ b/tests/codex-integration/codex-catalog.test.ts +@@ -5445,11 +5445,11 @@ describe("Codex catalog routed normalization", () => { + const expected = [ + { slug: "deepseek/deepseek-v4-flash", efforts: ["low", "high", "max", "ultra"] }, + { slug: "deepseek/deepseek-v4-pro", efforts: ["low", "high", "max", "ultra"] }, +- { slug: "opencode-go/deepseek-v4-flash", efforts: ["low", "high", "max", "ultra"] }, +- { slug: "opencode-go/deepseek-v4-pro", efforts: ["low", "high", "max", "ultra"] }, +- { slug: "opencode-go/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, +- { slug: "opencode-go/glm-5.1", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, +- { slug: "opencode-go/glm-5", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, ++ { slug: "opencode-go/deepseek-v4-flash", efforts: ["low", "high", "max"] }, ++ { slug: "opencode-go/deepseek-v4-pro", efforts: ["low", "high", "max"] }, ++ { slug: "opencode-go/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max"] }, ++ { slug: "opencode-go/glm-5.1", efforts: ["low", "medium", "high", "xhigh", "max"] }, ++ { slug: "opencode-go/glm-5", efforts: ["low", "medium", "high", "xhigh", "max"] }, + { slug: "zai/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "zai/glm-5.2[1m]", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "zhipu-bigmodel/glm-4.6", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, +diff --git a/tests/codex-integration/codex-v2-gate.test.ts b/tests/codex-integration/codex-v2-gate.test.ts +index 8d3e2d9dc5..6e8b6a18c1 100644 +--- a/tests/codex-integration/codex-v2-gate.test.ts ++++ b/tests/codex-integration/codex-v2-gate.test.ts +@@ -100,14 +100,13 @@ function installModeHintRuntime(supported = true): string { + describe("catalog ultra (always-on)", () => { + const routed = [{ id: "glm-5.2", provider: "opencode-go", reasoningEfforts: ["low", "medium", "high", "xhigh"] }]; + +- test("routed + old natives always advertise mock max AND ultra", () => { ++ test("Go keeps declared efforts while old natives retain mock tiers", () => { + const entries = buildCatalogEntries(template(), ["gpt-5.5"], routed as never, [], false); + const native = entries.find(e => e.slug === "gpt-5.5")!; + const glm = entries.find(e => e.slug === "opencode-go/glm-5.2")!; + expect(efforts(native)).toContain("ultra"); + expect(efforts(native)).toContain("max"); +- expect(efforts(glm)).toContain("ultra"); +- expect(efforts(glm)).toContain("max"); // mock max: adapters/wire clamp keep it honest ++ expect(efforts(glm)).toEqual(["low", "medium", "high", "xhigh"]); + }); + + test("gpt-5.6-sol keeps native ultra + max; luna has max but no native ultra (upstream ladder)", () => { +diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json +index 114c699eaf..8dba5cfb55 100644 +--- a/tests/fixtures/test-layout-expected.json ++++ b/tests/fixtures/test-layout-expected.json +@@ -92,6 +92,8 @@ + "bun-stream-caps.test.ts": "lib", + "cancel-body-on-abort.test.ts": "server", + "catalog-cursor-search.test.ts": "codex-integration", ++ "catalog-full-picker-order.test.ts": "codex-integration", ++ "catalog-go-exact-efforts.test.ts": "codex-integration", + "catalog-input-modality-enum.test.ts": "codex-integration", + "catalog-llamacpp-capabilities.test.ts": "codex-integration", + "catalog-oauth-observation.test.ts": "codex-integration", + +```` diff --git a/devlog/_plan/260906_lane_b_catalog_stack/040_management.md b/devlog/_plan/260906_lane_b_catalog_stack/040_management.md new file mode 100644 index 0000000000..85b1ff25a5 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/040_management.md @@ -0,0 +1,1297 @@ +# 040 — Provider model removal and static default-only catalog retention + +Class: C3 catalog/UI composition; the existing management mutation and deletion portions require C4 review care. This is one future PABCD cycle after 030. The source's two capabilities share the catalog-management delivery unit; keep their commits independently reviewable and their tests in this layer. + +## Outcome and necessity + +Provider rows expose Hide for discovered models and Delete for persisted custom models. Both remove the row from the visible catalog and provider count across reload, using existing custom-model and visibility APIs. Static `liveModels:false` providers with no explicit models publish configured `defaultModel` plus retainModels, without discovery/auth refresh. No new delete API, persistence store, pricing editor, global model-order UI or deployment is needed. + +## Current owners and callers + +- `src/codex/catalog/provider-fetch.ts:1489-1550`: `fetchProviderModelsWithAuth` returns forward-auth rows before static seeding. Extend the existing Vertex seed union with `seedStaticDefault`; the static return precedes OAuth/network. Preserve the forward exception and authoritative empty live-result behavior. +- `src/server/management/shared.ts:182-193`: `fetchAllModels` calls canonical gather and may finish initial selection. `/api/selected-models` at `model-routes.ts:787` intentionally returns the FULL unfiltered available map plus selected/live provenance. Add a separate disabled map; do not prefilter available and break selection consumers. +- `gui/src/provider-workspace/usage.ts:22-75` parses maps/counts. Add a defensive `parseDisabledModels`, provider map type, and subtract hidden ids when counting; retain complete available arrays. +- `ProviderWorkspaceShell.tsx:188-194,559` owns refresh, counts and detail data; `Providers.tsx:454-466` passes data; `ProviderDetails.tsx:289-300` passes it into `ProviderModels`. Both detail and child have `key={item.name}`, preventing cross-provider transient-state leakage on selection. +- `gui/src/components/provider-workspace/ProviderModels.tsx:38-155` owns custom GET/add and filtered rows. Retain stable persisted IDs, combine server disabled ids with optimistic removals, parse successful POST's returned id, and refresh after each operation. +- `gui/src/model-visibility.ts:66` already owns request serialization. Reuse `putModelVisibility`; do not make another client. +- `src/server/management/model-routes.ts:528-645,652-695,770-781` owns visibility, custom create and DELETE. Trace returned `catalogRefresh`, persisted disabled keys, and next GET. User-visible Hide changes catalog presentation, not direct-routing permission, per `structure/03_catalog-and-subagents.md:14-16`. + +## Focused implementation deltas + +```diff + const seedVertexDefault = /* existing Vertex predicate */; ++const seedStaticDefault = prov.liveModels === false ++ && (prov.models?.length ?? 0) === 0 ++ && Boolean(prov.defaultModel); + const configuredIds = [...new Set([ +- ...(seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : []), ++ ...((seedVertexDefault || seedStaticDefault) && prov.defaultModel ? [prov.defaultModel] : []), + ...(prov.models ?? []), + ...(prov.retainModels ?? []), + ])]; +``` + +Keep metadata derivation and ordered deduplication; explicit nonempty models must not start importing an unrelated default. Preserve the existing forward return and no-network static branch. + +```diff + const available: Record = {}; +-for (const m of models) (available[m.provider] ??= []).push(m.id); ++const disabled: Record = {}; ++for (const m of models) { ++ (available[m.provider] ??= []).push(m.id); ++ if ((config.disabledModels ?? []).some(slug => slugEquals(slug, m.provider, m.id))) ++ (disabled[m.provider] ??= []).push(m.id); ++} +-return jsonResponse({ selected, available, liveModelCounts }); ++return jsonResponse({ selected, available, disabled, liveModelCounts }); +``` + +This is the public source delta, not the final acceptance of its OpenAI behavior. Before implementing it, main must reconcile native/manual identity with 010 (#3653), as specified in the private review report. Preserve separate selected and live-count semantics throughout prop wiring. + +```diff +-const [customModelIds, setCustomModelIds] = useState([]); ++type CustomModelRef = { id?: string; modelId: string }; ++const [customModels, setCustomModels] = useState([]); ++const customModelIds = useMemo(() => customModels.map(model => model.modelId), [customModels]); ++const hiddenSet = useMemo(() => new Set([...disabledModels, ...removedModelIds]), [disabledModels, removedModelIds]); +``` + +Render a separate action button after the existing copy-id button and badges. Persisted custom ID selects Delete/Trash/customDeleteConfirm; discovered-only rows select Hide/EyeOff/hideConfirm. Disable concurrent operations, preserve keyboard/aria labels, show errors, and refresh on failed or successful mutation. The appendix includes the complete public source behavior, including custom DELETE followed by visibility PUT. That sequence needs acceptance amendments before carrying; do not claim it is transactional or silently hide partial failure. + +## Activation and CI test matrix + +| Test path | Trigger | Required observation | +|---|---|---| +| `tests/codex-integration/codex-catalog.test.ts` | `liveModels:false`, omitted/empty models and default only | Exactly one default routed model; fetch counter zero; same result with retain union/deduplication | +| Same | Nonempty explicit models + different default; no default/no lists; forward auth; valid empty live discovery | Explicit list wins; truly empty stays empty; forward native policy unchanged; no failed-live default injection into authoritative empty result | +| Same | Config contains disabled provider/model | Shared `filterCatalogVisibleModels` removes only matching provider id; raw/encoded contract unchanged | +| `tests/server/model-discovery-management-api.test.ts` | GET selected models with one disabled and an unrelated provider key | Full available still returned; disabled scoped to provider; selected/live provenance unchanged | +| Same + `tests/codex-integration/model-visibility-management-api.test.ts` | Native OpenAI, manual OpenAI, raw/encoded id and unavailable/default fallback across hide/reload | Exact identity scopes match 010 and disabled state survives refresh; add cross-layer regression before readiness | +| `gui/tests/provider-model-custom-add.test.tsx` | Discovered Hide confirmation, cancellation, network/server failure | One scoped PUT on confirm, zero on cancel; row/count changes only on success; readable failure and retry | +| Same | Persisted custom row overlaps discovered row; delete success and visibility success/failure | Stable id used for DELETE; overlap stays hidden after remount on full success; partial failure is visible and recoverable, no false rollback claim | +| Same | Custom-only row delete→add same id; native/manual OpenAI deletion; add returns malformed JSON | Add can actually restore usable visible row, native/manual target correct, no success message on invalid payload | +| Same + existing workspace tests | Old response missing disabled, malformed maps, switching provider while request pending | Parser defaults safe; counts and selected membership remain distinct; provider switch cannot hide the next provider's same-named row | + +Source tests explicitly cover default-only no-fetch, shared hidden filter, GET disabled map, count filtering, custom stable-id request sequence, visibility failure, Hide copy, native flag, and hidden rows. Cancellation, DELETE failure, custom-only re-add, native/manual overlap and true reload against management state need explicit added assertions. Existing mocks returning success for all PUT bodies do not prove API acceptance. + +CI-only focused command: `bun test tests/server/model-discovery-management-api.test.ts tests/codex-integration/model-visibility-management-api.test.ts tests/codex-integration/codex-catalog.test.ts`; GUI: `cd gui && bun test tests/provider-model-custom-add.test.tsx tests/model-visibility.test.tsx`. Do not execute either locally. The main CI suite includes these paths and source import consumers, per verifier section. + +## Documentation and visual acceptance + +- Modify English plus fr/ja/ko/ru/tr/zh-cn/zh-tw provider reference and codex-integration troubleshooting: static rows are models ∪ retainModels, plus default only when models empty/omitted; still subject to existing selection/visibility and forward-provider behavior. Exact path inventory follows. +- All nine GUI locale modules gain `models.hide` and `models.hideConfirm`; English is TKey owner. Preserve other lanes' locale additions; never replace whole locale files. +- Source adds `docs-site/public/pr-screenshots/3659-provider-model-removal.png` (blob prefix `dafe828720`), but textual patch has only a binary marker. A screenshot must be read or refreshed from a CI-built isolated app, showing Delete versus Hide, confirmation, count change, reload and an error state. The binary was NOT visually inspected by this researcher; source presence is not UI validation. +- Proposed SoT MODIFY `structure/03_catalog-and-subagents.md` adjacent to provider discovery paragraph: “Static catalogs use the ordered union of configured models and retainModels, seeding defaultModel only when the explicit models list is empty. Provider workspace removal uses stable custom ids for deletion and the shared visibility contract for hiding discovered rows. Hide changes catalog visibility without blocking direct routing.” Main owns the later C-phase edit. +- Proposed MODIFY `docs-site/src/content/docs/reference/management-api.md` in `/api/selected-models` response description: add provider-scoped `disabled: Record` alongside existing available/selected/liveModelCounts, documenting ids and keeping available unfiltered. This source PR omits that API-contract doc; coordinate with 010/020 edits rather than drop the additive field. + +## Dependency and readiness decisions + +010 (#3653) establishes native/manual OpenAI target discrimination. 020 preserves context selection. 030 establishes effort/order behavior. 040 must build on their combined verified tree: its `model-routes.ts` edit shares 010's owner, and it shares catalog test/provider-reference files with 030. Lane D's Logs changes overlap all locale files, so integrate locale keys hunk-by-hunk. + +The public source corrected earlier Hide/Delete wording and added a native target flag; do not repeat the old stale finding as if no fix exists. Main must resolve remaining composition and partial-failure acceptance items in `.tmp/lane-b/plan-catalog-management-review.md`, then amend this plan before B. This is not merge-ready from historical focused passes. CI approvals, exact-head reviews and docs-build coverage remain unproven here. + +## Source and baseline + +Read on 2026-09-06 KST in `/Users/jun/.codex/worktrees/f80e/opencodex` at `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. Pinned source: [PR #3659](https://github.com/lidge-jun/opencodex/pull/3659), head `ff4e5cd5352b9c1bd05e3de0091f3483ca130be5`, source base `af50c6d3451078a7d298b044c08fd2684c9e8eeb`. Inputs are captured `.tmp/lane-b/3659.json` and `.patch`; no claim of a fresh remote status check is made. `git show -s` independently confirmed the head commit author below. + +| Source commit | Actual commit author | Subject | +|---|---|---| +| `2a41fea0229f7d2bcc9e90d6b614ad94bbd6802f` | gqchen <276851182@qq.com> | feat(gui): remove models from provider catalog | +| `34ace947a31aa154d77cd6d0eac67669304dd72b` | gqchen <276851182@qq.com> | fix(codex): sync static default-only providers | +| `13e6ea29e6904afff68c6eccd177c9e929f494d7` | gqchen <276851182@qq.com> | docs(pr): add provider model removal screenshot | +| `e005d028d3b4697676f17817b10de4c8ea4e2987` | gqchen <276851182@qq.com> | fix(gui): address provider model removal review | +| `ff4e5cd5352b9c1bd05e3de0091f3483ca130be5` | gqchen <276851182@qq.com> | fix(gui): distinguish hidden provider models | + +Preserve original commit authors on a clean replay; for reimplementation or squash put `Co-authored-by: gqchen <276851182@qq.com>` in each carried logical commit and the final squash body. PR login alone is not an author trailer. + +Safe carry strategy: main revalidates pinned source/head and incoming parent; replay the complete reviewed source series in order or reproduce its exact delta with attribution. Preserve source follow-up commits, not only the initial feature commit. Publish a child against its still-open parent; after parent squash, rebuild the child on the new dev ancestry and re-run exact-head CI. Retarget surviving children before parent branch deletion. Push uses the user's authorized `--no-verify` to avoid local hooks; this does not substitute for CI. Once dev contains the complete carried behavior, close the superseded source PR with a carry reference; do not close it for a partial/default-only slice. No linked issue is invented. + +## Exact change ledger + +Every source changed file is accounted for below. “Same base” means a read-only `git hash-object` of current file bytes matches the patch's old blob prefix; it does not prove future cherry-pick cleanliness. “Drift” requires contextual reconciliation. Source binary is explicitly unreviewed. All textual hunks were inspected as source behavior; appendix preserves exact before/after, including complete NEW test content. No source production file was edited. + +| Operation | Exact path | Baseline / disposition | +|---|---|---| +| NEW | `docs-site/public/pr-screenshots/3659-provider-model-removal.png` | Binary skipped: payload absent from text patch; visual proof required | +| MODIFY | `docs-site/src/content/docs/fr/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/fr/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/ja/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/ja/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/ko/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/ko/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/reference/configuration/providers.md` | Drift; preserve current unrelated edits | +| MODIFY | `docs-site/src/content/docs/ru/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/ru/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/tr/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/tr/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/zh-cn/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/zh-cn/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/zh-tw/guides/codex-integration.md` | Same base; reviewed textual delta | +| MODIFY | `docs-site/src/content/docs/zh-tw/reference/configuration/providers.md` | Same base; reviewed textual delta | +| MODIFY | `gui/src/components/provider-workspace/ProviderDetails.tsx` | Same base; reviewed textual delta | +| MODIFY | `gui/src/components/provider-workspace/ProviderModels.tsx` | Same base; reviewed textual delta | +| MODIFY | `gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx` | Same base; reviewed textual delta | +| MODIFY | `gui/src/i18n/de.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/en.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/fr.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/ja.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/ko.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/ru.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/tr.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/zh-TW.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/i18n/zh.ts` | Drift; preserve current unrelated edits | +| MODIFY | `gui/src/icons.tsx` | Same base; reviewed textual delta | +| MODIFY | `gui/src/pages/Providers.tsx` | Same base; reviewed textual delta | +| MODIFY | `gui/src/provider-workspace/usage.ts` | Same base; reviewed textual delta | +| MODIFY | `gui/tests/provider-model-custom-add.test.tsx` | Same base; reviewed textual delta | +| MODIFY | `src/codex/catalog/provider-fetch.ts` | Same base; reviewed textual delta | +| MODIFY | `src/server/management/model-routes.ts` | Same base; reviewed textual delta | +| MODIFY | `tests/codex-integration/codex-catalog.test.ts` | Same base; reviewed textual delta | +| MODIFY | `tests/server/model-discovery-management-api.test.ts` | Same base; reviewed textual delta | +| MODIFY (planned SoT addition) | `structure/03_catalog-and-subagents.md` | Public contract prose delta specified above; not in source PR | +| MODIFY (planned API doc addition) | `docs-site/src/content/docs/reference/management-api.md` | Add disabled response field alongside preceding phases | + +## Execution boundary and verifier + +This is a docs-only roadmap deliverable, not an implementation or merge receipt. The main agent owns 000, the goal/FSM, branch operations, integration, and final acceptance. No local tests, suites, typecheck, builds, hooks, provider requests, commits, pushes, or GitHub writes were run by this researcher. The next implementation cycle must re-read this plan against its actual parent tip. + +Loop archetype: spec-satisfaction repair. Trigger: carry the pinned public source PR into lane B. Stop: required behaviors, exact-head CI, reviewer disposition and parent integration all have durable evidence. Expected result: DONE only after verified dev ancestry; NOOP only if equivalent behavior is already landed; unresolved correctness/CI evidence is pending, not DONE. Scope and unattended resource bounds are inherited from main's 000; this document does not arm or amend the goal. Upward escalation: return concrete caller/test evidence to main if the carry contract cannot be satisfied; additional worker dispatch requires main's planned scope. + +CI-only verifier, inspected at the baseline below: + +- `.github/workflows/ci.yml:5` uses unfiltered `pull_request`, so child PR bases are supported. `changes` at lines 181-218 admits `src/**`, `tests/**`, `scripts/**`, `gui/**`; source changes also admit packaging. +- Linux `test 1/4..4/4`, lines 255-316, invokes `scripts/ci/run-bun-test-batches.sh`. That runner enumerates `tests` (line 197), admits `.test.ts` files (lines 46-68), and excludes only storage/API-usage families into dedicated jobs; the catalog/management files in this plan are included. Each actual batch runs `bun test --isolate --timeout 60000` under a process timeout (lines 121-126). Read logs to prove the named files ran; aggregate green alone is insufficient. +- `gates`, lines 390-449, runs root TypeScript (`bun x tsc --noEmit`), GUI tests (`cd gui && bun test --isolate tests`), privacy scan and skill-surface check. GUI changes additionally run `bun run lint` and `bun run build`; `gui/package.json:8` expands build to `tsc -b && vite build`. GUI lint is `oxlint .`, including changed locale/UI inputs; the separately named `lint:i18n` script is not a dedicated CI step. +- macOS runs two full-suite shards (lines 451-547). Windows full-suite six shards (lines 658-769) run only on `workflow_dispatch` with lane `all`; do not infer Windows full-suite coverage from packaging smoke or PR aggregate success. Main must obtain an exact-ref dispatch if Windows full-suite evidence is required, then verify the run head. +- `.github/workflows/deploy-docs.yml:3-10,24-33` builds Astro only on main push or manual dispatch and then deploys. Normal PR CI has no Astro docs build. Do not trigger this deploy workflow merely to obtain a pre-merge check. Main must arrange an approved non-deploy CI verifier on the exact candidate commit or explicitly retain this as a readiness gap; this research does not add workflow code or authorize deployment. +- Save head SHA, parent/base SHA, run URL, executed job conclusions, named test logs, approvals and unresolved-thread disposition. Source-author reported passes are historical claims, not carried-head validation. Never attest local checks that were intentionally prohibited. + + +## Pinned public source delta + +Reconcile only the touched hunks at the implementation parent. The conceptual deltas above and acceptance amendments take precedence over copying this source verbatim. This appendix records public source-PR behavior only. + +````diff +diff --git a/docs-site/public/pr-screenshots/3659-provider-model-removal.png b/docs-site/public/pr-screenshots/3659-provider-model-removal.png +new file mode 100644 +index 0000000000..dafe828720 +Binary files /dev/null and b/docs-site/public/pr-screenshots/3659-provider-model-removal.png differ +diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md +index 091a63a787..fe0a5d687a 100644 +--- a/docs-site/src/content/docs/fr/guides/codex-integration.md ++++ b/docs-site/src/content/docs/fr/guides/codex-integration.md +@@ -311,8 +311,9 @@ S'il manque un modèle dans Codex, ou si l'ordre ou la visibilité du catalogue + d'autorisation n'atteint jamais le catalogue. + 2. **`disabledModels`** au niveau supérieur — masque les modèles dans le catalogue comme dans `/v1/models`, et + fait passer les identifiants GPT natifs non qualifiés à `visibility: "hide"`. +-3. **`liveModels: false` avec `models` vide** — lorsque la découverte en direct est désactivée et que `models` +- est vide ou absent, opencodex n'expose aucun modèle routé pour ce fournisseur. ++3. **`liveModels: false`** — lorsque la découverte en direct est désactivée, les modèles routés proviennent de ++ `models` et `retainModels`. Si `models` est vide ou absent, un `defaultModel` configuré est également inclus ; ++ si aucun de ces champs ne fournit d'identifiant, opencodex n'expose aucun modèle routé. + 4. **Cursor `GetUsableModels`** — l'adaptateur Cursor découvre les modèles par son appel RPC protobuf + `GetUsableModels`, et non par `/models` ; une modification côté Cursor peut donc changer les identifiants visibles + indépendamment des autres fournisseurs. +diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md +index bec7932c09..dcfcc0af63 100644 +--- a/docs-site/src/content/docs/fr/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md +@@ -93,7 +93,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Style de l'en-tête de clé Anthropic. La valeur par défaut est l'en-tête natif `x-api-key` ; ce champ n'est valable que pour les fournisseurs `anthropic` authentifiés par clé. | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Pool multi-clés. `apiKey` reflète l'entrée active ; chaque élément a `id`, `key`, `label` facultatif et `addedAt` numérique facultatif. | + | `defaultModel?` | `string` | Modèle utilisé lorsque ce fournisseur est sélectionné sans modèle explicite. | +-| `models?` | `string[]` | Liste initiale ou de repli des modèles. Avec `liveModels: false`, ce sont les seuls modèles découverts. | ++| `models?` | `string[]` | Liste initiale ou de repli. Avec `liveModels: false`, les modèles routés proviennent de `models` et `retainModels` ; `defaultModel` est aussi inclus lorsque `models` est vide. | + | `liveModels?` | `boolean` | Récupère le catalogue actif au démarrage et lors de la synchronisation (true par défaut). Les fournisseurs personnalisés utilisent `${baseUrl}/models` ; les fournisseurs intégrés peuvent employer une URL de registre et un filtre. | + | `selectedModels?` | `string[]` | Liste autorisée du catalogue après la découverte. Non vide expose uniquement ces identifiants ; vide ou omis expose tous les modèles découverts. | + | `contextWindow?` | `number` | Repli contextuel à l’échelle du fournisseur lorsque les métadonnées en amont sont absentes ; sinon, un plafond qui conserve des métadonnées en direct plus petites. Le tableau de bord Modèles expose cela séparément de `providerContextCaps`. | +@@ -435,8 +435,8 @@ modèle. Le même mappage s'applique à un sélecteur natif `vercel/` + + ## Listes autorisées de modèles statiques + +-Réglez `liveModels: false` pour exposer uniquement `models`. Si `models` est vide ou omis, le fournisseur n'expose +-aucun modèle routé. La découverte dynamique rejette plus de 4 Mio ou 2 000 lignes de modèle brutes avant leur mise en cache ; ++Réglez `liveModels: false` pour exposer uniquement les modèles configurés dans `models` et `retainModels`. Si `models` est vide ou omis, ++un `defaultModel` configuré est également inclus. Si aucun de ces champs ne fournit d'identifiant, aucun modèle routé n'est exposé. La découverte dynamique rejette plus de 4 Mio ou 2 000 lignes de modèle brutes avant leur mise en cache ; + les préréglages intégrés peuvent appliquer des limites inférieures et filtrer les lignes admissibles à la conversation. Les résultats trop volumineux ou mal formés + utilisent le catalogue obsolète ou configuré comme solution de repli. Un résultat valide ne contenant aucun modèle admissible fait autorité et n'est pas + silencieusement remplacé ou tronqué. +diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md +index b2ae7fcb91..62f5b0a662 100644 +--- a/docs-site/src/content/docs/guides/codex-integration.md ++++ b/docs-site/src/content/docs/guides/codex-integration.md +@@ -454,8 +454,9 @@ If a model is missing from Codex, or the catalog order/visibility looks wrong, c + catalog. + 2. **`disabledModels`** (top level) — hides models from both the catalog and `/v1/models`, and flips + bare native GPT slugs to `visibility: "hide"`. +-3. **`liveModels: false` with empty `models`** — when live discovery is off and `models` is empty or +- omitted, opencodex exposes no routed models for that provider. ++3. **`liveModels: false`** — with live discovery off, routed models come from `models` and ++ `retainModels`. When `models` is empty or omitted, a configured `defaultModel` is included too; ++ if none of those fields supplies an id, opencodex exposes no routed models. + 4. **Cursor `GetUsableModels`** — the Cursor adapter discovers models through its protobuf + `GetUsableModels` RPC, not `/models`, so a Cursor-side change can alter which ids are visible + independently of other providers. +diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md +index 06cd590084..d49fae078c 100644 +--- a/docs-site/src/content/docs/ja/guides/codex-integration.md ++++ b/docs-site/src/content/docs/ja/guides/codex-integration.md +@@ -197,8 +197,9 @@ ocx sync-cache + 空または省略すると、検出されたすべてのモデルが公開されます。ホワイトリストにない ID はカタログに到達しません。 + 2. **`disabledModels`** (トップレベル) — カタログと `/v1/models` の両方からモデルを非表示にし、反転します + 裸のネイティブ GPT スラッグを `visibility: "hide"` にします。 +-3. **`liveModels: false` と空の `models`** — ライブ検出がオフで、`models` が空の場合、または +-省略すると、opencodex はそのプロバイダーのルーティング モデルを公開しません。 ++3. **`liveModels: false`** — ライブ検出がオフの場合、ルーティングモデルは `models` と ++`retainModels` から取得されます。`models` が空または省略されている場合は構成済みの `defaultModel` も含まれ、 ++いずれのフィールドにも ID がない場合のみルーティングモデルを公開しません。 + 4. **Cursor `GetUsableModels`** — Cursor アダプターはその protobuf を通じてモデルを検出します。 + `/models` ではなく `GetUsableModels` RPC であるため、カーソル側の変更により、他のプロバイダーとは独立して表示される ID が変更される可能性があります。 + 5. **キャッシュと `ocx sync`** - ライブ カタログは約 5 分間キャッシュされます (`modelCacheTtlMs`、 +diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md +index bd33d34a3f..41e1de7aa3 100644 +--- a/docs-site/src/content/docs/ja/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md +@@ -81,7 +81,7 @@ account を削除しても mapping は保持され、同じ id を再追加す + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic キーのヘッダー スタイル。デフォルトはネイティブ `x-api-key` です。キー認証 `anthropic` プロバイダーにのみ有効です。 | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` |マルチキープール。 `apiKey` はアクティブなエントリをミラーリングします。各項目には `id`、`key`、オプションの `label`、およびオプションの数値 `addedAt` があります。 | + | `defaultModel?` | `string` |このプロバイダーが明示的なモデルなしで選択された場合に使用されるモデル。 | +-| `models?` | `string[]` |シード/フォールバック モデルのリスト。 `liveModels: false` では、発見されたモデルはこれらのみです。 | ++| `models?` | `string[]` |シード/フォールバック モデルのリスト。`liveModels: false` ではルーティングモデルは `models` と `retainModels` から取得され、`models` が空の場合は `defaultModel` も含まれます。 | + | `liveModels?` | `boolean` |開始/同期時にライブ カタログをフェッチします (デフォルトは `true`)。カスタムプロバイダーは `${baseUrl}/models` を使用します。組み込みはレジストリ URL とフィルターを使用する場合があります。 | + | `selectedModels?` | `string[]` |検出後のカタログ許可リスト。空でない場合は、それらの ID のみが公開されます。空または省略すると、検出されたすべてのモデルが公開されます。 | + | `modelDisplayNames?` | `Record` | このプロバイダーの正確なネイティブモデル ID をキーにした、永続的な表示専用ラベルです。大文字と小文字は区別されます。ラベルはプロバイダーカタログのメタデータより優先され、認証、アダプター、ルーティング、課金、上流リクエストには影響しません。マップは検出上限と同じ 2,000 件までです。 | +@@ -354,7 +354,7 @@ Vercel AI Gateway は、1 つのモデルを複数の基盤となる推論プロ + + ## 静的モデルのホワイトリスト + +-`models` のみを公開するように `liveModels: false` を設定します。 `models` が空であるか省略されている場合、プロバイダーはルーティングされたモデルを公開しません。ライブ ディスカバリは、キャッシュする前に 4 MiB または 2,000 を超える生のモデル行を拒否します。組み込みのプリセットは下限を使用し、チャットに適した行にフィルターをかけることができます。サイズが大きすぎる、または形式が正しくない結果は、古い/構成されたフォールバックに続きます。ゼロに適格な有効な結果は引き続き権威を持ち、暗黙的に置き換えられたり切り捨てられたりすることはありません。 ++構成済みモデルのみを公開するように `liveModels: false` を設定します。ルーティングモデルは `models` と `retainModels` から取得され、`models` が空または省略されている場合は構成済みの `defaultModel` も含まれます。いずれのフィールドにも ID がない場合のみ、ルーティングモデルを公開しません。ライブ ディスカバリは、キャッシュする前に 4 MiB または 2,000 を超える生のモデル行を拒否します。組み込みのプリセットは下限を使用し、チャットに適した行にフィルターをかけることができます。サイズが大きすぎる、または形式が正しくない結果は、古い/構成されたフォールバックに続きます。ゼロに適格な有効な結果は引き続き権威を持ち、暗黙的に置き換えられたり切り捨てられたりすることはありません。 + + 検出を実行する必要があるが、選択した ID のみが Codex および `/v1/models` に表示される必要がある場合は、`selectedModels` を使用します。ダッシュボードには、後で許可リストを変更できるように、検出された完全なリストが保持されます。 + +diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md +index 3f777153ec..ece36df6af 100644 +--- a/docs-site/src/content/docs/ko/guides/codex-integration.md ++++ b/docs-site/src/content/docs/ko/guides/codex-integration.md +@@ -197,7 +197,7 @@ Codex에서 model이 빠졌거나 catalog 순서/가시성이 이상해 보이 + + 1. provider의 **`selectedModels`** - 비어 있지 않은 allowlist는 해당 id만 Codex에 노출합니다. 비어 있거나 생략하면 발견된 model이 모두 노출됩니다. allowlist에 없는 id는 catalog에 절대 들어가지 않습니다. + 2. **`disabledModels`**(top level) - catalog와 `/v1/models`에서 model을 숨기고, bare native GPT slug는 `visibility: "hide"`로 바꿉니다. +-3. **`liveModels: false`와 비어 있는 `models`** - live discovery가 꺼져 있고 `models`가 비어 있거나 생략되면, opencodex는 그 provider에 대해 routed model을 하나도 노출하지 않습니다. ++3. **`liveModels: false`** - live discovery가 꺼져 있으면 routed model은 `models`와 `retainModels`에서 가져옵니다. `models`가 비어 있거나 생략되면 구성된 `defaultModel`도 포함되며, 어느 필드에도 ID가 없을 때만 routed model을 노출하지 않습니다. + 4. **Cursor `GetUsableModels`** - Cursor adapter는 `/models`가 아니라 protobuf `GetUsableModels` RPC로 model을 찾습니다. 그래서 Cursor 쪽 변경이 다른 provider와 무관하게 어떤 id가 보이는지 바꿀 수 있습니다. + 5. **캐시와 `ocx sync`** - live catalog는 약 5분(`modelCacheTtlMs`, 기본값 `300000`) 동안 캐시됩니다. `ocx sync`를 실행하면 새로 가져와서 catalog를 즉시 다시 쓸 수 있습니다. + 6. **실행 중인 Codex `app-server`** - 오래 살아 있는 Codex `app-server`(Desktop / CLI background host)가 이전 목록을 메모리에 쥐고 있으면 디스크 catalog를 다시 쓰는 것만으로는 부족합니다. `ocx sync`와 `ocx sync-cache`는 그런 process를 감지하면 경고합니다. `ocx sync --restart-codex`로 다시 시작하거나(아니면 일치하는 `app-server` process를 직접 중지한 뒤), Codex가 다시 만들게 해서 새 목록이 보이게 하세요. +diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md +index 3d65dbfb4d..49b911d2c8 100644 +--- a/docs-site/src/content/docs/ko/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md +@@ -81,7 +81,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic 키 헤더 형식입니다. 기본값은 네이티브 `x-api-key`이며, 키 인증 `anthropic` 공급자에만 유효합니다. | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 다중 키 풀입니다. `apiKey`는 활성 항목을 그대로 반영하며, 각 항목에는 `id`, `key`, 선택적 `label`, 선택적 숫자 `addedAt`가 들어갑니다. | + | `defaultModel?` | `string` | 이 공급자를 선택할 때 모델을 따로 지정하지 않으면 사용하는 모델입니다. | +-| `models?` | `string[]` | 시드/폴백 모델 목록입니다. `liveModels: false`이면 이 목록만 발견된 모델로 취급합니다. | ++| `models?` | `string[]` | 시드/폴백 모델 목록입니다. `liveModels: false`이면 라우팅 모델은 `models`와 `retainModels`에서 가져오며, `models`가 비어 있으면 `defaultModel`도 포함됩니다. | + | `liveModels?` | `boolean` | 시작 또는 동기화 시 라이브 카탈로그를 가져옵니다. 기본값은 `true`입니다. 사용자 지정 공급자는 `${baseUrl}/models`를 사용하고, 내장은 레지스트리 URL을 사용한 뒤 필터링할 수 있습니다. | + | `selectedModels?` | `string[]` | 발견 후 카탈로그 허용 목록입니다. 값이 비어 있지 않으면 그 id만 노출하고, 비어 있거나 생략하면 발견된 모델을 모두 노출합니다. | + | `modelDisplayNames?` | `Record` | 이 공급자의 정확한 네이티브 모델 id를 키로 쓰는 영구 표시 전용 이름입니다. 키는 대소문자를 구분합니다. 이름은 공급자 카탈로그 메타데이터보다 우선하며 인증, 어댑터, 라우팅, 청구 또는 업스트림 요청을 바꾸지 않습니다. 맵은 발견 한도와 같은 최대 2,000개 항목을 가질 수 있습니다. | +@@ -361,7 +361,7 @@ Vercel AI Gateway는 하나의 모델을 여러 기반 추론 공급자에 걸 + + ## 정적 모델 허용 목록 + +-`liveModels: false`로 두면 `models`만 노출합니다. `models`가 비어 있거나 생략되면 공급자는 어떤 라우팅 모델도 노출하지 않습니다. 라이브 발견은 캐싱 전에 4 MiB 또는 원시 모델 행 2,000개를 넘으면 거부합니다. 내장 프리셋은 더 낮은 한도를 쓰고 chat 가능한 행만 필터링할 수 있습니다. 너무 크거나 형식이 잘못된 결과는 오래된/설정된 폴백을 따릅니다. 유효하지만 선택 가능한 항목이 0개인 결과는 그대로 권위가 있으며, 조용히 다른 값으로 바꾸거나 잘라내지 않습니다. ++`liveModels: false`로 두면 구성된 모델만 노출합니다. 라우팅 모델은 `models`와 `retainModels`에서 가져오며, `models`가 비어 있거나 생략되면 구성된 `defaultModel`도 포함됩니다. 어느 필드에도 ID가 없을 때만 라우팅 모델을 노출하지 않습니다. 라이브 발견은 캐싱 전에 4 MiB 또는 원시 모델 행 2,000개를 넘으면 거부합니다. 내장 프리셋은 더 낮은 한도를 쓰고 chat 가능한 행만 필터링할 수 있습니다. 너무 크거나 형식이 잘못된 결과는 오래된/설정된 폴백을 따릅니다. 유효하지만 선택 가능한 항목이 0개인 결과는 그대로 권위가 있으며, 조용히 다른 값으로 바꾸거나 잘라내지 않습니다. + + `selectedModels`는 발견은 계속하되, 선택된 id만 Codex와 `/v1/models`에 나타나게 하고 싶을 때 사용합니다. 대시보드는 나중에 허용 목록을 바꿀 수 있도록 발견된 전체 목록을 보관합니다. + +diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md +index ab8a154ecb..2a56f4ceb9 100644 +--- a/docs-site/src/content/docs/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/reference/configuration/providers.md +@@ -136,7 +136,7 @@ predictions. Explicit provider/model price overrides still take precedence. + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key header style. Defaults to native `x-api-key`; valid only for key-auth `anthropic` providers. | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Multi-key pool. `apiKey` mirrors the active entry; each item has `id`, `key`, optional `label`, and optional numeric `addedAt`. | + | `defaultModel?` | `string` | Model used when this provider is selected without an explicit model. | +-| `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, these are the only discovered models. | ++| `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, routed models come from `models` and `retainModels`; `defaultModel` is also included when `models` is empty. | + | `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. | + | `selectedModels?` | `string[]` | Catalog allowlist after discovery. Non-empty exposes only those ids; empty or omitted exposes all discovered models. | + | `retainModels?` | `string[]` | Ids kept in the catalog even when live discovery omits them. They need not be repeated in `models`. Empty or omitted keeps today's behavior. | +@@ -738,8 +738,8 @@ container usually has no unlocked keychain session, so requests would fail close + `${ENV_VAR}` reference in the service environment there instead. Env references are left untouched + by `store`. + +-Set `liveModels: false` to expose only `models`. If `models` is empty or omitted, the provider exposes +-no routed models. Live discovery rejects more than 4 MiB or 2,000 raw model rows before caching; ++Set `liveModels: false` to expose only configured models from `models` and `retainModels`. If `models` ++is empty or omitted, a configured `defaultModel` is included too. If none of those fields supplies an id, the provider exposes no routed models. Live discovery rejects more than 4 MiB or 2,000 raw model rows before caching; + built-in presets may use lower limits and filter to chat-eligible rows. Oversized or malformed results + follow stale/configured fallback. A valid zero-eligible result remains authoritative and is not + silently replaced or truncated. +diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md +index e33bb6c835..ebdec89431 100644 +--- a/docs-site/src/content/docs/ru/guides/codex-integration.md ++++ b/docs-site/src/content/docs/ru/guides/codex-integration.md +@@ -304,8 +304,9 @@ Codex на встроенный провайдер `openai` и удалите л + allowlist, никогда не попадёт в каталог. + 2. **`disabledModels`** (верхний уровень) — скрывает модели и из каталога, и из `/v1/models`, а у + голых нативных GPT-slug устанавливает `visibility: "hide"`. +-3. **`liveModels: false` и пустой `models`** — если живое обнаружение выключено, а `models` пуст +- или отсутствует, opencodex не показывает ни одной маршрутизируемой модели этого провайдера. ++3. **`liveModels: false`** — если живое обнаружение выключено, маршрутизируемые модели берутся из ++ `models` и `retainModels`. Если `models` пуст или отсутствует, также включается настроенный `defaultModel`; ++ если ни одно из этих полей не содержит идентификатор, opencodex не показывает маршрутизируемых моделей. + 4. **Cursor `GetUsableModels`** — адаптер Cursor получает модели через protobuf RPC + `GetUsableModels`, а не через `/models`, поэтому изменение на стороне Cursor может менять + видимые id независимо от остальных провайдеров. +diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md +index a058fdb842..f31d0ffc13 100644 +--- a/docs-site/src/content/docs/ru/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md +@@ -94,7 +94,7 @@ cross-route credential fallback не существует. Строки API GPT- + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Header-style для ключа Anthropic. По умолчанию нативный `x-api-key`; допустим только для key-auth-провайдеров `anthropic`. | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Пул из нескольких ключей. `apiKey` зеркалит активную запись; каждый элемент содержит `id`, `key`, необязательный `label` и необязательное числовое `addedAt`. | + | `defaultModel?` | `string` | Модель, используемая когда этот провайдер выбран без явной модели. | +-| `models?` | `string[]` | Seed/fallback-список моделей. При `liveModels: false` это и есть единственный список обнаруженных моделей. | ++| `models?` | `string[]` | Seed/fallback-список. При `liveModels: false` маршрутизируемые модели берутся из `models` и `retainModels`; если `models` пуст, также включается `defaultModel`. | + | `liveModels?` | `boolean` | Получать live-каталог на start/sync (по умолчанию `true`). Custom-провайдеры используют `${baseUrl}/models`; built-in могут использовать registry URL и дополнительно фильтровать результат. | + | `selectedModels?` | `string[]` | Allowlist каталога после discovery. Непустой список показывает только эти id; пустой или отсутствующий показывает всё, что было обнаружено. | + | `modelDisplayNames?` | `Record` | Постоянные display-only имена с точным нативным id модели этого провайдера в качестве ключа. Ключи чувствительны к регистру. Имена имеют приоритет над metadata каталога провайдера и не меняют аутентификацию, adapter, routing, billing или upstream-запросы. Карта содержит не более 2 000 записей, как и discovery. | +@@ -439,8 +439,8 @@ Chat-запросов не добавляют поле `provider`, а Vercel AI + + ## Статические allowlist'ы моделей + +-Задайте `liveModels: false`, чтобы показывать только `models`. Если `models` пуст или отсутствует, +-провайдер не будет показывать ни одной маршрутизируемой модели. Live-discovery отвергает ответы ++Задайте `liveModels: false`, чтобы показывать только настроенные модели из `models` и `retainModels`. Если `models` пуст или отсутствует, ++также включается настроенный `defaultModel`. Если ни одно из этих полей не содержит идентификатор, провайдер не показывает маршрутизируемых моделей. Live-discovery отвергает ответы + размером более 4 MiB или более 2000 сырых model-row до кэширования; built-in preset'ы могут + использовать меньшие лимиты и фильтровать список до chat-совместимых строк. Oversized или + malformed-результаты откатываются к stale/configured fallback. Валидный результат с нулём +diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md +index 7692980e91..39ef68406d 100644 +--- a/docs-site/src/content/docs/tr/guides/codex-integration.md ++++ b/docs-site/src/content/docs/tr/guides/codex-integration.md +@@ -353,9 +353,9 @@ sırayla kontrol edin: + 2. **`disabledModels`** (üst düzey) — modelleri hem katalogdan hem de + `/v1/models` listesinden gizler ve yalın yerel GPT slug'larını `visibility: + "hide"` olarak değiştirir. +-3. **Boş `models` ile `liveModels: false`** — canlı keşif kapalı olduğunda ve +- `models` boş veya atlandığında opencodex bu sağlayıcı için hiçbir +- yönlendirilmiş model göstermez. ++3. **`liveModels: false`** — canlı keşif kapalı olduğunda yönlendirilmiş modeller `models` ve ++ `retainModels` alanlarından gelir. `models` boş veya atlanmışsa yapılandırılmış `defaultModel` da eklenir; ++ bu alanların hiçbiri bir kimlik sağlamıyorsa opencodex yönlendirilmiş model göstermez. + 4. **Cursor `GetUsableModels`** — Cursor adaptörü modelleri `/models` üzerinden + değil, protobuf `GetUsableModels` RPC'si üzerinden keşfeder; bu nedenle + Cursor tarafındaki bir değişiklik diğer sağlayıcılardan bağımsız olarak hangi +diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md +index 4213ab6001..27fe115fc1 100644 +--- a/docs-site/src/content/docs/tr/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md +@@ -100,7 +100,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic anahtar başlığı stili. Varsayılan olarak yerel `x-api-key`; yalnızca anahtar kimlik doğrulamalı `anthropic` sağlayıcıları için geçerlidir. | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Çoklu anahtar havuzu. `apiKey` aktif girdiyi yansıtır; her öğe `id`, `key`, isteğe bağlı `label` ve isteğe bağlı sayısal `addedAt` değerine sahiptir. | + | `defaultModel?` | `string` | Bu sağlayıcı açık bir model olmadan seçildiğinde kullanılan model. | +-| `models?` | `string[]` | Tohum/geri dönüş model listesi. `liveModels: false` olduğunda bunlar keşfedilen tek modellerdir. | ++| `models?` | `string[]` | Tohum/geri dönüş listesi. `liveModels: false` iken yönlendirilen modeller `models` ve `retainModels` alanlarından gelir; `models` boşsa `defaultModel` da eklenir. | + | `liveModels?` | `boolean` | Başlatmada/senkronizasyonda canlı kataloğu getirin (varsayılan `true`). Özel sağlayıcılar `${baseUrl}/models` kullanır; yerleşikler bir kayıt defteri URL'si ve filtresi kullanabilir. | + | `selectedModels?` | `string[]` | Keşiften sonra katalog izin listesi. Boş olmaması yalnızca bu kimlikleri gösterir; boş veya atlanmış olması keşfedilen tüm modelleri gösterir. | + | `contextWindow?` | `number` | Yukarı akış meta verileri olmadığında sağlayıcı genelinde bağlam geri dönüşü; aksi takdirde daha küçük canlı meta verileri koruyan bir sınır. Modeller kontrol paneli bunu `providerContextCaps` alanından ayrı olarak gösterir. | +@@ -476,8 +476,8 @@ uygulamadan önce yerel `zai/glm-5.2` kimliğini geri yükler. Aynı eşleme yer + + ## Statik model izin listeleri + +-Yalnızca `models`'ı göstermek için `liveModels: false` ayarlayın. `models` boşsa +-veya atlanırsa sağlayıcı yönlendirilen hiçbir modeli göstermez. Canlı keşif, ++Yalnızca yapılandırılmış modelleri göstermek için `liveModels: false` ayarlayın. Yönlendirilen modeller `models` ve `retainModels` alanlarından gelir; ++`models` boşsa veya atlanırsa yapılandırılmış `defaultModel` da eklenir. Bu alanların hiçbiri bir kimlik sağlamıyorsa yönlendirilmiş model gösterilmez. Canlı keşif, + önbelleğe almadan önce 4 MiB'den veya 2.000 ham model satırından fazlasını + reddeder; yerleşik önayarlar daha düşük sınırlar kullanabilir ve sohbete uygun + satırlara filtre uygulayabilir. Büyük boyutlu veya hatalı biçimlendirilmiş +diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +index e2a5601d62..e9ddcf3307 100644 +--- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md ++++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +@@ -260,8 +260,8 @@ provider 形式一样,从 `OPENCODEX_API_AUTH_TOKEN` 传入 `x-opencodex-api-k + 所有发现到的模型。一个不在 allowlist 里的 id 永远不会进入 catalog。 + 2. **`disabledModels`**(顶层) - 会同时隐藏 catalog 和 `/v1/models` 中的模型,并把裸原生 GPT slug + 切成 `visibility: "hide"`。 +-3. **`liveModels: false` 且 `models` 为空** - 当 live discovery 关闭而 `models` 为空或省略时,opencodex +- 不会为那个 provider 暴露任何路由模型。 ++3. **`liveModels: false`** - 关闭 live discovery 后,路由模型来自 `models` 和 `retainModels`。 ++ 当 `models` 为空或省略时,还会包含已配置的 `defaultModel`;这些字段都没有提供 id 时,opencodex 才不暴露路由模型。 + 4. **Cursor `GetUsableModels`** - Cursor adapter 通过它的 protobuf `GetUsableModels` RPC 发现模型,而不是 + `/models`,所以 Cursor 侧的变动会独立于其他 provider 改变哪些 id 可见。 + 5. **缓存和 `ocx sync`** - live catalog 的缓存时间大约是五分钟(`modelCacheTtlMs`,默认 `300000`)。 +diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +index f2d245b5ec..142faba847 100644 +--- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +@@ -81,7 +81,7 @@ selector,而不是分配一个新名称。 + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key 头部样式。默认使用原生 `x-api-key`;仅对 key-auth `anthropic` 提供者有效。 | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 多 key 池。`apiKey` 会镜像当前激活条目;每个条目都有 `id`、`key`、可选 `label`,以及可选的数值 `addedAt`。 | + | `defaultModel?` | `string` | 当选择该提供者但未显式指定模型时使用的模型。 | +-| `models?` | `string[]` | 种子/回退模型列表。配合 `liveModels: false` 时,这些就是唯一发现到的模型。 | ++| `models?` | `string[]` | 种子/回退模型列表。配合 `liveModels: false` 时,路由模型来自 `models` 和 `retainModels`;`models` 为空时还会包含 `defaultModel`。 | + | `liveModels?` | `boolean` | 启动/同步时获取实时目录(默认 `true`)。自定义提供者使用 `${baseUrl}/models`;内置项可能使用注册表 URL 并进行过滤。 | + | `selectedModels?` | `string[]` | 发现之后的目录允许列表。非空时只暴露这些 id;为空或省略时则暴露全部发现到的模型。 | + | `modelDisplayNames?` | `Record` | 持久的仅显示名称,以此提供者的精确原生模型 id 为键。键区分大小写。名称优先于提供者目录元数据,并且不会改变身份验证、适配器、路由、计费或上游请求。该映射最多可包含 2,000 个条目,与发现上限相同。 | +@@ -357,7 +357,7 @@ Vercel AI Gateway 可以在多个底层推理提供者之间路由一个模型 + + ## 静态模型允许列表 + +-将 `liveModels: false` 设为只暴露 `models`。如果 `models` 为空或省略,该提供者将不暴露任何路由模型。实时发现会在缓存前拒绝超过 4 MiB 或 2,000 条原始模型行;内置预设可能使用更低的限制,并过滤为可聊天的行。过大或格式错误的结果会走陈旧/配置回退。合法的、零可用结果的发现仍然具有权威性,不会被静默替换或截断。 ++将 `liveModels: false` 设为只暴露已配置模型。路由模型来自 `models` 和 `retainModels`;如果 `models` 为空或省略,还会包含已配置的 `defaultModel`。这些字段都没有提供 id 时才不暴露路由模型。实时发现会在缓存前拒绝超过 4 MiB 或 2,000 条原始模型行;内置预设可能使用更低的限制,并过滤为可聊天的行。过大或格式错误的结果会走陈旧/配置回退。合法的、零可用结果的发现仍然具有权威性,不会被静默替换或截断。 + + 当需要继续运行发现,但只有选定 id 应该出现在 Codex 和 `/v1/models` 中时,请使用 `selectedModels`。仪表板会保留完整的已发现列表,以便之后调整允许列表。 + +diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +index 4166276fbc..f096f22502 100644 +--- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md ++++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +@@ -266,8 +266,8 @@ OpenCodex 直接注入路由,請先將 Codex 切回內建 `openai` provider, + 已發現模型。不在 allowlist 中的 id 永遠不會進入目錄。 + 2. **`disabledModels`(頂層)**:會同時從目錄與 `/v1/models` 隱藏模型,並把裸原生 GPT slug 設為 + `visibility: "hide"`。 +-3. **`liveModels: false` 且 `models` 為空**:當即時探索關閉,且 `models` 為空或省略時,opencodex +- 不會為該 provider 暴露任何路由模型。 ++3. **`liveModels: false`**:關閉即時探索後,路由模型來自 `models` 和 `retainModels`。 ++ 當 `models` 為空或省略時,還會包含已設定的 `defaultModel`;這些欄位皆未提供 id 時,opencodex 才不暴露路由模型。 + 4. **Cursor `GetUsableModels`**:Cursor adapter 透過 protobuf `GetUsableModels` RPC 探索模型,而不是 + `/models`,所以 Cursor 端變更可獨立改變可見 id。 + 5. **cache 與 `ocx sync`**:即時目錄約快取五分鐘(`modelCacheTtlMs`,預設 `300000`)。執行 +diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +index 7a27de4f61..5154957052 100644 +--- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md ++++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +@@ -63,7 +63,7 @@ ocx models provider openrouter on + | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic 金鑰標頭風格。預設為原生 `x-api-key`;僅對 key-auth `anthropic` 供應商有效。 | + | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 多金鑰池。`apiKey` 反映現用項目;每個項目有 `id`、`key`、可選 `label` 與可選數值 `addedAt`。 | + | `defaultModel?` | `string` | 在未指定明確模型時選擇此供應商所使用的模型。 | +-| `models?` | `string[]` | 播種/後備模型清單。在 `liveModels: false` 時,這些是唯一探索的模型。 | ++| `models?` | `string[]` | 播種/後備模型清單。`liveModels: false` 時,路由模型來自 `models` 和 `retainModels`;`models` 為空時還會包含 `defaultModel`。 | + | `liveModels?` | `boolean` | 在啟動/同步時擷取即時目錄(預設 `true`)。自訂供應商使用 `${baseUrl}/models`;內建可能使用 registry URL 並過濾。 | + | `selectedModels?` | `string[]` | 探索後的目錄允許清單。非空時僅暴露那些 id;空或省略時暴露所有探索的模型。 | + | `contextWindow?` | `number` | 供應商範圍的 Codex 可見 context 上限。較小的即時中繼資料被保留。 | +@@ -324,7 +324,7 @@ Vercel AI Gateway 可在多個底層推論供應商之間路由一個模型。`v + + ## 靜態模型允許清單 + +-設定 `liveModels: false` 以僅暴露 `models`。若 `models` 為空或省略,供應商暴露無路由模型。即時探索在快取前拒絕超過 4 MiB 或 2,000 個原始模型列;內建預設可能使用較低限制並過濾到 chat 合格列。過大或格式錯誤的結果遵循過時/設定的後備。有效的零合格結果恆為權威,且不被靜默取代或截斷。 ++設定 `liveModels: false` 以僅暴露已設定模型。路由模型來自 `models` 和 `retainModels`;若 `models` 為空或省略,還會包含已設定的 `defaultModel`。這些欄位皆未提供 id 時才不暴露路由模型。即時探索在快取前拒絕超過 4 MiB 或 2,000 個原始模型列;內建預設可能使用較低限制並過濾到 chat 合格列。過大或格式錯誤的結果遵循過時/設定的後備。有效的零合格結果恆為權威,且不被靜默取代或截斷。 + + 當探索應仍然執行但只有 selected id 應出現在 Codex 與 `/v1/models` 時,請使用 `selectedModels`。儀表板保留完整的探索清單供日後允許清單變更。 + +diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx +index 645511f69d..7a1f66a213 100644 +--- a/gui/src/components/provider-workspace/ProviderDetails.tsx ++++ b/gui/src/components/provider-workspace/ProviderDetails.tsx +@@ -32,6 +32,7 @@ export default function ProviderDetails({ + availableModels, + hasLiveModels, + selectedModels, ++ disabledModels, + modelsLoading, + modelsLoadFailed, + onRetryModels, +@@ -65,6 +66,7 @@ export default function ProviderDetails({ + /** Server-reported live-catalog provenance; see filterModels(). */ + hasLiveModels: boolean; + selectedModels: string[]; ++ disabledModels: string[]; + modelsLoading?: boolean; + modelsLoadFailed?: boolean; + onRetryModels?: () => void; +@@ -293,6 +295,7 @@ export default function ProviderDetails({ + availableModels={availableModels} + hasLiveModels={hasLiveModels} + selectedModels={selectedModels} ++ disabledModels={disabledModels} + modelsLoading={modelsLoading} + modelsLoadFailed={modelsLoadFailed} + needsReauth={ +diff --git a/gui/src/components/provider-workspace/ProviderModels.tsx b/gui/src/components/provider-workspace/ProviderModels.tsx +index 56cc588292..2f89b886d6 100644 +--- a/gui/src/components/provider-workspace/ProviderModels.tsx ++++ b/gui/src/components/provider-workspace/ProviderModels.tsx +@@ -7,14 +7,19 @@ import { useEffect, useMemo, useRef, useState } from "react"; + import { useT } from "../../i18n/shared"; + import type { WorkspaceItem } from "../../provider-workspace/catalog"; + import { filterModels } from "../../provider-workspace/report"; ++import { IconEyeOff, IconTrash } from "../../icons"; ++import { putModelVisibility } from "../../model-visibility"; + import { encodedModelIdCollides } from "../../../../src/providers/slug-codec"; + ++type CustomModelRef = { id?: string; modelId: string }; ++ + export default function ProviderModels({ + item, + apiBase, + availableModels, + hasLiveModels, + selectedModels, ++ disabledModels, + modelsLoading = false, + modelsLoadFailed = false, + needsReauth = false, +@@ -25,6 +30,7 @@ export default function ProviderModels({ + apiBase: string; + availableModels: string[]; + selectedModels: string[]; ++ disabledModels: string[]; + /** Server-reported: did the last successful discovery return any rows? */ + hasLiveModels: boolean; + modelsLoading?: boolean; +@@ -38,16 +44,20 @@ export default function ProviderModels({ + const [query, setQuery] = useState(""); + const [customModelId, setCustomModelId] = useState(""); + const [customSaving, setCustomSaving] = useState(false); ++ const [removingModelId, setRemovingModelId] = useState(null); ++ const [removedModelIds, setRemovedModelIds] = useState>(() => new Set()); + const [customError, setCustomError] = useState(""); + const [customSuccess, setCustomSuccess] = useState(""); +- const [customModelIds, setCustomModelIds] = useState([]); ++ const [customModels, setCustomModels] = useState([]); + const [customModelsReady, setCustomModelsReady] = useState(false); + const [customModelsLoadFailed, setCustomModelsLoadFailed] = useState(false); + const [customModelsLoadEpoch, setCustomModelsLoadEpoch] = useState(0); + const [copiedId, setCopiedId] = useState(null); + const copyResetRef = useRef(null); + const selectedSet = useMemo(() => new Set(selectedModels), [selectedModels]); ++ const hiddenSet = useMemo(() => new Set([...disabledModels, ...removedModelIds]), [disabledModels, removedModelIds]); + const configuredModels = useMemo(() => item.models ?? [], [item.models]); ++ const customModelIds = useMemo(() => customModels.map(model => model.modelId), [customModels]); + const trimmedCustomModelId = customModelId.trim(); + const knownModelIds = [ + ...availableModels, +@@ -63,8 +73,9 @@ export default function ProviderModels({ + || item.defaultModel === trimmedCustomModelId + || encodedModelIdCollides(trimmedCustomModelId, knownModelIds); + const models = useMemo( +- () => filterModels(availableModels, item.defaultModel, query, configuredModels, customModelIds, hasLiveModels), +- [availableModels, item.defaultModel, query, configuredModels, customModelIds, hasLiveModels], ++ () => filterModels(availableModels, item.defaultModel, query, configuredModels, customModelIds, hasLiveModels) ++ .filter(modelId => !hiddenSet.has(modelId)), ++ [availableModels, item.defaultModel, query, configuredModels, customModelIds, hasLiveModels, hiddenSet], + ); + + useEffect(() => { +@@ -76,17 +87,20 @@ export default function ProviderModels({ + const rows: unknown = await response.json(); + if (!Array.isArray(rows)) throw new Error("Invalid custom model list"); + if (!active) return; +- setCustomModelIds(rows.flatMap(row => { ++ setCustomModels(rows.flatMap(row => { + if (!row || typeof row !== "object") return []; +- const model = row as { provider?: unknown; modelId?: unknown }; +- return model.provider === item.name && typeof model.modelId === "string" ? [model.modelId] : []; ++ const model = row as { id?: unknown; provider?: unknown; modelId?: unknown }; ++ return model.provider === item.name ++ && typeof model.modelId === "string" ++ ? [{ ...(typeof model.id === "string" ? { id: model.id } : {}), modelId: model.modelId }] ++ : []; + })); + setCustomModelsLoadFailed(false); + setCustomError(""); + setCustomModelsReady(true); + } catch { + if (!active) return; +- setCustomModelIds([]); ++ setCustomModels([]); + // Without this the component stays permanently unable to add a model: `customModelsReady` + // never flips back and the effect has no trigger left, so a single transient GET failure + // disabled Add until the whole panel remounted. +@@ -136,7 +150,20 @@ export default function ProviderModels({ + body: JSON.stringify({ provider: item.name, modelId: trimmedCustomModelId }), + }); + if (response.ok) { +- setCustomModelIds(ids => ids.includes(trimmedCustomModelId) ? ids : [...ids, trimmedCustomModelId]); ++ const added: unknown = await response.json(); ++ if (!added || typeof added !== "object" || typeof (added as { id?: unknown }).id !== "string") { ++ setCustomError(t("models.customSaveFailed")); ++ return; ++ } ++ const id = (added as { id: string }).id; ++ setCustomModels(models => models.some(model => model.modelId === trimmedCustomModelId) ++ ? models ++ : [...models, { id, modelId: trimmedCustomModelId }]); ++ setRemovedModelIds(ids => { ++ const next = new Set(ids); ++ next.delete(trimmedCustomModelId); ++ return next; ++ }); + setCustomModelId(""); + setCustomSuccess(t("models.customAdded")); + onRetryModels?.(); +@@ -150,6 +177,39 @@ export default function ProviderModels({ + } + }; + ++ const removeModel = async (modelId: string) => { ++ const customModel = customModels.find(model => model.modelId === modelId && model.id); ++ if (removingModelId || !window.confirm(t(customModel ? "models.customDeleteConfirm" : "models.hideConfirm", { name: modelId }))) return; ++ const visibilityTarget = { id: modelId, ...(item.name === "openai" ? { native: true } : {}) }; ++ setRemovingModelId(modelId); ++ setCustomError(""); ++ setCustomSuccess(""); ++ try { ++ if (customModel?.id) { ++ const deleteResponse = await fetch(`${apiBase}/api/custom-models/${encodeURIComponent(customModel.id)}`, { method: "DELETE" }); ++ if (!deleteResponse.ok) { ++ setCustomError(t("models.customSaveFailed")); ++ return; ++ } ++ setCustomModels(models => models.filter(model => model.modelId !== modelId)); ++ } ++ const visibilityResponse = await putModelVisibility(apiBase, "models", item.name, [visibilityTarget], false); ++ if (!visibilityResponse.ok) { ++ onRetryModels?.(); ++ setCustomError(t("models.saveFailed")); ++ return; ++ } ++ setRemovedModelIds(ids => new Set(ids).add(modelId)); ++ setCustomSuccess(t(customModel ? "models.customDeleted" : "models.applied")); ++ onRetryModels?.(); ++ } catch { ++ onRetryModels?.(); ++ setCustomError(t("models.networkError")); ++ } finally { ++ setRemovingModelId(null); ++ } ++ }; ++ + const emptyBase = availableModels.length === 0 + && configuredModels.length === 0 + && customModelIds.length === 0 +@@ -247,7 +307,9 @@ export default function ProviderModels({ + {visibleModels.map(modelId => { + const isDefault = modelId === item.defaultModel; + const isSelected = selectedSet.has(modelId); ++ const isCustom = customModels.some(model => model.modelId === modelId && model.id); + const copied = copiedId === modelId; ++ const removeLabel = t(isCustom ? "models.customDelete" : "models.hide"); + return ( +
  • + +
  • + ); + })} +diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +index 91faecb6fc..24fe187f94 100644 +--- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx ++++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +@@ -24,7 +24,7 @@ import { + import { providerKind } from "../../provider-workspace/kind"; + import { readJsonIfOk, readJsonOrThrow } from "../../fetch-json"; + import { readSessionListCache, writeSessionListCache } from "../../session-list-cache"; +-import { buildProviderModelUsage, buildProviderUsageTotals, countAvailableModels, parseAvailableModels, parseLiveModelCounts, parseSelectedModels, type ProviderAvailableModels, type ProviderLiveModelCounts, type ProviderModelCounts, type ProviderSelectedModels } from "../../provider-workspace/usage"; ++import { buildProviderModelUsage, buildProviderUsageTotals, countAvailableModels, parseAvailableModels, parseDisabledModels, parseLiveModelCounts, parseSelectedModels, type ProviderAvailableModels, type ProviderDisabledModels, type ProviderLiveModelCounts, type ProviderModelCounts, type ProviderSelectedModels } from "../../provider-workspace/usage"; + import { + freshQuotaReportRecord, + freshQuotaReportsFromResponse, +@@ -47,6 +47,7 @@ export interface DetailSlotData { + /** Did the last successful discovery return rows? Server-reported, never inferred. */ + hasLiveModels: boolean; + selectedModels: string[]; ++ disabledModels: string[]; + modelsLoading: boolean; + modelsLoadFailed: boolean; + onRetryModels?: () => void; +@@ -141,6 +142,7 @@ export default function ProviderWorkspaceShell({ + const [availableModels, setAvailableModels] = useState({}); + const [liveModelCounts, setLiveModelCounts] = useState({}); + const [selectedModels, setSelectedModels] = useState({}); ++ const [disabledModels, setDisabledModels] = useState({}); + const [modelsLoading, setModelsLoading] = useState(false); + const [modelsLoadFailed, setModelsLoadFailed] = useState(false); + const quotasCacheKey = `ocx.providers.quotas.v1:${apiBase}`; +@@ -189,6 +191,7 @@ export default function ProviderWorkspaceShell({ + setAvailableModels(parseAvailableModels(data)); + setLiveModelCounts(parseLiveModelCounts(data)); + setSelectedModels(parseSelectedModels(data)); ++ setDisabledModels(parseDisabledModels(data)); + setModelsLoadFailed(false); + succeeded = true; + } catch { +@@ -559,6 +562,7 @@ export default function ProviderWorkspaceShell({ + availableModels: availableModels[selectedItem.name] ?? [], + hasLiveModels: (liveModelCounts[selectedItem.name] ?? 0) > 0, + selectedModels: selectedModels[selectedItem.name] ?? [], ++ disabledModels: disabledModels[selectedItem.name] ?? [], + modelsLoading, + modelsLoadFailed, + onRetryModels: retryModels, +diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts +index 4d4b79e5d5..c29aad7355 100644 +--- a/gui/src/i18n/de.ts ++++ b/gui/src/i18n/de.ts +@@ -577,6 +577,8 @@ export const de: Record = { + "models.customEdit": "Bearbeiten", + "models.customDelete": "Löschen", + "models.customDeleteConfirm": "Modell {name} löschen?", ++ "models.hide": "Ausblenden", ++ "models.hideConfirm": "Modell {name} aus dem Katalog ausblenden?", + "models.customBadge": "Benutzerdefiniert", + "models.customSummary": "{count} benutzerdefiniert", + "models.customFieldModelId": "Modell-ID (Endpunkt-Slug)", +diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts +index cf9eb253dd..ae4761051f 100644 +--- a/gui/src/i18n/en.ts ++++ b/gui/src/i18n/en.ts +@@ -602,6 +602,8 @@ export const en = { + "models.customEdit": "Edit", + "models.customDelete": "Delete", + "models.customDeleteConfirm": "Delete the {name} model?", ++ "models.hide": "Hide", ++ "models.hideConfirm": "Hide the {name} model from the catalog?", + "models.customBadge": "Custom", + "models.customSummary": "{count} custom", + "models.customFieldModelId": "Model ID (endpoint slug)", +diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts +index 2d90382f1a..455898688e 100644 +--- a/gui/src/i18n/fr.ts ++++ b/gui/src/i18n/fr.ts +@@ -587,6 +587,8 @@ export const fr: Record = { + "models.customEdit": "Modifier", + "models.customDelete": "Supprimer", + "models.customDeleteConfirm": "Supprimer le modèle {name} ?", ++ "models.hide": "Masquer", ++ "models.hideConfirm": "Masquer le modèle {name} du catalogue ?", + "models.customBadge": "Personnalisé", + "models.customSummary": "{count} personnalisés", + "models.customFieldModelId": "ID du modèle (slug du point de terminaison)", +diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts +index c9d2e9ea4a..d413daa401 100644 +--- a/gui/src/i18n/ja.ts ++++ b/gui/src/i18n/ja.ts +@@ -2315,6 +2315,8 @@ export const ja: Record = { + "models.customEdit": "Edit", + "models.customDelete": "Delete", + "models.customDeleteConfirm": "Delete the {name} model?", ++ "models.hide": "非表示", ++ "models.hideConfirm": "モデル {name} をカタログから非表示にしますか?", + "models.customBadge": "Custom", + "models.customSummary": "{count} custom", + "models.customFieldModelId": "Model ID (endpoint slug)", +diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts +index d9c983a5fb..7ffa869a59 100644 +--- a/gui/src/i18n/ko.ts ++++ b/gui/src/i18n/ko.ts +@@ -588,6 +588,8 @@ export const ko: Record = { + "models.customEdit": "편집", + "models.customDelete": "삭제", + "models.customDeleteConfirm": "{name} 모델을 삭제하시겠습니까?", ++ "models.hide": "숨기기", ++ "models.hideConfirm": "{name} 모델을 카탈로그에서 숨기시겠습니까?", + "models.customBadge": "커스텀", + "models.customSummary": "커스텀 {count}개", + "models.customFieldModelId": "모델 ID (엔드포인트 슬러그)", +diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts +index 950cea7a81..324eae13fd 100644 +--- a/gui/src/i18n/ru.ts ++++ b/gui/src/i18n/ru.ts +@@ -590,6 +590,8 @@ export const ru: Record = { + "models.customEdit": "Изменить", + "models.customDelete": "Удалить", + "models.customDeleteConfirm": "Удалить модель {name}?", ++ "models.hide": "Скрыть", ++ "models.hideConfirm": "Скрыть модель {name} из каталога?", + "models.customBadge": "Пользовательская", + "models.customSummary": "Пользовательских: {count}", + "models.customFieldModelId": "ID модели (slug эндпоинта)", +diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts +index 5db3ca23b5..37865f57c8 100644 +--- a/gui/src/i18n/tr.ts ++++ b/gui/src/i18n/tr.ts +@@ -593,6 +593,8 @@ export const tr: Record = { + "models.customEdit": "Düzenle", + "models.customDelete": "Sil", + "models.customDeleteConfirm": "{name} modeli silinsin mi?", ++ "models.hide": "Gizle", ++ "models.hideConfirm": "{name} modeli katalogda gizlensin mi?", + "models.customBadge": "Özel", + "models.customSummary": "{count} özel", + "models.customFieldModelId": "Model ID", +diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts +index 06f8e6fa4b..b8a842bbcf 100644 +--- a/gui/src/i18n/zh-TW.ts ++++ b/gui/src/i18n/zh-TW.ts +@@ -456,6 +456,8 @@ export const zhTW: Record = { + "models.customEdit": "編輯", + "models.customDelete": "刪除", + "models.customDeleteConfirm": "要刪除模型 {name} 嗎?", ++ "models.hide": "隱藏", ++ "models.hideConfirm": "要從目錄中隱藏模型 {name} 嗎?", + "models.customBadge": "自訂", + "models.customSummary": "{count} 個自訂模型", + "models.customFieldModelId": "模型 ID(端點標識)", +diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts +index 315869d88d..28758e6157 100644 +--- a/gui/src/i18n/zh.ts ++++ b/gui/src/i18n/zh.ts +@@ -585,6 +585,8 @@ export const zh: Record = { + "models.customEdit": "编辑", + "models.customDelete": "删除", + "models.customDeleteConfirm": "要删除模型 {name} 吗?", ++ "models.hide": "隐藏", ++ "models.hideConfirm": "要从目录中隐藏模型 {name} 吗?", + "models.customBadge": "自定义", + "models.customSummary": "{count} 个自定义模型", + "models.customFieldModelId": "模型 ID(端点标识)", +diff --git a/gui/src/icons.tsx b/gui/src/icons.tsx +index 6ec2ebf096..70afe03db9 100644 +--- a/gui/src/icons.tsx ++++ b/gui/src/icons.tsx +@@ -24,6 +24,7 @@ export const IconRefresh = (p: P) => (); + export const IconPlay = (p: P) => (); + export const IconTrash = (p: P) => (); ++export const IconEyeOff = (p: P) => (); + export const IconPencil = (p: P) => (); + export const IconAlert = (p: P) => (); + export const IconInfo = (p: P) => (); +diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx +index 8701b3acd3..84832e2d1b 100644 +--- a/gui/src/pages/Providers.tsx ++++ b/gui/src/pages/Providers.tsx +@@ -459,6 +459,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { + availableModels={data.availableModels} + hasLiveModels={data.hasLiveModels} + selectedModels={data.selectedModels} ++ disabledModels={data.disabledModels} + modelsLoading={data.modelsLoading} + modelsLoadFailed={data.modelsLoadFailed} + onRetryModels={data.onRetryModels} +diff --git a/gui/src/provider-workspace/usage.ts b/gui/src/provider-workspace/usage.ts +index 033bdbcee2..4cae58417a 100644 +--- a/gui/src/provider-workspace/usage.ts ++++ b/gui/src/provider-workspace/usage.ts +@@ -16,6 +16,7 @@ import type { ProviderModelUsageRow } from "../components/provider-workspace/typ + export type ProviderModelCounts = Record; + export type ProviderAvailableModels = Record; + export type ProviderSelectedModels = Record; ++export type ProviderDisabledModels = Record; + + /** Parse `/api/selected-models` available map into provider -> model id list. */ + export function parseAvailableModels(data: unknown): ProviderAvailableModels { +@@ -64,10 +65,26 @@ export function parseSelectedModels(data: unknown): ProviderSelectedModels { + return models; + } + ++/** Parse `/api/selected-models` disabled map into provider -> hidden model id list. */ ++export function parseDisabledModels(data: unknown): ProviderDisabledModels { ++ if (!data || typeof data !== "object") return {}; ++ const disabled = (data as { disabled?: unknown }).disabled; ++ if (!disabled || typeof disabled !== "object" || Array.isArray(disabled)) return {}; ++ ++ const models: ProviderDisabledModels = {}; ++ for (const [provider, ids] of Object.entries(disabled)) { ++ if (!Array.isArray(ids)) continue; ++ models[provider] = ids.filter((id): id is string => typeof id === "string"); ++ } ++ return models; ++} ++ + export function countAvailableModels(data: unknown): ProviderModelCounts { + const counts: ProviderModelCounts = {}; ++ const disabled = parseDisabledModels(data); + for (const [provider, models] of Object.entries(parseAvailableModels(data))) { +- counts[provider] = models.length; ++ const hidden = new Set(disabled[provider] ?? []); ++ counts[provider] = models.filter(model => !hidden.has(model)).length; + } + return counts; + } +diff --git a/gui/tests/provider-model-custom-add.test.tsx b/gui/tests/provider-model-custom-add.test.tsx +index 6ebc04134a..ee3046845b 100644 +--- a/gui/tests/provider-model-custom-add.test.tsx ++++ b/gui/tests/provider-model-custom-add.test.tsx +@@ -5,6 +5,7 @@ import type { Root } from "react-dom/client"; + import { LanguageProvider } from "../src/i18n/provider"; + import ProviderModels from "../src/components/provider-workspace/ProviderModels"; + import type { WorkspaceItem } from "../src/provider-workspace/catalog"; ++import { countAvailableModels } from "../src/provider-workspace/usage"; + + const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + const originalFetch = globalThis.fetch; +@@ -40,11 +41,17 @@ const item = { + defaultModel: "claude-opus-5", + } as WorkspaceItem; + ++test("provider model counts exclude removed models", () => { ++ expect(countAvailableModels({ available: { vendor: ["a", "b", "c"] }, disabled: { vendor: ["b"] } })) ++ .toEqual({ vendor: 2 }); ++}); ++ + async function mountProviderModels( + availableModels = ["claude-opus-5"], + onRetryModels?: () => void, + providerItem = item, + hasLiveModels = true, ++ disabledModels: string[] = [], + ): Promise<{ root: Root; container: HTMLElement; input: HTMLInputElement; addButton: HTMLButtonElement }> { + const container = document.createElement("div"); + document.body.append(container); +@@ -59,6 +66,7 @@ async function mountProviderModels( + availableModels={availableModels} + hasLiveModels={hasLiveModels} + selectedModels={[]} ++ disabledModels={disabledModels} + apiBase="http://localhost:10100" + onRetryModels={onRetryModels} + /> +@@ -205,6 +213,194 @@ test("custom-only catalog keeps configured fallback models visible", async () => + await act(async () => { root.unmount(); }); + }); + ++test("custom models use their stable id and persist discovered-model visibility when deleted", async () => { ++ const requests: Array<{ url: string; method: string; body: unknown }> = []; ++ globalThis.fetch = (async (input, init) => { ++ if (!init?.method || init.method === "GET") { ++ return Response.json([ ++ { id: "custom-1", provider: "AiCodeWith", modelId: "claude-opus-5.1-custom" }, ++ ]); ++ } ++ requests.push({ ++ url: String(input), ++ method: init.method, ++ body: typeof init.body === "string" ? JSON.parse(init.body) : undefined, ++ }); ++ return Response.json({ ok: true }); ++ }) as typeof fetch; ++ testWindow.confirm = () => true; ++ ++ let refreshes = 0; ++ const { root, container } = await mountProviderModels( ++ ["claude-opus-5", "claude-opus-5.1-custom"], ++ () => { refreshes += 1; }, ++ ); ++ await act(async () => { await Promise.resolve(); }); ++ ++ const customChip = [...container.querySelectorAll(".pws-model-chip")] ++ .find(chip => chip.querySelector(".pws-model-id")?.textContent === "claude-opus-5.1-custom")!; ++ const deleteButton = customChip.querySelector('button[aria-label="Delete"]')!; ++ await act(async () => { ++ deleteButton.click(); ++ await Promise.resolve(); ++ await Promise.resolve(); ++ }); ++ ++ expect(requests).toEqual([ ++ { ++ url: "http://localhost:10100/api/custom-models/custom-1", ++ method: "DELETE", ++ body: undefined, ++ }, ++ { ++ url: "http://localhost:10100/api/model-visibility", ++ method: "PUT", ++ body: { ++ scope: "models", ++ provider: "AiCodeWith", ++ targets: [{ id: "claude-opus-5.1-custom" }], ++ enabled: false, ++ }, ++ }, ++ ]); ++ expect(refreshes).toBe(1); ++ expect([...container.querySelectorAll(".pws-model-id")].map(node => node.textContent)) ++ .toEqual(["claude-opus-5"]); ++ expect(container.querySelector('[role="status"]')?.textContent).toContain("Custom model deleted"); ++ ++ await act(async () => { root.unmount(); }); ++}); ++ ++test("a custom model stays visible when its visibility update fails", async () => { ++ globalThis.fetch = (async (_input, init) => { ++ if (!init?.method || init.method === "GET") { ++ return Response.json([ ++ { id: "custom-1", provider: "AiCodeWith", modelId: "claude-opus-5.1-custom" }, ++ ]); ++ } ++ if (init.method === "DELETE") return Response.json({ ok: true }); ++ return Response.json({ error: "failed" }, { status: 500 }); ++ }) as typeof fetch; ++ testWindow.confirm = () => true; ++ ++ const { root, container } = await mountProviderModels(["claude-opus-5.1-custom"]); ++ await act(async () => { await Promise.resolve(); }); ++ ++ await act(async () => { ++ container.querySelector('button[aria-label="Delete"]')!.click(); ++ await Promise.resolve(); ++ await Promise.resolve(); ++ }); ++ ++ expect([...container.querySelectorAll(".pws-model-id")].map(node => node.textContent)) ++ .toEqual(["claude-opus-5.1-custom"]); ++ expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save failed"); ++ ++ await act(async () => { root.unmount(); }); ++}); ++ ++test("discovered models are labeled as hidden and removed from the local catalog", async () => { ++ const requests: Array<{ url: string; method: string; body: unknown }> = []; ++ globalThis.fetch = (async (input, init) => { ++ if (!init?.method || init.method === "GET") return Response.json([]); ++ requests.push({ ++ url: String(input), ++ method: init.method, ++ body: typeof init.body === "string" ? JSON.parse(init.body) : undefined, ++ }); ++ return Response.json({ ok: true }); ++ }) as typeof fetch; ++ let confirmation = ""; ++ testWindow.confirm = message => { ++ confirmation = String(message); ++ return true; ++ }; ++ ++ let refreshes = 0; ++ const { root, container } = await mountProviderModels( ++ ["claude-opus-5", "claude-sonnet-5"], ++ () => { refreshes += 1; }, ++ ); ++ await act(async () => { await Promise.resolve(); }); ++ ++ const discoveredChip = [...container.querySelectorAll(".pws-model-chip")] ++ .find(chip => chip.querySelector(".pws-model-id")?.textContent === "claude-sonnet-5")!; ++ const hideButton = discoveredChip.querySelector('button[aria-label="Hide"]')!; ++ expect(hideButton.title).toBe("Hide"); ++ await act(async () => { ++ hideButton.click(); ++ await Promise.resolve(); ++ }); ++ ++ expect(confirmation).toBe("Hide the claude-sonnet-5 model from the catalog?"); ++ expect(requests).toEqual([{ ++ url: "http://localhost:10100/api/model-visibility", ++ method: "PUT", ++ body: { ++ scope: "models", ++ provider: "AiCodeWith", ++ targets: [{ id: "claude-sonnet-5" }], ++ enabled: false, ++ }, ++ }]); ++ expect(refreshes).toBe(1); ++ expect([...container.querySelectorAll(".pws-model-id")].map(node => node.textContent)) ++ .toEqual(["claude-opus-5"]); ++ expect(container.querySelector('[role="status"]')?.textContent).toContain("Applied"); ++ ++ await act(async () => { root.unmount(); }); ++}); ++ ++test("native OpenAI models use a native visibility target when removed", async () => { ++ const requests: unknown[] = []; ++ globalThis.fetch = (async (_input, init) => { ++ if (!init?.method || init.method === "GET") return Response.json([]); ++ requests.push(typeof init.body === "string" ? JSON.parse(init.body) : undefined); ++ return Response.json({ ok: true }); ++ }) as typeof fetch; ++ testWindow.confirm = () => true; ++ const openAiItem = { ++ ...item, ++ name: "openai", ++ models: ["gpt-5.5"], ++ defaultModel: "gpt-5.5", ++ } as WorkspaceItem; ++ ++ const { root, container } = await mountProviderModels(["gpt-5.5"], undefined, openAiItem); ++ await act(async () => { await Promise.resolve(); }); ++ ++ await act(async () => { ++ container.querySelector('button[aria-label="Hide"]')!.click(); ++ await Promise.resolve(); ++ }); ++ ++ expect(requests).toEqual([{ ++ scope: "models", ++ provider: "openai", ++ targets: [{ id: "gpt-5.5", native: true }], ++ enabled: false, ++ }]); ++ ++ await act(async () => { root.unmount(); }); ++}); ++ ++test("disabled discovered models stay out of the provider model list", async () => { ++ globalThis.fetch = (async () => Response.json([])) as typeof fetch; ++ const { root, container } = await mountProviderModels( ++ ["claude-opus-5", "claude-sonnet-5"], ++ undefined, ++ item, ++ true, ++ ["claude-sonnet-5"], ++ ); ++ await act(async () => { await Promise.resolve(); }); ++ ++ expect([...container.querySelectorAll(".pws-model-id")].map(node => node.textContent)) ++ .toEqual(["claude-opus-5"]); ++ ++ await act(async () => { root.unmount(); }); ++}); ++ + // A single transient GET used to leave `customModelsReady` false forever: the effect had no + // remaining trigger, so Add stayed disabled until the whole panel remounted. Drive the full + // recovery in one mount: failed load -> retry -> successful load -> Add enabled -> exactly one POST. +diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts +index f810bfb422..55b90cfea4 100644 +--- a/src/codex/catalog/provider-fetch.ts ++++ b/src/codex/catalog/provider-fetch.ts +@@ -1506,11 +1506,14 @@ async function fetchProviderModelsWithAuth( + && prov.googleMode === "vertex" + && (prov.models?.length ?? 0) === 0 + && Boolean(prov.defaultModel); +- // Ordered dedupe union: Vertex seed, then `models`, then `retainModels`. `configured` is the ++ const seedStaticDefault = prov.liveModels === false ++ && (prov.models?.length ?? 0) === 0 ++ && Boolean(prov.defaultModel); ++ // Ordered dedupe union: implicit default seed, then `models`, then `retainModels`. `configured` is the + // single seed for the static path, the degraded fallback, drop diagnostics, and provider hints, + // so a retain-only id must enter here or it never exists to be retained (#1690). + const configuredIds = [...new Set([ +- ...(seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : []), ++ ...((seedVertexDefault || seedStaticDefault) && prov.defaultModel ? [prov.defaultModel] : []), + ...(prov.models ?? []), + ...(prov.retainModels ?? []), + ])]; +@@ -1563,9 +1566,8 @@ async function fetchProviderModelsWithAuth( + : resolveAuth.resolve(name, prov)); + const apiKey = auth.apiKey; + // A configured default is a real callable selector and must remain discoverable when a +- // compatible provider's live /models request fails (issue #308). Keep this separate from the +- // explicit static list: `liveModels: false` + empty `models[]` intentionally publishes zero +- // rows, while a failed live discovery may degrade to the default selector. ++ // compatible provider's live /models request fails (issue #308). Static providers already seed ++ // their default selector above when no explicit model list exists. + const failedDiscoveryConfigured = configured.length > 0 || !prov.defaultModel || prov.adapter !== "anthropic" + ? configured + : [{ +diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts +index e9ea26a90e..fd3f1eb43c 100644 +--- a/src/server/management/model-routes.ts ++++ b/src/server/management/model-routes.ts +@@ -787,7 +787,14 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise = {}; +- for (const m of models) (available[m.provider] ??= []).push(m.id); ++ const disabled: Record = {}; ++ const disabledSlugs = config.disabledModels ?? []; ++ for (const m of models) { ++ (available[m.provider] ??= []).push(m.id); ++ if (disabledSlugs.some(slug => slugEquals(slug, m.provider, m.id))) { ++ (disabled[m.provider] ??= []).push(m.id); ++ } ++ } + const selected: Record = {}; + // Live-catalog provenance. The GUI cannot infer this by subtracting known custom ids: an id + // that is both custom and discovered would make a real live catalog look custom-only. +@@ -797,7 +804,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { + .toEqual([]); + }); + ++ test("filters dashboard-hidden provider models before catalog sync", () => { ++ const models = [ ++ { provider: "vendor", id: "visible-model" }, ++ { provider: "vendor", id: "hidden-model" }, ++ ]; ++ ++ expect(filterCatalogVisibleModels(models, { ++ disabledModels: ["vendor/hidden-model"], ++ providers: { vendor: {} }, ++ })).toEqual([{ provider: "vendor", id: "visible-model" }]); ++ }); ++ + test("repairs a provider row after its shadowing combo alias is disabled", () => { + const alias = "vendor/deepseek-v4-flash"; + const combo = deriveComboCatalogModel( +@@ -4061,6 +4073,36 @@ describe("Codex catalog routed normalization", () => { + } + }); + ++ test("liveModels false uses the default model when no static list is configured", async () => { ++ const originalFetch = globalThis.fetch; ++ let fetchCalls = 0; ++ globalThis.fetch = (() => { ++ fetchCalls += 1; ++ throw new Error("fetch should not be called"); ++ }) as typeof fetch; ++ try { ++ const models = await gatherRoutedModels({ ++ providers: { ++ "static-default": { ++ baseUrl: "https://example.invalid/v1", ++ adapter: "openai-chat", ++ authMode: "key", ++ liveModels: false, ++ defaultModel: "only-model", ++ }, ++ }, ++ }); ++ ++ expect(fetchCalls).toBe(0); ++ expect(models.map(m => `${m.provider}/${m.id}`)).toEqual([ ++ "static-default/only-model", ++ ]); ++ } finally { ++ globalThis.fetch = originalFetch; ++ clearModelCache("static-default"); ++ } ++ }); ++ + test("Google Antigravity honors an explicit static catalog and suppresses stale discovery", async () => { + const providerName = "google-antigravity"; + const provider = structuredClone(OAUTH_PROVIDERS[providerName].providerConfig); +diff --git a/tests/server/model-discovery-management-api.test.ts b/tests/server/model-discovery-management-api.test.ts +index ad132847bb..6d44c2f9ea 100644 +--- a/tests/server/model-discovery-management-api.test.ts ++++ b/tests/server/model-discovery-management-api.test.ts +@@ -39,4 +39,12 @@ describe("model discovery management API", () => { + expect(live.modelDiscovery.recentArrivals?.vendor).toEqual([]); + expect(live.disabledModels).toEqual(["vendor/new"]); + }); ++ ++ test("selected-models reports disabled discovered model ids by provider", async () => { ++ const live = config(); ++ live.disabledModels = ["vendor/known", "other/ignored"]; ++ const result = await call(live, "/api/selected-models"); ++ expect(result.json.available).toEqual({ vendor: ["known"] }); ++ expect(result.json.disabled).toEqual({ vendor: ["known"] }); ++ }); + }); + +```` diff --git a/devlog/_plan/260906_lane_b_catalog_stack/050_fable.md b/devlog/_plan/260906_lane_b_catalog_stack/050_fable.md new file mode 100644 index 0000000000..24e6e61080 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/050_fable.md @@ -0,0 +1,344 @@ +# 050 — Preserve Fable 1M picker selection (source PR #3649) + +Status: implementation plan only; no implementation or runtime validation performed. +Anchored 2026-09-06 to checkout `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`. +All paths below are repository-relative. Main owns 000, the goalplan, integration, and phase transitions. + +## Loop specification + +- Class: C3 catalog-to-ingress contract; the native passthrough portion requires explicit security review under MAINTAINERS.md:57-71. This research task is docs-only. +- Archetype: spec-satisfaction repair; one later PABCD cycle for this entire numbered plan. +- Trigger: Fable base and marked canonical selectors collapse into one client picker family (public source-PR report). +- Goal: retain independently selectable canonical Fable and 1M rows while forwarding canonical Fable on Messages/count_tokens. +- Non-goals: other Anthropic families, model-cap changes, generalized alias rewrite, Desktop registry repair (#3646), auth-policy changes, GUI component work, release operations. +- Verifier: exact-head hosted Cross-platform CI with actual Linux/macOS tests and gates; client picker evidence is a separate required artifact, not inferred from a unit test. +- Stop: all acceptance rows below have evidence, source contribution survives carry/squash, and main proves the carry is on dev before closing #3649. DONE is not available from this document alone. +- Memory artifact: this document plus main's 000 index and scratch review `.tmp/lane-b/plan-fable-review.md`. +- Bounds for this delegated research: local read-only source/metadata, two permitted documentation writes, no credentials or paid provider requests, no recursive workers needed; finish one bounded research pass. The consuming implementation inherits 000_plan.md: no numeric token/cost cap, six-hour work-phase checkpoint; restate this at the consuming P. +- Escalation: return concrete blockers to main; downstream delegation requires a P amendment; main reclaims a packet after two distinct workers fail it. +- Terminal meanings: DONE = later verified landing; NOOP = fresh dev already supplies the exact behavior/tests; BLOCKED = missing external CI/client evidence; UNSAFE/NEEDS_HUMAN = unresolved review decision; resource bounds never imply DONE. + +## Source and attribution + +Public source: https://github.com/lidge-jun/opencodex/pull/3649 + +- Source base: `45f3bed84be10a7e045a20aae1db46ab822bf7d0`. +- Source head: `95becce94255982667cef10308806770d49cc05b`. +- Behavior commit: `9a7795aa34df219654512366040d87a219fb4ada` (`fix(gateway): preserve Fable 1M picker selection`). +- Follow-up regression: `284fe8ca0b793d51c6cdd609f1c9acf219f2eaf9` (`test(gateway): cover marked Fable picker alias`). +- Head is a merge of `284fe8ca0` and `45f3bed84`; do not cherry-pick that merge as a third implementation change. +- Actual Git author AND committer of all three: `Éverton Toffanetto ` (`everton-dgn`). Verified with `git show -s --format=fuller` against locally available commit objects, corroborated by the supplied JSON commit list. +- Any squash or rewritten carry must include `Co-authored-by: Éverton Toffanetto `. Preserve original authors on cherry-picked commits; attribution in prose alone is insufficient. +- Input evidence: `.tmp/lane-b/3649.json` and `.tmp/lane-b/3649.patch`. JSON reports OPEN/MERGEABLE; this is a supplied snapshot, not a new live readiness claim. It contains no successful hosted CI evidence. + +## Current owners and activation path + +1. `src/server/index.ts:1479-1516` serves Anthropic discovery and calls `buildAnthropicModelInfos`. `?ids=cli` or a `claude-code/` UA selects readable IDs; explicit desktop/unknown UA retains Desktop IDs. Native registry setup remains untouched. +2. `src/claude/model-info.ts:146-165` owns 1M row generation. It requires authoritative context >= 1M, rejects already-marked IDs, deduplicates, and caps advertised input at min(1M, maxInputTokens). +3. `src/claude/model-info.ts:196-227` owns routed row emission; `listedModelId` already reflects the Cursor Fast exception. Put the Fable condition here, not in the generic alias encoder. +4. `src/claude/alias.ts:141-148` leaves canonical Anthropic IDs bare and exposes reversible native aliases. `resolveAlias` at :112-128 returns bare slugs for the native pseudo-provider. Reuse these functions without changing their public exports. +5. `src/claude/inbound-model-options.ts:39-68` resolves aliases before modelMap. `src/claude/inbound.ts` exports the resolver used by the server. Preserve that order. +6. `src/server/claude-messages.ts:634-668` strips `[1m]`, honors ocx-route, then parses Fast/effort. Insert the narrowly scoped Fable restoration between route override and synthetic-row parsing. `wantsNativePassthrough` at :151-168 subsequently examines the canonical model. +7. `src/server/claude-messages.ts:1069-1096` has the corresponding count_tokens path; insert restoration after countRoute and before Fast-only normalization. + +No configuration-only remedy repairs the emitted selector identity. No-op is ruled out by the current generic `${base.id}[1m]` at model-info.ts:153 and the absent helper/call sites. Reuse wins over a new registry, provider, flag, or global decoder. + +## Exact change map for the later implementation cycle + +| Operation | Path | Change | +|---|---|---| +| MODIFY | `src/claude/model-info.ts` | Optional selectorId in local push1mVariant; readable canonical Anthropic Fable-only 1M alias | +| MODIFY | `src/server/claude-messages.ts` | Import existing encoder; private Fable decoder; two ingress call sites | +| MODIFY | `tests/claude-integration/claude-model-info.test.ts` | Source positive test plus Fable-specific style/window regressions below | +| MODIFY | `tests/claude-integration/claude-native-passthrough.test.ts` | Source three-request regression plus canonical legacy/marker compatibility assertions | +| MODIFY | `docs-site/src/content/docs/guides/claude-code.md` | Explain the bounded Fable 1M exception in both canonical-ID statements | +| NEW | none (production/tests) | Existing owners and registered tests suffice | + +This research writes only this numbered document and the delegated scratch report. No schema/layout manifest update is needed: both test basenames already exist in `scripts/test-layout/layout.json:296-298` and `tests/fixtures/test-layout-expected.json:133-135`. + +## Focused source patch to carry + +Use the complete public four-file patch below. It includes the follow-up marked Messages case; carrying only the first commit drops that regression. Context line numbers belong to the source patch; match current owners above and refresh at P. + +```diff +diff --git a/src/claude/model-info.ts b/src/claude/model-info.ts +index cbecf8c60a..bcd047a281 100644 +--- a/src/claude/model-info.ts ++++ b/src/claude/model-info.ts +@@ -143,14 +143,19 @@ export function buildAnthropicModelInfos( + // the auto-context widening that let a 372K route carry the marker (and be + // over-filled) is the #854 defect and does not come back. Guards (audit R1#11): + // same dedupe set, never double-suffix. +- const push1mVariant = (base: AnthropicModelInfo, contextWindow: number | undefined, maxInputTokens?: number) => { ++ const push1mVariant = ( ++ base: AnthropicModelInfo, ++ contextWindow: number | undefined, ++ maxInputTokens?: number, ++ selectorId?: string, ++ ) => { + // The [1m] marker makes Claude Code account 1e6 tokens for the row, so it + // may only name models whose AUTHORITATIVE effective window is >= 1M — + // never the auto-context widening, which would mark a 372K route and have + // Claude Code over-fill it (the #854 defect). + if (contextWindow === undefined || contextWindow < ONE_MILLION) return; + if (base.id.includes("[1m]")) return; +- const id = `${base.id}[1m]`; ++ const id = selectorId ?? `${base.id}[1m]`; + if (seen.has(id)) return; + seen.add(id); + // The marker fixes Claude Code's accounting at 1e6, but a model may accept less input +@@ -220,7 +225,15 @@ export function buildAnthropicModelInfos( + out.push(info); + // Anthropic passthrough guard (audit 021 #3): never auto-widen canonical claude + // routes — only a genuine >=1M window earns the variant row there. +- push1mVariant(info, m.contextWindow, routedMaxInput); ++ // Claude Code groups canonical Fable ids before it compares the [1m] marker. This ++ // reversible alias only separates picker families; it is not an OpenAI-native route. ++ // The Messages ingress restores the canonical Anthropic id before passthrough. ++ const oneMillionSelector = idStyle === "readable" ++ && m.provider === "anthropic" ++ && listedModelId.startsWith("claude-fable-") ++ ? `${claudeCodeNativeAlias(listedModelId)}[1m]` ++ : undefined; ++ push1mVariant(info, m.contextWindow, routedMaxInput, oneMillionSelector); + // The whole model is passed, not a (provider, id) pair: a combo row lives in its own + // namespace with no config.providers entry, so the caller classifies it from the + // aggregated supportsServiceTier the row already carries. +diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts +index 70572f9c67..d2eedfa4ab 100644 +--- a/src/server/claude-messages.ts ++++ b/src/server/claude-messages.ts +@@ -12,6 +12,7 @@ import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/a + import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; + import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxEffortDirective, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; + import { resolveDesktop3pAlias } from "../claude/desktop-3p"; ++import { claudeCodeNativeAlias } from "../claude/alias"; + import { recordDesktopRequest } from "../claude/desktop-health"; + import { stripOneMillionMarker } from "../claude/context-windows"; + import { captureClaudeInbound } from "../claude/inbound-debug"; +@@ -78,6 +79,13 @@ function decodeClaudeFastSelector(raw: string, cc?: OcxConfig["claudeCode"]): st + return decodedBase === bare ? exact : `${decodedBase}--fast`; + } + ++/** Restore the reversible Fable picker alias before Anthropic passthrough checks. */ ++function decodeFablePickerAlias(raw: string, cc?: OcxConfig["claudeCode"]): string { ++ const decoded = resolveInboundModel(raw, cc); ++ if (!decoded.startsWith("claude-fable-")) return raw; ++ return claudeCodeNativeAlias(decoded) === raw ? decoded : raw; ++} ++ + function isRec(v: unknown): v is Rec { + return !!v && typeof v === "object" && !Array.isArray(v); + } +@@ -648,6 +656,9 @@ async function handleClaudeMessagesWithBudget( + effortOverride = extractOcxEffortDirective(anthropicBody); + } + } ++ if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { ++ anthropicBody.model = decodeFablePickerAlias(anthropicBody.model, config.claudeCode); ++ } + if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { + requestedModel = anthropicBody.model; + // Decode for Fast only. A Claude alias is `claude-ocx---`, so it +@@ -1070,6 +1081,8 @@ export async function handleClaudeCountTokens( + model = stripOneMillionMarker(countRoute); + raw.model = model; + } ++ model = decodeFablePickerAlias(model, config.claudeCode); ++ raw.model = model; + // Fast-only: count_tokens never parsed an effort row, so it must not start. It returns a + // token estimate and sends no tier, so only the IDENTITY is corrected - without this the + // synthetic id reaches native passthrough as a model Anthropic has never heard of. +diff --git a/tests/claude-integration/claude-model-info.test.ts b/tests/claude-integration/claude-model-info.test.ts +index 0a2d151a1b..b9a23342af 100644 +--- a/tests/claude-integration/claude-model-info.test.ts ++++ b/tests/claude-integration/claude-model-info.test.ts +@@ -44,6 +44,22 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => + expect(info!.capabilities.effort.max.supported).toBe(true); + }); + ++ test("readable Fable rows keep base and 1M selections distinct in Claude Code", () => { ++ const infos = buildAnthropicModelInfos([], [{ ++ provider: "anthropic", ++ id: "claude-fable-5-1", ++ contextWindow: 1_000_000, ++ maxInputTokens: 1_000_000, ++ }], undefined, "readable"); ++ ++ expect(infos.map(info => info.id)).toEqual([ ++ "claude-fable-5-1", ++ "claude-ocx-native--claude-fable-5-1[1m]", ++ ]); ++ expect(infos[1]!.display_name).toBe("claude-fable-5-1 (anthropic) · 1M"); ++ expect(infos[1]!.max_input_tokens).toBe(1_000_000); ++ }); ++ + test("native effective ladder only advertises clamp-identity rungs (audit R4#1)", () => { + for (const slug of ["gpt-5.5", "gpt-5.4", "gpt-5.6-sol"]) { + for (const rung of nativeEffectiveLadder(slug)) { +diff --git a/tests/claude-integration/claude-native-passthrough.test.ts b/tests/claude-integration/claude-native-passthrough.test.ts +index c8c798ac9a..af87aa83cb 100644 +--- a/tests/claude-integration/claude-native-passthrough.test.ts ++++ b/tests/claude-integration/claude-native-passthrough.test.ts +@@ -206,6 +206,47 @@ test("count_tokens passes through with native credentials", async () => { + } + }); + ++test("Fable 1M picker alias preserves native passthrough on both Messages endpoints", async () => { ++ const captured: Captured[] = []; ++ const upstream = mockAnthropicUpstream(captured); ++ saveConfig(cfg(upstream.url.toString().replace(/\/$/, ""))); ++ const server = startServer(0); ++ const pickerModel = "claude-ocx-native--claude-fable-5-1"; ++ try { ++ const messagesWithoutMarker = await fetch(new URL("/v1/messages", server.url), { ++ method: "POST", ++ headers: OAUTH_HEADERS, ++ body: JSON.stringify({ ...claudeBody(), model: pickerModel }), ++ }); ++ expect(messagesWithoutMarker.status).toBe(200); ++ await messagesWithoutMarker.text(); ++ ++ const messagesWithMarker = await fetch(new URL("/v1/messages", server.url), { ++ method: "POST", ++ headers: OAUTH_HEADERS, ++ body: JSON.stringify({ ...claudeBody(), model: `${pickerModel}[1m]` }), ++ }); ++ expect(messagesWithMarker.status).toBe(200); ++ await messagesWithMarker.text(); ++ ++ const countTokens = await fetch(new URL("/v1/messages/count_tokens", server.url), { ++ method: "POST", ++ headers: OAUTH_HEADERS, ++ body: JSON.stringify({ model: `${pickerModel}[1m]`, messages: [{ role: "user", content: "hi" }] }), ++ }); ++ expect(countTokens.status).toBe(200); ++ expect(await countTokens.json()).toEqual({ input_tokens: 4242 }); ++ ++ expect(captured).toHaveLength(3); ++ expect(captured[0]!.body.model).toBe("claude-fable-5-1"); ++ expect(captured[1]!.body.model).toBe("claude-fable-5-1"); ++ expect(captured[2]!.body.model).toBe("claude-fable-5-1"); ++ } finally { ++ await server.stop(true); ++ upstream.stop(true); ++ } ++}); ++ + test("exposed native passthrough requires dedicated admission and never forwards admission credentials", async () => { + const admissionSecret = "sk-ant-api03-key"; + const providerBearer = "sk-ant-oat01-provider"; +``` + +## Additional bounded acceptance edits + +In the existing model-info test file, directly after the carried Fable test, add this behavioral matrix (existing imports suffice): + +```ts +test("Fable 1M aliases preserve style, window and input-ceiling boundaries", () => { + const fable = { provider: "anthropic", id: "claude-fable-5-1", contextWindow: 1_000_000, maxInputTokens: 922_000 }; + const readable = buildAnthropicModelInfos([], [fable], undefined, "readable"); + expect(readable[1]!.id).toBe("claude-ocx-native--claude-fable-5-1[1m]"); + expect(readable[1]!.max_input_tokens).toBe(922_000); + const desktop = buildAnthropicModelInfos([], [fable], undefined, "desktop3p"); + expect(desktop[1]!.id).toBe(`${desktop[0]!.id}[1m]`); + const smaller = buildAnthropicModelInfos([], [{ ...fable, contextWindow: 200_000 }], undefined, "readable"); + expect(smaller.map(row => row.id)).toEqual(["claude-fable-5-1"]); + const unknown = buildAnthropicModelInfos([], [{ provider: "anthropic", id: "claude-fable-5-1" }], undefined, "readable"); + expect(unknown.map(row => row.id)).toEqual(["claude-fable-5-1"]); + const other = buildAnthropicModelInfos([], [{ ...fable, id: "claude-opus-5" }], undefined, "readable"); + expect(other.map(row => row.id)).toEqual(["claude-opus-5", "claude-opus-5[1m]"]); +}); +``` + +Extend the carried native-passthrough test after its first three requests with the following legacy selectors. Adjust captured length from 3 to 6; assert every captured model is canonical. Existing mock upstream and credential fixture are reused. + +```ts +for (const model of ["claude-fable-5-1", "claude-fable-5-1[1m]", `${pickerModel}[1M]`]) { + const response = await fetch(new URL("/v1/messages", server.url), { + method: "POST", headers: OAUTH_HEADERS, + body: JSON.stringify({ ...claudeBody(), model }), + }); + expect(response.status).toBe(200); + await response.text(); +} +expect(captured).toHaveLength(6); +for (const call of captured) { + expect(call.body.model).toBe("claude-fable-5-1"); + expect(call.headers.get("anthropic-beta")).toBe(OAUTH_HEADERS["anthropic-beta"]); +} +``` + +The round-trip guard and prefix guard must remain; do not replace them with generic decoding of every alias. Existing alias, mapped-model, disabled-passthrough, and exposed-listener regressions remain required CI coverage. Review-specific additions, if needed, are recorded only in scratch. + +## Documentation diff and GUI evidence + +The source PR modifies no docs even though its body checks the docs box. Add the bounded exception to the English guide; do not claim all canonical 1M rows are encoded or that base Fable loses its native ID. + +```diff +--- a/docs-site/src/content/docs/guides/claude-code.md ++++ b/docs-site/src/content/docs/guides/claude-code.md +@@ +-Desktop's third-party gateway mode can offer its effort selector. Real Anthropic models keep their +-canonical ids. The synthetic 2026 date is an internal slot, not a release date. Legacy hash aliases ++Desktop's third-party gateway mode can offer its effort selector. Real Anthropic base rows keep ++their canonical ids. For Claude Code, a canonical Fable model with an authoritative 1M window ++uses `claude-ocx-native--claude-fable-5-1[1m]` for its separate 1M selection. This reversible ++selector distinguishes picker families; Messages and count_tokens restore the canonical Fable ++id before native passthrough. Desktop 3P selectors keep their existing format. ++The synthetic 2026 date is an internal slot, not a release date. Legacy hash aliases +@@ +-(reasoning-effort ladder, thinking types) in the official `ModelInfo` shape. Real Anthropic models +-keep their canonical ids on both surfaces. ++(reasoning-effort ladder, thinking types) in the official `ModelInfo` shape. Real Anthropic base ++rows keep their canonical ids; the readable Fable 1M exception is described above. +``` + +Do not describe the unmarked row as necessarily a 200K upstream model: both rows may advertise a genuine 1M window; the marker controls client accounting. Preserve the existing rule that beta headers, not a model suffix alone, convey provider context semantics. + +Translation coordination before readiness: main's documentation owner must inspect `docs-site/src/content/docs/{ko,ja,zh-cn,zh-tw,fr,ru,tr}/guides/claude-code.md` for equivalent unconditional canonical-ID statements and add the same scoped exception where needed. No translation edits are delegated to this research worker; record the final exact locale touch set at consuming P. This is a readiness debt, not permission to leave contradictory translations. + +No OCX React component changes are required. Capture client evidence for fresh selection, saved old bare/marked selection, switching Fable→Opus→Fable 1M, settings persistence, and restart. Record Claude Code version, discovery payload, selected ID and resulting upstream model in a sanitized artifact. Screenshots should show both picker rows and retained selection. Do not present source-author harness claims as current reproduced evidence. If the PR description mentions GUI, include its screenshot as required by repository PR policy. + +## CI-only verification contract + +NO local tests, typecheck, builds, suite helpers or provider probes. Commands below describe CI execution, not commands to execute on the local workstation. + +- `.github/workflows/ci.yml:7` triggers on every pull_request base, including stacked children; no dev-only base filter. +- `changes` at :182-187 includes src/tests, so this implementation triggers expensive jobs. A docs-only roadmap CI success can legitimately skip tests and proves no runtime behavior. +- Linux `test` at :255-267 is four shards; :314-316 invokes `bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD"`. +- The helper at :46-68 excludes only storage-policy/storage/api-usage families, not these tests. At :196-211 it sorts all test files and distributes every eligible file exactly once across shard indices; :122-125 executes `bun test --isolate --timeout 60000` on the selected filenames. Both existing changed test paths are included. +- `gates` at :392-431 runs `bun x tsc --noEmit`, the additional contract tsconfig, GUI tests, and privacy scan. GUI lint is conditional on gui changes; do not mistake that skip for a failure on this backend-only layer. +- macOS at :451-465 runs two shards; :532 invokes `bun test --isolate --timeout 60000 tests --shard=.../2`. +- Windows at :658-686 is **dispatch-only**, lane `all`; :754 invokes six shards. Ordinary PR green is not Windows full-suite evidence. Main decides/dispatches any required exact-head Windows run; this research starts none. +- Focused failure diagnosis target, if a CI runner needs it: `bun test tests/claude-integration/claude-model-info.test.ts tests/claude-integration/claude-native-passthrough.test.ts tests/claude-integration/claude-models-discovery.test.ts tests/claude-integration/claude-alias.test.ts`. Do not add a workflow solely to run this command. +- Save run URL, event, head_sha, checkout/merge SHA, non-skipped job conclusions and test log paths. Resolve fork `action_required` via main's normal approval process; hygiene labels/author checkboxes/old-head approval are not product-test evidence. + +| Acceptance | Activation and observable evidence | +|---|---| +| Distinct rows | Readable Anthropic Fable >=1M emits bare base and reversible `[1m]` sibling with honest display name | +| Narrow family/style | Opus retains canonical marker; Desktop retains its prior selector; no other provider is rewritten | +| Capacity guard | 200K or undefined Fable window emits no sibling; 922K input cap under 1M window remains 922K | +| Marker compatibility | New alias with/without marker plus upper-case marker and legacy canonical selectors reach canonical Fable on mock upstream | +| Both endpoints | Messages status/stream consumed; count_tokens returns upstream 4242, not local estimate; captured model canonical | +| Routing coexistence | Existing alias/modelMap/Fast/effort regressions stay green; no global resolver or Desktop registry edit | +| Client persistence | Versioned client evidence demonstrates saved selection after switching and restart, including legacy behavior | +| Gates | All required exact-head CI jobs execute/pass; Windows status reported separately | + +## Dependencies, carry and close-out + +The four source paths plus English guide have **zero touched-path overlap** with supplied source PRs #3653/#3654/#3571/#3659 (JSON file-list intersection inspected). Fable is independently reviewable: no semantic requirement to land context persistence or Go ordering first. Per 000_plan.md, the user-requested stack uses `codex/lane-b-05-fable` based on `codex/lane-b-04-management`; do not invent a runtime dependency. Discovery still consumes the current predecessor's catalog and limits, so rerun exact-head gates after restack. + +Lane D owns #3646 remote Desktop aliases. Coordinate before altering `claude-messages.ts` or decoder ordering; do not carry its unknown-registry fallback into this Fable slice. This plan deliberately changes neither `desktop-3p.ts` nor `inbound-model-options.ts`. + +Safe later carry: apply the two non-merge source commits in order on main's selected layer, preserving author metadata, then add the bounded tests/docs as own commits. Do not reset the bound checkout, cherry-pick the source merge parent, or replace other lanes' edits. A lower-layer squash means refresh onto the new dev ancestry and replay only unique Fable commits, then obtain fresh CI/review. Main owns `--no-verify` pushes and bottom-up merges. Retarget children before removing a parent branch. Close source PR #3649 only after proving its replacement commit is in dev, and link the replacement. No linked issue is specified in the source body; do not close #3646 or unrelated issues. + +## Unresolved items for main + +- Exact-head hosted verification and actual picker compatibility are not established by this research. +- Legacy request forwarding is testable with the additions above; saved client selection migration remains a separate client-level observation. +- The old review's missing marked Messages case is already resolved by `284fe8ca0`; Fable-only scope is explicit in the current source body and comments. Do not repeat these as open code defects. +- Translation touch set must be fixed at P before implementation readiness. +- Relevant security review is tracked only in `.tmp/lane-b/plan-fable-review.md`; this tracked plan contains public source behavior, not unpublished findings. + +Roadmap handoff: this document is ready for main's A audit. The unresolved checks above are explicit later B/C acceptance work, not a request to implement during the docs-only cycle. Final landing and closure follow `060_landing.md`. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/060_landing.md b/devlog/_plan/260906_lane_b_catalog_stack/060_landing.md new file mode 100644 index 0000000000..4a1ca51e5d --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/060_landing.md @@ -0,0 +1,31 @@ +# Land the verified catalog stack + +## Before and after + +Before: five original contribution PRs are open, their carried changes form separate reviewed branches, and dev may have advanced from peer lanes. After: all five behavior contracts are reachable from dev, source authors remain credited, replacements and originals are closed appropriately, and resolved issues #3650/#3651 are closed with landing proof. + +## Exact change map + +- MODIFY this unit's numbered completion record with replacement PR numbers, source/current SHAs, CI URLs, merge SHAs and issue closure results. +- MODIFY child PR base refs from the open parent branch to dev after parent landing. Keep local and remote parent refs until all children are safely retargeted. +- MODIFY a branch only for demonstrated integration conflicts or failing current-head checks. Preserve unrelated A/C/D work; resolve shared config fields, locale keys and alias helpers by combining contracts, never choosing an entire side blindly. +- CLOSE original #3653/#3654/#3571/#3659/#3649 as superseded only after the respective replacement's merge commit is on dev. +- CLOSE #3650/#3651 only after full visibility/context acceptance criteria are satisfied. +- MOVE the finished public unit from `_plan` to `_fin` only at terminal completion. No credentials or private audit material enters this unit. + +## Sequence and activation scenarios + +1. Fetch dev; compare each queued replacement to its reviewed head. Trigger: peer dev advanced. Effect: inspect actual overlap, merge/reconcile dev into the affected layer, cascade to children and rerun exact-head checks when the tested tree changed. +2. Confirm each lower layer's CI has actual typecheck, functional tests and GUI gates where applicable; inspect required review findings including security review. No stale approval is reused after code changes. +3. Merge the bottom PR with a merge commit when allowed to preserve ancestry; verify GitHub merge state plus `git merge-base --is-ancestor origin/dev` after fetching. +4. Immediately close the carried source PR with replacement and landing evidence; close linked issue if its complete report is addressed. Preserve original contribution trailers in merge/squash content. +5. Retarget the child before cleanup; compare `git diff ...` to its intended layer. If a squash changed ancestry, restack rather than leaving already-squashed content in the child diff. +6. Repeat until all five are landed. Assert final ancestry, original PR/issue states and clean tracked work. Record remaining unrelated items without expanding scope. + +## Verification + +Before final landing, dispatch `ci.yml` with `lane=all` on the final integrated stack head and verify all six Windows test shards in addition to Linux/macOS. Ordinary PR runs skip Windows test shards; Windows keyring/package smoke alone is not full Windows test evidence. + +Read-only `gh pr view`, `gh run view` and `gh issue view` supply fresh state; assertions operate on exact numbers/SHAs, not titles. Git ancestry checks run locally; repository tests do not. A C receipt wraps the read-only verifier and must fail if any required original remains open, intended merge is absent, CI did not execute real tests, or attribution is missing. + +Expected outcome is DONE. Pending CI, a conflict or a repairable review finding continues the same goal. An external blocker is recorded with exact evidence rather than closing the remaining work as complete. From daee875fed5ef1264d9b85e8c4b8d4e96f0c9ca9 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:39:46 +0900 Subject: [PATCH 009/236] fix(models): preserve manual OpenAI visibility and dashboard identity Carry the complete final diff of PR #3653 at 956eedac439922cf7645f130ef8432833e813a9a, including its Fast-row assertions and original screenshot. Co-authored-by: Robin Bially <7304732+RobinBially@users.noreply.github.com> --- .../manual-openai-model-toggle.png | Bin 0 -> 60736 bytes .../content/docs/reference/management-api.md | 10 ++- gui/src/models-groups.ts | 3 +- .../models-native-group-controls.test.ts | 6 ++ src/server/management/model-routes.ts | 5 +- src/server/management/model-rows.ts | 6 +- .../model-visibility-management-api.test.ts | 79 +++++++++++++++++- 7 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 docs-site/public/screenshots/manual-openai-model-toggle.png diff --git a/docs-site/public/screenshots/manual-openai-model-toggle.png b/docs-site/public/screenshots/manual-openai-model-toggle.png new file mode 100644 index 0000000000000000000000000000000000000000..d8a0dab0de58bdfee4764341465eeff6a41b4dec GIT binary patch literal 60736 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 N5a{}z2lM~g|380#hNu7l literal 0 HcmV?d00001 diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 784c6e17f5..ae24513a97 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -191,12 +191,20 @@ first and submit the returned digest. Prefer quarantine when recovery may be nee | `GET /api/models` | Return the dashboard/CLI model rows | `catalog_busy` when gathering is saturated | | `GET /api/client-config?client=...` | Build a read-only client config for any supported file integration | 400 unsupported client; 503 catalog unavailable | | `PUT /api/disabled-models` | Replace the shared disabled-model list | 400 invalid JSON | -| `PUT /api/model-visibility` | Atomically change provider- or model-level visibility | 400 invalid provider, scope, target, or body | +| `PUT /api/model-visibility` | Atomically change provider- or model-level visibility | 400 invalid provider, scope, target, or body; 409 `initial_model_selection_pending` (refresh the model list and retry) | | `GET, POST /api/custom-models` | List custom models or add one | 400 invalid fields; 404 provider missing; 409 duplicate model | | `PUT, DELETE /api/custom-models/{id}` | Edit or delete one custom model | 400 invalid id/fields; 404 not found; 409 duplicate model | | `GET, PUT /api/selected-models` | Read provider allowlists and availability, or replace one allowlist | 400 missing provider/body; 404 unknown provider; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | Read preset summaries or choose preset/all/custom mode | 400 invalid mode or unsupported preset; 404 unknown provider; PUT 409 `initial_model_selection_pending` | +A manual model replaces the Models dashboard row with the same provider and model ID. +For OpenAI, the manual row keeps `openai/` and supports the same visibility controls +as other routed models; removing it restores the bare native dashboard row. Explicit +account-qualified native rows stay separate. This does not rename bare native routes or +change account entitlements. Non-native OpenAI visibility targets must match a configured +manual model. + + Valid PUT requests to `/api/selected-models` and `/api/model-presets` return HTTP 409 with code `initial_model_selection_pending` until a reliable initial model list is available. Refresh model discovery (for example, `GET /api/models`) and retry after it succeeds. ### OAuth accounts, provider keys, and data-plane keys diff --git a/gui/src/models-groups.ts b/gui/src/models-groups.ts index a8d6ddc69c..3e24aaf459 100644 --- a/gui/src/models-groups.ts +++ b/gui/src/models-groups.ts @@ -81,7 +81,8 @@ export function buildProviderModelGroups 0 && providerRows.every(row => row.native === true), - nativeProviderGroup: providerRows.some(row => row.native === true), + nativeProviderGroup: providerRows.some(row => row.native === true) + || (provider === "openai" && configured?.authMode === "forward"), liveModels: configured?.liveModels !== false, configuredModels: configured?.models ?? [], contextWindow: configured?.contextWindow, diff --git a/gui/tests/models-native-group-controls.test.ts b/gui/tests/models-native-group-controls.test.ts index ffd27ad17f..14c2f2c6d8 100644 --- a/gui/tests/models-native-group-controls.test.ts +++ b/gui/tests/models-native-group-controls.test.ts @@ -98,3 +98,9 @@ test("the native group exposes the context modal alongside the custom-model and // The custom-add and cap controls no longer sit behind an isNative guard. expect(src).not.toMatch(/\{!isNative && { + const groups = buildProviderModelGroups([customRow("gpt-5.5")], [{name:"openai",authMode:"forward"}]); + expect(groups[0]!.nativeProviderGroup).toBe(true); + expect(groups[0]!.native).toBe(false); +}); diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index e9ea26a90e..c3e9d58cf9 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -566,7 +566,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise model.provider === provider && model.modelId === id); + if (!id || (native && (provider !== "openai" || !supportedNative.has(id))) + || (provider === "openai" && !native && !configuredOpenAiCustom)) { return jsonResponse({ error: "invalid model visibility target" }, 400); } const key = `${native ? "native" : "routed"}:${id}`; diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 4a3fbeaa64..4635a9fbfd 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -169,7 +169,11 @@ export async function listManagementModelRows( ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}), }; }).filter((row): row is ManagementModelRow => row !== null); - const rows = [...native, ...dedupedRouted, ...visibleCustomModels]; + // Manual OpenAI rows retain their routed selector but replace the bare dashboard row. + // Account-qualified rows remain distinct, explicitly selected routes. + const visibleNative = native.filter(model => model.id.includes("/") + || !customNamespaced.has(routedSlug(model.provider, model.id))); + const rows = [...visibleNative, ...dedupedRouted, ...visibleCustomModels]; // Include disabled rows and configured aliases before the export visibility filter: // a hidden real `x--fast` must never become a synthetic selector for another model. const knownIds = config.fastRows === false ? new Set() : knownEffortRowIds(config); diff --git a/tests/codex-integration/model-visibility-management-api.test.ts b/tests/codex-integration/model-visibility-management-api.test.ts index 6259818667..15bc17f808 100644 --- a/tests/codex-integration/model-visibility-management-api.test.ts +++ b/tests/codex-integration/model-visibility-management-api.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync} from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { nativeModelRows } from "../../src/codex/catalog"; import { loadConfig, saveConfig } from "../../src/config"; @@ -7,6 +7,8 @@ import { handleManagementAPI } from "../../src/server/management-api"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { ManagementRequest as Request } from "../helpers/management-auth"; +import { listManagementModelRows } from "../../src/server/management/model-rows"; const TEST_DIR = join(import.meta.dir, `.tmp-model-visibility-management-${process.pid}`); const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -365,4 +367,77 @@ describe("atomic model visibility management", () => { expect(loadConfig()).toEqual(before); }); }); -import { ManagementRequest as Request } from "../helpers/management-auth"; + +test("configured manual OpenAI rows can be toggled alongside native rows", async () => { + const config = loadConfig(); + config.providers.openai = {adapter:"openai-responses",authMode:"forward",baseUrl:"https://chatgpt.com/backend-api/codex",liveModels:false}; + config.customModels = [{id:"manual-gpt",provider:"openai",modelId:"gpt-5.5",contextWindow:128_000}]; + config.disabledModels = ["openai/gpt-5.5", "gpt-5.4"]; + expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"gpt-5.5",native:false}],enabled:true},config)).status).toBe(200); + expect(config.disabledModels).toEqual(["gpt-5.4"]); + expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"gpt-5.5",native:false},{id:"gpt-5.4",native:true}],enabled:false},config)).status).toBe(200); + expect(config.disabledModels).toContain("openai/gpt-5.5"); + expect(config.disabledModels).toContain("gpt-5.4"); + expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"not-configured",native:false}],enabled:true},config)).status).toBe(400); +}); + +test("manual models replace management rows with the same provider/id and deletion restores natives", async () => { + const config = loadConfig(); + config.providers.openai = {adapter:"openai-responses",authMode:"forward",baseUrl:"https://chatgpt.com/backend-api/codex",liveModels:false}; + config.customModels = [ + {id:"manual-gpt",provider:"openai",modelId:"gpt-5.5",contextWindow:128_000}, + {id:"manual-google",provider:"google-antigravity",modelId:"gemini-3.1-pro",contextWindow:128_000}, + ]; + config.codexAccountNamespaces = { desktop: "@main" }; + config.codexAccountPickerEnabled = true; + const accountModel = "gpt-5.5-account-fixture"; + const qualifiedId = `desktop/${accountModel}`; + writeFileSync(join(isolatedCodexHome!.path, "models_cache.json"), JSON.stringify({ + models: [{ + slug: accountModel, supported_in_api: true, visibility: "list", + base_instructions: "You are Codex.", comp_hash: null, shell_type: "unified_exec", + supported_reasoning_levels: [{ effort: "medium" }], model_messages: {}, + }], + })); + // Even an exact qualified-ID collision must preserve the account-bound native route. + config.customModels.push({ id: "manual-qualified", provider: "openai", modelId: qualifiedId }); + const rows = await listManagementModelRows(config,{entitlementWaitMs:0}); + expect(rows.filter(row=>row.provider==="openai" && row.id==="gpt-5.5")).toEqual([ + expect.objectContaining({namespaced:"openai/gpt-5.5",custom:true,customId:"manual-gpt",contextWindow:128_000,fastRowAvailable:true}), + ]); + expect(rows.filter(row=>row.provider==="google-antigravity" && row.id==="gemini-3.1-pro")).toHaveLength(1); + expect(rows.filter(row => row.id === qualifiedId && row.native)).toEqual([ + expect.objectContaining({ namespaced: qualifiedId, provider: "openai", native: true }), + ]); + config.disabledModels = ["openai/gpt-5.5"]; + const disabledRows = await listManagementModelRows(config, { entitlementWaitMs: 0 }); + expect(disabledRows.find(row => row.namespaced === "openai/gpt-5.5")).toMatchObject({ + custom: true, disabled: true, fastRowAvailable: false, + }); + config.disabledModels = []; + config.customModels = []; + const restored = await listManagementModelRows(config,{entitlementWaitMs:0}); + expect(restored.some(row => row.id === qualifiedId && row.native)).toBe(true); + expect(restored.filter(row=>row.provider==="openai" && row.id==="gpt-5.5")).toEqual([ + expect.objectContaining({namespaced:"gpt-5.5",native:true}), + ]); +}); + +test("manual OpenAI visibility preserves the pending-selection error contract", async () => { + const config = loadConfig(); + config.providers.openai = { + adapter: "openai-responses", authMode: "forward", liveModels: false, + baseUrl: "https://chatgpt.com/backend-api/codex", + initialModelSelection: { version: 1, registrationId: "11111111-1111-4111-8111-111111111111", status: "pending" }, + }; + config.customModels = [{ id: "manual-gpt", provider: "openai", modelId: "gpt-5.5" }]; + const before = structuredClone(config); + for (const target of [{ id: "gpt-5.5", native: false }, { id: "not-configured", native: false }]) { + const response = await putWithConfig({ scope: "models", provider: "openai", targets: [target], enabled: true }, config); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "initial_model_selection_pending" }); + expect(config).toEqual(before); + } + expect((await putWithConfig({ scope: "invalid", provider: "openai", targets: [], enabled: true }, config)).status).toBe(400); + expect(config).toEqual(before); +}); From 42c448e279656b905f2587ce19d0fc2a7ba3f99e Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 01:40:25 +0900 Subject: [PATCH 010/236] docs(models): align translated manual visibility contract --- docs-site/src/content/docs/fr/reference/management-api.md | 5 ++++- docs-site/src/content/docs/ja/reference/management-api.md | 5 ++++- docs-site/src/content/docs/ko/reference/management-api.md | 5 ++++- docs-site/src/content/docs/reference/management-api.md | 1 - docs-site/src/content/docs/ru/reference/management-api.md | 5 ++++- docs-site/src/content/docs/tr/reference/management-api.md | 5 ++++- docs-site/src/content/docs/zh-cn/reference/management-api.md | 5 ++++- docs-site/src/content/docs/zh-tw/reference/management-api.md | 5 ++++- 8 files changed, 28 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index 0ea012916e..0ae564647f 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -172,12 +172,15 @@ d’abord et soumettez le résumé renvoyé. Préférez la quarantaine lorsqu’ | `GET /api/models` | Renvoyer les lignes de modèles destinées au tableau de bord et à l'interface en ligne de commande | `catalog_busy` lorsque la collecte est saturée | | `GET /api/client-config?client=...` | Créez une configuration client en lecture seule pour toute intégration de fichiers prise en charge | 400 client non pris en charge ; 503 catalogue indisponible | | `PUT /api/disabled-models` | Remplacer la liste partagée des modèles désactivés | 400 invalide JSON | -| `PUT /api/model-visibility` | Modifier atomiquement la visibilité au niveau du fournisseur ou du modèle | 400 fournisseur, portée, cible ou corps non valide | +| `PUT /api/model-visibility` | Modifier atomiquement la visibilité au niveau du fournisseur ou du modèle | 400 fournisseur, portée, cible ou corps non valide; 409 `initial_model_selection_pending` (Actualisez la liste des modèles, puis réessayez.) | | `GET, POST /api/custom-models` | Répertoriez les modèles personnalisés ou ajoutez-en un | 400 champs invalides ; 404 fournisseur manquant ; 409 dupliquer le modèle | | `PUT, DELETE /api/custom-models/{id}` | Modifier ou supprimer un modèle personnalisé | 400 invalide id/fields ; 404 introuvable ; 409 modèle en double | | `GET, PUT /api/selected-models` | Lire les listes autorisées et la disponibilité des fournisseurs, ou remplacer une liste autorisée | 400 fournisseur ou corps manquant ; 404 fournisseur inconnu; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | Lire les préréglages ou choisir le mode preset/all/custom | 400 mode invalide ou préréglage indisponible; 404 fournisseur inconnu; PUT 409 `initial_model_selection_pending` | +Un modèle manuel remplace la ligne du tableau de bord Models ayant le même fournisseur et identifiant de modèle. Pour OpenAI, la ligne manuelle conserve `openai/` et ses contrôles de visibilité. Sa suppression restaure la ligne native sans qualificatif de compte. Les lignes natives qualifiées par compte restent distinctes. Les routes natives et les droits du compte ne changent pas. Une cible de visibilité OpenAI non native doit correspondre à un modèle manuel configuré. + + Tant qu’une liste initiale fiable n’est pas disponible, les requêtes PUT valides vers `/api/selected-models` et `/api/model-presets` renvoient HTTP 409 avec le code `initial_model_selection_pending`. Actualisez la découverte des modèles (par exemple, `GET /api/models`), puis réessayez après sa réussite. ### Comptes OAuth, clés de fournisseur et clés du plan de données diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 8caca6d535..2c6270ef17 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -144,12 +144,15 @@ Authorization: Bearer | `GET /api/models` |ダッシュボード/CLI モデルの行を返す |収集が飽和したときの `catalog_busy` | | `GET /api/client-config?client=...` |サポートされているファイル連携の読み取り専用クライアント設定を作成する | 400 クライアントがサポートされていません。 503 カタログは利用できません | | `PUT /api/disabled-models` |共有の無効モデル リストを置き換える | 400 無効な JSON | -| `PUT /api/model-visibility` |プロバイダーレベルまたはモデルレベルの可視性をアトミックに変更 | 400 プロバイダー、スコープ、ターゲット、または本文が無効です。 +| `PUT /api/model-visibility` |プロバイダーレベルまたはモデルレベルの可視性をアトミックに変更 | 400 プロバイダー、スコープ、ターゲット、または本文が無効です。; 409 `initial_model_selection_pending` (モデル一覧を更新してから再試行してください。) | | `GET, POST /api/custom-models` |カスタム モデルをリストするか追加する | 400 個の無効なフィールド。 404 プロバイダーがありません。 409 複製モデル | | `PUT, DELETE /api/custom-models/{id}` | 1 つのカスタム モデルを編集または削除する | 400 個の無効な ID/フィールド。 404 が見つかりません。 409 複製モデル | | `GET, PUT /api/selected-models` | プロバイダーの許可リストと可用性を読む、または許可リストを置き換える | 400 プロバイダー/本文の不足; 404 不明なプロバイダー; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | プリセット情報を読む、または preset/all/custom モードを選ぶ | 400 不正なモードまたは未提供のプリセット; 404 不明なプロバイダー; PUT 409 `initial_model_selection_pending` | +手動モデルは、Models ダッシュボードで provider と model ID が一致する行を置き換えます。OpenAI の手動行は `openai/` を維持し、表示状態を変更できます。削除すると、アカウント修飾子のないネイティブ行が復元されます。アカウント修飾付きのネイティブ行は別に保持されます。ネイティブルートやアカウントの権限は変更しません。OpenAI の非ネイティブ表示対象は、設定済みの手動モデルと一致する必要があります。 + + 信頼できる初回モデル一覧が確定するまで、有効な `PUT /api/selected-models` と `PUT /api/model-presets` も HTTP 409 とコード `initial_model_selection_pending` を返します。`GET /api/models` などでモデル一覧を更新し、取得に成功してから再試行してください。 ### OAuth アカウント、プロバイダー キー、およびデータプレーン キー diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index e8347adfaa..503df00925 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -147,12 +147,15 @@ Authorization: Bearer | `GET /api/models` | 대시보드/CLI model 행을 반환합니다 | 수집이 포화 상태이면 `catalog_busy` | | `GET /api/client-config?client=...` | 지원되는 파일 연동의 읽기 전용 client config를 만듭니다 | 400 지원되지 않는 client; 503 catalog 사용 불가 | | `PUT /api/disabled-models` | 공유 disabled-model 목록을 교체합니다 | 400 잘못된 JSON | -| `PUT /api/model-visibility` | provider 또는 model 수준의 visibility를 원자적으로 변경합니다 | 400 잘못된 provider, scope, target, 또는 본문 | +| `PUT /api/model-visibility` | provider 또는 model 수준의 visibility를 원자적으로 변경합니다 | 400 잘못된 provider, scope, target, 또는 본문; 409 `initial_model_selection_pending` (목록을 새로고침한 뒤 다시 시도하세요.) | | `GET, POST /api/custom-models` | custom model을 나열하거나 하나를 추가합니다 | 400 잘못된 필드; 404 provider 없음; 409 중복 model | | `PUT, DELETE /api/custom-models/{id}` | custom model 하나를 수정하거나 삭제합니다 | 400 잘못된 id/필드; 404 찾을 수 없음; 409 중복 model | | `GET, PUT /api/selected-models` | provider allowlist와 가용성을 읽거나 allowlist 하나를 교체합니다 | 400 provider/body 누락; 404 알 수 없는 provider; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | 프리셋 정보를 읽거나 preset/all/custom 모드를 선택합니다 | 400 잘못된 mode 또는 지원하지 않는 프리셋; 404 알 수 없는 provider; PUT 409 `initial_model_selection_pending` | +수동 모델은 Models 대시보드에서 provider와 model ID가 같은 행을 대체합니다. OpenAI 수동 행은 `openai/`을 유지하며 표시 여부를 바꿀 수 있습니다. 수동 행을 삭제하면 계정 한정자가 없는 네이티브 행이 다시 나타납니다. 계정 한정자가 있는 네이티브 행은 별도로 유지됩니다. 네이티브 경로나 계정 권한은 바뀌지 않습니다. OpenAI의 비네이티브 표시 대상은 설정된 수동 모델과 일치해야 합니다. + + 신뢰할 수 있는 초기 모델 목록을 확보하기 전에는 유효한 `PUT /api/selected-models`와 `PUT /api/model-presets` 요청도 HTTP 409와 `initial_model_selection_pending` 코드를 반환합니다. `GET /api/models` 등으로 모델 목록을 정상적으로 갱신한 뒤 재시도하세요. ### OAuth 계정, provider key, 데이터 평면 키 diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index ae24513a97..c5973f3a77 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -204,7 +204,6 @@ account-qualified native rows stay separate. This does not rename bare native ro change account entitlements. Non-native OpenAI visibility targets must match a configured manual model. - Valid PUT requests to `/api/selected-models` and `/api/model-presets` return HTTP 409 with code `initial_model_selection_pending` until a reliable initial model list is available. Refresh model discovery (for example, `GET /api/models`) and retry after it succeeds. ### OAuth accounts, provider keys, and data-plane keys diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 308a385ede..a02f3664f8 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -166,12 +166,15 @@ Endpoint'ы storage cleanup могут перемещать или навсег | `GET /api/models` | Вернуть model-row'ы для дашборда и CLI | `catalog_busy`, когда сборка перегружена | | `GET /api/client-config?client=...` | Собрать read-only client config для любой поддерживаемой файловой интеграции | 400 unsupported client; 503 catalog unavailable | | `PUT /api/disabled-models` | Полностью заменить общий список disabled-models | 400 invalid JSON | -| `PUT /api/model-visibility` | Атомарно изменить видимость на уровне провайдера или модели | 400 invalid provider, scope, target or body | +| `PUT /api/model-visibility` | Атомарно изменить видимость на уровне провайдера или модели | 400 invalid provider, scope, target or body; 409 `initial_model_selection_pending` (Обновите список моделей и повторите попытку.) | | `GET, POST /api/custom-models` | Показать список custom-моделей или добавить одну | 400 invalid fields; 404 provider missing; 409 duplicate model | | `PUT, DELETE /api/custom-models/{id}` | Изменить или удалить одну custom-модель | 400 invalid id/fields; 404 not found; 409 duplicate model | | `GET, PUT /api/selected-models` | Прочитать allowlist'ы и availability провайдеров либо заменить один allowlist | 400 missing provider/body; 404 unknown provider; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | Прочитать пресеты или выбрать режим preset/all/custom | 400 неверный режим или неподдерживаемый пресет; 404 неизвестный провайдер; PUT 409 `initial_model_selection_pending` | +Ручная модель заменяет строку панели Models с тем же провайдером и идентификатором модели. Для OpenAI ручная строка сохраняет `openai/` и поддерживает управление видимостью. При её удалении восстанавливается нативная строка без уточнения аккаунта. Нативные строки с указанием аккаунта остаются отдельными. Нативные маршруты и права аккаунта не меняются. Ненативная цель видимости OpenAI должна соответствовать настроенной ручной модели. + + Пока достоверный исходный список моделей не получен, корректные PUT-запросы к `/api/selected-models` и `/api/model-presets` возвращают HTTP 409 с кодом `initial_model_selection_pending`. Обновите список моделей, например через `GET /api/models`, и повторите запрос после успешного получения списка. ### OAuth-аккаунты, ключи провайдеров и ключи data plane diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index 262ad8eabc..d210aeafd6 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -184,12 +184,15 @@ gönderin. Kurtarma gerekebileceğinde karantinayı tercih edin. | `GET /api/models` | Kontrol paneli/CLI model satırlarını döndürün | Toplama doyduğunda `catalog_busy` | | `GET /api/client-config?client=...` | Desteklenen herhangi bir dosya entegrasyonu için salt okunur bir istemci yapılandırması oluşturun | 400 desteklenmeyen istemci; 503 katalog kullanılamıyor | | `PUT /api/disabled-models` | Paylaşılan devre dışı model listesini değiştirin | 400 geçersiz JSON | -| `PUT /api/model-visibility` | Sağlayıcı veya model düzeyindeki görünürlüğü atomik olarak değiştirin | 400 geçersiz sağlayıcı, kapsam, hedef veya gövde | +| `PUT /api/model-visibility` | Sağlayıcı veya model düzeyindeki görünürlüğü atomik olarak değiştirin | 400 geçersiz sağlayıcı, kapsam, hedef veya gövde; 409 `initial_model_selection_pending` (Model listesini yenileyip tekrar deneyin.) | | `GET, POST /api/custom-models` | Özel modelleri listeleyin veya bir tane ekleyin | 400 geçersiz alanlar; 404 sağlayıcı eksik; 409 yinelenen model | | `PUT, DELETE /api/custom-models/{id}` | Bir özel modeli düzenleyin veya silin | 400 geçersiz kimlik/alanlar; 404 bulunamadı; 409 yinelenen model | | `GET, PUT /api/selected-models` | Sağlayıcı izin listelerini ve kullanılabilirliğini okuyun veya bir izin listesini değiştirin | 400 eksik sağlayıcı/gövde; 404 bilinmeyen sağlayıcı; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | Ön ayarları okuyun veya preset/all/custom modunu seçin | 400 geçersiz mod veya desteklenmeyen ön ayar; 404 bilinmeyen sağlayıcı; PUT 409 `initial_model_selection_pending` | +Manuel model, Models panosunda aynı sağlayıcı ve model kimliğine sahip satırın yerini alır. OpenAI manuel satırı `openai/` kimliğini ve görünürlük kontrollerini korur. Silindiğinde hesap niteleyicisi olmayan yerel satır geri gelir. Hesapla nitelenen yerel satırlar ayrı kalır. Yerel rotalar ve hesap yetkileri değişmez. Yerel olmayan OpenAI görünürlük hedefi, yapılandırılmış bir manuel modelle eşleşmelidir. + + Güvenilir ilk model listesi hazır olana kadar `/api/selected-models` ve `/api/model-presets` için geçerli PUT istekleri de HTTP 409 ve `initial_model_selection_pending` kodunu döndürür. Model keşfini örneğin `GET /api/models` ile yenileyin ve başarılı olduktan sonra yeniden deneyin. ### OAuth hesapları, sağlayıcı anahtarları ve veri düzlemi anahtarları diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 103921d1ed..340d2ee94d 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -144,12 +144,15 @@ Authorization: Bearer | `GET /api/models` | 返回仪表板/CLI 模型行 | 收集饱和时返回 `catalog_busy` | | `GET /api/client-config?client=...` | 为任意支持的文件集成构建只读客户端配置 | 400 不支持的客户端;503 目录不可用 | | `PUT /api/disabled-models` | 替换共享的禁用模型列表 | 400 无效 JSON | -| `PUT /api/model-visibility` | 原子性地更改 provider 级或 model 级可见性 | 400 provider、scope、target 或请求体无效 | +| `PUT /api/model-visibility` | 原子性地更改 provider 级或 model 级可见性 | 400 provider、scope、target 或请求体无效; 409 `initial_model_selection_pending` (刷新模型列表后重试。) | | `GET, POST /api/custom-models` | 列出自定义模型或添加一个 | 400 字段无效;404 provider 缺失;409 模型重复 | | `PUT, DELETE /api/custom-models/{id}` | 编辑或删除一个自定义模型 | 400 id/字段无效;404 未找到;409 模型重复 | | `GET, PUT /api/selected-models` | 读取 provider 允许列表和可用性,或替换一个允许列表 | 400 缺少 provider/请求体;404 未知 provider; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | 读取预设信息或选择 preset/all/custom 模式 | 400 模式无效或不支持该预设;404 未知提供者; PUT 409 `initial_model_selection_pending` | +手动模型会替换 Models 仪表板中 provider 和 model ID 相同的行。OpenAI 手动行保留 `openai/`,并支持可见性控制。删除手动行后,不带账户限定符的原生行会恢复。带账户限定符的原生行仍单独保留。原生路由和账户权限不会改变。非原生 OpenAI 可见性目标必须匹配已配置的手动模型。 + + 可靠的初始模型列表尚未确认时,有效的 `PUT /api/selected-models` 和 `PUT /api/model-presets` 请求也会返回 HTTP 409 和代码 `initial_model_selection_pending`。请使用 `GET /api/models` 等方式刷新模型列表,成功后再重试。 ### OAuth 账户、provider 密钥和数据平面密钥 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 0d91e4b98d..3aaf9bf997 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -144,12 +144,15 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `GET /api/models` | 回傳儀表板/CLI 模型列 | 收集飽和時 `catalog_busy` | | `GET /api/client-config?client=...` | 為 `opencode`、`pi`、`omp`、`hermes`、`openclaw`、`kimi`、`gajae` 或 `dsh` 建構唯讀客戶端設定 | 400 不支援客戶端;503 目錄不可用 | | `PUT /api/disabled-models` | 取代共享的 disabled-model 清單 | 400 無效 JSON | -| `PUT /api/model-visibility` | 原子地變更供應商或模型層級可見性 | 400 無效供應商、scope、目標或 body | +| `PUT /api/model-visibility` | 原子地變更供應商或模型層級可見性 | 400 無效供應商、scope、目標或 body; 409 `initial_model_selection_pending` (重新整理模型清單後再試。) | | `GET, POST /api/custom-models` | 列出自訂模型或新增一個 | 400 無效欄位;404 供應商缺失;409 重複模型 | | `PUT, DELETE /api/custom-models/{id}` | 編輯或刪除一個自訂模型 | 400 無效 id/欄位;404 未找到;409 重複模型 | | `GET, PUT /api/selected-models` | 讀取供應商允許清單與可用性,或取代一個允許清單 | 400 缺失供應商/body;404 未知供應商; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | 讀取預設資訊或選擇 preset/all/custom 模式 | 400 模式無效或不支援該預設;404 未知供應商; PUT 409 `initial_model_selection_pending` | +手動模型會取代 Models 儀表板中 provider 與 model ID 相同的列。OpenAI 手動列保留 `openai/`,並支援可見性控制。刪除手動列後,不含帳戶限定符的原生列會恢復。含帳戶限定符的原生列仍獨立保留。原生路由與帳戶權限不變。非原生 OpenAI 可見性目標必須符合已設定的手動模型。 + + 尚未確認可靠的初始模型清單時,有效的 `PUT /api/selected-models` 和 `PUT /api/model-presets` 請求也會回傳 HTTP 409 和代碼 `initial_model_selection_pending`。請使用 `GET /api/models` 等方式更新模型清單,成功後再重試。 ### OAuth 帳號、供應商金鑰與 data-plane 金鑰 From dccc665f8804f91782cefa1ea41b25b1da485474 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:29:30 -0600 Subject: [PATCH 011/236] fix(codex): fail closed on uncertain account deletion (cherry picked from commit 4c389f0250c5be2bdb9fa63e9d95c90af84193b7) --- src/codex/account-lifecycle.ts | 12 +-- .../codex-account-delete-atomicity.test.ts | 86 +++++++++++++++++-- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 75f748a805..b413033095 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -1,6 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; import { - atomicWriteFile, deleteConfigTopLevelKey, getConfigPath, saveConfigPreservingClaudeCode, @@ -107,13 +106,10 @@ function restoreRuntimeConfig(target: OcxConfig, snapshot: OcxConfig): void { Object.assign(target, snapshot); } -function restorePersistedConfig(configPath: string, previousBytes: string): void { - try { - if (readFileSync(configPath, "utf8") === previousBytes) return; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; +function assertPersistedConfigUnchanged(configPath: string, previousBytes: string): void { + if (readFileSync(configPath, "utf8") !== previousBytes) { + throw new CodexAccountDeleteRollbackError(); } - atomicWriteFile(configPath, previousBytes); } /** @@ -162,7 +158,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): } catch (error) { restoreRuntimeConfig(runtimeConfig, previousConfig); try { - restorePersistedConfig(configPath, previousPersistedConfig); + assertPersistedConfigUnchanged(configPath, previousPersistedConfig); } catch { throw new CodexAccountDeleteRollbackError(); } diff --git a/tests/codex-integration/codex-account-delete-atomicity.test.ts b/tests/codex-integration/codex-account-delete-atomicity.test.ts index fccaa8abe5..955ea1425d 100644 --- a/tests/codex-integration/codex-account-delete-atomicity.test.ts +++ b/tests/codex-integration/codex-account-delete-atomicity.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdirSync, readFileSync} from "node:fs"; +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { join } from "node:path"; import * as accountStoreModule from "../../src/codex/account-store"; import { @@ -8,6 +14,7 @@ import { } from "../../src/codex/account-store"; import { CodexAccountDeleteCleanupError, + CodexAccountDeleteRollbackError, deleteCodexAccount, } from "../../src/codex/account-lifecycle"; import { @@ -85,7 +92,26 @@ describe("Codex account delete persistence ordering", () => { } }); - test("a failure after durable config replacement restores the prior config", () => { + test("a failure before durable config replacement rethrows while disk remains unchanged", () => { + const config = seededConfig(); + const before = structuredClone(config); + const beforeBytes = readFileSync(getConfigPath(), "utf8"); + const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode") + .mockImplementation(() => { throw new Error("forced pre-write failure"); }); + + try { + expect(() => deleteCodexAccount(config, ACCOUNT_ID)).toThrow("forced pre-write failure"); + expect(config).toEqual(before); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); + expect(getCodexAccountCredential(ACCOUNT_ID)).not.toBeNull(); + expect(isAccountNeedsReauth(ACCOUNT_ID)).toBe(true); + expect(getAccountQuota(ACCOUNT_ID)).not.toBeNull(); + } finally { + saveSpy.mockRestore(); + } + }); + + test("a failure after durable config replacement leaves changed disk untouched", () => { const config = seededConfig(); const before = structuredClone(config); const beforeBytes = readFileSync(getConfigPath(), "utf8"); @@ -97,11 +123,61 @@ describe("Codex account delete persistence ordering", () => { }); try { - expect(() => deleteCodexAccount(config, ACCOUNT_ID)).toThrow("forced post-write failure"); + expect(() => deleteCodexAccount(config, ACCOUNT_ID)).toThrow(CodexAccountDeleteRollbackError); expect(config).toEqual(before); - expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); - expect(loadConfig().codexAccounts?.some(account => account.id === ACCOUNT_ID)).toBe(true); + expect(readFileSync(getConfigPath(), "utf8")).not.toBe(beforeBytes); + expect(loadConfig().codexAccounts?.some(account => account.id === ACCOUNT_ID)).toBe(false); + expect(getCodexAccountCredential(ACCOUNT_ID)).not.toBeNull(); + expect(isAccountNeedsReauth(ACCOUNT_ID)).toBe(true); + expect(getAccountQuota(ACCOUNT_ID)).not.toBeNull(); + } finally { + saveSpy.mockRestore(); + } + }); + + test("a concurrent external edit remains byte-identical after uncertain failure", () => { + const config = seededConfig(); + const before = structuredClone(config); + const realSave = configModule.saveConfigPreservingClaudeCode; + const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode") + .mockImplementation(candidate => { + realSave(candidate); + const external = loadConfig(); + external.port = 12345; + writeFileSync(getConfigPath(), JSON.stringify(external, null, 2) + "\n"); + throw new Error("forced concurrent failure"); + }); + + try { + expect(() => deleteCodexAccount(config, ACCOUNT_ID)).toThrow(CodexAccountDeleteRollbackError); + const persisted = loadConfig(); + expect(persisted.port).toBe(12345); + expect(persisted.codexAccounts?.some(account => account.id === ACCOUNT_ID)).toBe(false); + expect(config).toEqual(before); + expect(getCodexAccountCredential(ACCOUNT_ID)).not.toBeNull(); + expect(isAccountNeedsReauth(ACCOUNT_ID)).toBe(true); + expect(getAccountQuota(ACCOUNT_ID)).not.toBeNull(); + } finally { + saveSpy.mockRestore(); + } + }); + + test("a missing config after uncertain failure is not recreated", () => { + const config = seededConfig(); + const before = structuredClone(config); + const realSave = configModule.saveConfigPreservingClaudeCode; + const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode") + .mockImplementation(candidate => { + realSave(candidate); + unlinkSync(getConfigPath()); + throw new Error("forced missing-file failure"); + }); + + try { + expect(() => deleteCodexAccount(config, ACCOUNT_ID)).toThrow(CodexAccountDeleteRollbackError); + expect(existsSync(getConfigPath())).toBe(false); + expect(config).toEqual(before); expect(getCodexAccountCredential(ACCOUNT_ID)).not.toBeNull(); expect(isAccountNeedsReauth(ACCOUNT_ID)).toBe(true); expect(getAccountQuota(ACCOUNT_ID)).not.toBeNull(); From 30ad8ba577805cf17df3c3c566e38c37a94f2fd3 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:15:09 -0600 Subject: [PATCH 012/236] fix(codex): compare rollback snapshots bytewise (cherry picked from commit 74808a8bcb500d441f7ec8fa2d5b651b706e44c1) --- src/codex/account-lifecycle.ts | 6 ++-- .../codex-account-delete-atomicity.test.ts | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index b413033095..703e08f247 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -106,8 +106,8 @@ function restoreRuntimeConfig(target: OcxConfig, snapshot: OcxConfig): void { Object.assign(target, snapshot); } -function assertPersistedConfigUnchanged(configPath: string, previousBytes: string): void { - if (readFileSync(configPath, "utf8") !== previousBytes) { +function assertPersistedConfigUnchanged(configPath: string, previousBytes: Buffer): void { + if (!readFileSync(configPath).equals(previousBytes)) { throw new CodexAccountDeleteRollbackError(); } } @@ -129,7 +129,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): const previousConfig = structuredClone(runtimeConfig); const configPath = getConfigPath(); const hasPersistedConfig = existsSync(configPath); - const previousPersistedConfig = hasPersistedConfig ? readFileSync(configPath, "utf8") : undefined; + const previousPersistedConfig = hasPersistedConfig ? readFileSync(configPath) : undefined; const hadStoredAccount = (runtimeConfig.codexAccounts ?? []) .some(account => !account.isMain && account.id === accountId); const hadVisiblePickerBinding = hadStoredAccount diff --git a/tests/codex-integration/codex-account-delete-atomicity.test.ts b/tests/codex-integration/codex-account-delete-atomicity.test.ts index 955ea1425d..25728bbda5 100644 --- a/tests/codex-integration/codex-account-delete-atomicity.test.ts +++ b/tests/codex-integration/codex-account-delete-atomicity.test.ts @@ -163,6 +163,36 @@ describe("Codex account delete persistence ordering", () => { } }); + test("distinct bytes with the same decoded text are treated as changed", () => { + const config = seededConfig(); + const before = structuredClone(config); + const validBytes = Buffer.from('{"value":"\uFFFD"}\n', "utf8"); + const malformedBytes = Buffer.concat([ + Buffer.from('{"value":"', "utf8"), + Buffer.from([0x80]), + Buffer.from('"}\n', "utf8"), + ]); + expect(validBytes.equals(malformedBytes)).toBe(false); + expect(validBytes.toString("utf8")).toBe(malformedBytes.toString("utf8")); + writeFileSync(getConfigPath(), validBytes); + const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode") + .mockImplementation(() => { + writeFileSync(getConfigPath(), malformedBytes); + throw new Error("forced byte-alias failure"); + }); + + try { + expect(() => deleteCodexAccount(config, ACCOUNT_ID)).toThrow(CodexAccountDeleteRollbackError); + expect(readFileSync(getConfigPath()).equals(malformedBytes)).toBe(true); + expect(config).toEqual(before); + expect(getCodexAccountCredential(ACCOUNT_ID)).not.toBeNull(); + expect(isAccountNeedsReauth(ACCOUNT_ID)).toBe(true); + expect(getAccountQuota(ACCOUNT_ID)).not.toBeNull(); + } finally { + saveSpy.mockRestore(); + } + }); + test("a missing config after uncertain failure is not recreated", () => { const config = seededConfig(); const before = structuredClone(config); From 39072c7a629ea4f7cc8081bf3201077650052e2a Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 01:41:32 +0900 Subject: [PATCH 013/236] docs: refresh upstream WebSocket stack layer --- devlog/_plan/260906_a_runtime_stack/021_ws_refresh.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 devlog/_plan/260906_a_runtime_stack/021_ws_refresh.md diff --git a/devlog/_plan/260906_a_runtime_stack/021_ws_refresh.md b/devlog/_plan/260906_a_runtime_stack/021_ws_refresh.md new file mode 100644 index 0000000000..f6de6471c3 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/021_ws_refresh.md @@ -0,0 +1,3 @@ +# WebSocket layer P refresh + +Consume 020 above prepared SSE parent 4b34cbb8d, with source #3679 b05cccf264b4ab61db5d8dee8232c2f89bb1b541. Public author updated the old head and resolved the three original review threads. Retain Clive Rosfield attribution and -x source identity. Existing shared proxy-formats documentation contains SSE paragraph; preserve both sections. B owns concurrent providerContextLimits config changes; A updates only applyProxyEnv. This layer stays independently verified and draft while full CI runs; main merges only after full required gates. No local project checks. From 10fbda2e031230d57873b5afb828324c18ae090f Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:02:29 +0800 Subject: [PATCH 014/236] fix(responses): honor proxy routing for upstream websocket (cherry picked from commit b05cccf264b4ab61db5d8dee8232c2f89bb1b541) --- .../src/content/docs/guides/providers.md | 6 + .../src/content/docs/reference/adapters.md | 5 + .../content/docs/reference/proxy-formats.md | 13 ++ src/config.ts | 11 +- src/lib/provider-outbound.ts | 47 +----- src/lib/proxy-env.ts | 67 +++++++++ src/server/responses/codex-ws-pool.ts | 8 +- src/server/responses/codex-ws-session.ts | 4 +- src/server/responses/ws-upstream.ts | 11 +- structure/04_transports-and-sidecars.md | 8 +- tests/responses/ws-upstream-reuse.test.ts | 29 +++- tests/responses/ws-upstream.test.ts | 82 +++++++++-- tests/server/proxy-env.test.ts | 135 +++++++++++++++++- 13 files changed, 350 insertions(+), 76 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 6a37cf8a70..255c0d8dc4 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -620,6 +620,12 @@ A provider is included when opencodex has a matching wire adapter, **not** based (AI Studio, Vertex, and Antigravity/Cloud Code Assist modes), `azure` / `azure-openai`, `kiro`, and `cursor`. A proprietary API without one of these implementations, such as native Amazon Bedrock, is not supported directly. + +Provider configuration selects the adapter; upstream transport selection is separate. Eligible +Responses traffic can use WSS with [explicit proxy routing](/reference/proxy-formats/#json-and-sse-output). +Invalid or unsupported WebSocket proxy settings fall back to HTTP/SSE, which uses Bun's HTTP +proxy rules rather than the WSS-specific `ALL_PROXY` fallback. + **GitHub Copilot** is an OAuth provider (`ocx login github-copilot`) that exchanges a GitHub device-flow login for a short-lived Copilot API token — not a pasted API key. **GitLab Duo** remains a key/subscription-token gateway on its OpenAI-compatible endpoint. **Cloudflare AI diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 1db98357d3..e2a24c67df 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -95,6 +95,11 @@ body and response, with narrow compatibility rewrites for routed gateways. `forward` uses configured static headers without relaying caller authorization; `key` uses the configured provider key. +Adapter selection does not select the upstream transport. Eligible requests can use the +[upstream WebSocket proxy route](/reference/proxy-formats/#json-and-sse-output); invalid or unsupported +WebSocket proxy settings fall back to HTTP/SSE. HTTP fetch-based Responses handling uses Bun's +HTTP proxy rules and does not inherit the WSS-specific `ALL_PROXY` fallback. + Noncanonical Responses gateways receive Codex's client-executed `tool_search` declaration as a collision-safe public function tool. Matching request history and JSON/SSE function calls are translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 19049e87f5..59e4e5f10e 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -118,6 +118,19 @@ the raw JSON frame and its SSE envelope at 4 MiB, and closes the upstream when i would overflow. That overflow emits a terminal downstream `response.failed` event followed by `[DONE]`. +The upstream WebSocket checks `NO_PROXY`/`no_proxy` first. Otherwise it uses the first non-empty +`HTTPS_PROXY`, `https_proxy`, `ALL_PROXY`, or `all_proxy` value; `HTTP_PROXY` alone does not proxy a +WSS connection. HTTP and HTTPS proxy URLs are passed to Bun. If the selected value is invalid or +uses an unsupported protocol, opencodex skips the WebSocket attempt and uses HTTP/SSE instead of +dialing the upstream directly. + +These rules belong to the upstream WebSocket transport, independently of the selected provider +adapter. HTTP fetch-based Responses requests, including SSE fallback, use Bun's HTTP proxy rules +and do not use `ALL_PROXY`. `config.proxy` fills missing `HTTP_PROXY`/`HTTPS_PROXY` values; the +resulting scheme-specific value also takes precedence over an existing `ALL_PROXY` for WebSocket. +For an HTTPS upstream that requires a proxy, set `HTTPS_PROXY` or `config.proxy`; `HTTP_PROXY` +alone leaves both WSS and its HTTPS fallback without a scheme-matched proxy. + Every terminal Responses usage object includes both detail objects, even when the provider did not report those details: diff --git a/src/config.ts b/src/config.ts index 5d67275dce..72da455382 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3738,11 +3738,12 @@ function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements } /** - * Mirror `config.proxy` into HTTP(S)_PROXY env vars so Bun's native fetch routes every outbound - * provider call through the proxy — no per-callsite changes (verified: Bun honors these plus - * NO_PROXY). User-set env vars always win; localhost/127.0.0.1 are appended to NO_PROXY so the - * CLI's own health checks and running-proxy API calls stay direct. Call once per process entry - * that makes outbound provider requests (server start, catalog sync). + * Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports + * such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY + * variables win; config fills missing scheme proxies, which take precedence over ALL_PROXY for WS. + * localhost/127.0.0.1 are appended to NO_PROXY so the CLI's own health checks and + * running-proxy API calls stay direct. Call once per process entry that makes outbound provider + * requests (server start, catalog sync). */ export function applyProxyEnv(config: OcxConfig): void { applyProxyEnvWith(config); diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index 495fef0b8b..02bdbc2077 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -7,7 +7,7 @@ import { resolvePublicAddresses, } from "./destination-policy"; import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; -import { effectiveProxyFor, outboundProxyConfigured } from "./proxy-env"; +import { effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env"; import { publicProviderBaseUrl } from "./provider-url"; type ProviderGetInit = Omit; @@ -37,10 +37,6 @@ function pickPinnedAddress(addresses: Array<{ address: string; family: number }> return addresses.find(address => address.family === 4) ?? addresses[0]!; } -function configuredProxyFor(): boolean { - return outboundProxyConfigured(); -} - /** * Registry-owned fake-IP transparency exception (Clash/Surge/Mihomo TUN mode). * @@ -76,45 +72,6 @@ function transparentFakeIpException( return isCanonicalUrl(name, url); } -function normalizeProxyHostname(hostname: string): string { - const normalized = hostname.trim().toLowerCase().replace(/\.+$/, ""); - return normalized.startsWith("[") && normalized.endsWith("]") - ? normalized.slice(1, -1) - : normalized; -} - -function noProxyMatches(url: URL): boolean { - const raw = process.env.NO_PROXY ?? process.env.no_proxy ?? ""; - const hostname = normalizeProxyHostname(url.hostname); - const port = url.port || (url.protocol === "https:" ? "443" : "80"); - for (const rawEntry of raw.split(",")) { - let entry = rawEntry.trim().toLowerCase(); - if (!entry) continue; - if (entry === "*") return true; - entry = entry.replace(/^https?:\/\//, "").split("/", 1)[0]!; - - let entryHost = entry; - let entryPort = ""; - const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry); - if (bracketed) { - entryHost = bracketed[1]!; - entryPort = bracketed[2] ?? ""; - } else if ((entry.match(/:/g)?.length ?? 0) === 1) { - const separator = entry.lastIndexOf(":"); - const possiblePort = entry.slice(separator + 1); - if (/^\d+$/.test(possiblePort)) { - entryHost = entry.slice(0, separator); - entryPort = possiblePort; - } - } - if (entryPort && entryPort !== port) continue; - entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, "")); - if (!entryHost) continue; - if (hostname === entryHost || hostname.endsWith(`.${entryHost}`)) return true; - } - return false; -} - let proxyBoundaryWarned = false; let proxyDnsDegradationWarned = false; @@ -181,7 +138,7 @@ async function providerOutboundRequest( return provider.fetch(url, { ...init, method, redirect: "manual" }); } const parsed = postUrl ?? new URL(url); - const proxyConfigured = configuredProxyFor(); + const proxyConfigured = outboundProxyConfigured(); // Snapshot the scheme-matched proxy once, before the DNS await, so admission and transport // below reason about the same value. `null` here means "no proxy fetch would actually use", // even if some other proxy variable is set. diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts index 46df592689..0ac9ed735c 100644 --- a/src/lib/proxy-env.ts +++ b/src/lib/proxy-env.ts @@ -3,6 +3,73 @@ export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const; export type ProxyEnvKey = typeof PROXY_ENV_KEYS[number]; export type ProxyEnvMap = Record; +export type ProxyRoute = + | { kind: "direct" } + | { kind: "proxy"; proxy: string } + | { kind: "fallback" }; + +export function normalizeProxyHostname(hostname: string): string { + const normalized = hostname.trim().toLowerCase().replace(/\.+$/, ""); + return normalized.startsWith("[") && normalized.endsWith("]") + ? normalized.slice(1, -1) + : normalized; +} + +export function noProxyMatches( + url: URL, + env: ProxyEnvMap = process.env, +): boolean { + const raw = env.NO_PROXY ?? env.no_proxy ?? ""; + const hostname = normalizeProxyHostname(url.hostname); + const port = url.port || (url.protocol === "https:" || url.protocol === "wss:" ? "443" : "80"); + for (const rawEntry of raw.split(",")) { + let entry = rawEntry.trim().toLowerCase(); + if (!entry) continue; + if (entry === "*") return true; + entry = entry.replace(/^(?:https?|wss?):\/\//, "").split("/", 1)[0]!; + + let entryHost = entry; + let entryPort = ""; + const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry); + if (bracketed) { + entryHost = bracketed[1]!; + entryPort = bracketed[2] ?? ""; + } else if ((entry.match(/:/g)?.length ?? 0) === 1) { + const separator = entry.lastIndexOf(":"); + const possiblePort = entry.slice(separator + 1); + if (/^\d+$/.test(possiblePort)) { + entryHost = entry.slice(0, separator); + entryPort = possiblePort; + } + } + if (entryPort && entryPort !== port) continue; + entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, "")); + if (entryHost && (hostname === entryHost || hostname.endsWith(`.${entryHost}`))) return true; + } + return false; +} + +export function resolveProxyRoute( + url: URL, + env: ProxyEnvMap = process.env, +): ProxyRoute { + if (noProxyMatches(url, env)) return { kind: "direct" }; + const key = url.protocol === "https:" || url.protocol === "wss:" + ? "HTTPS_PROXY" + : "HTTP_PROXY"; + const proxy = [key, key.toLowerCase(), "ALL_PROXY", "all_proxy"] + .map(candidate => env[candidate]?.trim()) + .find(Boolean); + if (!proxy) return { kind: "direct" }; + try { + const protocol = new URL(proxy).protocol; + return protocol === "http:" || protocol === "https:" + ? { kind: "proxy", proxy } + : { kind: "fallback" }; + } catch { + return { kind: "fallback" }; + } +} export function proxyEnvPresent( key: ProxyEnvKey, diff --git a/src/server/responses/codex-ws-pool.ts b/src/server/responses/codex-ws-pool.ts index 378cf2d4a3..5d406bee4f 100644 --- a/src/server/responses/codex-ws-pool.ts +++ b/src/server/responses/codex-ws-pool.ts @@ -25,7 +25,7 @@ function digest(input: unknown): string { } /** Identity comes from the selected outgoing request, never a model label or caller hint. */ -export function codexWsReuseIdentity(url: string, headers: Record, frameText: string): CodexWsReuseIdentity | null { +export function codexWsReuseIdentity(url: string, headers: Record, frameText: string, proxy?: string): CodexWsReuseIdentity | null { if (url !== CODEX_RESPONSES_HTTP_URL) return null; let body: unknown; try { body = JSON.parse(frameText); } catch { return null; } @@ -52,7 +52,7 @@ export function codexWsReuseIdentity(url: string, headers: Record): CodexWsSession | null { + acquire(identity: CodexWsReuseIdentity, url: string, headers: Record, proxy?: string): CodexWsSession | null { this.sweep(); for (const entry of this.entries.values()) { if (entry.identity.scope !== identity.scope || entry.identity.key === identity.key) continue; @@ -94,7 +94,7 @@ export class CodexWsPool { this.remove(oldest); } const createdAt = this.now(); - const session = new CodexWsSession(url, headers, true, () => this.changed(entry)); + const session = new CodexWsSession(url, headers, true, () => this.changed(entry), proxy); const entry: Entry = { identity, session, createdAt, idleAt: createdAt, retired: false }; session.reserve(); this.entries.set(identity.key, entry); diff --git a/src/server/responses/codex-ws-session.ts b/src/server/responses/codex-ws-session.ts index bbf62f8137..32716a5297 100644 --- a/src/server/responses/codex-ws-session.ts +++ b/src/server/responses/codex-ws-session.ts @@ -10,8 +10,8 @@ export class CodexWsSession { private readonly completedIds = new Set(); constructor(url: string, headers: Record, readonly retainable = false, - private readonly changed: () => void = () => {}) { - this.socket = new WebSocket(url, { headers } as unknown as string[]); + private readonly changed: () => void = () => {}, proxy?: string) { + this.socket = new WebSocket(url, { headers, ...(proxy ? { proxy } : {}) } as unknown as string[]); this.socket.addEventListener("open", this.onOpen); this.socket.addEventListener("message", this.onIdleMessage); this.socket.addEventListener("close", this.onClose); diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts index e9773d02a3..87b3767d2b 100644 --- a/src/server/responses/ws-upstream.ts +++ b/src/server/responses/ws-upstream.ts @@ -13,6 +13,7 @@ // (passthrough relay, adapter parsers, usage sniffing) is unchanged. import { compareBunVersions } from "../../lib/bun-stream-caps"; +import { resolveProxyRoute } from "../../lib/proxy-env"; import type { CodexWsQuotaObserver } from "./codex-ws-metadata"; import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, prepareCodexHttpInit, prepareCodexWsRequest } from "./codex-ws-request"; import { codexWsExchange } from "./codex-ws-exchange"; @@ -150,6 +151,10 @@ export function codexWsUpstreamFetch( return sseFallback(url, init); } + const wsUrl = wsUpstreamUrlFor(url); + const proxyRoute = resolveProxyRoute(new URL(wsUrl)); + if (proxyRoute.kind === "fallback") return sseFallback(url, init); + const proxy = proxyRoute.kind === "proxy" ? proxyRoute.proxy : undefined; // A genuine caller `originator` is already in these headers via the forward // set. Never fabricate one here: pool/forward traffic must not impersonate // Codex CLI, per the metadata-integrity contract. (The backend's fast lane @@ -164,9 +169,9 @@ export function codexWsUpstreamFetch( } let session: CodexWsSession; try { - const identity = codexWsReuseIdentity(url, headers, frameText); - session = (identity ? codexWsPool.acquire(identity, wsUpstreamUrlFor(url), headers) : null) - ?? new CodexWsSession(wsUpstreamUrlFor(url), headers); + const identity = codexWsReuseIdentity(url, headers, frameText, proxy); + session = (identity ? codexWsPool.acquire(identity, wsUrl, headers, proxy) : null) + ?? new CodexWsSession(wsUrl, headers, false, undefined, proxy); if (!session.busy && !session.reserve()) { session.dispose(); return sseFallback(url, init); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 4ee22c114c..a45a98c87b 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -435,7 +435,7 @@ These are transport-fidelity guarantees, not a provider-billing guarantee. Eligible complete-input creates can retain a canonical upstream socket within one selected account, credential, thread and turn. Model/tier and immutable -handshake headers must also match. Turn-state and turn-metadata headers are +handshake headers and the selected outbound proxy must also match. Turn-state and turn-metadata headers are projected into their same-name per-frame metadata slots; explicit body values win. The pool retains at most 32 sockets, expires idle sockets after 30 seconds, and retires a socket after five minutes or 32 successful exchanges (after active work @@ -644,7 +644,11 @@ the upgrade with 426 so Codex falls back to HTTP cleanly. That setting controls the client-facing upgrade only. The transparent upstream ChatGPT WS optimization described above is selected independently and still -returns the same downstream SSE contract. +returns the same downstream SSE contract. Its WSS route checks NO_PROXY first, then selects the +first non-empty HTTPS_PROXY, https_proxy, ALL_PROXY, or all_proxy value. HTTP_PROXY alone does not +route WSS. Unsupported or malformed selected proxy values skip the WebSocket attempt and use the +existing SSE path immediately; they never fall through to a lower-priority proxy or direct WebSocket +egress. HTTP/SSE fallback retains Bun fetch's own proxy rules, which do not consult ALL_PROXY. The endpoint handles `response.create`, ignores `response.processed`, supports warmup `generate: false`, and feeds the same request pipeline as HTTP/SSE. diff --git a/tests/responses/ws-upstream-reuse.test.ts b/tests/responses/ws-upstream-reuse.test.ts index fd0a8fb5a1..b957fdb317 100644 --- a/tests/responses/ws-upstream-reuse.test.ts +++ b/tests/responses/ws-upstream-reuse.test.ts @@ -6,6 +6,8 @@ import { prepareCodexWsRequest } from "../../src/server/responses/codex-ws-reque const URL = "https://chatgpt.com/backend-api/codex/responses"; const realWebSocket = globalThis.WebSocket; +const proxyEnvKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; +let savedProxyEnv: Record; let sequence = 0; class Socket extends EventTarget { @@ -13,7 +15,7 @@ class Socket extends EventTarget { static onSend: (socket: Socket, frame: Record) => void = (socket) => socket.complete(); readyState = 0; frames: Record[] = []; - constructor(readonly url: string) { + constructor(readonly url: string, readonly options?: { proxy?: string }) { super(); Socket.all.push(this); queueMicrotask(() => { if (this.readyState === 0) { this.readyState = 1; this.dispatchEvent(new Event("open")); } }); @@ -58,7 +60,11 @@ function bodyWith(fields: Record) { options.body = JSON.stringify({ ...JSON.parse(options.body as string), ...fields }); return options; } -beforeEach(() => { globalThis.WebSocket = Socket as unknown as typeof WebSocket; }); +beforeEach(() => { + globalThis.WebSocket = Socket as unknown as typeof WebSocket; + savedProxyEnv = Object.fromEntries(proxyEnvKeys.map(key => [key, process.env[key]])); + for (const key of proxyEnvKeys) delete process.env[key]; +}); afterEach(() => { runOptionalShutdownHooks(); @@ -67,6 +73,25 @@ afterEach(() => { Socket.onSend = socket => socket.complete(); sequence = 0; globalThis.WebSocket = realWebSocket; + for (const key of proxyEnvKeys) delete process.env[key]; + for (const key of proxyEnvKeys) { + if (savedProxyEnv[key] !== undefined) process.env[key] = savedProxyEnv[key]; + } +}); + +test("proxy changes and NO_PROXY retire the old route while unchanged routes reuse", async () => { + for (const proxy of ["http://proxy-a.example:8080", "http://proxy-b.example:8080"]) { + process.env.HTTPS_PROXY = proxy; + await drain(); + await drain(); + } + process.env.NO_PROXY = "chatgpt.com:443"; + await drain(); + await drain(); + expect(Socket.all.map(socket => socket.options?.proxy)) + .toEqual(["http://proxy-a.example:8080", "http://proxy-b.example:8080", undefined]); + expect(Socket.all.map(socket => socket.frames.length)).toEqual([2, 2, 2]); + expect(Socket.all.map(socket => socket.readyState)).toEqual([3, 3, 1]); }); test("same account/thread/turn reuses one socket without trimming either HTTP input", async () => { diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index fd09513070..cfb087a4bb 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, jest, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; import { providerFetch } from "../../src/server/responses/fetch-helpers"; import { handleResponses } from "../../src/server/responses"; import { isEagerRelaySseResponse } from "../../src/server/relay"; @@ -162,18 +162,24 @@ describe("shouldUseCodexWsUpstream", () => { }); type Listener = (event: unknown) => void; +type FakeWebSocketOptions = { + headers?: Record; + proxy?: string; +}; /** Minimal scriptable stand-in for Bun's WebSocket. */ class FakeWebSocket { static instances: FakeWebSocket[] = []; static script: (ws: FakeWebSocket) => void = () => {}; url: string; + options?: FakeWebSocketOptions; sent: string[] = []; closed = false; listeners = new Map(); - constructor(url: string) { + constructor(url: string, options?: FakeWebSocketOptions) { this.url = url; + this.options = options; FakeWebSocket.instances.push(this); queueMicrotask(() => FakeWebSocket.script(this)); } @@ -205,12 +211,23 @@ class FakeWebSocket { const RealWebSocket = globalThis.WebSocket; const RealFetch = globalThis.fetch; +const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"] as const; +let savedProxyEnv: Record; + +beforeEach(() => { + savedProxyEnv = Object.fromEntries(PROXY_ENV_KEYS.map(key => [key, process.env[key]])); + for (const key of PROXY_ENV_KEYS) delete process.env[key]; +}); afterEach(() => { globalThis.WebSocket = RealWebSocket; globalThis.fetch = RealFetch; FakeWebSocket.instances = []; FakeWebSocket.script = () => {}; + for (const key of PROXY_ENV_KEYS) delete process.env[key]; + for (const key of PROXY_ENV_KEYS) { + if (savedProxyEnv[key] !== undefined) process.env[key] = savedProxyEnv[key]; + } }); function installFake(script: (ws: FakeWebSocket) => void) { @@ -525,6 +542,41 @@ describe("codexWsUpstreamFetch", () => { expect(text).not.toContain("must-not-leak"); }); + test("passes the selected proxy without changing handshake headers", async () => { + process.env.HTTPS_PROXY = "http://proxy.example:8080"; + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: {} }) }); + }); + + await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (() => { + throw new Error("fallback must not run"); + }) as unknown as typeof fetch); + + const options = FakeWebSocket.instances[0]!.options; + expect(options?.proxy).toBe("http://proxy.example:8080"); + expect(options?.headers?.authorization).toBe("Bearer test"); + expect(options?.headers?.["openai-beta"]).toContain("responses_websockets"); + expect(options?.headers?.["content-type"]).toBeUndefined(); + }); + + test.each([ + ["unsupported protocol", "socks5://proxy.example:1080"], + ["invalid URL", "not a proxy URL"], + ])("falls back once without dialing for an %s", async (_label, proxy) => { + process.env.HTTPS_PROXY = proxy; + const sentinel = new Response("sse-fallback"); + let fallbackCalls = 0; + const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), (async () => { + fallbackCalls += 1; + return sentinel; + }) as typeof fetch); + + expect(response).toBe(sentinel); + expect(fallbackCalls).toBe(1); + expect(FakeWebSocket.instances).toHaveLength(0); + }); + test("relays event frames as an SSE response and sends one response.create frame", async () => { installFake(ws => { ws.emit("open", {}); @@ -654,6 +706,7 @@ describe("codexWsUpstreamFetch", () => { }); test("falls back to the HTTP fetch when the upgrade is rejected before open", async () => { + process.env.HTTPS_PROXY = "http://proxy.example:8080"; installFake(ws => ws.close()); const sentinel = new Response("sse-fallback", { status: 429 }); let fallbackCalls = 0; @@ -666,6 +719,7 @@ describe("codexWsUpstreamFetch", () => { expect(response).toBe(sentinel); expect(isCodexWsUpstreamResponse(response)).toBe(false); expect(fallbackCalls).toBe(1); + expect(FakeWebSocket.instances[0]!.options?.proxy).toBe("http://proxy.example:8080"); }); test("falls back to the HTTP fetch when the upgrade deadline elapses without open or close", async () => { @@ -800,15 +854,17 @@ describe("codexWsUpstreamFetch", () => { }); test("preserves caller headers on the handshake without fabricating an originator", async () => { - const seen: Record[] = []; + process.env.HTTPS_PROXY = "http://proxy.example:8080"; + process.env.NO_PROXY = "chatgpt.com:443"; + const seen: FakeWebSocketOptions[] = []; FakeWebSocket.script = ws => { ws.emit("open", {}); ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: {} }) }); }; class HeaderCapturingWebSocket extends FakeWebSocket { - constructor(url: string, options?: { headers?: Record }) { - super(url); - seen.push(options?.headers ?? {}); + constructor(url: string, options?: FakeWebSocketOptions) { + super(url, options); + seen.push(options ?? {}); } } globalThis.WebSocket = HeaderCapturingWebSocket as unknown as typeof WebSocket; @@ -817,18 +873,19 @@ describe("codexWsUpstreamFetch", () => { await codexWsUpstreamFetch(CODEX_URL, streamingInit(), fallback); // Without a caller originator none is invented: pool/forward traffic must // not impersonate Codex CLI (metadata-integrity contract). - expect(seen[0].originator).toBeUndefined(); - expect(seen[0]["openai-beta"]).toContain("responses_websockets"); - expect(seen[0].authorization).toBe("Bearer test"); + expect(seen[0].proxy).toBeUndefined(); + expect(seen[0].headers?.originator).toBeUndefined(); + expect(seen[0].headers?.["openai-beta"]).toContain("responses_websockets"); + expect(seen[0].headers?.authorization).toBe("Bearer test"); // HTTP body-framing headers do not belong on a WS handshake. - expect(seen[0]["content-type"]).toBeUndefined(); + expect(seen[0].headers?.["content-type"]).toBeUndefined(); // A genuine caller originator is forwarded verbatim. await codexWsUpstreamFetch(CODEX_URL, { ...streamingInit(), headers: { ...streamingInit().headers as Record, originator: "codex_cli_rs" }, }, fallback); - expect(seen[1].originator).toBe("codex_cli_rs"); + expect(seen[1].headers?.originator).toBe("codex_cli_rs"); }); test("aborting before open rejects like an aborted fetch", async () => { @@ -1194,6 +1251,8 @@ describe("oversized Codex create frames", () => { }); test("dials the configured provider's own wss URL for an opt-in upstream", async () => { + process.env.HTTPS_PROXY = "http://proxy.example:8080"; + process.env.NO_PROXY = "sub2api.example.com:443"; installFake(ws => { ws.emit("open", {}); ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { id: "r-ws" } }) }); @@ -1206,6 +1265,7 @@ describe("oversized Codex create frames", () => { ); expect(FakeWebSocket.instances).toHaveLength(1); expect(FakeWebSocket.instances[0]!.url).toBe("wss://sub2api.example.com/v1/responses"); + expect(FakeWebSocket.instances[0]!.options?.proxy).toBeUndefined(); expect(response.headers.get("content-type")).toContain("text/event-stream"); expect(await response.text()).toContain("response.completed"); }); diff --git a/tests/server/proxy-env.test.ts b/tests/server/proxy-env.test.ts index e43ad2d9be..c795c6cf2d 100644 --- a/tests/server/proxy-env.test.ts +++ b/tests/server/proxy-env.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createServer } from "node:http"; import { applyProxyEnv } from "../../src/config"; +import { resolveProxyRoute } from "../../src/lib/proxy-env"; import type { OcxConfig } from "../../src/types"; -const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", "OCX_TEST_PROXY_REF", "OCX_TEST_NO_PROXY_REF"] as const; +const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "OCX_TEST_PROXY_REF", "OCX_TEST_NO_PROXY_REF"] as const; let saved: Record; beforeEach(() => { @@ -30,6 +32,128 @@ function configWithRawProxy(proxy: unknown, noProxy?: unknown): OcxConfig { return { proxy, noProxy, providers: {} } as unknown as OcxConfig; } +describe("resolveProxyRoute", () => { + test("wss uses HTTPS_PROXY and never HTTP_PROXY", () => { + const target = new URL("wss://chatgpt.com/backend-api/codex/responses"); + expect(resolveProxyRoute(target, { + HTTPS_PROXY: "http://secure-proxy.example:8443", + HTTP_PROXY: "http://plain-proxy.example:8080", + })).toEqual({ kind: "proxy", proxy: "http://secure-proxy.example:8443" }); + expect(resolveProxyRoute(target, { + HTTP_PROXY: "http://plain-proxy.example:8080", + })).toEqual({ kind: "direct" }); + }); + + test.each([ + ["exact host", "wss://chatgpt.com/path", "chatgpt.com", "direct"], + ["domain suffix", "wss://api.chatgpt.com/path", ".chatgpt.com", "direct"], + ["wildcard suffix", "wss://api.chatgpt.com/path", "*.chatgpt.com", "direct"], + ["wss default port", "wss://chatgpt.com/path", "chatgpt.com:443", "direct"], + ["ws default port", "ws://chatgpt.com/path", "chatgpt.com:80", "direct"], + ["port mismatch", "wss://chatgpt.com/path", "chatgpt.com:80", "proxy"], + ["bracketed IPv6", "wss://[2001:db8::1]/path", "[2001:db8::1]:443", "direct"], + ["URL-style entry", "wss://chatgpt.com/path", "https://chatgpt.com/ignored", "direct"], + ] as const)("honors NO_PROXY for %s", (_label, target, noProxy, expectedKind) => { + expect(resolveProxyRoute(new URL(target), { + HTTPS_PROXY: "http://secure-proxy.example:8443", + NO_PROXY: noProxy, + }).kind).toBe(expectedKind); + }); + + test("uses stable proxy precedence and fails closed on the first unusable proxy", () => { + const target = new URL("wss://chatgpt.com/backend-api/codex/responses"); + const route = (env: Record) => resolveProxyRoute(target, env); + expect([ + route({ HTTPS_PROXY: "http://upper-https:1", https_proxy: "http://lower-https:2", ALL_PROXY: "http://upper-all:3", all_proxy: "http://lower-all:4" }), + route({ HTTPS_PROXY: " ", https_proxy: "http://lower-https:2", ALL_PROXY: "http://upper-all:3" }), + route({ ALL_PROXY: "http://upper-all:3", all_proxy: "http://lower-all:4" }), + route({ all_proxy: "https://lower-all:4" }), + route({ HTTPS_PROXY: "socks5://unsupported:1080", ALL_PROXY: "http://must-not-win:3" }), + route({ HTTPS_PROXY: "not a proxy URL", ALL_PROXY: "http://must-not-win:3" }), + route({}), + ]).toEqual([ + { kind: "proxy", proxy: "http://upper-https:1" }, + { kind: "proxy", proxy: "http://lower-https:2" }, + { kind: "proxy", proxy: "http://upper-all:3" }, + { kind: "proxy", proxy: "https://lower-all:4" }, + { kind: "fallback" }, + { kind: "fallback" }, + { kind: "direct" }, + ]); + }); + + test("preserves uppercase NO_PROXY precedence when it is explicitly empty", () => { + expect(resolveProxyRoute(new URL("wss://chatgpt.com/path"), { + HTTPS_PROXY: "http://secure-proxy.example:8443", + NO_PROXY: "", + no_proxy: "chatgpt.com", + })).toEqual({ kind: "proxy", proxy: "http://secure-proxy.example:8443" }); + }); + + test("Bun WebSocket sends WSS through an HTTP CONNECT proxy", async () => { + let resolveConnect!: (target: string) => void; + const connected = new Promise(resolve => { resolveConnect = resolve; }); + const proxy = createServer(); + proxy.on("connect", (request, socket) => { + resolveConnect(request.url ?? ""); + socket.end("HTTP/1.1 502 Probe Complete\r\nContent-Length: 0\r\n\r\n"); + }); + await new Promise((resolve, reject) => { + proxy.once("error", reject); + proxy.listen(0, "127.0.0.1", resolve); + }); + const address = proxy.address(); + if (!address || typeof address === "string") throw new Error("proxy did not bind a TCP port"); + const socket = new WebSocket("wss://proxy-probe.invalid/backend-api/codex/responses", { + proxy: `http://127.0.0.1:${address.port}`, + } as unknown as string[]); + try { + expect(await Promise.race([ + connected, + new Promise((_, reject) => setTimeout(() => reject(new Error("CONNECT was not observed")), 5_000)), + ])).toBe("proxy-probe.invalid:443"); + } finally { + try { socket.close(); } catch { /* probe is already complete */ } + await new Promise(resolve => proxy.close(() => resolve())); + } + }, 10_000); + + test.skipIf(process.platform !== "win32")("Bun fetch honors NO_PROXY on Windows", async () => { + let providerRequests = 0; + let proxyRequests = 0; + const provider = createServer((_request, response) => { + providerRequests += 1; + response.end("direct"); + }); + const proxy = createServer((_request, response) => { + proxyRequests += 1; + response.end("proxied"); + }); + const listen = async (server: typeof provider): Promise => { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("server did not bind a TCP port"); + return address.port; + }; + const [providerPort, proxyPort] = await Promise.all([listen(provider), listen(proxy)]); + process.env.HTTP_PROXY = `http://127.0.0.1:${proxyPort}`; + process.env.NO_PROXY = "127.0.0.1"; + try { + expect(await (await fetch(`http://127.0.0.1:${providerPort}/models`)).text()).toBe("direct"); + expect(providerRequests).toBe(1); + expect(proxyRequests).toBe(0); + } finally { + await Promise.all([ + new Promise(resolve => provider.close(() => resolve())), + new Promise(resolve => proxy.close(() => resolve())), + ]); + } + }); +}); + describe("applyProxyEnv with values the schema does not constrain", () => { test("warns once per discarded proxy setting without exposing its raw value", () => { const secret = "raw-proxy-credential-sentinel-2947"; @@ -122,6 +246,14 @@ describe("applyProxyEnv", () => { expect(process.env.HTTP_PROXY).toBe("http://proxy.corp:8080"); }); + test.each(["ALL_PROXY", "all_proxy"])("config fills a scheme proxy ahead of %s for WSS", key => { + process.env[key] = "http://fallback-proxy.example:8081"; + applyProxyEnv(configWithProxy("http://configured-proxy.example:8080")); + expect(process.env[key]).toBe("http://fallback-proxy.example:8081"); + expect(resolveProxyRoute(new URL("wss://chatgpt.com/backend-api/codex/responses"))) + .toEqual({ kind: "proxy", proxy: "http://configured-proxy.example:8080" }); + }); + test("appends loopback entries to an existing NO_PROXY without duplicating", () => { process.env.NO_PROXY = "internal.corp,localhost"; applyProxyEnv(configWithProxy("http://proxy.corp:8080")); @@ -217,4 +349,3 @@ describe("applyProxyEnv with proxy: \"auto\" (#1525)", () => { expect(process.env.HTTP_PROXY).toBeUndefined(); }); }); - From 21f3eb212181f65aed4265c7f46f2b5846316e92 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:42:08 +0900 Subject: [PATCH 015/236] test(codex): cover unreadable and transient deletion state --- .../codex-account-delete-atomicity.test.ts | 91 ++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-account-delete-atomicity.test.ts b/tests/codex-integration/codex-account-delete-atomicity.test.ts index 25728bbda5..3e49033acc 100644 --- a/tests/codex-integration/codex-account-delete-atomicity.test.ts +++ b/tests/codex-integration/codex-account-delete-atomicity.test.ts @@ -7,7 +7,10 @@ import { writeFileSync, } from "node:fs"; import { join } from "node:path"; +import * as fsModule from "node:fs"; import * as accountStoreModule from "../../src/codex/account-store"; +import * as websocketRegistryModule from "../../src/codex/websocket-registry"; +import * as quotaAutoRefreshStateModule from "../../src/codex/quota-auto-refresh-state"; import { getCodexAccountCredential, saveCodexAccountCredential, @@ -139,18 +142,22 @@ describe("Codex account delete persistence ordering", () => { test("a concurrent external edit remains byte-identical after uncertain failure", () => { const config = seededConfig(); const before = structuredClone(config); + let replacementBytes: Buffer | undefined; const realSave = configModule.saveConfigPreservingClaudeCode; const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode") .mockImplementation(candidate => { realSave(candidate); const external = loadConfig(); external.port = 12345; - writeFileSync(getConfigPath(), JSON.stringify(external, null, 2) + "\n"); + replacementBytes = Buffer.from(JSON.stringify(external, null, 2) + "\n", "utf8"); + writeFileSync(getConfigPath(), replacementBytes); throw new Error("forced concurrent failure"); }); try { expect(() => deleteCodexAccount(config, ACCOUNT_ID)).toThrow(CodexAccountDeleteRollbackError); + expect(replacementBytes).toBeDefined(); + expect(readFileSync(getConfigPath())).toEqual(replacementBytes); const persisted = loadConfig(); expect(persisted.port).toBe(12345); expect(persisted.codexAccounts?.some(account => account.id === ACCOUNT_ID)).toBe(false); @@ -216,6 +223,88 @@ describe("Codex account delete persistence ordering", () => { } }); + test("an unreadable config after uncertain failure preserves state and sanitizes errors", () => { + const config = seededConfig(); + const before = structuredClone(config); + const configPath = getConfigPath(); + const beforeBytes = readFileSync(configPath); + const readSpy = spyOn(fsModule, "readFileSync"); + const removeSpy = spyOn(accountStoreModule, "removeCodexAccountCredential"); + const invalidateSpy = spyOn(websocketRegistryModule, "invalidateCodexWebSocketsForAccount"); + const forgetSpy = spyOn(quotaAutoRefreshStateModule, "forgetCodexQuotaAutoRefreshAccount"); + const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode") + .mockImplementation(() => { + readSpy.mockImplementationOnce(() => { + throw new Error("EACCES /private/config.json Bearer read-secret-token"); + }); + throw new Error("write failed /private/config.json Bearer write-secret-token"); + }); + + try { + let thrown: unknown; + try { + deleteCodexAccount(config, ACCOUNT_ID); + } catch (error) { + thrown = error; + } + expect(readSpy).toHaveBeenLastCalledWith(configPath); + expect(thrown).toBeInstanceOf(CodexAccountDeleteRollbackError); + expect((thrown as Error).message).toBe( + "Account deletion failed and the previous config could not be restored. Restart before retrying.", + ); + expect(String(thrown)).not.toContain("/private/config.json"); + expect(String(thrown)).not.toContain("secret-token"); + expect((thrown as Error).cause).toBeUndefined(); + expect(config).toEqual(before); + expect(readFileSync(configPath)).toEqual(beforeBytes); + expect(getCodexAccountCredential(ACCOUNT_ID)).not.toBeNull(); + expect(isAccountNeedsReauth(ACCOUNT_ID)).toBe(true); + expect(getAccountQuota(ACCOUNT_ID)).not.toBeNull(); + expect(removeSpy).not.toHaveBeenCalled(); + expect(invalidateSpy).not.toHaveBeenCalled(); + expect(forgetSpy).not.toHaveBeenCalled(); + } finally { + readSpy.mockRestore(); + saveSpy.mockRestore(); + removeSpy.mockRestore(); + invalidateSpy.mockRestore(); + forgetSpy.mockRestore(); + } + }); + + test("a transient config skips persistence but still removes credentials and runtime state", () => { + const config = seededConfig(); + const configPath = getConfigPath(); + unlinkSync(configPath); + const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode"); + const removeSpy = spyOn(accountStoreModule, "removeCodexAccountCredential"); + const invalidateSpy = spyOn(websocketRegistryModule, "invalidateCodexWebSocketsForAccount"); + const forgetSpy = spyOn(quotaAutoRefreshStateModule, "forgetCodexQuotaAutoRefreshAccount"); + + try { + expect(deleteCodexAccount(config, ACCOUNT_ID)).toBe(true); + expect(saveSpy).not.toHaveBeenCalled(); + expect(existsSync(configPath)).toBe(false); + expect(config.codexAccounts).toEqual([]); + expect(config.codexAccountNamespaces).toEqual({ stable: ACCOUNT_ID }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(config.codexAccountPriorities).toBeUndefined(); + expect(config.activeCodexAccountPinned).toBeUndefined(); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(getCodexAccountCredential(ACCOUNT_ID)).toBeNull(); + expect(isAccountNeedsReauth(ACCOUNT_ID)).toBe(false); + expect(getAccountQuota(ACCOUNT_ID)).toBeNull(); + expect(removeSpy).toHaveBeenCalledWith(ACCOUNT_ID); + expect(invalidateSpy).toHaveBeenCalledWith(ACCOUNT_ID); + expect(forgetSpy).toHaveBeenCalledWith(ACCOUNT_ID); + } finally { + saveSpy.mockRestore(); + removeSpy.mockRestore(); + invalidateSpy.mockRestore(); + forgetSpy.mockRestore(); + } + }); + test("the durable config deletion happens before credential and runtime cleanup", () => { const config = seededConfig(); const realSave = configModule.saveConfigPreservingClaudeCode; From e556cc9f70e1998bf13a3edcbd5193d5372ace17 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 01:42:08 +0900 Subject: [PATCH 016/236] test(models): cover mixed OpenAI groups and exported identity --- .../011_visibility_build.md | 9 +++ .../model-visibility-management-api.test.ts | 62 +++++++++++++++++++ .../management-client-config-route.test.ts | 45 ++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/011_visibility_build.md diff --git a/devlog/_plan/260906_lane_b_catalog_stack/011_visibility_build.md b/devlog/_plan/260906_lane_b_catalog_stack/011_visibility_build.md new file mode 100644 index 0000000000..987134d7ed --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/011_visibility_build.md @@ -0,0 +1,9 @@ +# Visibility carry build + +Replacement PR: #3685, branch `codex/lane-b-01-visibility`, source #3653 at `956eedac439922cf7645f130ef8432833e813a9a`. + +The complete final diff and binary screenshot were carried in `daee875fe` with Robin Bially as commit author and an explicit coauthor trailer. Translated API references now document manual/native identity and pending discovery rejection. Three additional regression cases cover mixed provider-group toggles, atomic invalid trailing targets and client-export replacement/disable/restoration. + +Independent production and management-boundary review of `53649bab..daee875f` returned PASS with no actionable findings. The reviewer traced authentication, ownership, pending-state ordering, atomic updates, row identity and native entitlement behavior. Added tests receive final independent review; hosted CI and isolated remote GUI/document checks remain pending at this build checkpoint. No local repository suite, typecheck or build was run. + +C/D evidence is recorded in session scratch and the goalplan ledger without editing the tested head while CI runs. The later landing record will publish the final verified SHA and closure outcome. diff --git a/tests/codex-integration/model-visibility-management-api.test.ts b/tests/codex-integration/model-visibility-management-api.test.ts index 15bc17f808..96d5293ced 100644 --- a/tests/codex-integration/model-visibility-management-api.test.ts +++ b/tests/codex-integration/model-visibility-management-api.test.ts @@ -381,6 +381,68 @@ test("configured manual OpenAI rows can be toggled alongside native rows", async expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"not-configured",native:false}],enabled:true},config)).status).toBe(400); }); +test("provider-group toggles persist mixed native and manual OpenAI targets together", async () => { + const config = loadConfig(); + config.providers.openai = { + adapter: "openai-responses", authMode: "forward", liveModels: false, + baseUrl: "https://chatgpt.com/backend-api/codex", selectedModels: ["gpt-5.5"], + }; + config.customModels = [{ id: "manual-gpt", provider: "openai", modelId: "gpt-5.5" }]; + const unrelatedDisabled = [...config.disabledModels!]; + const unrelatedProvider = structuredClone(config.providers["google-antigravity"]); + const targets = [{ id: "gpt-5.5", native: false }, { id: "gpt-5.4", native: true }]; + saveConfig(config); + + const disabled = await putWithConfig({ scope: "provider", provider: "openai", targets, enabled: false }, config); + expect(disabled.status).toBe(200); + expect(await disabled.json()).toMatchObject({ ok: true, scope: "provider", provider: "openai", enabled: false }); + expect(config.disabledModels).toEqual([...unrelatedDisabled, "openai/gpt-5.5", "gpt-5.4"]); + expect(config.providers.openai.selectedModels).toEqual(["gpt-5.5"]); + expect(loadConfig().disabledModels).toEqual([...unrelatedDisabled, "openai/gpt-5.5", "gpt-5.4"]); + expect(loadConfig().providers.openai.selectedModels).toEqual(["gpt-5.5"]); + expect(loadConfig().providers["google-antigravity"]).toEqual(unrelatedProvider); + expect(refreshes).toBe(1); + + const enabled = await putWithConfig({ scope: "provider", provider: "openai", targets, enabled: true }, config); + expect(enabled.status).toBe(200); + expect(await enabled.json()).toMatchObject({ ok: true, scope: "provider", provider: "openai", enabled: true }); + expect(config.disabledModels).toEqual(unrelatedDisabled); + expect(config.providers.openai.selectedModels).toBeUndefined(); + expect(loadConfig().disabledModels).toEqual(unrelatedDisabled); + expect(loadConfig().providers.openai.selectedModels).toBeUndefined(); + expect(loadConfig().providers["google-antigravity"]).toEqual(unrelatedProvider); + expect(refreshes).toBe(2); +}); + +test("an invalid trailing target leaves a mixed OpenAI provider-group update atomic", async () => { + const config = loadConfig(); + config.providers.openai = { + adapter: "openai-responses", authMode: "forward", liveModels: false, + baseUrl: "https://chatgpt.com/backend-api/codex", selectedModels: ["gpt-5.5"], + }; + config.customModels = [{ id: "manual-gpt", provider: "openai", modelId: "gpt-5.5" }]; + saveConfig(config); + + for (const enabled of [false, true]) { + // Both valid targets would change state before the final invalid target is reached. + config.disabledModels = enabled ? ["other/keep", "openai/gpt-5.5", "gpt-5.4"] : ["other/keep"]; + saveConfig(config); + const before = structuredClone(config); + const persistedBefore = loadConfig(); + for (const invalid of [{ id: "not-configured", native: false }, { id: "gpt-9.9-imaginary", native: true }]) { + const response = await putWithConfig({ + scope: "provider", provider: "openai", enabled, + targets: [{ id: "gpt-5.5", native: false }, { id: "gpt-5.4", native: true }, invalid], + }, config); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: "invalid model visibility target" }); + expect(config).toEqual(before); + expect(loadConfig()).toEqual(persistedBefore); + expect(refreshes).toBe(0); + } + } +}); + test("manual models replace management rows with the same provider/id and deletion restores natives", async () => { const config = loadConfig(); config.providers.openai = {adapter:"openai-responses",authMode:"forward",baseUrl:"https://chatgpt.com/backend-api/codex",liveModels:false}; diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index a7d4d080cb..1f3f2b2908 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -425,6 +425,51 @@ describe("GET /api/client-config", () => { expect(body.modelCount).toBe(enabled.modelCount - 1); }, 15_000); + test("manual OpenAI replacement, disable, and removal reach the loader and exported selectors", async () => { + const config = baseConfig({ + // Test base-selector identity here; the Fast projection has its own regressions below. + fastRows: false, + providers: { + ...baseConfig().providers, + openai: { + adapter: "openai-responses", authMode: "forward", liveModels: false, + baseUrl: "https://chatgpt.com/backend-api/codex", models: [], + }, + }, + }); + const manual = [{ id: "manual-gpt", provider: "openai", modelId: "gpt-5.5", contextWindow: 128_000 }]; + const stages = [ + { customModels: [], disabledModels: [], selectors: ["gpt-5.5"], native: true }, + { customModels: manual, disabledModels: [], selectors: ["openai/gpt-5.5"], native: false }, + { customModels: manual, disabledModels: ["openai/gpt-5.5"], selectors: [], native: false }, + // Removing the manual row restores the bare route even while its routed disable key remains. + { customModels: [], disabledModels: ["openai/gpt-5.5"], selectors: ["gpt-5.5"], native: true }, + ]; + for (const stage of stages) { + config.customModels = stage.customModels; + config.disabledModels = stage.disabledModels; + const rows = await loadExportModels(config); + const matchingRows = rows.filter(row => row.provider === "openai" && row.id === "gpt-5.5"); + expect(matchingRows.map(row => row.namespaced)).toEqual(stage.selectors); + if (stage.selectors.length > 0) { + expect(matchingRows[0]!.native === true).toBe(stage.native); + if (!stage.native) expect(matchingRows[0]!.contextWindow).toBe(128_000); + } + const response = await clientConfigApi(config, "?client=pi"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + const models = (body.config as PiGeneratedConfig).providers[OPENCODE_PROVIDER_ID].models; + // An array export exposes duplicates that a keyed document could silently overwrite. + expect(models.filter(model => model.id === "gpt-5.5" || model.id === "openai/gpt-5.5") + .map(model => model.id)).toEqual(stage.selectors); + if (stage.selectors[0] === "openai/gpt-5.5") { + expect(models.find(model => model.id === "openai/gpt-5.5")?.contextWindow).toBe(128_000); + } + expect(models.filter(model => model.id === "a/m1")).toHaveLength(1); + expect(body.modelCount).toBe(models.length); + } + }, 15_000); + test("model order and dedupe are stable across repeated calls", async () => { const config = baseConfig(); const first = await (await clientConfigApi(config, "?client=opencode")).json() as ClientConfigEnvelope; From 9c255596965e0a65ce60ec716323dc1a814603c5 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:46:42 +0900 Subject: [PATCH 017/236] fix(oauth): retain operator settings across provider login Carry #3631 onto the current xAI transport baseline. Preserve canonical login fields, registry catalog reconciliation, and key billing intent. Source-commits: 015c04afd152380e4948c506d49acaa9215694d6, 3f4e7c5a46d1f078559bda9aa53b88a1d96c4e48, 171451d338ab76b55185e587986450397f20ee02 Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- .../docs/reference/cli/providers-accounts.md | 5 + src/oauth/index.ts | 130 +++++++------ tests/oauth/oauth-provider-reconcile.test.ts | 60 +++++- .../oauth-upsert-preserves-api-key.test.ts | 173 +++++++++++++++++- 4 files changed, 291 insertions(+), 77 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 3bcb6092bc..ccb29a38b4 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -75,6 +75,11 @@ ocx login xai ocx login anthropic ``` +OAuth reauthentication preserves operator settings such as model selections, pricing overrides, +and account failover preferences. Login-owned transport/authentication fields and registry-owned +catalog metadata are refreshed. A live-discovery provider keeps its selected default model; a +static provider can replace a default that no longer exists in its refreshed catalog. + A proxy that is already running picks up the new credential without a restart: the CLI asks it to reload that one provider from disk, and the request carries no credential of its own. If the running proxy cannot accept that request — most often because it started from a build that predates diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 3a2ea7922e..8fd29f5a6b 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1239,6 +1239,37 @@ function isLegacyAntigravityStaticCatalog(provider: OcxProviderConfig): boolean ]); } +/** Refresh registry-owned catalog fields while preserving valid operator selections. */ +function applyOAuthPresetCatalog( + provider: OcxProviderConfig, + preset: OcxProviderConfig, +): void { + for (const field of OAUTH_RECONCILE_FIELDS) { + if (JSON.stringify(provider[field]) === JSON.stringify(preset[field])) continue; + if (preset[field] !== undefined) { + provider[field] = cloneProviderField(preset[field]) as never; + } else { + delete provider[field]; + } + } + if (provider.liveModels === undefined && preset.liveModels !== undefined) { + provider.liveModels = preset.liveModels; + } + // Heal only a selection that the refreshed static catalog no longer contains. Providers + // with live discovery do not expose an enumerable account catalog here, so their saved + // default remains operator-owned. + if ( + provider.liveModels !== true && + provider.defaultModel + && preset.defaultModel + && preset.models + && preset.models.length > 0 + && !(provider.models ?? []).includes(provider.defaultModel) + ) { + provider.defaultModel = preset.defaultModel; + } +} + /** Promote only the versioned canonical static seed; unmarked `liveModels: false` remains user intent. */ function migrateLegacyAntigravityStaticCatalog(config: OcxConfig): boolean { if (config.googleAntigravityStaticCatalogVersion !== 1) return false; @@ -1277,24 +1308,7 @@ function projectOAuthProviderReconciliation(config: OcxConfig): OAuthReconcilePr } if (def && prov.authMode === "oauth") { const preset = def.providerConfig; - for (const field of OAUTH_RECONCILE_FIELDS) { - if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue; - if (preset[field] !== undefined) { - prov[field] = cloneProviderField(preset[field]) as never; - } else { - delete prov[field]; - } - } - if (prov.liveModels === undefined && preset.liveModels !== undefined) { - prov.liveModels = preset.liveModels; - } - // Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot). - // Skip providers without a static preset `models` list: for live-discovery providers - // (e.g. command-code OAuth) the account-scoped catalog is not enumerable here, so any - // persisted defaultModel is a user selection and must not be overwritten by the seed. - if (prov.defaultModel && preset.defaultModel && preset.models && preset.models.length > 0 && !(prov.models ?? []).includes(prov.defaultModel)) { - prov.defaultModel = preset.defaultModel; - } + applyOAuthPresetCatalog(prov, preset); } if (JSON.stringify(prov) !== beforeProvider) { changed = true; @@ -1408,24 +1422,18 @@ function preservableApiKeyPool(value: unknown): NonNullable 0 ? pool : undefined; } -/** - * Add/refresh an OAuth provider's config entry on a config object (does not persist). - * - * Providers whose registry entry sets `allowKeyAuthOverride` (xai, github-copilot) can be - * billed through a stored API key instead of the OAuth login (router.ts honors - * `authMode: "key"` for them). A blind preset overwrite here deletes `apiKey`/`apiKeyPool` - * on every OAuth login, silently destroying the stored key and forcing a re-paste — and it - * flips billing back to the subscription without the user asking. Carry the key fields over - * and keep key billing while usable key material remains and the user was not explicitly on - * oauth. If the final key was removed and only the old key mode remains, let the OAuth - * preset restore `authMode: "oauth"` so the newly saved OAuth credential can be used. - * - * After preservation, `apiKey` always has exactly one matching pool entry (inserting via the - * same content-derived id as the API-key manager when the active key was missing from the - * pool). Key mode reflects stored user intent (explicit `"key"` or omitted mode with safe - * key material) — never whether the login CLI process can resolve an env reference. Env-backed - * availability is decided at proxy routing time in `router.ts`. - */ +const OAUTH_LOGIN_OWNED_PROVIDER_FIELDS = [ + "adapter", + "baseUrl", + "authMode", + "headers", + "apiKeyTransport", + "responsesPath", + "googleMode", + "keyOptional", +] as const satisfies readonly (keyof OcxProviderConfig)[]; + +/** Add/refresh only an OAuth provider's login-owned config fields (does not persist). */ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (provider === "chatgpt") return; const def = OAUTH_PROVIDERS[provider]; @@ -1434,40 +1442,28 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); if (namespaceCollision) throw new Error(namespaceCollision); const existing = config.providers[provider]; - const next: OcxProviderConfig = { ...def.providerConfig }; - // `liveModels` is a user-facing provider toggle. Preserve either explicit setting across login; - // Antigravity's CCA discovery now uses its real RPC, so legacy `true` remains a valid choice. - if (typeof existing?.liveModels === "boolean" && !isLegacyCommandCodeStaticCatalog(existing)) { - next.liveModels = existing.liveModels; - } - // The Command Code protocol-version pin is an operator compatibility control. A re-login, - // add-account, or reauth rebuilds the row from the preset, which has no version; carry the - // existing pin so authentication changes do not silently revert the documented control. - if (existing?.commandCodeVersion !== undefined) { - next.commandCodeVersion = existing.commandCodeVersion; - } - // Reauth/add-account refreshes credentials, not the operator's post-upgrade wire choice. - if (provider === "xai") { - if (existing?.modelAdapters !== undefined) next.modelAdapters = { ...existing.modelAdapters }; - if (existing?.xaiResponsesDefaultVersion !== undefined) { - next.xaiResponsesDefaultVersion = existing.xaiResponsesDefaultVersion; - } - } - // User-configured price overlays are operator data, not preset state; a - // re-login, add-account, or reauth must not silently drop them from the - // Logs/Usage estimates. - if (existing?.modelCosts !== undefined) { - next.modelCosts = existing.modelCosts; + // Clone operator state, including xAI wire choices and their migration version. + const next: OcxProviderConfig = structuredClone(existing ?? def.providerConfig); + for (const field of OAUTH_LOGIN_OWNED_PROVIDER_FIELDS) { + const value = def.providerConfig[field]; + if (value === undefined) delete next[field]; + else next[field] = structuredClone(value) as never; } - // The per-provider account-failover opt-out is operator intent about SPENDING, and the login - // path is exactly where losing it does damage: adding a second account both rebuilds this row - // from the preset and creates the 2-account quorum that turns presence-driven rotation on - // (#2568d). Dropping the opt-out here would enable the thing the operator switched off, at the - // moment they were doing something unrelated. - if (existing?.oauthAccountFailover !== undefined) { - next.oauthAccountFailover = existing.oauthAccountFailover; + // Login used to rebuild the whole row from the preset, so catalog data refreshed + // immediately. Keep that timing without overwriting unrelated operator-owned fields. + applyOAuthPresetCatalog(next, def.providerConfig); + // The original Command Code seed was an implementation-owned static catalog, not an + // operator opt-out. Promote that exact legacy shape when OAuth login refreshes the row. + if (provider === "command-code" && existing && isLegacyCommandCodeStaticCatalog(existing)) { + next.liveModels = def.providerConfig.liveModels; } + // OAuth-only providers must never retain credentials for a different auth mechanism. + delete next.apiKey; + delete next.apiKeyPool; + delete (next as unknown as Record).azureCredential; if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) { + // Retain stored key billing intent without resolving env references in the login process. + // An explicit OAuth choice stays OAuth even when usable key material is retained. // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes. let storedApiKey = sanitizeApiKeyValue(existing.apiKey); const storedApiKeyPool = preservableApiKeyPool(existing.apiKeyPool); diff --git a/tests/oauth/oauth-provider-reconcile.test.ts b/tests/oauth/oauth-provider-reconcile.test.ts index 132dea3c7a..fe624a54f7 100644 --- a/tests/oauth/oauth-provider-reconcile.test.ts +++ b/tests/oauth/oauth-provider-reconcile.test.ts @@ -229,7 +229,7 @@ describe("OAuth provider reconciliation", () => { // holds; only the rows the projection actually changed may be replaced. expect(config.providers.untouched).toBe(liveUntouched); }); - test("refreshes a saved Antigravity 3.5 preset without touching credentials or user fields", async () => { + test("refreshes a saved Antigravity live catalog without touching credentials or user fields", async () => { const home = mkdtempSync(join(tmpdir(), "ocx-gemini-36-reconcile-")); homes.push(home); process.env.OPENCODEX_HOME = home; @@ -261,7 +261,7 @@ describe("OAuth provider reconciliation", () => { expect(reconcileOAuthProviders(config)).toBe(true); const provider = config.providers["google-antigravity"]; - expect(provider.defaultModel).toBe("gemini-3.8-flash"); + expect(provider.defaultModel).toBe("gemini-3.5-flash-low"); expect(provider.models).toEqual([ "gemini-3.8-flash", "gemini-3.7-flash", @@ -288,7 +288,7 @@ describe("OAuth provider reconciliation", () => { }); const persisted = loadConfig(); - expect(persisted.providers["google-antigravity"]?.defaultModel).toBe("gemini-3.8-flash"); + expect(persisted.providers["google-antigravity"]?.defaultModel).toBe("gemini-3.5-flash-low"); expect(persisted.providers["google-antigravity"]?.liveModels).toBe(true); expect(reconcileOAuthProviders(config)).toBe(false); }); @@ -331,10 +331,9 @@ describe("OAuth provider reconciliation", () => { }); test("an explicit 3.7 default survives the 3.8 launch while its capabilities refresh", () => { - // The 3.5 case above starts from a RETIRED id, so it only exercises the stale-default - // healing branch. This one is the opposite claim, and the one that matters for an - // additive rollout: a user who deliberately chose 3.7 must still be on 3.7 afterwards. - // Google still serves it, so healing it onto 3.8 would be silently overriding a choice. + // The earlier live-discovery case preserves an id outside the static seed. This case + // preserves a still-listed choice during an additive catalog rollout, while refreshing + // its capability records. const home = mkdtempSync(join(tmpdir(), "ocx-antigravity-explicit-default-")); homes.push(home); process.env.OPENCODEX_HOME = home; @@ -367,6 +366,53 @@ describe("OAuth provider reconciliation", () => { expect(provider.modelReasoningEfforts?.["gemini-3.8-flash"]).toEqual(["low", "medium", "high"]); }); + test("does not validate a live-models default against the static preset", () => { + const config = { + port: 10100, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + ...structuredClone(OAUTH_PROVIDERS["google-antigravity"].providerConfig), + authMode: "oauth", + liveModels: true, + models: ["account-specific-model"], + defaultModel: "account-specific-model", + }, + }, + } satisfies OcxConfig; + + expect(reconcileOAuthProviders(config, false)).toBe(true); + expect(config.providers["google-antigravity"].defaultModel).toBe("account-specific-model"); + + upsertOAuthProvider(config, "google-antigravity"); + expect(config.providers["google-antigravity"].defaultModel).toBe("account-specific-model"); + }); + + test.each(["reconcile", "upsert"] as const)("%s heals an obsolete static default without enabling live discovery", operation => { + const preset = OAUTH_PROVIDERS["google-antigravity"].providerConfig; + const config: OcxConfig = { + port: 10100, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + ...structuredClone(preset), + liveModels: false, + defaultModel: "retired-static-model", + models: ["retired-static-model"], + }, + }, + }; + + if (operation === "reconcile") expect(reconcileOAuthProviders(config, false)).toBe(true); + else upsertOAuthProvider(config, "google-antigravity"); + + const provider = config.providers["google-antigravity"]!; + expect(provider.liveModels).toBe(false); + expect(provider.defaultModel).toBe(preset.defaultModel); + expect(provider.models).toEqual(preset.models); + expect(reconcileOAuthProviders(config, false)).toBe(false); + }); + test("preserves an explicit Antigravity static opt-out without the legacy migration marker", () => { const config = { port: 10100, diff --git a/tests/oauth/oauth-upsert-preserves-api-key.test.ts b/tests/oauth/oauth-upsert-preserves-api-key.test.ts index e8cc0fced9..fa5801f194 100644 --- a/tests/oauth/oauth-upsert-preserves-api-key.test.ts +++ b/tests/oauth/oauth-upsert-preserves-api-key.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { upsertOAuthProvider } from "../../src/oauth"; +import { OAUTH_PROVIDERS, upsertOAuthProvider } from "../../src/oauth"; import { migrateXaiResponsesDefault } from "../../src/providers/xai-responses-opt-in"; import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; import { @@ -346,7 +346,7 @@ describe("upsertOAuthProvider credential preservation", () => { } }); - test("still applies the plain preset for oauth-only providers", () => { + test("applies OAuth credentials while preserving notes for oauth-only providers", () => { const config = { port: 10100, defaultProvider: "anthropic", @@ -366,15 +366,182 @@ describe("upsertOAuthProvider credential preservation", () => { expect(provider.authMode).toBe("oauth"); expect(provider.apiKey).toBeUndefined(); expect(provider.apiKeyPool).toBeUndefined(); - expect(provider.note).toBeUndefined(); + expect(provider.note).toBe("stale-note"); + }); + + test("replaces stale login transport fields instead of retaining an alternate credential destination", () => { + const config = configWithKey("anthropic", "openai-chat", "https://stale.example.invalid"); + const existing = config.providers.anthropic!; + existing.headers = { Authorization: "Bearer stale-header-sentinel" }; + existing.apiKeyTransport = "x-api-key"; + existing.responsesPath = "/stale-responses"; + existing.googleMode = "vertex"; + existing.keyOptional = true; + const before = structuredClone(existing); + const preset = OAUTH_PROVIDERS.anthropic!.providerConfig; + + upsertOAuthProvider(config, "anthropic"); + + const provider = config.providers.anthropic!; + expect(provider.adapter).toBe("anthropic"); + expect(provider.baseUrl).toBe("https://api.anthropic.com"); + expect(provider.authMode).toBe("oauth"); + expect(provider.headers).toEqual(preset.headers); + expect(provider.apiKeyTransport).toBe(preset.apiKeyTransport); + expect(provider.responsesPath).toBeUndefined(); + expect(provider.googleMode).toBeUndefined(); + expect(provider.keyOptional).toBeUndefined(); + expect(provider.apiKey).toBeUndefined(); + expect(provider.apiKeyPool).toBeUndefined(); + expect(existing).toEqual(before); + }); + + test("isolates nested operator and unchanged catalog data from the previous row and registry", () => { + const preset = OAUTH_PROVIDERS.anthropic!.providerConfig; + const presetBefore = structuredClone(preset); + const existing = { + ...structuredClone(preset), + modelCosts: { "operator-model": { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }, + forwardCompatibleFlag: { labels: ["keep"] }, + }; + const before = structuredClone(existing); + const config: OcxConfig = { port: 10100, defaultProvider: "anthropic", providers: { anthropic: existing } }; + + upsertOAuthProvider(config, "anthropic"); + + const provider = config.providers.anthropic! as typeof existing; + expect(provider.models).not.toBe(preset.models); + expect(provider.models).not.toBe(existing.models); + provider.modelCosts["operator-model"]!.input = 99; + provider.forwardCompatibleFlag.labels.push("changed"); + provider.models!.push("test-only-model"); + expect(existing).toEqual(before); + expect(preset).toEqual(presetBefore); + }); + + test("refreshes registry-owned catalog fields immediately without losing operator fields", () => { + const config = { + port: 10100, + defaultProvider: "anthropic", + providers: { + anthropic: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "oauth", + models: ["retired-model"], + defaultModel: "retired-model", + contextWindow: 1, + disabled: true, + note: "operator-note", + }, + }, + } as unknown as OcxConfig; + + upsertOAuthProvider(config, "anthropic"); + + const provider = config.providers.anthropic!; + const preset = OAUTH_PROVIDERS.anthropic!.providerConfig; + expect(provider.models).toEqual(preset.models); + expect(provider.contextWindow).toBe(preset.contextWindow); + expect(provider.defaultModel).toBe(preset.defaultModel); + expect(provider.disabled).toBe(true); + expect(provider.note).toBe("operator-note"); + }); + + test.each(["login", "add-account", "reauthentication"])( + "preserves operator policy and unknown fields during %s-shaped upsert", + operation => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + const existing = config.providers.xai! as OcxConfig["providers"][string] & Record; + existing.disabled = true; + existing.requestPacing = { enabled: true, minIntervalMs: 250 }; + existing.retryOn429 = { attempts: 4, intervalMs: 900 }; + existing.refreshPolicy = "lazy-only"; + existing.selectedModels = ["grok-4"]; + existing.note = `operator-${operation}`; + existing.modelCosts = { "grok-4": { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }; + existing.oauthAccountFailover = { enabled: false }; + existing.forwardCompatibleFlag = { enabled: true }; + + upsertOAuthProvider(config, "xai"); + + const provider = config.providers.xai! as typeof existing; + expect(provider.disabled).toBe(true); + expect(provider.requestPacing).toEqual({ enabled: true, minIntervalMs: 250 }); + expect(provider.retryOn429).toEqual({ attempts: 4, intervalMs: 900 }); + expect(provider.refreshPolicy).toBe("lazy-only"); + expect(provider.selectedModels).toEqual(["grok-4"]); + expect(provider.note).toBe(`operator-${operation}`); + expect(provider.modelCosts).toEqual({ "grok-4": { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }); + expect(provider.oauthAccountFailover).toEqual({ enabled: false }); + expect(provider.forwardCompatibleFlag).toEqual({ enabled: true }); + expect(provider.apiKey).toBe("stored-key-sentinel"); + }, + ); + + test("removes incompatible API-key and Azure credentials while preserving unknown fields", () => { + const config = { + port: 10100, + defaultProvider: "anthropic", + providers: { + anthropic: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "key", + apiKey: "stale-key", + apiKeyPool: [{ id: "stale", key: "stale-key" }], + azureCredential: { token: "stale" }, + disabled: true, + forwardCompatibleFlag: "retain-me", + }, + }, + } as unknown as OcxConfig; + + upsertOAuthProvider(config, "anthropic"); + + const provider = config.providers.anthropic! as OcxConfig["providers"][string] & Record; + expect(provider.apiKey).toBeUndefined(); + expect(provider.apiKeyPool).toBeUndefined(); + expect(provider.azureCredential).toBeUndefined(); + expect(provider.disabled).toBe(true); + expect(provider.forwardCompatibleFlag).toBe("retain-me"); + expect(provider.authMode).toBe("oauth"); }); test("a fresh login on an unconfigured provider gets the untouched preset", () => { const config = { port: 10100, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; + const preset = OAUTH_PROVIDERS.xai!.providerConfig; + const before = structuredClone(preset); upsertOAuthProvider(config, "xai"); const provider = config.providers.xai!; expect(provider.authMode).toBe("oauth"); expect(provider.apiKey).toBeUndefined(); expect(provider.apiKeyPool).toBeUndefined(); + expect(provider.models).not.toBe(preset.models); + provider.models!.push("test-only-model"); + expect(preset).toEqual(before); + }); + + test("promotes the legacy Command Code static catalog during OAuth upsert", () => { + const config = { + port: 10100, + defaultProvider: "command-code", + providers: { + "command-code": { + adapter: "command-code", + baseUrl: "https://api.commandcode.ai", + authMode: "oauth", + liveModels: false, + defaultModel: "deepseek-v4-flash", + models: ["deepseek-v4-flash", "kimi-k3", "glm-5.2"], + note: "operator-note", + }, + }, + } as unknown as OcxConfig; + + upsertOAuthProvider(config, "command-code"); + + expect(config.providers["command-code"]!.liveModels).toBe(true); + expect(config.providers["command-code"]!.note).toBe("operator-note"); }); }); From b0f0a94855196126ed30db868730d6b26927d2cc Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 01:47:46 +0900 Subject: [PATCH 018/236] docs: refresh native MESSAGE recovery layer --- devlog/_plan/260906_a_runtime_stack/031_recovery_refresh.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 devlog/_plan/260906_a_runtime_stack/031_recovery_refresh.md diff --git a/devlog/_plan/260906_a_runtime_stack/031_recovery_refresh.md b/devlog/_plan/260906_a_runtime_stack/031_recovery_refresh.md new file mode 100644 index 0000000000..9112a2c8d7 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/031_recovery_refresh.md @@ -0,0 +1,3 @@ +# Recovery layer P refresh + +Consume 030 above prepared WS parent10fbda2e0. Original #3568 remains open at036a9321788464fdf33a387c9f44a834a844bdc1; carry all three voiys commits in order. No catalog/effort hunks from #3571. Add the planned cache exact-expiry/no-TTL-extension and canonical-Go conversion negatives, with a scoped inherited worker owning only the named three regression files after original carry. Main owns production integration, author commits and review. Runtime correction: isolated checks now invoke repository node_modules/.bin/bun and assert package.json dependencies.bun=1.4.0 before any execution. Full per-head CI remains mandatory before landing. #3661 remains partial, with no automatic close reference. From 106e004f98e199869b94ba0c8c34f45e0776af12 Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 5 Sep 2026 03:17:03 +0200 Subject: [PATCH 019/236] fix(responses): recover native messages for Go subagents (cherry picked from commit e8f8726040dbc45b1e946d59db6b9c477459b8d7) --- .../docs/reference/configuration/providers.md | 25 +++ scripts/test-layout/layout.json | 2 + src/adapters/openai-responses.ts | 2 + src/adapters/opencode-go.ts | 35 +++++ .../responses/agent-task-recovery-cache.ts | 11 ++ src/server/responses/agent-task-recovery.ts | 28 +++- src/server/responses/core.ts | 11 +- tests/fixtures/test-layout-expected.json | 2 + .../opencode-go-agent-messages.test.ts | 56 +++++++ .../server-agent-task-recovery-replay.test.ts | 142 ++++++++++++++++++ 10 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 src/adapters/opencode-go.ts create mode 100644 tests/providers/opencode-go-agent-messages.test.ts create mode 100644 tests/server/server-agent-task-recovery-replay.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d6adbdac9c..7282a21bd3 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -810,3 +810,28 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 "visionSidecar": { "enabled": true } } ``` + +## OpenCode Go session and agent messages + +On Go Responses routes, plaintext Codex `agent_message` items become user messages. +Author and recipient remain explicit text metadata, and the content parts are preserved. +Encrypted and unknown content is not normalized; native encrypted tasks still require the +separate opt-in [task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery). + +With task recovery enabled, replayed `NEW_TASK` and `MESSAGE` items reuse a cached assignment only +after validating the caller and matching the parent-thread scope. Replay restoration +does not make a new recovery request or extend cache expiry. Expired or unseen +ciphertext is not replaced. Fresh encrypted `NEW_TASK` and `MESSAGE` items use the same +opt-in recovery path, including native-parent `send_message` delivery. Message type, +sender, recipient, parent scope and caller credentials remain part of validation or cache identity. + +When a request contains several agent messages, cached replay restoration checks each +message independently. The cache separates message type, sender, recipient and ciphertext +within the admitted caller/account and parent scope. Fresh recovery only handles the +current tail message (ignoring trailing `compaction_trigger` or `additional_tools` metadata). +It does not batch-recover unseen historical messages; those remain unchanged. A cache miss +or expiry does not extend the history-recovery contract. + +Sender and recipient on Go Responses are context for the receiving model, not a new +machine-readable routing protocol. Tool routing continues to use the existing collaboration +contracts. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 29dd2c5f1c..b740dcbf20 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -889,6 +889,7 @@ "openai-responses-passthrough.test.ts": "responses", "opencode-cli.test.ts": "providers", "opencode-free-provider.test.ts": "providers", + "opencode-go-agent-messages.test.ts": "providers", "opencode-go-deepseek.test.ts": "providers", "opencode-go-grok46-responses.test.ts": "providers", "opencode-go-luna-wire.test.ts": "providers", @@ -1064,6 +1065,7 @@ "selected-models.test.ts": "codex-integration", "self-launch-argv.test.ts": "lib", "server-403-permission-e2e.test.ts": "server", + "server-agent-task-recovery-replay.test.ts": "server", "server-auth.test.ts": "server", "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index a007e21a4a..c4bbae4a09 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,3 +1,4 @@ +import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go"; import { createHash } from "node:crypto"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types"; @@ -2355,6 +2356,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed._rawBody, forward || parsed._previousResponseInputExpanded === true, ); + if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody); outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId); // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the // tier write so a force-fast/default decision can never mutate parsed._rawBody. diff --git a/src/adapters/opencode-go.ts b/src/adapters/opencode-go.ts new file mode 100644 index 0000000000..94055a292a --- /dev/null +++ b/src/adapters/opencode-go.ts @@ -0,0 +1,35 @@ +/** Match the Go destination, including user-renamed provider entries. */ +export function isOpenCodeGo(baseUrl: string): boolean { + try { + const url = new URL(baseUrl); + return url.origin === "https://opencode.ai" && url.pathname.replace(/\/+$/, "") === "/zen/go/v1"; + } catch { return false; } +} + +/** Public Responses rejects Codex's private agent_message variant, even with plaintext content. */ +export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const record = body as Record; + if (!Array.isArray(record.input)) return body; + let changed = false; + const input = record.input.map((item: unknown) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + const message = item as Record; + if (message.type !== "agent_message" || !Array.isArray(message.content) || message.content.length === 0) return item; + // Genuine ciphertext and unknown part types must retain their existing fail-closed path. + if (!message.content.every(part => part && typeof part === "object" + && ["input_text", "input_image", "input_file"].includes(part.type))) return item; + const identities = Object.fromEntries(["author", "recipient"] + .filter(key => typeof message[key] === "string") + .map(key => [key, message[key]])); + changed = true; + return { + type: "message", role: "user", + content: [ + ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []), + ...message.content, + ], + }; + }); + return changed ? { ...record, input } : body; +} diff --git a/src/server/responses/agent-task-recovery-cache.ts b/src/server/responses/agent-task-recovery-cache.ts index 44e32c62bb..93d0c1778b 100644 --- a/src/server/responses/agent-task-recovery-cache.ts +++ b/src/server/responses/agent-task-recovery-cache.ts @@ -149,3 +149,14 @@ export function agentTaskRecoveryWaiterCountForTests(): number { export function agentTaskRecoveryCacheSnapshotForTests(): { entries: number; bytes: number } { return { entries: RECOVERY_CACHE.size, bytes: recoveryCacheBytes }; } + +/** Read an existing recovery without starting a request or extending its lifetime. */ +export function cachedAgentTaskRecovery(key: string): string | null { + const entry = RECOVERY_CACHE.get(key); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + deleteRecoveryCacheEntry(key, entry); + return null; + } + return entry.assignment; +} diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index e1c35932ff..89af59a1ef 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -5,6 +5,7 @@ import { readBoundedResponseBody } from "../../lib/bounded-body"; import { isApiAuthRequired, isProxyAdmissionSecret } from "../auth-cors"; import { structurallyValidFernetTokens } from "./encrypted-payload"; import { + cachedAgentTaskRecovery, discardCachedAgentTaskRecovery, resetAgentTaskRecoveryCache, resolveCachedAgentTaskRecovery, @@ -61,7 +62,7 @@ interface AgentEnvelope { itemIndex: number; encryptedIndex: number; headerText: string; - messageType: "NEW_TASK"; + messageType: "NEW_TASK" | "MESSAGE"; taskName: string; sender: string; ciphertext: string; @@ -69,7 +70,7 @@ interface AgentEnvelope { recipient: string; } -const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/; +const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/; function findEnvelope(input: unknown): AgentEnvelope | null { if (!Array.isArray(input)) return null; @@ -90,7 +91,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null { if (!Array.isArray(content)) return null; let headerText: string | null = null; - let messageType: "NEW_TASK" | null = null; + let messageType: "NEW_TASK" | "MESSAGE" | null = null; let taskName: string | null = null; let sender: string | null = null; let encryptedIndex = -1; @@ -113,7 +114,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null { || part.text.slice(match.index + match[0].length).trim().length > 0 ) return null; headerText = match[0].startsWith("\n") ? match[0].slice(1) : match[0]; - messageType = "NEW_TASK"; + messageType = match[1] as "NEW_TASK" | "MESSAGE"; taskName = match[2]!; sender = match[3]!; } @@ -496,3 +497,22 @@ export function discardEncryptedAgentTaskRecovery( export function resetAgentTaskRecoveryState(): void { resetAgentTaskRecoveryCache(); } + +/** Codex replays the original encrypted agent messages after tool calls. Reuse only an admitted cache hit. */ +export function restoreCachedEncryptedAgentTasks( + req: Request, input: unknown, config: OcxConfig, + context: { parentThreadId?: string | null } = {}, +): number { + if (!Array.isArray(input)) return 0; + let restored = 0; + for (const item of input) { + if (!item || typeof item !== "object" || item.type !== "agent_message") continue; + const single = [item]; + // Revalidates caller credentials and the exact supported agent envelope before cache access. + const admitted = admittedRecovery(req, single, config, context.parentThreadId); + if (!admitted) continue; + const assignment = cachedAgentTaskRecovery(admitted.cacheKey); + if (assignment && injectAssignment(single, admitted.envelope, assignment)) restored += 1; + } + return restored; +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 9e087bcc90..aa813e9c51 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -318,6 +318,7 @@ import { agentTaskRecoveryConfig, discardEncryptedAgentTaskRecovery, recoverEncryptedAgentTask, + restoreCachedEncryptedAgentTasks, } from "./agent-task-recovery"; import { relaySseEagerBounded } from "../relay-eager"; import { @@ -3234,14 +3235,18 @@ async function handleResponsesInner( inboundWire === "responses" && threadSpawn - && unreadableEncryptedAgentTask && agentTaskRecovery && !isCanonicalOpenAiForwardProvider(route.provider) && !options.comboAttempt && !canPassThroughEncryptedV2AgentTask(route, inboundWire) ) { - let recovered = false; - try { + let recovered = restoreCachedEncryptedAgentTasks( + req, (body as { input?: unknown } | undefined)?.input, config, { parentThreadId }, + ) > 0; + unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask( + (body as { input?: unknown } | undefined)?.input, + ); + if (unreadableEncryptedAgentTask) try { recovered = await recoverEncryptedAgentTask( req, (body as { input?: unknown } | undefined)?.input, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 114c699eaf..0b79ffe21d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -726,6 +726,7 @@ "openai-responses-passthrough.test.ts": "responses", "opencode-cli.test.ts": "providers", "opencode-free-provider.test.ts": "providers", + "opencode-go-agent-messages.test.ts": "providers", "opencode-go-deepseek.test.ts": "providers", "opencode-go-grok46-responses.test.ts": "providers", "opencode-go-luna-wire.test.ts": "providers", @@ -901,6 +902,7 @@ "selected-models.test.ts": "codex-integration", "self-launch-argv.test.ts": "lib", "server-403-permission-e2e.test.ts": "server", + "server-agent-task-recovery-replay.test.ts": "server", "server-auth.test.ts": "server", "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", diff --git a/tests/providers/opencode-go-agent-messages.test.ts b/tests/providers/opencode-go-agent-messages.test.ts new file mode 100644 index 0000000000..e12860d0e0 --- /dev/null +++ b/tests/providers/opencode-go-agent-messages.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { normalizeOpenCodeGoAgentMessages } from "../../src/adapters/opencode-go"; +import { parseRequest } from "../../src/responses/parser"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import type { OcxProviderConfig } from "../../src/types"; + +const base: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "synthetic-key" }; +const body = () => ({ model: "muse-spark-1.3-contributor", input: [{ type: "agent_message", id: "amsg_test", author: "/root/reader", recipient: "/root/checker", content: [{ type: "input_text", text: "Exact assignment\nwith lines." }] }], stream: true }); + +test("Responses converts plaintext task and peer messages without mutating replay or losing routing identities", async () => { + const raw = body(); const original = structuredClone(raw); const budget = createTranslatorBudget(); + const request = await createResponsesPassthroughAdapter(base).buildRequest(parseRequest(raw), { headers: new Headers(), translatorBudget: budget }); + const sent = JSON.parse(request.body as string); + expect(sent.input[0].type).toBe("message"); + expect(sent.input[0].role).toBe("user"); + expect(sent.input[0].content[0].text).toContain('"author":"/root/reader"'); + expect(sent.input[0].content[0].text).toContain('"recipient":"/root/checker"'); + expect(sent.input[0].content[1]).toEqual(raw.input[0]!.content[0]); + expect(sent.input[0].id).toBeUndefined(); + expect(raw).toEqual(original); + budget.dispose(); +}); + +test("ciphertext and unknown content are never reclassified as plaintext", () => { + for (const part of [{ type: "encrypted_content", encrypted_content: "opaque" }, { type: "future_type", text: "opaque" }]) { + const raw = { input: [{ type: "agent_message", content: [part] }] }; + expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw); + } +}); + +test("image parts stay intact beside the assignment", () => { + const image = { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }; + const raw = { input: [{ type: "agent_message", content: [{ type: "input_text", text: "Inspect image" }, image] }] }; + const result = normalizeOpenCodeGoAgentMessages(raw) as typeof raw; + expect(result.input[0]!.content[1]).toBe(image); +}); + +test("native forward keeps agent_message and auth/session headers unchanged", async () => { + const budget = createTranslatorBudget(); + const provider = { ...base, baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }; + const request = await createResponsesPassthroughAdapter(provider).buildRequest(parseRequest(body()), { headers: new Headers({ "session-id": "native-id", authorization: "Bearer native-test" }), translatorBudget: budget }); + expect(JSON.parse(request.body as string).input[0].type).toBe("agent_message"); + expect(new Headers(request.headers).get("x-opencode-session")).toBeNull(); + expect(new Headers(request.headers).get("session-id")).toBe("native-id"); + expect(new Headers(request.headers).get("authorization")).toBe("Bearer native-test"); + budget.dispose(); +}); + +test("other destinations do not get Go normalization or session identity", async () => { + const budget = createTranslatorBudget(); + const request = await createResponsesPassthroughAdapter({ ...base, baseUrl: "https://example.test/v1" }).buildRequest(parseRequest(body()), { headers: new Headers({ "session-id": "child-id" }), translatorBudget: budget }); + expect(JSON.parse(request.body as string).input[0].type).toBe("agent_message"); + expect(new Headers(request.headers).get("x-opencode-session")).toBeNull(); + budget.dispose(); +}); diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts new file mode 100644 index 0000000000..cfb735af01 --- /dev/null +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -0,0 +1,142 @@ +import { afterEach, expect, test } from "bun:test"; +import { recoverEncryptedAgentTask, resetAgentTaskRecoveryState, restoreCachedEncryptedAgentTasks } from "../../src/server/responses/agent-task-recovery"; +import { codexHeaders, encryptedInput, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; +afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); + +test("replay reuses admitted recovery after a tool result without another network call", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Read nonce.txt exactly.")); }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig({ enabled: true }); + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config, { parentThreadId: "parent" })).toBe(true); + const replay = [...encryptedInput(), { type: "function_call_output", call_id: "tool", output: "result" }]; + expect(restoreCachedEncryptedAgentTasks(req, replay, config, { parentThreadId: "parent" })).toBe(1); + expect(JSON.stringify(replay)).toContain("Read nonce.txt exactly."); + expect(JSON.stringify(replay)).not.toContain(FERNET_TASK); + expect(calls).toBe(1); +}); + +test("replay does not recover unseen envelopes, other parents, or other callers", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Private assignment.")); }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config, { parentThreadId: "parent" })).toBe(0); + expect(calls).toBe(0); + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config, { parentThreadId: "parent" })).toBe(true); + for (const [request, parent] of [[req, "another-parent"], [new Request("http://localhost/v1/responses", { headers: codexHeaders("another-account") }), "parent"], [new Request("http://localhost/v1/responses"), "parent"]] as const) { + const input = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(request, input, config, { parentThreadId: parent })).toBe(0); + expect(JSON.stringify(input)).toContain(FERNET_TASK); + } + expect(calls).toBe(1); +}); + +test("Responses handler restores a cached task in a continued child turn", async () => { + const { post, providerResponse } = await import("../helpers/agent-task-recovery"); + let recoveries = 0; + const bodies: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + if (String(url).includes("chatgpt.com")) { + recoveries++; + return new Response(recoverySse("Read nonce.txt exactly.")); + } + bodies.push(String(init?.body)); + return providerResponse(); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + expect((await post(config, "xai/grok-4.5", encryptedInput(), codexHeaders())).status).toBe(200); + expect((await post(config, "xai/grok-4.5", [...encryptedInput(), { type: "message", role: "user", content: "Continue the original task." }], codexHeaders())).status).toBe(200); + expect(recoveries).toBe(1); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain("Read nonce.txt exactly."); + expect(bodies[1]).not.toContain(FERNET_TASK); +}); + +function encryptedMessage(): unknown[] { + return JSON.parse(JSON.stringify(encryptedInput()).replace("Message Type: NEW_TASK", "Message Type: MESSAGE")); +} + +test("MESSAGE recovery reaches the provider and survives tool-result replay", async () => { + const { post, providerResponse } = await import("../helpers/agent-task-recovery"); + let recoveries = 0; + const bodies: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + if (String(url).includes("chatgpt.com")) { + expect(String(init?.body)).toContain("Message Type: MESSAGE"); + recoveries++; + return new Response(recoverySse("Stop waiting and report your result.")); + } + bodies.push(String(init?.body)); + return providerResponse(); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + expect((await post(config, "xai/grok-4.5", encryptedMessage(), codexHeaders())).status).toBe(200); + expect((await post(config, "xai/grok-4.5", [...encryptedMessage(), { + type: "message", role: "user", content: "Continue after the tool result.", + }], codexHeaders())).status).toBe(200); + expect(recoveries).toBe(1); + expect(bodies).toHaveLength(2); + for (const body of bodies) { + expect(body).toContain("Stop waiting and report your result."); + expect(body).not.toContain(FERNET_TASK); + } +}); + +test("MESSAGE cache remains isolated by message type, account, parent and sender", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Private message.")); }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + expect(await recoverEncryptedAgentTask(req, encryptedMessage(), {}, config, { parentThreadId: "parent" })).toBe(true); + expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config, { parentThreadId: "parent" })).toBe(0); + for (const [request, parent] of [[req, "other-parent"], [new Request("http://localhost/v1/responses", { headers: codexHeaders("other-account") }), "parent"]] as const) { + expect(restoreCachedEncryptedAgentTasks(request, encryptedMessage(), config, { parentThreadId: parent })).toBe(0); + } + const malformed = JSON.parse(JSON.stringify(encryptedMessage())); + malformed[0].author = "/root/wrong-sender"; + expect(await recoverEncryptedAgentTask(req, malformed, {}, config)).toBe(false); + const unknown = JSON.parse(JSON.stringify(encryptedMessage()).replace("Message Type: MESSAGE", "Message Type: UNKNOWN")); + expect(await recoverEncryptedAgentTask(req, unknown, {}, config)).toBe(false); + expect(calls).toBe(1); +}); + + +test("mixed history restores cached NEW_TASK and MESSAGE separately before recovering only the new tail", async () => { + let calls = 0; + const payloads = ["Initial assignment.", "First message.", "Second message."]; + globalThis.fetch = (async () => new Response(recoverySse(payloads[calls++]!))) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig({ enabled: true }); + const scope = { parentThreadId: "parent" }; + const nextMessage = () => JSON.parse(JSON.stringify(encryptedMessage()).replace(FERNET_TASK, SECOND_FERNET_TASK)); + + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config, scope)).toBe(true); + expect(await recoverEncryptedAgentTask(req, encryptedMessage(), {}, config, scope)).toBe(true); + const input = [...encryptedInput(), ...encryptedMessage(), ...nextMessage()]; + expect(restoreCachedEncryptedAgentTasks(req, input, config, scope)).toBe(2); + expect(calls).toBe(2); + expect(await recoverEncryptedAgentTask(req, input, {}, config, scope)).toBe(true); + expect(calls).toBe(3); + for (const payload of payloads) expect(JSON.stringify(input)).toContain(payload); + expect(JSON.stringify(input)).not.toContain(SECOND_FERNET_TASK); + + const replay = [...encryptedInput(), ...encryptedMessage(), ...nextMessage(), { + type: "function_call_output", call_id: "tool", output: "done", + }]; + expect(restoreCachedEncryptedAgentTasks(req, replay, config, scope)).toBe(3); + expect(calls).toBe(3); +}); + +test("fresh recovery only handles the current tail, leaving uncached history unchanged", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Current message.")); }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig({ enabled: true }); + const historical = encryptedInput(); + const input = [...historical, ...encryptedMessage()]; + expect(await recoverEncryptedAgentTask(req, input, {}, config)).toBe(true); + expect(input[0]).toEqual(encryptedInput()[0]); + expect(JSON.stringify(input[1])).toContain("Current message."); + expect(calls).toBe(1); +}); From c279e3166af5421e7323a06ca1de0947d7dee7f2 Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 5 Sep 2026 04:09:31 +0200 Subject: [PATCH 020/236] docs: clarify Go Responses agent-message destination (cherry picked from commit 4464892336c75b8861ee4caeddfd97d6c4e0e6ab) --- docs-site/src/content/docs/reference/adapters.md | 6 ++++++ .../src/content/docs/reference/configuration/providers.md | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index e2a24c67df..36b5b70b0a 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -105,6 +105,12 @@ collision-safe public function tool. Matching request history and JSON/SSE funct translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward keeps the native private type unchanged. +For OpenCode Go at `https://opencode.ai/zen/go/v1`, plaintext Codex `agent_message` +items become public user messages, preserving content parts and readable author/recipient +metadata. This conversion leaves encrypted or unknown content unchanged and does not apply +to other destinations. See [Go agent messages](/reference/configuration/providers/#opencode-go-session-and-agent-messages) +for the separate opt-in encrypted-task recovery behavior. + The canonical ChatGPT Codex forward destination also normalizes two public Responses shapes that its stricter backend rejects: fully textual `system` messages inside `input` are appended to the top-level `instructions` string in request order, and the top-level `truncation` field is removed. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 7282a21bd3..8e551f05c9 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -813,7 +813,10 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 ## OpenCode Go session and agent messages -On Go Responses routes, plaintext Codex `agent_message` items become user messages. +With the [`openai-responses` adapter](/reference/adapters/#openai-responses) and +base URL `https://opencode.ai/zen/go/v1`, plaintext Codex `agent_message` items +become user messages. This conversion is scoped to that destination, including +renamed provider entries; other Responses destinations keep their input unchanged. Author and recipient remain explicit text metadata, and the content parts are preserved. Encrypted and unknown content is not normalized; native encrypted tasks still require the separate opt-in [task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery). From fc031d4e1b1ee92891489d8b9999cb5b18c359eb Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 5 Sep 2026 11:48:03 +0200 Subject: [PATCH 021/236] docs: describe Go agent-message forward-auth exception (cherry picked from commit 036a9321788464fdf33a387c9f44a834a844bdc1) --- docs-site/src/content/docs/reference/adapters.md | 7 ++++--- .../src/content/docs/reference/configuration/providers.md | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 36b5b70b0a..2848fed564 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -105,10 +105,11 @@ collision-safe public function tool. Matching request history and JSON/SSE funct translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward keeps the native private type unchanged. -For OpenCode Go at `https://opencode.ai/zen/go/v1`, plaintext Codex `agent_message` -items become public user messages, preserving content parts and readable author/recipient +For OpenCode Go at `https://opencode.ai/zen/go/v1`, requests with `authMode` other +than `"forward"` convert plaintext Codex `agent_message` items into public user messages, preserving content parts and readable author/recipient metadata. This conversion leaves encrypted or unknown content unchanged and does not apply -to other destinations. See [Go agent messages](/reference/configuration/providers/#opencode-go-session-and-agent-messages) +to other destinations. Providers using `authMode: "forward"` retain these items unchanged. +See [Go agent messages](/reference/configuration/providers/#opencode-go-session-and-agent-messages) for the separate opt-in encrypted-task recovery behavior. The canonical ChatGPT Codex forward destination also normalizes two public Responses shapes that diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 8e551f05c9..51e71fa1f4 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -815,7 +815,8 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 With the [`openai-responses` adapter](/reference/adapters/#openai-responses) and base URL `https://opencode.ai/zen/go/v1`, plaintext Codex `agent_message` items -become user messages. This conversion is scoped to that destination, including +become user messages when `authMode` is not `"forward"` (for example, `"key"`). +Providers using `authMode: "forward"` retain these items unchanged. This conversion is scoped to that destination, including renamed provider entries; other Responses destinations keep their input unchanged. Author and recipient remain explicit text metadata, and the content parts are preserved. Encrypted and unknown content is not normalized; native encrypted tasks still require the From 7ca5890ba27b84f238445f0dfa4f4f8cba7f6be7 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:52:00 +0900 Subject: [PATCH 022/236] fix(oauth): clear account-bound CCA project during login --- src/oauth/index.ts | 3 + .../oauth/oauth-login-cli-live-update.test.ts | 87 ++++++++++++++++++- .../oauth-upsert-preserves-api-key.test.ts | 25 ++++++ 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 8fd29f5a6b..408a661494 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1449,6 +1449,9 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (value === undefined) delete next[field]; else next[field] = structuredClone(value) as never; } + // A login may activate a different account. CCA dispatch must take that account's + // project from its credential snapshot, never retain the previous account's project. + if (next.googleMode === "cloud-code-assist") delete next.project; // Login used to rebuild the whole row from the preset, so catalog data refreshed // immediately. Keep that timing without overwriting unrelated operator-owned fields. applyOAuthPresetCatalog(next, def.providerConfig); diff --git a/tests/oauth/oauth-login-cli-live-update.test.ts b/tests/oauth/oauth-login-cli-live-update.test.ts index f205ffdef6..6106ac6e5e 100644 --- a/tests/oauth/oauth-login-cli-live-update.test.ts +++ b/tests/oauth/oauth-login-cli-live-update.test.ts @@ -4,7 +4,9 @@ import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig, saveConfig, writePid, writeRuntimePort } from "../../src/config"; -import { upsertOAuthProvider } from "../../src/oauth"; +import { OAUTH_PROVIDERS, runLogin, upsertOAuthProvider } from "../../src/oauth"; +import { getAccountSet, saveCredential } from "../../src/oauth/store"; +import { clearGenericFailoverHealth, preferredInitialAccount } from "../../src/oauth/generic-account-failover"; import { commitKeyLoginProvider, notifyRunningProxy, @@ -59,6 +61,89 @@ afterEach(() => { }); describe("CLI OAuth live-update credential preservation", () => { + test("Antigravity login emits the new active account's bearer and project without proactive preference", async () => { + const providerName = "google-antigravity"; + const cfg: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: providerName, + oauthAccountFailover: { enabled: false }, + providers: { + [providerName]: { + ...structuredClone(OAUTH_PROVIDERS[providerName]!.providerConfig), + project: "project-a", + liveModels: false, + oauthAccountFailover: { enabled: false }, + }, + }, + }; + saveConfig(cfg); + await saveCredential(providerName, { + access: "access-a", refresh: "refresh-a", expires: Date.now() + 3_600_000, + accountId: "account-a", projectId: "project-a", + }); + const accountA = getAccountSet(providerName)!.activeAccountId; + const originalLogin = OAUTH_PROVIDERS[providerName]!.login; + const originalFetch = globalThis.fetch; + const emitted: Array<{ bearer: string | null; project: unknown }> = []; + let server: ReturnType | undefined; + try { + OAUTH_PROVIDERS[providerName]!.login = async () => ({ + access: "access-b", refresh: "refresh-b", expires: Date.now() + 3_600_000, + accountId: "account-b", projectId: "project-b", + }); + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "127.0.0.1" || url.hostname === "localhost") return originalFetch(input, init); + if (url.origin !== "https://daily-cloudcode-pa.googleapis.com" + || !["/v1internal:generateContent", "/v1internal:streamGenerateContent"].includes(url.pathname)) { + throw new Error("Unexpected external request in CCA login regression"); + } + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); + const body = typeof init?.body === "string" ? init.body : input instanceof Request ? await input.clone().text() : ""; + emitted.push({ bearer: headers.get("authorization"), project: (JSON.parse(body) as { project?: unknown }).project }); + const payload = { + response: { + candidates: [{ content: { role: "model", parts: [{ text: "account-b response" }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 }, + }, + }; + return url.searchParams.get("alt") === "sse" + ? new Response(`data: ${JSON.stringify(payload)}\n\n`, { headers: { "content-type": "text/event-stream" } }) + : Response.json(payload); + }) as typeof globalThis.fetch; + + await runLogin(providerName, {}, { forceLogin: true }); + const accounts = getAccountSet(providerName)!; + expect(accounts.activeAccountId).not.toBe(accountA); + expect(accounts.accounts.find(account => account.id === accounts.activeAccountId)?.credential.accountId).toBe("account-b"); + clearGenericFailoverHealth(providerName); + const persisted = loadConfig(); + expect(persisted.providers[providerName]!.oauthAccountFailover?.enabled).toBe(false); + expect(preferredInitialAccount(persisted, providerName)).toBeNull(); + + server = startServer(0); + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "google-antigravity/gemini-3.8-flash", input: "hello", stream: false }), + }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("account-b response"); + // One successful initial dispatch: no 401 or reactive rotation can repair a bad pair. + expect(emitted).toEqual([{ bearer: "Bearer access-b", project: "project-b" }]); + expect(persisted.providers[providerName]!.project).toBeUndefined(); + } finally { + try { + await server?.stop(true); + } finally { + globalThis.fetch = originalFetch; + OAUTH_PROVIDERS[providerName]!.login = originalLogin; + clearGenericFailoverHealth(providerName); + } + } + }, 15_000); + test("does not post provider credentials when a legacy health listener has no verified pid", async () => { const receivedPaths: string[] = []; let healthProbeCount = 0; diff --git a/tests/oauth/oauth-upsert-preserves-api-key.test.ts b/tests/oauth/oauth-upsert-preserves-api-key.test.ts index fa5801f194..caec3363b6 100644 --- a/tests/oauth/oauth-upsert-preserves-api-key.test.ts +++ b/tests/oauth/oauth-upsert-preserves-api-key.test.ts @@ -396,6 +396,31 @@ describe("upsertOAuthProvider credential preservation", () => { expect(existing).toEqual(before); }); + test("clears the previous CCA project only after resolving the canonical login mode", () => { + const config = configWithKey("google-antigravity", "google", "https://stale.example.invalid"); + const existing = config.providers["google-antigravity"]!; + existing.googleMode = "vertex"; + existing.project = "previous-account-project"; + + upsertOAuthProvider(config, "google-antigravity"); + + expect(config.providers["google-antigravity"]!.googleMode).toBe("cloud-code-assist"); + expect(config.providers["google-antigravity"]!.project).toBeUndefined(); + expect(existing.project).toBe("previous-account-project"); + }); + + test("preserves an operator project when the canonical login mode is not CCA", () => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + const existing = config.providers.xai!; + existing.googleMode = "cloud-code-assist"; + existing.project = "operator-project"; + + upsertOAuthProvider(config, "xai"); + + expect(config.providers.xai!.googleMode).toBeUndefined(); + expect(config.providers.xai!.project).toBe("operator-project"); + }); + test("isolates nested operator and unchanged catalog data from the previous row and registry", () => { const preset = OAUTH_PROVIDERS.anthropic!.providerConfig; const presetBefore = structuredClone(preset); From 332a30e6d3a6cb3941acbf68df0656df3778c731 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 01:52:17 +0900 Subject: [PATCH 023/236] fix(responses): preserve termination scope across recovery replay --- src/server/responses/core.ts | 1 + .../opencode-go-agent-messages.test.ts | 126 ++++++++++++++++- .../server/agent-task-recovery-cache.test.ts | 58 ++++++++ .../server-agent-task-recovery-replay.test.ts | 130 +++++++++++++++++- 4 files changed, 313 insertions(+), 2 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index aa813e9c51..69e10a4a93 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3280,6 +3280,7 @@ async function handleResponsesInner( (reparsed as unknown as Record)[key] = parsed[key]; } } + bindTurnTerminationScope(reparsed, resolvedConversationId); parsed = reparsed; // The recovery mutated `body.input` in place, so `_rawBody` now carries decrypted task // text. Bar it from the continuation cache before any recording path can reach it — diff --git a/tests/providers/opencode-go-agent-messages.test.ts b/tests/providers/opencode-go-agent-messages.test.ts index e12860d0e0..f79f529a5e 100644 --- a/tests/providers/opencode-go-agent-messages.test.ts +++ b/tests/providers/opencode-go-agent-messages.test.ts @@ -1,7 +1,8 @@ import { expect, test } from "bun:test"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; -import { normalizeOpenCodeGoAgentMessages } from "../../src/adapters/opencode-go"; +import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "../../src/adapters/opencode-go"; import { parseRequest } from "../../src/responses/parser"; +import { routeModel } from "../../src/router"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import type { OcxProviderConfig } from "../../src/types"; @@ -54,3 +55,126 @@ test("other destinations do not get Go normalization or session identity", async expect(new Headers(request.headers).get("x-opencode-session")).toBeNull(); budget.dispose(); }); + +test("canonical Go forward auth preserves private agent messages and the raw replay body", async () => { + const raw = body(); + const original = structuredClone(raw); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter({ ...base, authMode: "forward" }).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + expect(request.url).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(JSON.parse(request.body as string).input[0]).toMatchObject({ + type: "agent_message", author: "/root/reader", recipient: "/root/checker", + content: original.input[0]!.content, + }); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + +test.each(["https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/go/v1/"])( + "a renamed provider at %s still converts plaintext agent messages", + async baseUrl => { + const raw = body(); + const original = structuredClone(raw); + const route = routeModel({ + port: 0, defaultProvider: "my-go", providers: { "my-go": { ...base, baseUrl, models: [raw.model] } }, + }, `my-go/${raw.model}`); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter(route.provider).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + const sent = JSON.parse(request.body as string); + expect(request.url).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(sent.input[0]).toMatchObject({ type: "message", role: "user" }); + expect(sent.input[0].content.slice(1)).toEqual(original.input[0]!.content); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } + }, +); + +test.each([ + "https://opencode.ai.evil.test/zen/go/v1", + "http://opencode.ai/zen/go/v1", + "https://opencode.ai/zen/v1", + "https://opencode.ai/zen/go/v10", +])("Go-like destination %s preserves private agent messages", async baseUrl => { + const raw = body(); + const original = structuredClone(raw); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter({ ...base, baseUrl }).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + expect(JSON.parse(request.body as string).input[0]).toMatchObject({ + type: "agent_message", content: original.input[0]!.content, + }); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + +test.each(["not a URL", "https://", "/zen/go/v1"])( + "malformed destination %s is not classified as Go", + baseUrl => expect(isOpenCodeGo(baseUrl)).toBe(false), +); + +test("Go conversion preserves file payloads beside text without mutating raw replay", async () => { + const file = { type: "input_file", filename: "assignment.txt", file_data: "data:text/plain;base64,SGVsbG8=" }; + const message = body().input[0]!; + const raw = { ...body(), input: [{ ...message, content: [...message.content, file] }] }; + const original = structuredClone(raw); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter(base).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + const sent = JSON.parse(request.body as string); + expect(sent.input[0]).toMatchObject({ type: "message", role: "user" }); + expect(sent.input[0].content.slice(1)).toEqual(original.input[0]!.content); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + +for (const { name, content } of [ + { name: "empty content", content: [] }, + { name: "text mixed with an unknown part", content: [ + { type: "input_text", text: "Known prefix" }, { type: "future_type", text: "Do not lose this" }, + ] }, + { name: "text mixed with ciphertext", content: [ + { type: "input_text", text: "Routing header" }, { type: "encrypted_content", encrypted_content: "opaque" }, + ] }, +]) test(`Go preserves ${name} without partially converting it`, async () => { + const raw = { ...body(), input: [{ ...body().input[0]!, content }] }; + const original = structuredClone(raw); + expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter(base).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + expect(JSON.parse(request.body as string).input[0]).toMatchObject({ type: "agent_message", content }); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); diff --git a/tests/server/agent-task-recovery-cache.test.ts b/tests/server/agent-task-recovery-cache.test.ts index 3107bff1a1..3a7324340d 100644 --- a/tests/server/agent-task-recovery-cache.test.ts +++ b/tests/server/agent-task-recovery-cache.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { + agentTaskRecoveryCacheSnapshotForTests, + agentTaskRecoveryWaiterCountForTests, + cachedAgentTaskRecovery, resetAgentTaskRecoveryCache, resolveCachedAgentTaskRecovery, } from "../../src/server/responses/agent-task-recovery-cache"; @@ -14,6 +17,61 @@ describe("agent task recovery cache", () => { resetAgentTaskRecoveryCache(); }); + test("read-only hits retain the original expiry and exact-expiry reads release UTF-8 bytes", async () => { + const insertedAt = 1_800_000_000_000; + let now = insertedAt; + Date.now = () => now; + let requests = 0; + expect(cachedAgentTaskRecovery("missing")).toBeNull(); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + expect(await resolveCachedAgentTaskRecovery("task", 200, async () => { + requests++; + return "한😀"; // Three UTF-8 bytes plus four, rather than three UTF-16 code units. + })).toBe("한😀"); + + for (const elapsed of [0, 60_000, 15 * 60 * 1000 - 1]) { + now = insertedAt + elapsed; + expect(cachedAgentTaskRecovery("task")).toBe("한😀"); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 1, bytes: 7 }); + } + now = insertedAt + 15 * 60 * 1000; + expect(cachedAgentTaskRecovery("task")).toBeNull(); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + expect(cachedAgentTaskRecovery("task")).toBeNull(); + expect(requests).toBe(1); + + // Repeated expiry reads must not subtract bytes belonging to a later entry. + await resolveCachedAgentTaskRecovery("later", 200, async () => "ok"); + expect(cachedAgentTaskRecovery("task")).toBeNull(); + expect(cachedAgentTaskRecovery("later")).toBe("ok"); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 1, bytes: 2 }); + }); + + test("read-only misses do not join or restart an in-flight recovery", async () => { + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + let requests = 0; + const pending = resolveCachedAgentTaskRecovery("pending", 200, async () => { + requests++; + await gate; + return "recovered"; + }); + try { + expect(cachedAgentTaskRecovery("pending")).toBeNull(); + expect(cachedAgentTaskRecovery("unknown")).toBeNull(); + expect(cachedAgentTaskRecovery("pending")).toBeNull(); + expect(agentTaskRecoveryWaiterCountForTests()).toBe(1); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + expect(requests).toBe(1); + } finally { + release?.(); + await pending; + } + expect(cachedAgentTaskRecovery("pending")).toBe("recovered"); + expect(agentTaskRecoveryWaiterCountForTests()).toBe(0); + expect(requests).toBe(1); + }); + test("expires recovered plaintext after fifteen minutes", async () => { let now = 1_800_000_000_000; Date.now = () => now; diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts index cfb735af01..fd572e9fd8 100644 --- a/tests/server/server-agent-task-recovery-replay.test.ts +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -1,4 +1,10 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; +import { createKiroAdapter } from "../../src/adapters/kiro"; +import { ADAPTER_REGISTRY } from "../../src/adapters/registry"; +import { parseRequest } from "../../src/responses/parser"; +import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../src/responses/turn-termination"; +import { conversationIdFromResponsesRequest } from "../../src/server/request-log-conversation"; +import type { OcxParsedRequest } from "../../src/types"; import { recoverEncryptedAgentTask, resetAgentTaskRecoveryState, restoreCachedEncryptedAgentTasks } from "../../src/server/responses/agent-task-recovery"; import { codexHeaders, encryptedInput, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); @@ -128,6 +134,128 @@ test("mixed history restores cached NEW_TASK and MESSAGE separately before recov expect(calls).toBe(3); }); +test("Responses handler restores known history and recovers only the new MESSAGE tail", async () => { + const { post, providerResponse } = await import("../helpers/agent-task-recovery"); + const assignments = ["Initial assignment.", "First message.", "Second message."]; + const recoveryBodies: string[] = []; + const providerBodies: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const requestBody = String(init?.body); + if (String(url).includes("chatgpt.com")) { + recoveryBodies.push(requestBody); + return new Response(recoverySse(assignments[recoveryBodies.length - 1] ?? "Unexpected extra recovery.")); + } + providerBodies.push(requestBody); + return providerResponse(); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const headers = codexHeaders(); + const nextMessage = () => JSON.parse(JSON.stringify(encryptedMessage()).replace(FERNET_TASK, SECOND_FERNET_TASK)); + const turns = [ + encryptedInput(), + [...encryptedInput(), ...encryptedMessage()], + [...encryptedInput(), ...encryptedMessage(), ...nextMessage()], + ]; + + for (const [index, input] of turns.entries()) { + const response = await post(config, "xai/grok-4.5", input, headers); + expect(response.status).toBe(200); + await response.text(); + expect(recoveryBodies).toHaveLength(index + 1); + expect(providerBodies).toHaveLength(index + 1); + const sent = providerBodies[index]!; + let previousPosition = -1; + for (const assignment of assignments.slice(0, index + 1)) { + const position = sent.indexOf(assignment); + expect(position).toBeGreaterThan(previousPosition); + previousPosition = position; + } + expect(sent).not.toContain(FERNET_TASK); + expect(sent).not.toContain(SECOND_FERNET_TASK); + } + // Recovery may receive only the fresh tail, never a batch of cached history. + expect(JSON.parse(recoveryBodies[2]!).input).toEqual(nextMessage()); + + const response = await post(config, "xai/grok-4.5", [ + ...encryptedInput(), ...encryptedMessage(), ...nextMessage(), + { type: "message", role: "user", content: "Continue with all three instructions." }, + ], headers); + expect(response.status).toBe(200); + await response.text(); + expect(recoveryBodies).toHaveLength(3); + expect(providerBodies).toHaveLength(4); + for (const assignment of assignments) expect(providerBodies[3]).toContain(assignment); + expect(providerBodies[3]).toContain("Continue with all three instructions."); + expect(providerBodies[3]).not.toContain(FERNET_TASK); + expect(providerBodies[3]).not.toContain(SECOND_FERNET_TASK); +}); + +test("cached-history reparse preserves recorded final-answer scope without suppressing a user follow-up", async () => { + const { post, providerResponse } = await import("../helpers/agent-task-recovery"); + const sessionId = `recovery-final-replay-${crypto.randomUUID()}`; + const headers = codexHeaders("acct-caller", { session_id: sessionId }); + const config = routedConfig({ enabled: true }); + const deliveredAnswer = "The assignment is complete."; + const recorded = parseRequest({ model: "xai/grok-4.5", input: "Earlier turn" }); + bindTurnTerminationScope(recorded, conversationIdFromResponsesRequest({ sessionIdHeader: sessionId })); + rememberDeliveredFinalAnswer(recorded, { output: [{ + type: "message", role: "assistant", phase: "final_answer", + content: [{ type: "output_text", text: deliveredAnswer }], + }] }); + + let recoveries = 0; + const providerBodies: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + if (String(url).includes("chatgpt.com")) { + recoveries++; + return new Response(recoverySse("Read the assignment.")); + } + providerBodies.push(String(init?.body)); + return providerResponse(); + }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers }); + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config)).toBe(true); + + // Keep the ordinary transport fixture, but exercise Kiro's real pre-send termination hook. + // The remembered record above belongs to a different parsed object: only core can bind + // the new object produced by recovery reparse to the same conversation. + const kiro = createKiroAdapter({ adapter: "kiro", baseUrl: "https://kiro.test", authMode: "key", apiKey: "synthetic-key" }); + const createChat = ADAPTER_REGISTRY["openai-chat"].create; + const inspectedBodies: string[] = []; + const factory = spyOn(ADAPTER_REGISTRY["openai-chat"], "create").mockImplementation((provider, context) => ({ + ...createChat(provider, context), + localTerminal(parsed: OcxParsedRequest) { + inspectedBodies.push(JSON.stringify(parsed._rawBody)); + return kiro.localTerminal?.(parsed); + }, + })); + const finalMessage = { type: "message", role: "assistant", content: [{ type: "output_text", text: deliveredAnswer }] }; + try { + for (let attempt = 0; attempt < 2; attempt++) { + const response = await post(config, "xai/grok-4.5", [...encryptedInput(), finalMessage], headers); + expect(response.status).toBe(200); + expect((await response.json() as { output: unknown[] }).output).toEqual([]); + expect(providerBodies).toHaveLength(0); + } + const followUp = await post(config, "xai/grok-4.5", [ + ...encryptedInput(), finalMessage, + { type: "message", role: "user", content: "Now explain your result." }, + ], headers); + expect(followUp.status).toBe(200); + await followUp.text(); + expect(providerBodies).toHaveLength(1); + expect(providerBodies[0]).toContain("Now explain your result."); + expect(inspectedBodies).toHaveLength(3); + for (const inspected of inspectedBodies) { + expect(inspected).toContain("Read the assignment."); + expect(inspected).not.toContain(FERNET_TASK); + } + expect(recoveries).toBe(1); + } finally { + factory.mockRestore(); + } +}); + test("fresh recovery only handles the current tail, leaving uncached history unchanged", async () => { let calls = 0; globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Current message.")); }) as typeof fetch; From 1a4e9d8dfe6cc4483cb0c4ee70a3117bcc956605 Mon Sep 17 00:00:00 2001 From: agentHits Date: Sat, 5 Sep 2026 04:02:08 +0300 Subject: [PATCH 024/236] feat(oauth): add google-antigravity to OAuth 401 replay and force-refresh providers (cherry picked from commit 9a5419b3a9aced2d370cd475d231aaa0c291f77c) --- src/oauth/index.ts | 2 +- src/server/responses/core.ts | 20 +- ...oogle-antigravity-oauth-401-replay.test.ts | 388 ++++++++++++++++++ 3 files changed, 405 insertions(+), 5 deletions(-) create mode 100644 tests/server/server-google-antigravity-oauth-401-replay.test.ts diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 408a661494..4edd6375c8 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -548,7 +548,7 @@ export async function getValidAccessTokenSnapshot(provider: string): Promise | undefined; let anthropicPoolAccountId: string | null = null; @@ -4567,7 +4571,11 @@ async function handleResponsesInner( } const refreshedProvider = resolveProviderTransport( route.providerName, - { ...route.provider, apiKey: refreshed.accessToken }, + { + ...route.provider, + apiKey: refreshed.accessToken, + ...(refreshed.projectId ? { project: refreshed.projectId } : {}), + }, parsed.options.promptCacheKey, route.providerName === "github-copilot" ? resolveCopilotApiBaseUrl(refreshed.apiBaseUrl) @@ -6365,7 +6373,11 @@ async function handleResponsesInner( } const refreshedProvider = resolveProviderTransport( route.providerName, - { ...route.provider, apiKey: refreshed.accessToken }, + { + ...route.provider, + apiKey: refreshed.accessToken, + ...(refreshed.projectId ? { project: refreshed.projectId } : {}), + }, parsed.options.promptCacheKey, route.providerName === "github-copilot" ? resolveCopilotApiBaseUrl(refreshed.apiBaseUrl) diff --git a/tests/server/server-google-antigravity-oauth-401-replay.test.ts b/tests/server/server-google-antigravity-oauth-401-replay.test.ts new file mode 100644 index 0000000000..4a7060513e --- /dev/null +++ b/tests/server/server-google-antigravity-oauth-401-replay.test.ts @@ -0,0 +1,388 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { forceRefreshOAuthAccessSnapshot, getValidAccessTokenSnapshot } from "../../src/oauth"; +import { saveCredential } from "../../src/oauth/store"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; +const DAILY_API_BASE = "https://daily-cloudcode-pa.googleapis.com"; +const PUBLIC_OAUTH_AUTHENTICATION_ERROR = "OAuth authentication failed. Check the OpenCodex account status and retry."; +const WINDOWS_PATH_CANARY = "C:\\Users\\Alice\\.opencodex\\auth.json.ocx-tmp"; +const UNC_PATH_CANARY = "\\\\server\\share\\opencodex\\auth.json.ocx-tmp"; +const POSIX_PATH_CANARY = "/home/alice/.opencodex/auth.json.ocx-tmp"; + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; +let originalFetch: typeof fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-google-401-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-google-401-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); +}); + +async function seedOAuth(expires = Date.now() + 3_600_000): Promise { + await saveCredential("google-antigravity", { + access: "rejected-access", + refresh: "initial-refresh", + expires, + accountId: "antigravity-test-account", + projectId: "initial-project-id", + source: "oauth", + }); +} + +function antigravityConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + baseUrl: DAILY_API_BASE, + authMode: "oauth", + googleMode: "cloud-code-assist", + project: "initial-project-id", + models: ["gemini-3.8-flash"], + }, + }, + } as OcxConfig; +} + +function jsonSuccessBody(text: string): Record { + return { + response: { + candidates: [{ + content: { + role: "model", + parts: [{ text }], + }, + finishReason: "STOP", + }], + usageMetadata: { + promptTokenCount: 5, + candidatesTokenCount: 3, + totalTokenCount: 8, + }, + }, + }; +} + +function sseSuccessBody(text: string): string { + return `data: ${JSON.stringify(jsonSuccessBody(text))}\n\n`; +} + +async function postResponses(server: ReturnType): Promise { + return originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "google-antigravity/gemini-3.8-flash", + input: "hello", + stream: false, + }), + }); +} + +async function postChat(server: ReturnType): Promise { + return originalFetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "google-antigravity/gemini-3.8-flash", + messages: [{ role: "user", content: "hello" }], + stream: false, + }), + }); +} + +function installOAuthFetch( + apiStatuses: number[], + options: { + tokenErrorDescription?: string; + refreshedProjectId?: string; + } = {}, +): { chatAuth: string[]; chatProjects: string[]; counts: { refresh: number } } { + const chatAuth: string[] = []; + const chatProjects: string[] = []; + const counts = { refresh: 0 }; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + + // Google OAuth refresh token endpoint + if (url === GOOGLE_TOKEN_ENDPOINT) { + counts.refresh += 1; + if (options.tokenErrorDescription !== undefined) { + return new Response(JSON.stringify({ + error: "invalid_grant", + error_description: options.tokenErrorDescription, + }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ + access_token: "fresh-access", + refresh_token: "fresh-refresh", + expires_in: 3600, + }), { headers: { "content-type": "application/json" } }); + } + + // Google Cloud Code Assist project discovery + if (url.includes(":loadCodeAssist")) { + return new Response(JSON.stringify({ + cloudaicompanionProject: options.refreshedProjectId ?? "refreshed-project-id", + }), { headers: { "content-type": "application/json" } }); + } + + // Google Antigravity Generate Content endpoint + if (url.includes("/v1internal:streamGenerateContent") || url.includes("/v1internal:generateContent")) { + const auth = new Headers(init?.headers).get("authorization") ?? ""; + chatAuth.push(auth); + if (typeof init?.body === "string") { + try { + const parsedBody = JSON.parse(init.body) as { project?: string }; + if (parsedBody.project) chatProjects.push(parsedBody.project); + } catch { /* ignore */ } + } + const status = apiStatuses.shift() ?? 200; + if (status === 401) { + return new Response(JSON.stringify({ + error: { + code: 401, + message: "Request had invalid authentication credentials.", + status: "UNAUTHENTICATED", + }, + }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + if (url.includes("alt=sse")) { + return new Response(sseSuccessBody("ok after google refresh"), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + return new Response(JSON.stringify(jsonSuccessBody("ok after google refresh")), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + return originalFetch(input, init); + }) as typeof fetch; + return { chatAuth, chatProjects, counts }; +} + +describe("Google Antigravity OAuth upstream 401 replay", () => { + test("forceRefreshOAuthAccessSnapshot supports google-antigravity", async () => { + await seedOAuth(); + installOAuthFetch([], { refreshedProjectId: "rediscovered-project-xyz" }); + + const snapshot = await getValidAccessTokenSnapshot("google-antigravity"); + expect(snapshot.provider).toBe("google-antigravity"); + expect(snapshot.accessToken).toBe("rejected-access"); + + const refreshed = await forceRefreshOAuthAccessSnapshot(snapshot); + expect(refreshed.provider).toBe("google-antigravity"); + expect(refreshed.accessToken).toBe("fresh-access"); + expect(refreshed.projectId).toBe("rediscovered-project-xyz"); + }); + + test("initial OAuth refresh projects raw provider failures before responding", async () => { + await seedOAuth(0); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([], { + tokenErrorDescription: `EACCES writing ${WINDOWS_PATH_CANARY}, ${UNC_PATH_CANARY}, or ${POSIX_PATH_CANARY}`, + }); + const server = startServer(0); + try { + const response = await postResponses(server); + const json = await response.json() as { error?: { code?: string; message?: string; type?: string } }; + const message = json.error?.message ?? ""; + expect(response.status).toBe(401); + expect(json.error?.type).toBe("authentication_error"); + expect(message).toBe(PUBLIC_OAUTH_AUTHENTICATION_ERROR); + expect(message).not.toContain(WINDOWS_PATH_CANARY); + expect(message).not.toContain(UNC_PATH_CANARY); + expect(message).not.toContain(POSIX_PATH_CANARY); + expect(message).not.toContain("auth.json"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual([]); + } finally { + await server.stop(true); + } + }); + + test("OAuth 401 replay projects raw refresh failures before responding", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401], { + tokenErrorDescription: `EACCES writing ${WINDOWS_PATH_CANARY}, ${UNC_PATH_CANARY}, or ${POSIX_PATH_CANARY}`, + }); + const server = startServer(0); + try { + const response = await postResponses(server); + const json = await response.json() as { error?: { code?: string; message?: string; type?: string } }; + const message = json.error?.message ?? ""; + expect(response.status).toBe(401); + expect(json.error?.type).toBe("authentication_error"); + expect(message).toBe(PUBLIC_OAUTH_AUTHENTICATION_ERROR); + expect(message).not.toContain(WINDOWS_PATH_CANARY); + expect(message).not.toContain(UNC_PATH_CANARY); + expect(message).not.toContain(POSIX_PATH_CANARY); + expect(message).not.toContain("auth.json"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access"]); + } finally { + await server.stop(true); + } + }); + + test("401 then 200 on /v1/responses performs one refresh and one replay with refreshed token and project", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401, 200], { refreshedProjectId: "new-project-456" }); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(response.status).toBe(200); + const json = await response.json() as { output?: { type: string; content?: { text?: string }[] }[] }; + expect(json.output?.find(item => item.type === "message")?.content?.[0]?.text).toBe("ok after google refresh"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]); + expect(observed.chatProjects).toEqual(["initial-project-id", "new-project-456"]); + } finally { + await server.stop(true); + } + }); + + test("401 then 200 on /v1/chat/completions performs one refresh and one replay seamlessly", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401, 200], { refreshedProjectId: "chat-project-789" }); + const server = startServer(0); + try { + const response = await postChat(server); + expect(response.status).toBe(200); + const json = await response.json() as { choices?: { message?: { content?: string } }[] }; + expect(json.choices?.[0]?.message?.content).toBe("ok after google refresh"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]); + expect(observed.chatProjects).toEqual(["initial-project-id", "chat-project-789"]); + } finally { + await server.stop(true); + } + }); + + test("401 then 401 replays once and propagates the second error cleanly", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401, 401]); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(response.status).toBe(401); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]); + } finally { + await server.stop(true); + } + }); + + test("concurrent 401 responses join one IdP refresh", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + let refreshCalls = 0; + let signalRefreshStarted!: () => void; + const refreshStarted = new Promise(resolve => { signalRefreshStarted = resolve; }); + let releaseRefresh!: () => void; + const refreshGate = new Promise(resolve => { releaseRefresh = resolve; }); + let releaseRejectedRequests!: () => void; + const rejectedRequestsReady = new Promise(resolve => { releaseRejectedRequests = resolve; }); + const attemptsByBearer = new Map(); + + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === GOOGLE_TOKEN_ENDPOINT) { + refreshCalls += 1; + signalRefreshStarted(); + await refreshGate; + return new Response(JSON.stringify({ + access_token: "fresh-access", + refresh_token: "fresh-refresh", + expires_in: 3600, + }), { headers: { "content-type": "application/json" } }); + } + if (url.includes(":loadCodeAssist")) { + return new Response(JSON.stringify({ + cloudaicompanionProject: "concurrent-project-id", + }), { headers: { "content-type": "application/json" } }); + } + if (url.includes("/v1internal:streamGenerateContent") || url.includes("/v1internal:generateContent")) { + const bearer = new Headers(init?.headers).get("authorization") ?? ""; + attemptsByBearer.set(bearer, (attemptsByBearer.get(bearer) ?? 0) + 1); + if (bearer === "Bearer rejected-access") { + if (attemptsByBearer.get(bearer) === 2) releaseRejectedRequests(); + await rejectedRequestsReady; + return new Response(JSON.stringify({ + error: { + code: 401, + message: "Request had invalid authentication credentials.", + status: "UNAUTHENTICATED", + }, + }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + if (url.includes("alt=sse")) { + return new Response(sseSuccessBody("concurrent ok"), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + return new Response(JSON.stringify(jsonSuccessBody("concurrent ok")), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const server = startServer(0); + try { + const first = postResponses(server); + const second = postResponses(server); + await refreshStarted; + releaseRefresh(); + const [a, b] = await Promise.all([first, second]); + expect([a.status, b.status]).toEqual([200, 200]); + expect(refreshCalls).toBe(1); + expect(attemptsByBearer.get("Bearer rejected-access")).toBe(2); + expect(attemptsByBearer.get("Bearer fresh-access")).toBe(2); + } finally { + await server.stop(true); + } + }); +}); From bceae4b079f0e30fd4ce3fa65051425408da851f Mon Sep 17 00:00:00 2001 From: agentHits Date: Sat, 5 Sep 2026 05:14:44 +0300 Subject: [PATCH 025/236] fix(responses): fail-closed guard on project-less CCA 401 replay and add streaming tests (cherry picked from commit fcfe877c710d2c9255ec0b2a3d87840fd87f9f88) --- src/server/responses/core.ts | 9 ++ ...oogle-antigravity-oauth-401-replay.test.ts | 148 +++++++++++++++++- 2 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2b353befa0..c9af28f82d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -4561,6 +4561,11 @@ async function handleResponsesInner( releaseCodexAuthContextProbeLease(authCtx); return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); } + if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) { + upstream.abort(); + releaseCodexAuthContextProbeLease(authCtx); + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist project is required"))); + } sentOAuthSnapshot = refreshed; replayOAuthCredentialSnapshot = { accountId: refreshed.accountId, @@ -6363,6 +6368,10 @@ async function handleResponsesInner( cleanupUpstreamAbort(); return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err)); } + if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) { + cleanupUpstreamAbort(); + return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist project is required"))); + } sentOAuthSnapshot = refreshed; replayOAuthCredentialSnapshot = { accountId: refreshed.accountId, diff --git a/tests/server/server-google-antigravity-oauth-401-replay.test.ts b/tests/server/server-google-antigravity-oauth-401-replay.test.ts index 4a7060513e..8114657f34 100644 --- a/tests/server/server-google-antigravity-oauth-401-replay.test.ts +++ b/tests/server/server-google-antigravity-oauth-401-replay.test.ts @@ -39,13 +39,13 @@ afterEach(() => { if (testDir) removeTreeWithRetry(testDir); }); -async function seedOAuth(expires = Date.now() + 3_600_000): Promise { +async function seedOAuth(expires = Date.now() + 3_600_000, projectId?: string | null): Promise { await saveCredential("google-antigravity", { access: "rejected-access", refresh: "initial-refresh", expires, accountId: "antigravity-test-account", - projectId: "initial-project-id", + ...(projectId !== undefined ? (projectId ? { projectId } : {}) : { projectId: "initial-project-id" }), source: "oauth", }); } @@ -68,6 +68,24 @@ function antigravityConfig(): OcxConfig { } as OcxConfig; } +function antigravityPassthroughConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "openai-responses", + baseUrl: DAILY_API_BASE, + authMode: "oauth", + googleMode: "cloud-code-assist", + project: "initial-project-id", + models: ["gemini-3.8-flash"], + }, + }, + } as OcxConfig; +} + function jsonSuccessBody(text: string): Record { return { response: { @@ -91,14 +109,14 @@ function sseSuccessBody(text: string): string { return `data: ${JSON.stringify(jsonSuccessBody(text))}\n\n`; } -async function postResponses(server: ReturnType): Promise { +async function postResponses(server: ReturnType, stream = false): Promise { return originalFetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "google-antigravity/gemini-3.8-flash", input: "hello", - stream: false, + stream, }), }); } @@ -119,7 +137,7 @@ function installOAuthFetch( apiStatuses: number[], options: { tokenErrorDescription?: string; - refreshedProjectId?: string; + refreshedProjectId?: string | null; } = {}, ): { chatAuth: string[]; chatProjects: string[]; counts: { refresh: number } } { const chatAuth: string[] = []; @@ -149,11 +167,50 @@ function installOAuthFetch( // Google Cloud Code Assist project discovery if (url.includes(":loadCodeAssist")) { + if (options.refreshedProjectId === null) { + return new Response(JSON.stringify({}), { status: 404, headers: { "content-type": "application/json" } }); + } return new Response(JSON.stringify({ cloudaicompanionProject: options.refreshedProjectId ?? "refreshed-project-id", }), { headers: { "content-type": "application/json" } }); } + if (url.includes(":onboardUser")) { + if (options.refreshedProjectId === null) { + return new Response(JSON.stringify({}), { status: 404, headers: { "content-type": "application/json" } }); + } + } + + // Responses passthrough endpoint + if (url.endsWith("/responses") && url.includes("daily-cloudcode-pa.googleapis.com")) { + const auth = new Headers(init?.headers).get("authorization") ?? ""; + chatAuth.push(auth); + const status = apiStatuses.shift() ?? 200; + if (status === 401) { + return new Response(JSON.stringify({ + error: { + code: 401, + message: "Request had invalid authentication credentials.", + status: "UNAUTHENTICATED", + }, + }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ + id: "resp-passthrough", + output: [{ + id: "msg-passthrough", + type: "message", + content: [{ type: "output_text", text: "ok after passthrough" }], + }], + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + // Google Antigravity Generate Content endpoint if (url.includes("/v1internal:streamGenerateContent") || url.includes("/v1internal:generateContent")) { const auth = new Headers(init?.headers).get("authorization") ?? ""; @@ -385,4 +442,85 @@ describe("Google Antigravity OAuth upstream 401 replay", () => { await server.stop(true); } }); + + test("negative project-less refresh rejects replay in native Responses passthrough", async () => { + await seedOAuth(undefined, null); + saveConfig(antigravityPassthroughConfig()); + const observed = installOAuthFetch([401], { refreshedProjectId: null }); + const server = startServer(0); + try { + const response = await postResponses(server); + const json = await response.json() as { error?: { code?: string; message?: string; type?: string } }; + expect(response.status).toBe(401); + expect(json.error?.type).toBe("authentication_error"); + expect(json.error?.message).toBe(PUBLIC_OAUTH_AUTHENTICATION_ERROR); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access"]); + } finally { + await server.stop(true); + } + }); + + test("negative project-less refresh rejects replay in generic adapter", async () => { + await seedOAuth(undefined, null); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401], { refreshedProjectId: null }); + const server = startServer(0); + try { + const response = await postResponses(server); + const json = await response.json() as { error?: { code?: string; message?: string; type?: string } }; + expect(response.status).toBe(401); + expect(json.error?.type).toBe("authentication_error"); + expect(json.error?.message).toBe(PUBLIC_OAUTH_AUTHENTICATION_ERROR); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access"]); + } finally { + await server.stop(true); + } + }); + + test("negative project-less refresh rejects replay in chat completions", async () => { + await seedOAuth(undefined, null); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401], { refreshedProjectId: null }); + const server = startServer(0); + try { + const response = await postChat(server); + const json = await response.json() as { error?: { message?: string; type?: string } }; + expect(response.status).toBe(401); + expect(json.error?.type).toBe("authentication_error"); + expect(json.error?.message).toBe(PUBLIC_OAUTH_AUTHENTICATION_ERROR); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access"]); + } finally { + await server.stop(true); + } + }); + + test("401 then 200 on /v1/responses with stream: true performs one refresh and one replay with refreshed token and project", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401, 200], { refreshedProjectId: "stream-project-999" }); + const server = startServer(0); + try { + const response = await postResponses(server, true); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let streamText = ""; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + streamText += decoder.decode(chunk.value, { stream: true }); + } + expect(streamText).toContain("ok after google refresh"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]); + expect(observed.chatProjects).toEqual(["initial-project-id", "stream-project-999"]); + } finally { + await server.stop(true); + } + }); }); From 5b8c41d48b3cc7b533cbc581036971e696e6385e Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 01:58:14 +0900 Subject: [PATCH 026/236] docs: refresh conversation affinity integration layer --- devlog/_plan/260906_a_runtime_stack/041_affinity_refresh.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 devlog/_plan/260906_a_runtime_stack/041_affinity_refresh.md diff --git a/devlog/_plan/260906_a_runtime_stack/041_affinity_refresh.md b/devlog/_plan/260906_a_runtime_stack/041_affinity_refresh.md new file mode 100644 index 0000000000..38515e7742 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/041_affinity_refresh.md @@ -0,0 +1,5 @@ +# Affinity layer P refresh + +Consume 040 on prepared recovery parent332a30e6d. Original #3581 remains f60397d3408e0339ffc66acdcaca8133e40866c2, with SB Yoon attribution preserved. Retain new recovery cache/history logic and termination WeakMap rebind when applying the two core hunks. The new cohort flag must survive initial parse and both fresh/cache-only reparse; true/undefined never authorize cache-key-based session identity. No changes to OAuth command-code cache-key forwarding; enable the existing API-key commandcode registry capability only. + +Scoped regression worker after carry owns tests/helpers/agent-task-recovery.ts, tests/server/server-agent-task-recovery-replay.test.ts and tests/providers/command-code-provider.test.ts. Use the actual ADAPTER_REGISTRY openai-chat create seam already proven in the parent regression to observe parsed fields at real buildRequest. Main owns production and adapters documentation. Remote helper asserts project Bun1.4.0; no local suites/typecheck/build. Full exact-head CI and --admin integration remain final gates. From 059de1b96426b88a3b17b175d0f3220320cd6967 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:58:50 -0600 Subject: [PATCH 027/236] fix: preserve Command Code session affinity (cherry picked from commit f60397d3408e0339ffc66acdcaca8133e40866c2) --- src/adapters/command-code.ts | 25 ++++++- src/providers/registry.ts | 1 + src/server/responses/core.ts | 2 + src/types/request.ts | 2 + ...laude-code-thought-signature-scope.test.ts | 3 + tests/providers/command-code-provider.test.ts | 66 ++++++++++++++++++- tests/providers/commandcode-provider.test.ts | 15 +++++ 7 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index a9b429bc99..c20dc88be6 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { opendir } from "node:fs/promises"; @@ -213,6 +213,27 @@ function projectSlug(cwd: string): string { return cwd.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase().slice(0, 64) || "workspace"; } +export function commandCodeSessionId(parsed: OcxParsedRequest): string { + // Shared prompt-cache cohorts identify a cache population, not one conversation. Using one + // for session affinity would pin unrelated conversations to the same upstream worker. + const threadId = parsed._clientThreadId?.trim(); + const replayId = parsed._reasoningReplayScope?.clientThreadId?.trim(); + const cacheKey = parsed._promptCacheKeyIsSharedCohort === false + ? parsed.options.promptCacheKey?.trim() + : undefined; + const identity = threadId + ? ["thread", threadId] + : replayId + ? ["replay", replayId] + : cacheKey + ? ["cache", cacheKey] + : undefined; + if (!identity) return randomUUID(); + const hex = createHash("sha256").update(`command-code:${identity[0]}\0${identity[1]}`).digest("hex"); + // Replace the digest nibbles at the UUID version and variant positions; the skipped hex characters are intentional. + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} + interface GitWorkspaceInfo { isGitRepo: boolean; currentBranch: string; @@ -525,7 +546,7 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA "x-cli-environment": "production", "x-taste-learning": "false", "x-co-flag": "false", - "x-session-id": randomUUID(), + "x-session-id": commandCodeSessionId(parsed), }; if (cwd) headers["x-project-slug"] = projectSlug(cwd); return { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 64c01dbb10..82f70a6a6a 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2175,6 +2175,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ liveModels: true, preserveCustomDestination: true, defaultModel: "deepseek/deepseek-v4-flash", + promptCacheKey: true, // The default is also the cold-start seed: live discovery failure must not empty the catalog // for a freshly configured provider with no stale cache (issue #308 pattern). models: ["deepseek/deepseek-v4-flash"], diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 69e10a4a93..94dd6e19c8 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2953,6 +2953,7 @@ async function handleResponsesInner( let toolBridgeMaps: ReturnType; try { parsed = parseRequest(body); + parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort; // Captured before any parser mutates it, so both grammars see the client's id. const { fastRow, effortRow } = parseSyntheticRowId(parsed.modelId, config); if (fastRow) { @@ -3271,6 +3272,7 @@ async function handleResponsesInner( "_providerContinuationOwner", "_cursorConversationId", "_clientThreadId", + "_promptCacheKeyIsSharedCohort", "_cursorClientThreadId", "_reasoningReplayScope", "_cursorIsolateConversation", diff --git a/src/types/request.ts b/src/types/request.ts index ffee4eb8a3..1c6a5294da 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -68,6 +68,8 @@ export interface OcxParsedRequest { _cursorConversationId?: string; /** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */ _clientThreadId?: string; + /** True when promptCacheKey identifies a shared cache cohort rather than one conversation. */ + _promptCacheKeyIsSharedCohort?: boolean; /** Cursor-only thread owner; may be an opaque process-local Desktop session/thread identity. */ _cursorClientThreadId?: string; /** Conversation/provider/account/model-bound namespace for reasoning replay state. */ diff --git a/tests/claude-integration/claude-code-thought-signature-scope.test.ts b/tests/claude-integration/claude-code-thought-signature-scope.test.ts index 2437a8d157..eb544dce97 100644 --- a/tests/claude-integration/claude-code-thought-signature-scope.test.ts +++ b/tests/claude-integration/claude-code-thought-signature-scope.test.ts @@ -97,16 +97,19 @@ describe("Claude Code Anthropic inbound reasoning-replay scope", () => { const parsed = await drive({ promptCacheKey: "session-key-123", promptCacheKeyIsSharedCohort: false }); expect(parsed._clientThreadId).toBeUndefined(); expect(parsed._reasoningReplayScope?.clientThreadId).toBe("session-key-123"); + expect(parsed._promptCacheKeyIsSharedCohort).toBe(false); }); test("the shared Desktop prompt_cache_key cohort does not create a scope", async () => { const parsed = await drive({ promptCacheKey: "shared-cohort-key", promptCacheKeyIsSharedCohort: true }); expect(parsed._reasoningReplayScope).toBeUndefined(); + expect(parsed._promptCacheKeyIsSharedCohort).toBe(true); }); test("an Anthropic replay without prompt_cache_key does not create a scope", async () => { const parsed = await drive({}); expect(parsed._reasoningReplayScope).toBeUndefined(); + expect(parsed._promptCacheKeyIsSharedCohort).toBeUndefined(); }); test("an overlong prompt_cache_key is hashed, not stored raw", async () => { diff --git a/tests/providers/command-code-provider.test.ts b/tests/providers/command-code-provider.test.ts index 3e369a9c6b..9c7cd2604b 100644 --- a/tests/providers/command-code-provider.test.ts +++ b/tests/providers/command-code-provider.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { createCommandCodeAdapter } from "../../src/adapters/command-code"; +import { commandCodeSessionId, createCommandCodeAdapter } from "../../src/adapters/command-code"; import { loginCommandCode, parseCommandCodeCallback, shouldImportLocalCommandCodeAuth } from "../../src/oauth/command-code"; import { buildModelsRequest, OAUTH_PROVIDERS } from "../../src/oauth"; import { @@ -796,4 +796,68 @@ describe("Command Code provider", () => { const built = await builtRequest({ ...parsed(), stream: false }); expect(JSON.parse(built.body).params.stream).toBe(true); }); + + test("derives an opaque stable session id from trusted conversation identity", async () => { + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-8[0-9a-f]{3}-[0-9a-f]{12}$/i; + const identities = { + thread: "thread-secret-value", + replay: "replay-secret-value", + cache: "cache-secret-value", + }; + const thread = { + ...parsed(), + _clientThreadId: ` ${identities.thread} `, + _reasoningReplayScope: { clientThreadId: identities.replay }, + options: { ...parsed().options, promptCacheKey: identities.cache }, + }; + const sameThread = { + ...thread, + _reasoningReplayScope: { clientThreadId: "different-replay" }, + options: { ...thread.options, promptCacheKey: "different-cache" }, + }; + const replay = { + ...parsed(), + _reasoningReplayScope: { clientThreadId: identities.replay }, + options: { ...parsed().options, promptCacheKey: identities.cache }, + }; + const sameReplay = { ...replay, options: { ...replay.options, promptCacheKey: "different-cache" } }; + const cache = { + ...parsed(), + options: { ...parsed().options, promptCacheKey: ` ${identities.cache} ` }, + _promptCacheKeyIsSharedCohort: false, + }; + const sameCache = { + ...cache, + options: { ...cache.options, promptCacheKey: identities.cache }, + }; + + const threadId = commandCodeSessionId(thread); + expect(threadId).toBe(commandCodeSessionId(sameThread)); + expect(threadId).not.toBe(commandCodeSessionId({ ...thread, _clientThreadId: "different-thread" })); + expect(commandCodeSessionId(replay)).toBe(commandCodeSessionId(sameReplay)); + expect(commandCodeSessionId(cache)).toBe(commandCodeSessionId(sameCache)); + expect(commandCodeSessionId(replay)).not.toBe(commandCodeSessionId(cache)); + expect(threadId).toMatch(uuid); + expect(commandCodeSessionId(replay)).toMatch(uuid); + expect(commandCodeSessionId(cache)).toMatch(uuid); + for (const raw of Object.values(identities)) expect(threadId).not.toContain(raw); + + const built = await builtRequest(thread); + expect(built.headers["x-session-id"]).toBe(threadId); + }); + + test("does not derive affinity from a shared cohort or prompt text", () => { + const shared = { + ...parsed(), + options: { ...parsed().options, promptCacheKey: "shared-cache-key" }, + _promptCacheKeyIsSharedCohort: true, + }; + expect(commandCodeSessionId(shared)).not.toBe(commandCodeSessionId(shared)); + const unclassifiedCache = { + ...parsed(), + options: { ...parsed().options, promptCacheKey: "possibly-shared-cache-key" }, + }; + expect(commandCodeSessionId(unclassifiedCache)).not.toBe(commandCodeSessionId(unclassifiedCache)); + expect(commandCodeSessionId(parsed())).not.toBe(commandCodeSessionId(parsed())); + }); }); diff --git a/tests/providers/commandcode-provider.test.ts b/tests/providers/commandcode-provider.test.ts index e76355dc4f..f70df51093 100644 --- a/tests/providers/commandcode-provider.test.ts +++ b/tests/providers/commandcode-provider.test.ts @@ -67,6 +67,7 @@ describe("Command Code provider", () => { liveModels: true, preserveCustomDestination: true, defaultModel: "deepseek/deepseek-v4-flash", + promptCacheKey: true, apiKeyValidation: "unknown", reasoningEfforts: [], modelReasoningEfforts: { @@ -176,6 +177,20 @@ describe("Command Code provider", () => { expect(body).not.toHaveProperty("parallel_tool_calls"); }); + test("forwards the enabled prompt cache key to chat completions", () => { + const route = routeModel( + commandcodeConfig(), + "commandcode/deepseek/deepseek-v4-flash", + ); + const request = createOpenAIChatAdapter(route.provider).buildRequest({ + modelId: route.modelId, + context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] }, + stream: true, + options: { promptCacheKey: "command-code-session-cache" }, + }); + expect(JSON.parse(String(request.body)).prompt_cache_key).toBe("command-code-session-cache"); + }); + test("discovers the live catalog with context windows and preserves slash ids", async () => { globalThis.fetch = (async (input, init) => { expect(String(input)).toBe("https://api.commandcode.ai/provider/v1/models"); From 238efd5db14ad4b0ed4f1800099e9681f0853460 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:59:44 +0900 Subject: [PATCH 028/236] test(oauth): verify Antigravity replay identity and exclusions --- .../docs/reference/cli/providers-accounts.md | 5 + scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + ...oogle-antigravity-oauth-401-replay.test.ts | 179 ++++++++++++++++-- 4 files changed, 167 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index ccb29a38b4..be4028cb57 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -80,6 +80,11 @@ and account failover preferences. Login-owned transport/authentication fields an catalog metadata are refreshed. A live-discovery provider keeps its selected default model; a static provider can replace a default that no longer exists in its refreshed catalog. +For Antigravity, an upstream `401` can refresh the rejected account’s OAuth credential and +retry the request once. The retry uses that credential’s Cloud Code Assist project. If refresh +fails or no usable project is available, the request returns an authentication error; use the +reauthentication flow above. A second `401` does not start another refresh/retry cycle. + A proxy that is already running picks up the new credential without a restart: the CLI asks it to reload that one provider from disk, and the request carries no credential of its own. If the running proxy cannot accept that request — most often because it started from a build that predates diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 29dd2c5f1c..4f3811b228 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1068,6 +1068,7 @@ "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", "server-combo-failover-e2e.test.ts": "server", + "server-google-antigravity-oauth-401-replay.test.ts": "server", "server-images-bodyless-content-length.test.ts": "server", "server-images.test.ts": "server", "server-key-failover-e2e.test.ts": "server", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 114c699eaf..01b5d7209e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -905,6 +905,7 @@ "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", "server-combo-failover-e2e.test.ts": "server", + "server-google-antigravity-oauth-401-replay.test.ts": "server", "server-images-bodyless-content-length.test.ts": "server", "server-images.test.ts": "server", "server-key-failover-e2e.test.ts": "server", diff --git a/tests/server/server-google-antigravity-oauth-401-replay.test.ts b/tests/server/server-google-antigravity-oauth-401-replay.test.ts index 8114657f34..8019cdd60e 100644 --- a/tests/server/server-google-antigravity-oauth-401-replay.test.ts +++ b/tests/server/server-google-antigravity-oauth-401-replay.test.ts @@ -4,13 +4,14 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; import { forceRefreshOAuthAccessSnapshot, getValidAccessTokenSnapshot } from "../../src/oauth"; -import { saveCredential } from "../../src/oauth/store"; +import { getAccountSet, saveCredential } from "../../src/oauth/store"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; +const PROD_API_BASE = "https://cloudcode-pa.googleapis.com"; const DAILY_API_BASE = "https://daily-cloudcode-pa.googleapis.com"; const PUBLIC_OAUTH_AUTHENTICATION_ERROR = "OAuth authentication failed. Check the OpenCodex account status and retry."; const WINDOWS_PATH_CANARY = "C:\\Users\\Alice\\.opencodex\\auth.json.ocx-tmp"; @@ -109,12 +110,12 @@ function sseSuccessBody(text: string): string { return `data: ${JSON.stringify(jsonSuccessBody(text))}\n\n`; } -async function postResponses(server: ReturnType, stream = false): Promise { +async function postResponses(server: ReturnType, stream = false, providerName = "google-antigravity"): Promise { return originalFetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - model: "google-antigravity/gemini-3.8-flash", + model: `${providerName}/gemini-3.8-flash`, input: "hello", stream, }), @@ -138,14 +139,18 @@ function installOAuthFetch( options: { tokenErrorDescription?: string; refreshedProjectId?: string | null; + beforeFirstUnauthorized?: () => Promise; } = {}, ): { chatAuth: string[]; chatProjects: string[]; counts: { refresh: number } } { const chatAuth: string[] = []; const chatProjects: string[] = []; const counts = { refresh: 0 }; + let unauthorizedObserved = false; globalThis.fetch = (async (input, init) => { const url = input instanceof Request ? input.url : String(input); + const parsedUrl = new URL(url); + // Google OAuth refresh token endpoint if (url === GOOGLE_TOKEN_ENDPOINT) { counts.refresh += 1; @@ -166,7 +171,7 @@ function installOAuthFetch( } // Google Cloud Code Assist project discovery - if (url.includes(":loadCodeAssist")) { + if (url === `${PROD_API_BASE}/v1internal:loadCodeAssist`) { if (options.refreshedProjectId === null) { return new Response(JSON.stringify({}), { status: 404, headers: { "content-type": "application/json" } }); } @@ -175,26 +180,30 @@ function installOAuthFetch( }), { headers: { "content-type": "application/json" } }); } - if (url.includes(":onboardUser")) { + if (url === `${DAILY_API_BASE}/v1internal:onboardUser`) { if (options.refreshedProjectId === null) { return new Response(JSON.stringify({}), { status: 404, headers: { "content-type": "application/json" } }); } } // Responses passthrough endpoint - if (url.endsWith("/responses") && url.includes("daily-cloudcode-pa.googleapis.com")) { + if (url === `${DAILY_API_BASE}/responses`) { const auth = new Headers(init?.headers).get("authorization") ?? ""; chatAuth.push(auth); const status = apiStatuses.shift() ?? 200; - if (status === 401) { + if (status === 401 && !unauthorizedObserved) { + unauthorizedObserved = true; + await options.beforeFirstUnauthorized?.(); + } + if (status >= 400) { return new Response(JSON.stringify({ error: { - code: 401, + code: status, message: "Request had invalid authentication credentials.", - status: "UNAUTHENTICATED", + status: status === 401 ? "UNAUTHENTICATED" : "PERMISSION_DENIED", }, }), { - status: 401, + status, headers: { "content-type": "application/json" }, }); } @@ -212,7 +221,8 @@ function installOAuthFetch( } // Google Antigravity Generate Content endpoint - if (url.includes("/v1internal:streamGenerateContent") || url.includes("/v1internal:generateContent")) { + if (parsedUrl.origin === DAILY_API_BASE + && ["/v1internal:streamGenerateContent", "/v1internal:generateContent"].includes(parsedUrl.pathname)) { const auth = new Headers(init?.headers).get("authorization") ?? ""; chatAuth.push(auth); if (typeof init?.body === "string") { @@ -222,15 +232,19 @@ function installOAuthFetch( } catch { /* ignore */ } } const status = apiStatuses.shift() ?? 200; - if (status === 401) { + if (status === 401 && !unauthorizedObserved) { + unauthorizedObserved = true; + await options.beforeFirstUnauthorized?.(); + } + if (status >= 400) { return new Response(JSON.stringify({ error: { - code: 401, + code: status, message: "Request had invalid authentication credentials.", - status: "UNAUTHENTICATED", + status: status === 401 ? "UNAUTHENTICATED" : "PERMISSION_DENIED", }, }), { - status: 401, + status, headers: { "content-type": "application/json" }, }); } @@ -246,12 +260,135 @@ function installOAuthFetch( }); } - return originalFetch(input, init); + if (parsedUrl.hostname === "127.0.0.1" || parsedUrl.hostname === "localhost") return originalFetch(input, init); + throw new Error("Unexpected external request in Antigravity replay fixture"); }) as typeof fetch; return { chatAuth, chatProjects, counts }; } describe("Google Antigravity OAuth upstream 401 replay", () => { + test.each([200, 401])("native passthrough replays once and returns the second HTTP %i", async secondStatus => { + await seedOAuth(); + saveConfig(antigravityPassthroughConfig()); + const observed = installOAuthFetch([401, secondStatus]); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(response.status).toBe(secondStatus); + const text = await response.text(); + if (secondStatus === 200) expect(text).toContain("ok after passthrough"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]); + } finally { + await server.stop(true); + } + }); + + test.each([false, true])("HTTP 403 never triggers OAuth refresh (native=%s)", async native => { + await seedOAuth(); + saveConfig(native ? antigravityPassthroughConfig() : antigravityConfig()); + const observed = installOAuthFetch([403]); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(response.status).toBe(403); + await response.text(); + expect(observed.counts.refresh).toBe(0); + expect(observed.chatAuth).toEqual(["Bearer rejected-access"]); + } finally { + await server.stop(true); + } + }); + + test.each([false, true])("a custom key route does not consume Antigravity OAuth credentials (native=%s)", async native => { + await seedOAuth(); + const config = native ? antigravityPassthroughConfig() : antigravityConfig(); + // The canonical Antigravity name is normalized to OAuth by the router. A separately + // named key route is the supported non-OAuth boundary, not a fake canonical key mode. + const name = "antigravity-key-test"; + const provider = config.providers["google-antigravity"]!; + config.providers = { [name]: { ...provider, authMode: "key", apiKey: "static-key-sentinel" } }; + config.defaultProvider = name; + saveConfig(config); + const observed = installOAuthFetch([401]); + const server = startServer(0); + try { + const response = await postResponses(server, false, name); + expect(response.status).toBe(401); + await response.text(); + expect(observed.counts.refresh).toBe(0); + expect(observed.chatAuth).toEqual(["Bearer static-key-sentinel"]); + } finally { + await server.stop(true); + } + }); + + test("retains the same account's stored project when refresh discovery has no project", async () => { + await seedOAuth(); + saveConfig(antigravityConfig()); + const observed = installOAuthFetch([401, 200], { refreshedProjectId: null }); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(response.status).toBe(200); + expect(await response.text()).toContain("ok after google refresh"); + expect(observed.counts.refresh).toBe(1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]); + expect(observed.chatProjects).toEqual(["initial-project-id", "initial-project-id"]); + const snapshot = await getValidAccessTokenSnapshot("google-antigravity"); + expect(snapshot.projectId).toBe("initial-project-id"); + expect(snapshot.accessToken).toBe("fresh-access"); + } finally { + await server.stop(true); + } + }); + + test.each([false, true])("401 stays pinned to rejected account A after active switches to B (newer A generation=%s)", async newerGeneration => { + await seedOAuth(); + const accountA = getAccountSet("google-antigravity")!.activeAccountId; + const config = antigravityConfig(); + config.oauthAccountFailover = { enabled: false }; + config.providers["google-antigravity"]!.oauthAccountFailover = { enabled: false }; + saveConfig(config); + const observed = installOAuthFetch([401, 200], { + refreshedProjectId: "refreshed-project-a", + beforeFirstUnauthorized: async () => { + // Deterministic race point: the original A request was built and observed, but + // its HTTP 401 has not reached the recovery loop. No timing sleeps are needed. + if (newerGeneration) { + await saveCredential("google-antigravity", { + access: "newer-access-a", refresh: "newer-refresh-a", expires: Date.now() + 3_600_000, + accountId: "antigravity-test-account", projectId: "newer-project-a", source: "oauth", + }); + } + await saveCredential("google-antigravity", { + access: "access-b", refresh: "refresh-b", expires: Date.now() + 3_600_000, + accountId: "account-b", projectId: "project-b", source: "oauth", + }); + }, + }); + const server = startServer(0); + try { + const response = await postResponses(server); + expect(response.status).toBe(200); + expect(await response.text()).toContain("ok after google refresh"); + expect(observed.counts.refresh).toBe(newerGeneration ? 0 : 1); + expect(observed.chatAuth).toEqual(["Bearer rejected-access", newerGeneration ? "Bearer newer-access-a" : "Bearer fresh-access"]); + expect(observed.chatProjects).toEqual(["initial-project-id", newerGeneration ? "newer-project-a" : "refreshed-project-a"]); + const accounts = getAccountSet("google-antigravity")!; + expect(accounts.activeAccountId).not.toBe(accountA); + expect(accounts.accounts.find(account => account.id === accounts.activeAccountId)?.credential).toMatchObject({ + access: "access-b", projectId: "project-b", + }); + expect(accounts.accounts.find(account => account.id === accountA)?.credential).toMatchObject({ + access: newerGeneration ? "newer-access-a" : "fresh-access", + projectId: newerGeneration ? "newer-project-a" : "refreshed-project-a", + }); + } finally { + await server.stop(true); + } + }); + test("forceRefreshOAuthAccessSnapshot supports google-antigravity", async () => { await seedOAuth(); installOAuthFetch([], { refreshedProjectId: "rediscovered-project-xyz" }); @@ -381,6 +518,7 @@ describe("Google Antigravity OAuth upstream 401 replay", () => { globalThis.fetch = (async (input, init) => { const url = input instanceof Request ? input.url : String(input); + const parsedUrl = new URL(url); if (url === GOOGLE_TOKEN_ENDPOINT) { refreshCalls += 1; signalRefreshStarted(); @@ -391,12 +529,13 @@ describe("Google Antigravity OAuth upstream 401 replay", () => { expires_in: 3600, }), { headers: { "content-type": "application/json" } }); } - if (url.includes(":loadCodeAssist")) { + if (url === `${PROD_API_BASE}/v1internal:loadCodeAssist`) { return new Response(JSON.stringify({ cloudaicompanionProject: "concurrent-project-id", }), { headers: { "content-type": "application/json" } }); } - if (url.includes("/v1internal:streamGenerateContent") || url.includes("/v1internal:generateContent")) { + if (parsedUrl.origin === DAILY_API_BASE + && ["/v1internal:streamGenerateContent", "/v1internal:generateContent"].includes(parsedUrl.pathname)) { const bearer = new Headers(init?.headers).get("authorization") ?? ""; attemptsByBearer.set(bearer, (attemptsByBearer.get(bearer) ?? 0) + 1); if (bearer === "Bearer rejected-access") { @@ -424,7 +563,9 @@ describe("Google Antigravity OAuth upstream 401 replay", () => { headers: { "content-type": "application/json" }, }); } - return originalFetch(input, init); + const hostname = parsedUrl.hostname; + if (hostname === "127.0.0.1" || hostname === "localhost") return originalFetch(input, init); + throw new Error("Unexpected external request in concurrent Antigravity replay fixture"); }) as typeof fetch; const server = startServer(0); From 6b00fa8d6b450830f0a6ab5beebe1a8fe5243ac5 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:01:01 +0900 Subject: [PATCH 029/236] test(command-code): cover affinity through recovered history --- .../src/content/docs/reference/adapters.md | 13 +++ tests/helpers/agent-task-recovery.ts | 8 +- tests/providers/command-code-provider.test.ts | 70 ++++++++++++++++ .../server-agent-task-recovery-replay.test.ts | 79 +++++++++++++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 2848fed564..3760ad2869 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -148,6 +148,19 @@ of the HTTP retry loop. ChatGPT account id, and the OpenAI beta/originator/session headers. This is the ChatGPT-login path that also powers the [sidecars](/guides/sidecars/). +## Command Code session affinity + +The OAuth `command-code` adapter derives an opaque `x-session-id` from the client +thread identity, then the reasoning-replay conversation identity. When neither is +available, it uses a prompt-cache key only if the integration has explicitly +classified that key as belonging to one conversation. Shared or unclassified cache +keys do not establish session affinity; requests without a usable identity receive +a fresh session ID. Recovery and cached-history replay preserve this classification. + +The API-key `commandcode` provider uses the `openai-chat` adapter and supports +forwarding `prompt_cache_key`. This is separate from the OAuth adapter's session +header and does not guarantee a provider cache hit. + ## `anthropic` **Targets:** Anthropic **Messages** (`/v1/messages`). diff --git a/tests/helpers/agent-task-recovery.ts b/tests/helpers/agent-task-recovery.ts index bf8a173f70..4a6a95c5ae 100644 --- a/tests/helpers/agent-task-recovery.ts +++ b/tests/helpers/agent-task-recovery.ts @@ -147,7 +147,7 @@ export async function post( input: unknown[], headers: HeadersInit = {}, abortSignal?: AbortSignal, - options: { tools?: unknown[]; translatorBudget?: TranslatorBudget } = {}, + options: { tools?: unknown[]; translatorBudget?: TranslatorBudget; promptCacheKeyIsSharedCohort?: boolean } = {}, ): Promise { return handleResponses(new Request("http://localhost/v1/responses", { method: "POST", @@ -156,7 +156,11 @@ export async function post( ...Object.fromEntries(new Headers(headers)), }, body: JSON.stringify({ model, input, stream: false, ...(options.tools ? { tools: options.tools } : {}) }), - }), config, { model: "", provider: "" }, { abortSignal, translatorBudget: options.translatorBudget }); + }), config, { model: "", provider: "" }, { + abortSignal, + translatorBudget: options.translatorBudget, + promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort, + }); } export function encryptedInput(options: { diff --git a/tests/providers/command-code-provider.test.ts b/tests/providers/command-code-provider.test.ts index 9c7cd2604b..a3b81e408d 100644 --- a/tests/providers/command-code-provider.test.ts +++ b/tests/providers/command-code-provider.test.ts @@ -846,6 +846,76 @@ describe("Command Code provider", () => { expect(built.headers["x-session-id"]).toBe(threadId); }); + test("whitespace thread and replay identities fall through to the next trusted identity at the wire", async () => { + const replay: OcxParsedRequest = { + ...parsed(), + _clientThreadId: " \t\n ", + _reasoningReplayScope: { clientThreadId: " replay-after-blank-thread " }, + _promptCacheKeyIsSharedCohort: false, + options: { ...parsed().options, promptCacheKey: "distinct-cache-fallback" }, + }; + const cache: OcxParsedRequest = { + ...replay, + _reasoningReplayScope: { clientThreadId: " \t\n " }, + options: { ...parsed().options, promptCacheKey: " cache-after-blank-replay " }, + }; + const cleanReplay: OcxParsedRequest = { + ...parsed(), + _reasoningReplayScope: { clientThreadId: "replay-after-blank-thread" }, + }; + const cleanCache: OcxParsedRequest = { + ...parsed(), + _promptCacheKeyIsSharedCohort: false, + options: { ...parsed().options, promptCacheKey: "cache-after-blank-replay" }, + }; + const cases: Array<[OcxParsedRequest, OcxParsedRequest]> = [[replay, cleanReplay], [cache, cleanCache]]; + for (const [withWhitespace, clean] of cases) { + const built = await builtRequest(withWhitespace); + const expected = await builtRequest(clean); + expect(built.headers["x-session-id"]).toBe(expected.headers["x-session-id"]); + expect(commandCodeSessionId(withWhitespace)).toBe(built.headers["x-session-id"]); + } + }); + + test("whitespace-only trusted identities produce fresh session headers", async () => { + const blank: OcxParsedRequest = { + ...parsed(), + _clientThreadId: " \t ", + _reasoningReplayScope: { clientThreadId: "\n " }, + _promptCacheKeyIsSharedCohort: false, + options: { ...parsed().options, promptCacheKey: " \t\n " }, + }; + const first = (await builtRequest(blank)).headers["x-session-id"]; + const second = (await builtRequest(blank)).headers["x-session-id"]; + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(first).toMatch(uuid); + expect(second).toMatch(uuid); + expect(first).not.toBe(second); + }); + + test("the same literal in thread, replay and cache namespaces yields distinct stable session headers", async () => { + const literal = "same-identity-in-every-kind"; + const requests: OcxParsedRequest[] = [ + { ...parsed(), _clientThreadId: literal }, + { ...parsed(), _reasoningReplayScope: { clientThreadId: literal } }, + { + ...parsed(), + _promptCacheKeyIsSharedCohort: false, + options: { ...parsed().options, promptCacheKey: literal }, + }, + ]; + const ids: string[] = []; + for (const request of requests) { + const id = (await builtRequest(request)).headers["x-session-id"]!; + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-8[0-9a-f]{3}-[0-9a-f]{12}$/i); + expect(id).not.toContain(literal); + expect((await builtRequest(request)).headers["x-session-id"]).toBe(id); + expect(commandCodeSessionId(request)).toBe(id); + ids.push(id); + } + expect(new Set(ids).size).toBe(3); + }); + test("does not derive affinity from a shared cohort or prompt text", () => { const shared = { ...parsed(), diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts index fd572e9fd8..0e96c21aaf 100644 --- a/tests/server/server-agent-task-recovery-replay.test.ts +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -63,6 +63,85 @@ function encryptedMessage(): unknown[] { return JSON.parse(JSON.stringify(encryptedInput()).replace("Message Type: NEW_TASK", "Message Type: MESSAGE")); } +test.each([true, false, undefined])("fresh recovery and cache-only reparse preserve cohort marker %s and replay metadata", async (cohort) => { + const { post, providerResponse } = await import("../helpers/agent-task-recovery"); + const parentThread = `affinity-parent-${crypto.randomUUID()}`; + const headers = codexHeaders("acct-caller", { + "x-codex-parent-thread-id": parentThread, + "thread-id": "distinct-child-thread", + session_id: "distinct-session", + }); + const config = routedConfig({ enabled: true }); + let recoveries = 0; + const recoveryBodies: string[] = []; + const providerBodies: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const body = String(init?.body); + if (String(url).includes("chatgpt.com")) { + recoveries++; + recoveryBodies.push(body); + return new Response(recoverySse("Read the affinity assignment.")); + } + providerBodies.push(body); + return providerResponse(); + }) as typeof fetch; + + const observations: Array<{ + cohort: boolean | undefined; + thread: string | undefined; + replay: OcxParsedRequest["_reasoningReplayScope"]; + raw: string; + }> = []; + const createChat = ADAPTER_REGISTRY["openai-chat"].create; + const factory = spyOn(ADAPTER_REGISTRY["openai-chat"], "create").mockImplementation((provider, context) => { + const adapter = createChat(provider, context); + return { + ...adapter, + buildRequest(...[parsed, incoming]: Parameters) { + observations.push({ + cohort: parsed._promptCacheKeyIsSharedCohort, + thread: parsed._clientThreadId, + replay: structuredClone(parsed._reasoningReplayScope), + raw: JSON.stringify(parsed._rawBody), + }); + return adapter.buildRequest(parsed, incoming); + }, + }; + }); + try { + const turns = [ + encryptedInput(), + [...encryptedInput(), { type: "message", role: "user", content: "Continue the affinity assignment." }], + ]; + for (const [index, input] of turns.entries()) { + const response = await post(config, "xai/grok-4.5", input, headers, undefined, { + promptCacheKeyIsSharedCohort: cohort, + }); + expect(response.status).toBe(200); + await response.text(); + expect(recoveries).toBe(1); + expect(observations).toHaveLength(index + 1); + expect(providerBodies).toHaveLength(index + 1); + const observed = observations[index]!; + expect(observed.cohort).toBe(cohort); + expect(observed.thread).toBe(parentThread); + expect(observed.replay).toMatchObject({ clientThreadId: parentThread }); + expect(observed.replay).toEqual(observations[0]!.replay); + for (const body of [observed.raw, providerBodies[index]!]) { + expect(body).toContain("Read the affinity assignment."); + expect(body).not.toContain(FERNET_TASK); + expect(body).not.toContain("promptCacheKeyIsSharedCohort"); + } + } + expect(providerBodies[1]).toContain("Continue the affinity assignment."); + expect(recoveryBodies).toHaveLength(1); + expect(recoveryBodies[0]).toContain(FERNET_TASK); + expect(recoveryBodies[0]).not.toContain("promptCacheKeyIsSharedCohort"); + } finally { + factory.mockRestore(); + } +}); + test("MESSAGE recovery reaches the provider and survives tool-result replay", async () => { const { post, providerResponse } = await import("../helpers/agent-task-recovery"); let recoveries = 0; From 9b5ffaaaaa5ee954b1ff14707ed65785f22192b8 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:03:20 +0900 Subject: [PATCH 030/236] test(oauth): exercise native Antigravity replay transport explicitly --- ...oogle-antigravity-oauth-401-replay.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/server/server-google-antigravity-oauth-401-replay.test.ts b/tests/server/server-google-antigravity-oauth-401-replay.test.ts index 8019cdd60e..18c0162f9a 100644 --- a/tests/server/server-google-antigravity-oauth-401-replay.test.ts +++ b/tests/server/server-google-antigravity-oauth-401-replay.test.ts @@ -76,7 +76,11 @@ function antigravityPassthroughConfig(): OcxConfig { defaultProvider: "google-antigravity", providers: { "google-antigravity": { - adapter: "openai-responses", + // Canonical routing restores the Google adapter. The supported model-level + // override is applied afterwards and again when the OAuth replay is rebuilt. + // Synthetic native-branch coverage, not a claim about Google's supported API. + adapter: "google", + modelAdapters: { "gemini-3.8-flash": "openai-responses" }, baseUrl: DAILY_API_BASE, authMode: "oauth", googleMode: "cloud-code-assist", @@ -141,9 +145,10 @@ function installOAuthFetch( refreshedProjectId?: string | null; beforeFirstUnauthorized?: () => Promise; } = {}, -): { chatAuth: string[]; chatProjects: string[]; counts: { refresh: number } } { +): { chatAuth: string[]; chatProjects: string[]; requestPaths: string[]; counts: { refresh: number } } { const chatAuth: string[] = []; const chatProjects: string[] = []; + const requestPaths: string[] = []; const counts = { refresh: 0 }; let unauthorizedObserved = false; globalThis.fetch = (async (input, init) => { @@ -187,7 +192,8 @@ function installOAuthFetch( } // Responses passthrough endpoint - if (url === `${DAILY_API_BASE}/responses`) { + if (url === `${DAILY_API_BASE}/v1/responses`) { + requestPaths.push(parsedUrl.pathname); const auth = new Headers(init?.headers).get("authorization") ?? ""; chatAuth.push(auth); const status = apiStatuses.shift() ?? 200; @@ -223,6 +229,7 @@ function installOAuthFetch( // Google Antigravity Generate Content endpoint if (parsedUrl.origin === DAILY_API_BASE && ["/v1internal:streamGenerateContent", "/v1internal:generateContent"].includes(parsedUrl.pathname)) { + requestPaths.push(parsedUrl.pathname); const auth = new Headers(init?.headers).get("authorization") ?? ""; chatAuth.push(auth); if (typeof init?.body === "string") { @@ -263,7 +270,7 @@ function installOAuthFetch( if (parsedUrl.hostname === "127.0.0.1" || parsedUrl.hostname === "localhost") return originalFetch(input, init); throw new Error("Unexpected external request in Antigravity replay fixture"); }) as typeof fetch; - return { chatAuth, chatProjects, counts }; + return { chatAuth, chatProjects, requestPaths, counts }; } describe("Google Antigravity OAuth upstream 401 replay", () => { @@ -274,6 +281,7 @@ describe("Google Antigravity OAuth upstream 401 replay", () => { const server = startServer(0); try { const response = await postResponses(server); + expect(observed.requestPaths).toEqual(["/v1/responses", "/v1/responses"]); expect(response.status).toBe(secondStatus); const text = await response.text(); if (secondStatus === 200) expect(text).toContain("ok after passthrough"); @@ -291,6 +299,7 @@ describe("Google Antigravity OAuth upstream 401 replay", () => { const server = startServer(0); try { const response = await postResponses(server); + expect(observed.requestPaths).toEqual([native ? "/v1/responses" : "/v1internal:generateContent"]); expect(response.status).toBe(403); await response.text(); expect(observed.counts.refresh).toBe(0); @@ -314,6 +323,7 @@ describe("Google Antigravity OAuth upstream 401 replay", () => { const server = startServer(0); try { const response = await postResponses(server, false, name); + expect(observed.requestPaths).toEqual([native ? "/v1/responses" : "/v1internal:generateContent"]); expect(response.status).toBe(401); await response.text(); expect(observed.counts.refresh).toBe(0); @@ -591,6 +601,7 @@ describe("Google Antigravity OAuth upstream 401 replay", () => { const server = startServer(0); try { const response = await postResponses(server); + expect(observed.requestPaths).toEqual(["/v1/responses"]); const json = await response.json() as { error?: { code?: string; message?: string; type?: string } }; expect(response.status).toBe(401); expect(json.error?.type).toBe("authentication_error"); @@ -609,6 +620,7 @@ describe("Google Antigravity OAuth upstream 401 replay", () => { const server = startServer(0); try { const response = await postResponses(server); + expect(observed.requestPaths).toEqual(["/v1internal:generateContent"]); const json = await response.json() as { error?: { code?: string; message?: string; type?: string } }; expect(response.status).toBe(401); expect(json.error?.type).toBe("authentication_error"); From ad7cd9a410e5e857e3b088899184e140cb8e419d Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:09:05 +0900 Subject: [PATCH 031/236] fix(codex): expose bounded main quota refresh outcomes Carry #3658 onto the current credential-generation and Reserve publication boundary. Keep diagnostics ephemeral and omit stale identity results. Source-commit: 035dfc2c77e3825e507442dff3f98a6631ee0e99 Co-authored-by: Ingwannu --- .../docs/reference/cli/providers-accounts.md | 22 +++ src/cli/account-api.ts | 8 +- src/codex/auth-api.ts | 36 +++- src/codex/quota-refresh-outcome.ts | 27 +++ structure/05_gui-and-management-api.md | 14 ++ tests/cli/cli-account.test.ts | 58 ++++++ .../codex-integration/codex-auth-api.test.ts | 170 +++++++++++++++++- 7 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 src/codex/quota-refresh-outcome.ts diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index be4028cb57..cc2471a527 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -58,6 +58,28 @@ Use `--api-key` or an OAuth login for anything secret. ## Authentication +### Diagnosing missing main-account quota + +`ocx account list openai --quota --refresh --json` includes a `quotaRefresh` object on +the main-account row when that operation attempts a WHAM usage read. The existing +`GET /api/codex-auth/accounts?refresh=1` response exposes the same diagnostic. + +Its `status` is `ok`, `not_reported` (no parseable quota in a successful response), +`http_error`, `timeout`, `network_error`, `invalid_response`, or `internal_error`. +Only `http_error` includes a numeric `httpStatus`. No raw response, error message, +credential, or account identifier is included in this object. Cache-only reads, +credential deferrals, and invalidated account snapshots omit it; older servers +also omit it. Absence is not proof of success. A non-success HTTP status remains +`http_error` even if its error body cannot be read; `timeout` and `network_error` +describe failures before headers or while reading a successful response. + +A valid login does not guarantee that this separate usage request succeeds. +These categories do not change authentication, account selection, or quota +freshness rules, and do not turn unknown quota into zero usage. This diagnostic +currently covers the native main account, not pool-account refreshes. When +reporting missing quota, share the category and HTTP status rather than credential +files or a raw network capture. + ### `ocx login ` Start the provider's registered login flow. OAuth providers open a browser and store auto-refreshed diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index b21f72b85d..76c3456c9c 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -8,6 +8,7 @@ import { runningProxyUpdateHeaders } from "../oauth/login-cli"; import { isPublicOAuthProvider } from "../oauth/index"; import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers/registry"; import type { OcxConfig } from "../types"; +import { projectCodexQuotaRefreshOutcome, type CodexQuotaRefreshOutcome } from "../codex/quota-refresh-outcome"; export type AccountType = "codex" | "oauth" | "api-key"; @@ -24,6 +25,7 @@ export interface AccountRow { /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */ priority?: number; quota?: CodexQuotaDto | null; + quotaRefresh?: CodexQuotaRefreshOutcome; /** * Whether the pool is holding this account out of rotation. * @@ -237,6 +239,7 @@ interface CodexAccountDto { needsReauth?: boolean; priority?: number; quota?: CodexQuotaDto | null; + quotaRefresh?: unknown; paused?: boolean; } @@ -299,7 +302,10 @@ export async function fetchCodexRows( needsReauth: a.needsReauth, priority: typeof a.priority === "number" ? a.priority : 0, paused: a.paused === true, - ...(includeQuota ? { quota: projectQuota(a.quota) } : {}), + ...(includeQuota ? { + quota: projectQuota(a.quota), + quotaRefresh: projectCodexQuotaRefreshOutcome(a.quotaRefresh), + } : {}), })); return { rows, activeId, autoSwitchThreshold, status: 200 }; } diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 865711ba86..b778cb91f8 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -105,6 +105,7 @@ import { type MainAccountInfo, } from "./main-account-cache"; export { clearMainAccountInfoCache } from "./main-account-cache"; +import type { CodexQuotaRefreshOutcome } from "./quota-refresh-outcome"; import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock"; import { observeMainReserveRevocation } from "./reserve-availability"; import { maskEmail } from "../lib/privacy"; @@ -777,6 +778,8 @@ async function readMainAuthErrorCode(resp: Response): Promise { interface MainAccountInfoFetchResult { info: MainAccountInfo; + /** Ephemeral result of this attempt, omitted when no WHAM request was made. */ + quotaRefresh?: CodexQuotaRefreshOutcome; /** Whether this attempt safely inspected the physical native-main credential. */ credentialChecked: boolean; /** Meaningful only when credentialChecked is true. */ @@ -792,12 +795,14 @@ interface MainAccountInfoFetchResult { export interface MainAccountInfoSnapshot { info: MainAccountInfo; mainIdentityGeneration: number; + quotaRefresh?: CodexQuotaRefreshOutcome; } export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise { const result = await fetchMainAccountInfoAttempt(forceRefresh, 1); return { info: result.info, + ...(result.quotaRefresh ? { quotaRefresh: result.quotaRefresh } : {}), mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(), }; } @@ -900,11 +905,15 @@ async function fetchMainAccountInfoWhileOwned( ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) : undefined; const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration(); + // Keep diagnostics separate from authentication and freshness policy. Never serialize errors. + const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS); + let quotaPhase: "request" | "body" | "decode" | "publish" = "request"; try { const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, - signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS), + signal: quotaSignal, }); + quotaPhase = "publish"; if (!resp.ok) { const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); @@ -913,9 +922,14 @@ async function fetchMainAccountInfoWhileOwned( clearMainAccountInfoCache(); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration); } - return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; + return { + info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, + quotaRefresh: { status: "http_error", httpStatus: resp.status }, + }; } + quotaPhase = "body"; const data = (await resp.json()) as WhamUsageResponse; + quotaPhase = "publish"; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); if (retried) return retried; // A delayed response from a replaced bearer cannot revoke a newer Reserve grant, @@ -924,10 +938,12 @@ async function fetchMainAccountInfoWhileOwned( && matchesMainQuotaCredential(tokens.access_token, tokens.account_id)) { observeMainReserveRevocation(data, mainQuotaWriter); } + quotaPhase = "decode"; const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()); const usage = { ...data, ...(plan ? { plan_type: plan } : {}) }; const quota = parseUsageQuota(usage); const policyQuota = parseMainPolicyUsageQuota(usage); + quotaPhase = "publish"; const freshResetCredits = quota?.resetCredits; // Tag the count with the identity it was read from, so a later response that omits the // summary can restore the badge without ever crossing an account boundary. @@ -957,14 +973,24 @@ async function fetchMainAccountInfoWhileOwned( } return { info: result, + quotaRefresh: { status: quota ? "ok" : "not_reported" }, credentialChecked: true, hasCredential: true, ...(quota ? { freshQuota: quota } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; - } catch { + } catch (error) { const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); - return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; + if (retried) return retried; + let status: CodexQuotaRefreshOutcome["status"] = "internal_error"; + if ((quotaPhase === "request" || quotaPhase === "body") && quotaSignal.aborted) status = "timeout"; + else if (quotaPhase === "request") status = "network_error"; + else if (quotaPhase === "body") status = error instanceof SyntaxError ? "invalid_response" : "network_error"; + else if (quotaPhase === "decode") status = "invalid_response"; + return { + info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, + quotaRefresh: { status }, + }; } } @@ -1061,6 +1087,7 @@ export interface CodexAuthAccountDto { healthSummary: string; healthAction?: string; quotaProbeSkipped?: true; + quotaRefresh?: CodexQuotaRefreshOutcome; mainAccountHardLock?: MainAccountHardLockStatus; } @@ -1769,6 +1796,7 @@ export async function listCodexAuthAccountsSnapshot( id: MAIN_CODEX_ACCOUNT_ID, email: maskEmail(mainInfo.email) ?? "Codex App login", plan: mainInfo.plan, + ...(mainSnapshotLive && mainResult.quotaRefresh ? { quotaRefresh: mainResult.quotaRefresh } : {}), logLabel: "main", isMain: true, paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), diff --git a/src/codex/quota-refresh-outcome.ts b/src/codex/quota-refresh-outcome.ts new file mode 100644 index 0000000000..fb059ef69d --- /dev/null +++ b/src/codex/quota-refresh-outcome.ts @@ -0,0 +1,27 @@ +/** Diagnostic only: never use this outcome as quota, entitlement, or admission evidence. */ +export type CodexQuotaRefreshOutcome = + | { status: "http_error"; httpStatus: number } + | { status: "ok" | "not_reported" | "timeout" | "network_error" | "invalid_response" | "internal_error" }; + +/** The management response is untrusted at the CLI boundary; copy only the fixed vocabulary. */ +export function projectCodexQuotaRefreshOutcome(value: unknown): CodexQuotaRefreshOutcome | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const row = value as Record; + if (row.status === "http_error") { + return typeof row.httpStatus === "number" && Number.isInteger(row.httpStatus) + && row.httpStatus >= 100 && row.httpStatus <= 599 + ? { status: "http_error", httpStatus: row.httpStatus } + : undefined; + } + switch (row.status) { + case "ok": + case "not_reported": + case "timeout": + case "network_error": + case "invalid_response": + case "internal_error": + return { status: row.status }; + default: + return undefined; + } +} diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 7a38460fc4..c8fb3bc101 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -362,6 +362,20 @@ include credits-only and measured-zero readings, unsupported, unobserved, explic unavailable-with-last-good. Forced account/key enrichment settles before its control reports a completed check, and provider-report waiters are bound to the exact refresh epoch. +Main-account WHAM refresh diagnostics are an ephemeral `quotaRefresh` outcome carried +from `fetchMainAccountInfoWhileOwned` to the generation-checked account DTO and the +opt-in CLI quota JSON. They are not persisted or consumed by admission/rotation. +The CLI reconstructs the object using a fixed vocabulary and bounded numeric HTTP +status, so an unexpected management response cannot add raw upstream material. + +[Decision Log] +- 목적과 의도: Explain missing main-account quota without confusing a working login with a successful WHAM read. +- 기존 구현 및 제약 조건: HTTP failures and body/transport exceptions returned identical null metadata; existing authentication and freshness policy must remain unchanged. +- 검토한 주요 대안: Copy raw errors, infer plan/quota, reuse stale evidence, or add a bounded diagnostic outcome. +- 선택한 방식: Carry a non-persisted fixed category and optional numeric HTTP status through the existing management and CLI read paths. +- 다른 대안 대신 이 방식을 선택한 이유: It gives reporters actionable evidence without disclosing payloads, changing permissions, or introducing another cache. +- 장점, 단점 및 영향: Main-account failures become distinguishable; root-cause repair and pool diagnostics remain separate work, and clients must tolerate an absent field. + `src/usage/log.ts` writes append-only JSONL to `~/.opencodex/usage.jsonl` with file mode `0o600`. An opt-in shadow-call rewrite persists the bounded, redacted original helper model as `shadowCallRewrittenFrom`, so helper traffic remains identifiable after restart without storing diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 58146c6383..9f8acf3ab1 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { cmdAccount, classifyAccount, formatAccountTable, type AccountDeps } from "../../src/cli/account"; import type { AccountStdin } from "../../src/cli/account-api"; +import { projectCodexQuotaRefreshOutcome } from "../../src/codex/quota-refresh-outcome"; import { printSubcommandUsage } from "../../src/cli/help"; import { DEFAULT_ACCOUNT_PRIORITY, @@ -584,6 +585,63 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test("main quota diagnostics survive opt-in JSON without copying upstream data", async () => { + codexAccounts = [{ id: "__main__", isMain: true, quota: null, + quotaRefresh: { status: "http_error", httpStatus: 503, message: RAW_SENTINEL } }]; + const result = await run(["list", "openai", "--quota", "--refresh", "--json"]); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).accounts[0].quotaRefresh).toEqual({ status: "http_error", httpStatus: 503 }); + expect(result.output).not.toContain(RAW_SENTINEL); + const ordinary = await run(["list", "openai", "--json"]); + expect(JSON.parse(ordinary.stdout).accounts[0]).not.toHaveProperty("quotaRefresh"); + }); + + test.each([ + { status: "private-status-canary" }, + { status: "http_error", httpStatus: "503" }, + { status: "http_error", httpStatus: 999 }, + { status: "http_error", httpStatus: 503.5 }, + null, + ])("invalid quota diagnostic is omitted: %j", async quotaRefresh => { + codexAccounts = [{ id: "__main__", isMain: true, quota: null, quotaRefresh }]; + const result = await run(["list", "openai", "--quota", "--json"]); + expect(JSON.parse(result.stdout).accounts[0]).not.toHaveProperty("quotaRefresh"); + expect(result.output).not.toContain("canary"); + }); + + test.each(["ok", "not_reported", "timeout", "network_error", "invalid_response", "internal_error"])( + "quota JSON reconstructs %s without extra fields", async status => { + codexAccounts = [{ id: "__main__", isMain: true, quota: null, + quotaRefresh: { status, httpStatus: 503, accountId: RAW_SENTINEL, nested: { token: RAW_SENTINEL } } }]; + const result = await run(["list", "openai", "--quota", "--json"]); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).accounts[0].quotaRefresh).toEqual({ status }); + expect(result.output).not.toContain(RAW_SENTINEL); + }, + ); + + test.each([ + undefined, null, [], "private-diagnostic-canary", 0, true, {}, + { status: "private-status-canary" }, + { status: "http_error" }, + { status: "http_error", httpStatus: NaN }, + { status: "http_error", httpStatus: Infinity }, + { status: "http_error", httpStatus: 99 }, + { status: "http_error", httpStatus: 600 }, + { status: "http_error", httpStatus: 403.5 }, + { status: "http_error", httpStatus: "403" }, + ])("diagnostic projector rejects invalid values: %j", value => { + expect(projectCodexQuotaRefreshOutcome(value)).toBeUndefined(); + }); + + test.each([100, 599])("diagnostic projector bounds HTTP status %s and strips extra fields", httpStatus => { + const source = { status: "http_error", httpStatus, token: RAW_SENTINEL }; + const projected = projectCodexQuotaRefreshOutcome(source); + expect(projected).toEqual({ status: "http_error", httpStatus }); + expect(projected).not.toBe(source); + expect(JSON.stringify(projected)).not.toContain(RAW_SENTINEL); + }); + test("1: list renders all three account families, main alias, and padded columns", async () => { const result = await run(["list"]); diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 1cba651ee5..3191a8e6d2 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -14,7 +14,7 @@ import { handleCodexAuthAPI, updateAccountQuota, getAccountQuota, checkAccountIdCollision, getMainChatgptAccountId, markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, - clearMainAccountInfoCache, maskEmail, fetchMainAccountInfo, + clearMainAccountInfoCache, maskEmail, fetchMainAccountInfo, fetchMainAccountInfoSnapshot, clearCodexQuotaPrimeState, primeCodexPoolQuotas, seedCodexAuthAdmissionForTests, type CodexAuthAccountDto, listCodexAuthAccounts, @@ -27,6 +27,7 @@ import { saveCodexAccountCredential, } from "../../src/codex/account-store"; import * as accountStoreModule from "../../src/codex/account-store"; +import * as reserveAvailabilityModule from "../../src/codex/reserve-availability"; import { clearCodexUpstreamHealth, clearThreadAccountMap, @@ -255,6 +256,164 @@ function seedPoolAccount( }); } +describe("main quota refresh diagnostics", () => { + function writeMain(): void { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), account_id: "fixture-account" }, + })); + } + + test.each([401, 403, 429, 503])("HTTP %s is diagnostic, not proof of sign-out", async status => { + writeMain(); + globalThis.fetch = (async () => new Response("private-upstream-canary", { status })) as typeof fetch; + const main = (await listCodexAuthAccounts(makeConfig(), true)).find(row => row.isMain); + expect(main).toMatchObject({ quotaRefresh: { status: "http_error", httpStatus: status }, + plan: null, quota: null, hasCredential: true, needsReauth: false }); + expect(JSON.stringify(main)).not.toContain("private-upstream-canary"); + }); + + test.each(["network_error", "invalid_response", "not_reported", "body_reset"] as const)("classifies %s without serializing errors", async kind => { + writeMain(); + globalThis.fetch = (async () => { + if (kind === "network_error") throw new TypeError("private-network-canary"); + if (kind === "body_reset") return new Response(new ReadableStream({ + start(controller) { controller.error(new TypeError("private-stream-canary")); }, + })); + return kind === "invalid_response" ? new Response("private-json-canary") : Response.json({}); + }) as typeof fetch; + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toEqual({ status: kind === "body_reset" ? "network_error" : kind }); + expect(result.info.quota).toBeNull(); + expect(JSON.stringify(result)).not.toContain("canary"); + }); + + test("timeout reports only the fixed category", async () => { + writeMain(); + const signal = AbortSignal.abort(new DOMException("private-timeout-canary", "TimeoutError")); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(signal); + globalThis.fetch = (async () => { throw signal.reason; }) as typeof fetch; + try { + expect((await fetchMainAccountInfoSnapshot(true)).quotaRefresh).toEqual({ status: "timeout" }); + } finally { timeout.mockRestore(); } + }); + + test.each([401, 403])("HTTP %s takes precedence over an unreadable error body", async status => { + writeMain(); + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(controller) { controller.error(new TypeError("private-error-body-canary")); }, + }), { status })) as typeof fetch; + const main = (await listCodexAuthAccounts(makeConfig(), true)).find(row => row.isMain); + expect(main).toMatchObject({ quotaRefresh: { status: "http_error", httpStatus: status }, + quota: null, hasCredential: true, needsReauth: false }); + expect(JSON.stringify(main)).not.toContain("canary"); + }); + + test("HTTP status survives an aborted error body", async () => { + writeMain(); + const controller = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + globalThis.fetch = (async () => { + controller.abort(new DOMException("private-http-timeout-canary", "TimeoutError")); + return new Response(new ReadableStream({ + start(stream) { stream.error(controller.signal.reason); }, + }), { status: 403 }); + }) as typeof fetch; + try { + expect((await fetchMainAccountInfoSnapshot(true)).quotaRefresh) + .toEqual({ status: "http_error", httpStatus: 403 }); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + } finally { timeout.mockRestore(); } + }); + + test("timeout while reading a successful body reports timeout", async () => { + writeMain(); + const controller = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(stream) { + controller.abort(new DOMException("private-body-timeout-canary", "TimeoutError")); + stream.error(controller.signal.reason); + }, + }))) as typeof fetch; + try { + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toEqual({ status: "timeout" }); + expect(JSON.stringify(result)).not.toContain("canary"); + } finally { timeout.mockRestore(); } + }); + + test("Reserve observer failure is internal even if the request timer has expired", async () => { + writeMain(); + const controller = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + const observer = spyOn(reserveAvailabilityModule, "observeMainReserveRevocation") + .mockImplementation(() => { + controller.abort(); + throw new Error("private-reserve-publication-canary"); + }); + globalThis.fetch = (async () => Response.json({ plan_type: "plus", + rate_limit: { primary_window: { used_percent: 37 } } })) as typeof fetch; + try { + const result = await fetchMainAccountInfoSnapshot(true); + expect(observer).toHaveBeenCalledTimes(1); + expect(result.quotaRefresh).toEqual({ status: "internal_error" }); + expect(result.info.quota).toBeNull(); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)).toBeNull(); + expect(JSON.stringify(result)).not.toContain("canary"); + } finally { observer.mockRestore(); timeout.mockRestore(); } + }); + + test("decoded invalid usage is distinct from a response body failure", async () => { + writeMain(); + const observer = spyOn(reserveAvailabilityModule, "observeMainReserveRevocation") + .mockImplementation(() => {}); + globalThis.fetch = (async () => Response.json(null)) as typeof fetch; + try { + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toEqual({ status: "invalid_response" }); + expect(result.info.quota).toBeNull(); + } finally { observer.mockRestore(); } + }); + + test("missing credentials omit diagnostics without issuing a request", async () => { + let reads = 0; + globalThis.fetch = (async () => { reads++; return Response.json({}); }) as typeof fetch; + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toBeUndefined(); + expect(reads).toBe(0); + }); + + test("exhausted identity retry does not publish either account's diagnostic", async () => { + writeMain(); + let reads = 0; + globalThis.fetch = (async () => { + reads++; + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), + account_id: `replacement-${reads}` }, + })); + return Response.json({ rate_limit: { primary_window: { used_percent: 37 } } }); + }) as typeof fetch; + const result = await fetchMainAccountInfoSnapshot(true); + expect(reads).toBe(2); + expect(result.quotaRefresh).toBeUndefined(); + expect(result.info.quota).toBeNull(); + }); + + test("fresh success reports ok while cache reuse never claims another probe", async () => { + writeMain(); + let reads = 0; + globalThis.fetch = (async () => { reads++; return Response.json({ plan_type: "plus", + rate_limit: { primary_window: { used_percent: 37 } } }); }) as typeof fetch; + const fresh = await fetchMainAccountInfoSnapshot(true); + expect(fresh.quotaRefresh).toEqual({ status: "ok" }); + const cached = await fetchMainAccountInfoSnapshot(false); + expect(cached.quotaRefresh).toBeUndefined(); + expect(cached.info.quota).toEqual(fresh.info.quota); + expect(reads).toBe(1); + }); +}); + beforeEach(() => { resetLifecycleDrainStateForTests(); previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -432,6 +591,7 @@ describe("codex-auth API", () => { releasePool(); const response = await pending; const body = await response?.json() as { accounts: CodexAuthAccountDto[] }; + expect(body.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).not.toHaveProperty("quotaRefresh"); expect(body.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ email: "Codex App login", plan: null, @@ -462,7 +622,9 @@ describe("codex-auth API", () => { clearMainAccountInfoCache(); let drain = acquireNativeMainProfileDrain("credential-snapshot-cold-cache"); try { - expect((await listMain()).hasCredential).toBe(true); + const main = await listMain(); + expect(main.hasCredential).toBe(true); + expect(main).not.toHaveProperty("quotaRefresh"); } finally { drain?.release(); } @@ -472,7 +634,9 @@ describe("codex-auth API", () => { expect((await listMain()).hasCredential).toBe(false); drain = acquireNativeMainProfileDrain("credential-snapshot-warm-cache"); try { - expect((await listMain()).hasCredential).toBe(false); + const main = await listMain(); + expect(main.hasCredential).toBe(false); + expect(main).not.toHaveProperty("quotaRefresh"); } finally { drain?.release(); } From 92533a9b8b9dc6bbe897f0ba990cf3395331aa52 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:11:32 +0900 Subject: [PATCH 032/236] docs: amend A stack with deterministic Windows verification --- .../_plan/260906_a_runtime_stack/000_plan.md | 5 +- .../003_audit_resolution.md | 2 +- .../004_windows_amendment.md | 3 + .../005_amendment_audit.md | 3 + .../_plan/260906_a_runtime_stack/010_sse.md | 2 +- devlog/_plan/260906_a_runtime_stack/020_ws.md | 2 +- .../260906_a_runtime_stack/030_recovery.md | 4 +- .../260906_a_runtime_stack/040_affinity.md | 4 +- .../050_capabilities.md | 4 +- .../051_capability_refresh.md | 5 + .../070_windows_fixtures.md | 123 ++++++++++++++++++ .../{060_landing.md => 080_landing.md} | 0 12 files changed, 146 insertions(+), 11 deletions(-) create mode 100644 devlog/_plan/260906_a_runtime_stack/004_windows_amendment.md create mode 100644 devlog/_plan/260906_a_runtime_stack/005_amendment_audit.md create mode 100644 devlog/_plan/260906_a_runtime_stack/051_capability_refresh.md create mode 100644 devlog/_plan/260906_a_runtime_stack/070_windows_fixtures.md rename devlog/_plan/260906_a_runtime_stack/{060_landing.md => 080_landing.md} (100%) diff --git a/devlog/_plan/260906_a_runtime_stack/000_plan.md b/devlog/_plan/260906_a_runtime_stack/000_plan.md index 521e47c38a..c724b27854 100644 --- a/devlog/_plan/260906_a_runtime_stack/000_plan.md +++ b/devlog/_plan/260906_a_runtime_stack/000_plan.md @@ -17,13 +17,14 @@ | Cycle | Artifact | Consumes | Delivers | |---|---|---|---| -| roadmap | 000 + 010..060 | live dev and public contributor changes | audited full integration plan; docs only | +| roadmap | 000 + 010..080 | live dev and public contributor changes | audited full integration plan; docs only | | sse | 010_sse.md | existing SSE relay boundary | failure notification independent of tee cancellation | | ws | 020_ws.md | prior transport baseline | WS outbound policy and pool identity | | recovery | 030_recovery.md | validated transport stack | MESSAGE recovery + reparse/cache semantics | | affinity | 040_affinity.md | recovery/reparse fields | stable Command Code conversation identity | | capabilities | 050_capabilities.md | final effective dispatch behavior | policy selection congruent with dispatch | -| landing | 060_landing.md | independently verified stack layers | current dev inclusion and closeout | +| windows-fixtures | 070_windows_fixtures.md | current Windows failure evidence | deterministic verifier repair below A stack | +| landing | 080_landing.md | independently verified stack layers and verifier repair | current dev inclusion and closeout | The owner explicitly requested a stack. Independent transport fixes are retained as separate cumulative layers to expose interaction at each head; this publication order is not a claim of a hard dependency between SSE and WS. The actual code dependency is recovery before affinity. Each layer has its own PR diff, regression proof and CI. Bottom-up merge only; retarget before deleting parent branches. Keep stacks short by landing verified lower layers while subsequent cycles continue when possible. diff --git a/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md b/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md index e772aeb294..f871eeb920 100644 --- a/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md +++ b/devlog/_plan/260906_a_runtime_stack/003_audit_resolution.md @@ -2,7 +2,7 @@ Independent reviewer returned GO-WITH-FIXES (2). Both findings accepted and folded before B: -1. Implementation-cycle D previously implied full CI/dev landing, inconsistent with prepared stack layers. 010..050 now explicitly distinguish exact-head remote focused/type verified draft preparation from 060 full-gate landing. Final objective and full-CI-before-merge criteria remain unchanged. +1. Implementation-cycle D previously implied full CI/dev landing, inconsistent with prepared stack layers. 010..050 now explicitly distinguish exact-head remote focused/type verified draft preparation from 080 full-gate landing. Final objective and full-CI-before-merge criteria remain unchanged. 2. Affinity reparse tests required a cohort option the shared post helper did not accept. 040 now names tests/helpers/agent-task-recovery.ts option extension, internal handler forwarding, and true/false/undefined observation in real initial/cache-only adapter calls. Private remote host/user paths were replaced with placeholders; exact machine setup remains ignored scratch. No product edits or local suites in roadmap cycle. diff --git a/devlog/_plan/260906_a_runtime_stack/004_windows_amendment.md b/devlog/_plan/260906_a_runtime_stack/004_windows_amendment.md new file mode 100644 index 0000000000..53b8712654 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/004_windows_amendment.md @@ -0,0 +1,3 @@ +# Windows verifier amendment + +Full Windows CI for SSE head failed two unchanged shutdown-spill fixtures. Logs and causal analysis are retained in ignored ci-triage/report.md. C confirms no concurrent ownership of responses-state.test.ts. Add a separate windows-fixtures PABCD after capabilities and before final landing. It repairs test-only clocks/fallback isolation, independently validates on Windows, publishes a small foundation PR and inserts its verified change beneath the source stack. Refresh descendants bottom-up while preserving contributor commits and required current-head checks. No production ACL/budget change, no test skip, no unexamined rerun. The final landing document moves to080; no existing completion criterion is weakened. Owner explicitly authorized admin merge. diff --git a/devlog/_plan/260906_a_runtime_stack/005_amendment_audit.md b/devlog/_plan/260906_a_runtime_stack/005_amendment_audit.md new file mode 100644 index 0000000000..f871b8eb37 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/005_amendment_audit.md @@ -0,0 +1,3 @@ +# Capability and verifier amendment audit + +Independent reviewer: capability plan PASS; roadmap GO-WITH-FIXES one prerequisite finding. Accepted. Added a new windows-fixtures prerequisite to landing while retaining its existing capabilities edge. No task/criterion completion states or existing prerequisite edges were removed. The durable dependency graph now prevents final landing from being selected before Windows verifier completion. Replaced stale060 landing references with080. Temporary Windows verification workflow still requires concrete security review before push. diff --git a/devlog/_plan/260906_a_runtime_stack/010_sse.md b/devlog/_plan/260906_a_runtime_stack/010_sse.md index e70fc2c223..27aee24e97 100644 --- a/devlog/_plan/260906_a_runtime_stack/010_sse.md +++ b/devlog/_plan/260906_a_runtime_stack/010_sse.md @@ -4,7 +4,7 @@ Status: candidate plan, docs-only; implementation class C3 (stream lifecycle). E ## Implementation-cycle completion versus landing -This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `080_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. ## Source, authorship and drift diff --git a/devlog/_plan/260906_a_runtime_stack/020_ws.md b/devlog/_plan/260906_a_runtime_stack/020_ws.md index 766c662986..2db3f2d07b 100644 --- a/devlog/_plan/260906_a_runtime_stack/020_ws.md +++ b/devlog/_plan/260906_a_runtime_stack/020_ws.md @@ -4,7 +4,7 @@ Status: candidate plan after layer 010, docs-only; implementation class C4 for t ## Implementation-cycle completion versus landing -This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `080_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. ## Source, authorship and drift diff --git a/devlog/_plan/260906_a_runtime_stack/030_recovery.md b/devlog/_plan/260906_a_runtime_stack/030_recovery.md index 99ea201b44..92accfca44 100644 --- a/devlog/_plan/260906_a_runtime_stack/030_recovery.md +++ b/devlog/_plan/260906_a_runtime_stack/030_recovery.md @@ -6,7 +6,7 @@ land; no implementation or verification pass is claimed here. ## Implementation-cycle completion versus landing -This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `080_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. ## Loop specification and scope @@ -23,7 +23,7 @@ This decade cycle ends with a reviewed prepared draft PR, exact-carried-head foc - Verifier: exact-layer remote focused regressions, full Cross-platform CI, privacy and type gates, and independent recovery-boundary review. Commands below are planned for remote execution only; none ran during planning. -- Stop condition: reviewed prepared draft and exact-head remote focused/type evidence; full CI/dev inclusion are required by 060 before feature completion. Partial #3661 stays open. +- Stop condition: reviewed prepared draft and exact-head remote focused/type evidence; full CI/dev inclusion are required by 080 before feature completion. Partial #3661 stays open. - Memory artifact: this file and main-owned `000` roadmap/evidence ledger. - Outcomes: DONE only with the evidence above; NOOP only if current dev already contains equivalent behavior and regressions; BLOCKED for external CI/review diff --git a/devlog/_plan/260906_a_runtime_stack/040_affinity.md b/devlog/_plan/260906_a_runtime_stack/040_affinity.md index c35e7c5db2..8c1380a72e 100644 --- a/devlog/_plan/260906_a_runtime_stack/040_affinity.md +++ b/devlog/_plan/260906_a_runtime_stack/040_affinity.md @@ -6,7 +6,7 @@ This first-cycle artifact is docs only; re-read current source at this layer's P ## Implementation-cycle completion versus landing -This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `080_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. ## Loop specification and scope @@ -21,7 +21,7 @@ This decade cycle ends with a reviewed prepared draft PR, exact-carried-head foc default trust for unclassified cache keys, or extra OAuth cache-key forwarding. - Verifier: remote identity/forwarding/reparse regressions, full current-head CI, privacy/type gates and independent boundary review. No local verifier runs. -- Stop: reviewed prepared draft atop recovery, with exact-head remote focused/type evidence. Full current-head gates and dev ancestry remain required in 060. +- Stop: reviewed prepared draft atop recovery, with exact-head remote focused/type evidence. Full current-head gates and dev ancestry remain required in 080. - Memory artifact: this file plus main-owned roadmap/ledger. Main alone owns FSM, goal, implementation, Git and stack integration. - Resources: existing gh credentials and later own-branch writes only. Inherited diff --git a/devlog/_plan/260906_a_runtime_stack/050_capabilities.md b/devlog/_plan/260906_a_runtime_stack/050_capabilities.md index b545f21bae..51514b6215 100644 --- a/devlog/_plan/260906_a_runtime_stack/050_capabilities.md +++ b/devlog/_plan/260906_a_runtime_stack/050_capabilities.md @@ -2,7 +2,7 @@ ## Implementation-cycle completion versus landing -This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `060_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. +This decade cycle ends with a reviewed prepared draft PR, exact-carried-head focused remote activation evidence and remote typecheck, with full CI dispatched. That cycle D does not claim the bug shipped, full CI passed, or an issue resolved. `080_landing.md` retains the mandatory full current-head cross-platform/type/privacy/docs evidence, review, dev ancestry and immediate source-PR/fully-resolved-issue closure gates. Later P consumes the verified prepared stack parent; it need not have landed yet. Only final landing yields feature DONE. ## Candidate implementation contract @@ -14,7 +14,7 @@ Status: candidate planning, not implementation or merge approval. Revalidate at - Goal: runtime selection and ordinary management dry-run agree on effective transport capabilities and exclude unresolved, missing, or disabled providers before scoring. - Non-goals: new provider metadata, registry precedence redesign, catalog UI, OAuth refresh, request transport changes, Lab activation changes, release operations, or changing caller-supplied synthetic dry-run evidence semantics. - Verifier: remote focused routing/API regressions plus exact-head full Cross-platform CI and a remote documentation build. No local tests, typecheck, builds, or verifier execution in this planning assignment. -- Stop: independently working reviewed draft with original authorship and exact-head remote focused/type evidence. Full current-head gates/dev ancestry remain required by 060. +- Stop: independently working reviewed draft with original authorship and exact-head remote focused/type evidence. Full current-head gates/dev ancestry remain required by 080. - Memory artifact: this document and the main-owned roadmap/evidence ledger. - Outcomes: DONE only after verified dev integration; NOOP only if current dev independently contains all behavior and regressions; BLOCKED for unavailable external CI/credentials; NEEDS_HUMAN/UNSAFE for a policy decision outside authorization; a resource checkpoint is reassessment, never fabricated completion. - Delegation: inherited parallel read-only reviewers are authorized. Main reclaims a packet after two distinct failed workers; further write delegation requires a P amendment with exact ownership. diff --git a/devlog/_plan/260906_a_runtime_stack/051_capability_refresh.md b/devlog/_plan/260906_a_runtime_stack/051_capability_refresh.md new file mode 100644 index 0000000000..190122e007 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/051_capability_refresh.md @@ -0,0 +1,5 @@ +# Effective-capability layer P refresh + +Consume050 on prepared affinity6b00fa8d6. Original #3671 remains7b1beb9c5, with two Hako commits. Carry both; fix the remaining public review by initializing routeResolutionFailed to !provider || provider.disabled===true before resolving enabled candidates. Preserve synthetic caller-supplied dry-run evidence semantics and the core/Lab slot boundary. Explicitly test missing and disabled candidates under allow/penalize/exclude, with healthy sibling and with none, at both runtime/evaluator and ordinary management dry-run. + +Regression worker owns only tests/routing/routing-capability-model-matching.test.ts and tests/routing/routing-profile.test.ts. Main owns production, four existing guide locales and runtime SOT. No local tests/typecheck/build; pinned remote Bun1.4 --isolate focused checks, actual API assertions, privacy/docs/fullCI before landing. Windows verifier repair is registered as an additional required cycle and does not weaken any earlier gate. diff --git a/devlog/_plan/260906_a_runtime_stack/070_windows_fixtures.md b/devlog/_plan/260906_a_runtime_stack/070_windows_fixtures.md new file mode 100644 index 0000000000..daab52ed55 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/070_windows_fixtures.md @@ -0,0 +1,123 @@ +# 070 — Deterministic Windows shutdown-spill fixtures + +Status: P amendment; documentation only. Future implementation is a separate C2 test-harness cycle after 050, before 080. C confirmed no ownership collision. Main owns the FSM, implementation, remote execution and insertion of this foundation beneath the runtime stack. + +## Evidence and boundary + +[Windows job 101339545421](https://github.com/lidge-jun/opencodex/actions/runs/33978547130/job/101339545421), head `4b34cbb8d3f308cd2b01e8d87784c65afb50a40f`, Bun 1.4.0: 3048 pass, 39 skip, 2 fail, 1 unhandled error. The two failures are in `tests/responses/responses-state.test.ts`: + +- Stable-tail (1297): the 500 ms drain timer selected synchronous fallback; its unmocked ACL runner failed with EICACLS. The actual async-delay cause is unmeasured. Global ACL call 7 is also an unreliable publication marker: snapshot and directory hardening share this runner. +- Reserved budget (1438): only the ACL clock is synthetic. `spill-store.ts:232` charged real serialization/filesystem elapsed time and exhausted the deadline before temp-file hardening. The expensive operation is not identified. + +Local evidence inputs: `.tmp/a-runtime-stack/ci-triage/report.md`, `sse-windows5.log:4038–4217`, and `prior-101262480176.log:3894–3897` in that same scratch directory. The earlier job passed the two cases; its overall run was not green. Unchanged source on the sampled dev is not an independently reproduced current-dev failure. Do not describe this as an SSE regression, a proven harmless transient, or a green Windows gate. + +Future edit set: **only `tests/responses/responses-state.test.ts`**. No production, workflow, manifest, shared fixture or budget changes. Reuse `forceWindowsAclLane`, `isSpillAclTarget`, `ICACLS_OK`, existing clock/runner setters, and spill event recording. Keep existing deadline, fallback, exhaustion and watchdog tests. No sleeps for synchronization, timeout increases, skips or relaxed assertions. + +Read-only owners inspected: + +| Owner | Contract retained | +|---|---| +| `src/responses/state.ts:595` | Drain races the observed tail against a real timer; `Date.now` alone cannot freeze that timer. | +| `src/responses/state.ts:771` and `:813` | Separate fallback reserve, remaining-budget forwarding, and repeated observation until the publication tail is stable. | +| `src/responses/spill-store.ts:100`, `:153`, `:225` | Existing I/O events and injectable spill clock; each harden gets min(per-call cap, remaining whole-write budget). | +| `src/lib/windows-secret-acl.ts:360`, `:410`, `:589` | Async runner timer; injected ACL clock; grant/inheritance/remove calls consume one harden deadline. | +| `tests/responses/ws-upstream.test.ts:725` | Existing Bun `jest.useFakeTimers` / `advanceTimersByTime` / `useRealTimers` convention. | + +## Hunk 1 — Stable-tail ordering, not elapsed disk time + +At the test import, add `jest`. Retain 1000/500 budgets. Use fake timers **only within this test**, with `Date.now` fixed to a captured real epoch and ACL/spill clocks fixed consistently. Capture native `setImmediate` before enabling fake timers for an event-loop checkpoint; this drains runnable promise work without a sleep or timer advance. No new shared helper. + +Replace the global `aclCalls === 1/7` runner with gates on the first two distinct spill temp paths at `/grant:r`: + +```ts +const gatedTemps = new Set(); +setAsyncIcaclsRunnerForTests(async args => { + const target = args[0] ?? ""; + if (!isSpillAclTarget(args) || !target.endsWith(".tmp") || args[1] !== "/grant:r") { + return ICACLS_OK; // includes snapshot, directory and later ACL steps + } + if (!gatedTemps.has(target)) { + gatedTemps.add(target); + if (gatedTemps.size === 1) { firstEntered(); await firstGate; } + if (gatedTemps.size === 2) { secondEntered(); await secondGate; } + } + return ICACLS_OK; +}); +let syncSpillCalls = 0; +setIcaclsRunnerForTests(args => { + if (isSpillAclTarget(args)) syncSpillCalls++; + return ICACLS_OK; +}); +``` + +Both principal resolvers remain synthetic through `forceWindowsAclLane`. Both ACL runners cover **every** target; filtering controls gating/counting, never whether a real subprocess is used. A fallback must fail the ordering oracle (`syncSpillCalls === 0`), rather than being hidden by the successful mock. + +Replace the current orchestration and 25 ms sleep with this exact ordering: + +1. Enter `try/finally` before the first enqueue/await. Enable fake timers and fixed epoch clock; install both clock setters. Enqueue first response and await its temp gate. +2. Start `flushResponseState`, immediately attach both settlement handlers, recording `flushed` and any error in a resolved outcome object. This avoids an unhandled rejection if an earlier assertion fails. +3. Enqueue second response **after** starting flush, then release first. Await second temp gate. Await a native `setImmediate` checkpoint, advance fake timers by 25 ms, then another native checkpoint. The drain timer stays below 500 ms; no real elapsed filesystem time can fire it. +4. Assert flush is still pending, exactly two distinct temp paths were gated, and no synchronous spill ACL calls occurred. Record `setSpillIoForTest({ record })` events and assert exactly one `stub-swap` so the first publication actually installed while the second is gated. +5. Release second, await the handled flush outcome and rethrow any captured error. Retain `{ residentCount: 0, spillStubCount: 2 }`; add pending `{ count: 0, bytes: 0 }`, two `stub-swap` events, and zero synchronous spill calls. Both stored response IDs must still expand to their distinct payloads. +6. `finally`: release **both** gates, await any started flush outcome and `flushPendingResponseSpillsForTests()` while mocks/clocks remain installed, then restore the Date spy and real timers in a nested `finally`. Existing `afterEach` restores setters. Never restore mocks while a gated async operation still owns work. + +Use a discriminated outcome (`{ ok: true } | { ok: false; error: unknown }`) rather than an undefined-error sentinel. Keep cleanup valid when either startup await/assertion fails. Fake-timer compatibility and the native checkpoint are remote Windows acceptance items, not assumed proof. Do not solve a failed fixture by globally suppressing timers or adding a production seam. + +## Hunk 2 — One logical fallback budget, actual drain timer + +At 1438, preserve `totalMs = 500`, `fallbackReserveMs = 300` and the pending async spill gate. Add the missing spill clock; scope a Date spy to the flush so outer fallback accounting and nested ACL accounting advance together. Keep native timers in this test: the unchanged 200 ms drain timer must expire while the async gate remains held. + +```diff + let aclClock = 0; + setNowForTests(() => aclClock); ++setResponseSpillNowForTests(() => aclClock); +``` + +Record `{ target, timeoutMs, spentBefore }` for **spill** synchronous ACL calls. Snapshot ACL calls return `ICACLS_OK` without charging the spill clock. For each spill call, record before incrementing `aclClock += 20`; preserve successful command results. + +```ts +const epoch = Date.now(); +const nowSpy = spyOn(Date, "now").mockImplementation(() => epoch + aclClock); +// Start only after the async spill gate announces entry. +try { + await flushResponseState(); // native 200 ms drain timer selects sync fallback +} finally { + release(); + try { await flushPendingResponseSpillsForTests(); } + finally { nowSpy.mockRestore(); } +} +``` + +An enclosing `try/finally` must also cover enqueue and `await started`, releasing the gate on early failure. Preserve all three original assertions: at least six spill commands, maximum deadline <= 150, and `200 + aclClock <= 500`. Add: + +- Every timeout is positive and <= `300 - spentBefore` (independent literal budget oracle). +- Within each target's grant/inheritance/remove sequence, each next timeout is exactly 20 ms smaller; do **not** assert global monotonicity across targets because a new harden has its own per-call cap. +- The async gate has not been released when synchronous spill work begins; fallback actually ran, pending count/bytes become zero, one spill stub remains, and replay contains the original payload. + +The Date spy prevents unmeasured real disk latency from consuming this logical-budget fixture. It does not disable the native drain timer. Real-time termination coverage remains in the unchanged cap-expiry test (1339) and `shutdown fallback budget exhaustion is contained by a child watchdog` (1613), using `tests/helpers/responses-state-shutdown-budget-child.ts`. Do not claim this test measures OS elapsed latency. + +## Windows red, control and proof + +Main executes these later on real Windows with the repository-pinned Bun, in isolated remote checkouts. Nothing below authorizes local tests in this documentation task. + +1. Preserve the failed root-head job/logs above. Run the original two tests on the pinned pre-fix baseline; record actual results, including a pass. Do not require random failure or accept retries as a fix. +2. In remote scratch only, force the old stable-tail drain to expire by holding the second gate until a recorded fallback entry. Use a counted synchronous sentinel that reports EICACLS instead of invoking native ACL tools. Confirm rejection and the fallback call; never infer the missing-mock path from elapsed time alone. This is a controlled mechanism probe, not proof that the same delay happened in CI. +3. In remote scratch only, use the existing spill `record("write")` event to advance a separate wall clock by 301 ms once synchronous fallback has begun. On the original reserved-budget fixture, spill uses that clock and fails before temp hardening; with the proposed shared logical spill clock, the same wall-clock perturbation cannot consume the ACL budget. Record entry and clock values. Keep this probe separate from production and from the committed passing fixture. +4. Prove oracle sensitivity with isolated remote mutations: (a) stop drain after its first observed tail, expecting the revised stable-tail pending/zero-fallback oracle to fail; (b) reset the ACL deadline for each command, expecting per-target 20 ms decrease assertions to fail. Separately advance the **injected spill clock** beyond 300 at the write event and require ETIMEDOUT, proving deadline enforcement remains active. Restore every mutation before green verification; retain diff and failing assertion for each probe. +5. Run the unchanged named cap-expiry and child-watchdog controls, then the whole focused file on the new exact head: + +```sh +# Remote Windows only; these commands are a future verifier recipe. +bun test --isolate --timeout 60000 tests/responses/responses-state.test.ts +bun run typecheck +``` + +6. Dispatch the actual Windows full-suite workflow on that exact head, including `bun test --isolate --timeout 60000 tests --shard=5/6` and every other required shard. Inspect job execution, not aggregate success with skipped tests. Record head SHA, Bun version, commands, job URLs, counts and absence of unhandled errors. Run current-head Linux/macOS gates and required scans as well. + +Implementation D means an independently reviewed prepared foundation draft with exact-head focused Windows evidence and remote typecheck; it is **not landing**. Main inserts the verified foundation beneath the stack, refreshes descendants bottom-up with original attribution intact, obtains required current-head gates, then admin-merges in dependency order. Verify each landed SHA is an ancestor of freshly fetched dev before closing a superseded PR or fully resolved issue. Partial issues retain their residual scope. See `080_landing.md`. + +Documentation acceptance: this file names both failed fixtures, all clock/timer boundaries, complete runner/cleanup coverage, executable negative controls, one-file implementation scope and separate landing gates. No test execution or implementation success is claimed here. + +## Remote execution fallback amendment + +The existing direct Windows SSH endpoint is unavailable; the reachable auxiliary host is Linux without Windows interop. Use GitHub Actions for actual Windows proof. If the existing full-suite workflow cannot execute focused causal probes, a separate owner-only `codex/a-verify-windows` branch may hold a temporary verification workflow triggered only by pushes to that exact branch. This workflow is never included in a product PR or merged to dev. It uses `windows-latest`, read-only contents permission, pinned checkout with `persist-credentials: false`, the existing pinned-Bun setup, fixed repository test commands and the exact carried fixture commit. No secrets, untrusted command inputs, self-hosted runner access or release permissions. It may execute the narrowly specified scratch mutations with guaranteed source restoration and upload logs. Independent security audit of the concrete workflow is required before pushing it. Standard per-head full CI remains the final gate; the temporary verifier cannot mark those checks green. diff --git a/devlog/_plan/260906_a_runtime_stack/060_landing.md b/devlog/_plan/260906_a_runtime_stack/080_landing.md similarity index 100% rename from devlog/_plan/260906_a_runtime_stack/060_landing.md rename to devlog/_plan/260906_a_runtime_stack/080_landing.md From 5a8853076415048e07cf9df874ce13c2d566ac8b Mon Sep 17 00:00:00 2001 From: Hako <25837994+devswha@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:39:04 +0900 Subject: [PATCH 033/236] fix(routing): evaluate effective provider capabilities (cherry picked from commit 2b1e0e00c12d7287f9324a4a39ec7e966712affe) --- .../docs/fr/guides/routing-profile-editor.md | 5 + .../docs/guides/routing-profile-editor.md | 5 + .../docs/tr/guides/routing-profile-editor.md | 6 +- .../zh-tw/guides/routing-profile-editor.md | 2 + src/routing/capability.ts | 13 +- src/routing/compatibility/assemble.ts | 15 +- .../routing-capability-model-matching.test.ts | 210 +++++++++++++++++- 7 files changed, 249 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md index b437c28b3d..f2577bc013 100644 --- a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md @@ -37,6 +37,11 @@ résultat du plafond. ## Simuler un profil enregistré +Les capacités des candidats utilisent la configuration effective du fournisseur, +après application du registre. Les exigences de localité (`localOnly` et +`remoteAllowed`) utilisent donc l'adresse amont effective. Si elle ne peut pas être +classée, `unknownEvidence.capability` détermine l'admissibilité du candidat. + Sélectionnez un profil enregistré et utilisez **Évaluation à sec** pour ajouter des éléments propres à la requête, tels que la taille de la fenêtre de contexte, l’utilisation d’outils, l’entrée d’images ou la sortie structurée. La simulation évalue l’admissibilité et la notation, mais n’envoie jamais de requête à un modèle en amont. Les modifications non enregistrées ne sont pas prises en compte par la simulation. Enregistrez d’abord le profil afin que la révision et l’évaluation affichées correspondent à la même configuration. diff --git a/docs-site/src/content/docs/guides/routing-profile-editor.md b/docs-site/src/content/docs/guides/routing-profile-editor.md index 5cf5fc6d71..6bc7751cb7 100644 --- a/docs-site/src/content/docs/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/guides/routing-profile-editor.md @@ -38,6 +38,11 @@ cap outcome. ## Dry-run a saved profile +Candidate capabilities use the effective provider configuration after registry +overrides are applied. Locality requirements (`localOnly` and `remoteAllowed`) +therefore use the effective upstream address. If that address cannot be classified, +the profile's `unknownEvidence.capability` setting decides eligibility. + Select a saved profile and use **Dry-run evaluation** to add request evidence such as context-window size, tool use, image input, or structured output. Dry-run evaluates eligibility and scoring but never sends an upstream model request. Unsaved edits are not used by dry-run. Save the profile first so the displayed revision and evaluation refer to the same configuration. diff --git a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md index dd7aa50d72..6a02d786a7 100644 --- a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md @@ -52,6 +52,11 @@ ayrıdır. ## Kaydedilmiş bir profilde deneme çalıştırması (dry-run) yapma +Aday yetenekleri, kayıt defteri kuralları uygulandıktan sonraki etkin sağlayıcı +yapılandırmasını kullanır. Yerellik gereksinimleri (`localOnly` ve `remoteAllowed`) +bu nedenle etkin üst sunucu adresine göre değerlendirilir. Adres sınıflandırılamıyorsa, +adayın uygunluğunu profilin `unknownEvidence.capability` ayarı belirler. + Kaydedilmiş bir profili seçin ve bağlam penceresi boyutu, araç kullanımı, görsel girişi veya yapılandırılmış çıktı gibi istek kanıtları eklemek için **Deneme çalıştırması değerlendirmesi (Dry-run evaluation)**'ı kullanın. Deneme @@ -100,4 +105,3 @@ Düzenleyici şu uç noktaları kullanır: } ``` - diff --git a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md index e6ae93a76e..2dcf9d687b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md @@ -32,6 +32,8 @@ OpenCodex 儀表板中的 **Models → Routing** 分頁可以直接管理 `confi ## 試跑已儲存的設定檔 +候選能力使用套用 registry 覆寫後的有效供應商設定。因此,本地性需求(`localOnly` 與 `remoteAllowed`)會依據實際上游位址判定。若無法分類該位址,則由設定檔的 `unknownEvidence.capability` 決定候選是否合格。 + 選取一個已儲存的設定檔,使用 **Dry-run evaluation** 加入請求證據,例如 context-window 大小、工具使用、圖片輸入或結構化輸出。試跑會評估資格與評分,但永遠不會送出上游模型請求。 未儲存的編輯不會被試跑使用。請先儲存設定檔,讓顯示的 revision 與評估參照同一份設定。 diff --git a/src/routing/capability.ts b/src/routing/capability.ts index 8495951a0f..7f26e8bbdf 100644 --- a/src/routing/capability.ts +++ b/src/routing/capability.ts @@ -10,7 +10,7 @@ * how that affects eligibility. */ -import { modelInList, type OcxConfig } from "../types"; +import { modelInList, type OcxConfig, type OcxProviderConfig } from "../types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { serviceTierSupportForModel } from "../providers/service-tier"; import { PROVIDER_REGISTRY } from "../providers/registry"; @@ -149,14 +149,20 @@ function localRemoteEvidence(baseUrl: string | undefined): Pick entry.id === providerName); + const provider = resolvedProvider ?? config.providers[providerName]; + const registryEntry = resolvedProvider === undefined + ? PROVIDER_REGISTRY.find(entry => entry.id === providerName) + : undefined; const catalogRow = cachedCatalogModels().find(model => model.provider === providerName && model.id === modelId); const isNative = providerName === OPENAI_CODEX_PROVIDER_ID && !modelId.includes("/"); @@ -224,6 +230,7 @@ export function candidateCapabilityEvidence( ? [] : modelRecordValue(provider?.modelReasoningEfforts, modelId) ?? modelRecordValue(registryEntry?.modelReasoningEfforts, modelId) + ?? provider?.reasoningEfforts ?? (isNative ? nativeReasoningEfforts(modelId) : undefined); const tierSupport = provider diff --git a/src/routing/compatibility/assemble.ts b/src/routing/compatibility/assemble.ts index 1d543690a4..cbc036754f 100644 --- a/src/routing/compatibility/assemble.ts +++ b/src/routing/compatibility/assemble.ts @@ -52,11 +52,24 @@ export function assemblePolicyCandidateEvidence( return profile.candidates.map(candidate => { const key = `${candidate.provider}/${candidate.model}`; const compatibility = compatibilityByCandidate?.get(key); + const provider = config.providers[candidate.provider]; + let routed: OcxProviderConfig | undefined; + if (provider && provider.disabled !== true) { + try { + routed = options.routedProviderConfig(candidate.provider, provider); + } catch { + // An unresolved transport proves no capabilities. Do not abort healthy + // siblings: if this candidate is selected, normal route validation still + // reports its concrete error before dispatch. + } + } return { provider: candidate.provider, model: candidate.model, - capability: candidateCapabilityEvidence(config, candidate.provider, candidate.model), + capability: routed + ? candidateCapabilityEvidence(config, candidate.provider, candidate.model, routed) + : undefined, health: policyCandidateHealthEvidence(config, candidate, now), quota: quotaEvidenceForCandidate({ provider: candidate.provider, diff --git a/tests/routing/routing-capability-model-matching.test.ts b/tests/routing/routing-capability-model-matching.test.ts index bb956c2d8d..5d85b715fd 100644 --- a/tests/routing/routing-capability-model-matching.test.ts +++ b/tests/routing/routing-capability-model-matching.test.ts @@ -1,10 +1,19 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { validateConfigCandidate } from "../../src/config"; +import { NoEligiblePolicyCandidateError, routeModel, routedProviderConfig } from "../../src/router"; import { candidateCapabilityEvidence } from "../../src/routing/capability"; +import { assemblePolicyCandidateEvidence } from "../../src/routing/compatibility/assemble"; import { evaluatePolicyProfile } from "../../src/routing/evaluator"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; +import { getRoutingProfile } from "../../src/routing/profile"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; import { modelRecordValue } from "../../src/reasoning-effort"; import { isModelTextOnly } from "../../src/vision"; -import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import type { OcxConfig, OcxProviderConfig, OcxRoutingProfileConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; /** * `candidateCapabilityEvidence` describes what the resolver will do with a candidate, @@ -35,6 +44,203 @@ function configFor(provider: OcxProviderConfig): OcxConfig { return { providers: { custom: provider } } as unknown as OcxConfig; } +describe("policy capability evidence uses the effective provider", () => { + let testDir: string; + let previousHome: string | undefined; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-effective-capability-")); + process.env.OPENCODEX_HOME = testDir; + }); + + afterEach(() => { + closeRequestHistoryIndex(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(testDir); + }); + + function policyConfig( + name: string, + provider: OcxProviderConfig, + model: string, + require: OcxRoutingProfileConfig["require"], + ): OcxConfig { + const result = validateConfigCandidate({ + port: 10100, + defaultProvider: name, + providers: { [name]: provider }, + routingProfiles: { guarded: { candidates: [{ provider: name, model }], require } }, + }); + if (!result.ok) throw new Error(result.error); + return result.config; + } + + const localOnly = { localOnly: true, remoteAllowed: false }; + const loopback = "http://127.0.0.1:11434/v1"; + + test("a loopback URL discarded by registry routing cannot satisfy a local-only policy", () => { + const config = policyConfig("deepseek", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "deepseek-v4-flash", localOnly); + const before = structuredClone(config); + + expect(routeModel(config, "deepseek/deepseek-v4-flash").provider.baseUrl) + .toBe("https://api.deepseek.com"); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + expect(config).toEqual(before); + }); + + test.each(["custom-local", "ollama"])("a genuine local %s endpoint remains eligible", name => { + const config = policyConfig(name, { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", localOnly); + const before = structuredClone(config); + + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe(name); + expect(route.provider.baseUrl).toBe(loopback); + expect(route.routeDecision?.requirements).toEqual([ + { id: "local-only", expected: true, actual: true, outcome: "satisfied" }, + { id: "remote-allowed", expected: false, actual: false, outcome: "satisfied" }, + ]); + expect(config).toEqual(before); + }); + + test("an explicitly public endpoint remains ineligible for a local-only policy", () => { + const config = policyConfig("deepseek", { + adapter: "openai-chat", baseUrl: "https://api.deepseek.com", + }, "deepseek-v4-flash", localOnly); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("a local candidate is selected after excluding a registry-pinned remote candidate", () => { + const config = policyConfig("deepseek", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "deepseek-v4-flash", localOnly); + config.providers.local = { adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true }; + config.routingProfiles!.guarded!.candidates.push({ provider: "local", model: "local-model" }); + + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe("local"); + expect(route.provider.baseUrl).toBe(loopback); + expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); + }); + + test("registry no-vision defaults participate before policy image requirements", () => { + const config = policyConfig("deepseek", { + adapter: "openai-chat", baseUrl: "https://api.deepseek.com", + modelInputModalities: { "deepseek-v4-flash": ["text", "image"] }, + }, "deepseek-v4-flash", { imageInput: true }); + const routed = routeModel(config, "deepseek/deepseek-v4-flash"); + expect(isModelTextOnly(routed.provider, routed.modelId)).toBe(true); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("the effective model context ceiling gates a policy requirement", () => { + const config = policyConfig("openai-apikey", { + adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", + modelContextWindows: { "gpt-6-astra": 2_000_000 }, + }, "gpt-6-astra", { minContextWindow: 1_500_000 }); + const routed = routeModel(config, "openai-apikey/gpt-6-astra"); + expect(routed.provider.modelContextWindows?.["gpt-6-astra"]).toBe(1_050_000); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("canonical forward auth filled by routing satisfies the encrypted-task requirement", () => { + const config = policyConfig("openai", { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + }, "gpt-5.5", { encryptedCodexTasks: true }); + + const route = routeModel(config, "policy/guarded"); + expect(route.provider.authMode).toBe("forward"); + expect(route.routeDecision?.candidates[0]?.capability?.encryptedCodexTasks).toBe(true); + expect(config.providers.openai!.authMode).toBeUndefined(); + }); + + test("the effective provider-wide reasoning ladder participates in policy selection", () => { + const config = policyConfig("xiaomi-mimo", { + adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1", + }, "mimo-v2.5", { reasoningEffort: "high" }); + + const route = routeModel(config, "policy/guarded"); + expect(route.provider.reasoningEfforts).toEqual(["low", "medium", "high"]); + expect(route.routeDecision?.candidates[0]?.capability?.reasoningEfforts) + .toEqual(["low", "medium", "high"]); + expect(config.providers["xiaomi-mimo"]!.reasoningEfforts).toBeUndefined(); + }); + + test("a same-named custom transport does not inherit an unrelated registry model map", () => { + const config = policyConfig("meta-model", { + adapter: "openai-responses", baseUrl: "https://custom.example/v1", + }, "muse-spark-1.3", { reasoningEffort: "high" }); + const routed = routeModel(config, "meta-model/muse-spark-1.3"); + expect(routed.provider.baseUrl).toBe("https://custom.example/v1"); + expect(routed.provider.modelReasoningEfforts).toBeUndefined(); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("an invalid unselected transport cannot prevent a healthy sibling from routing", () => { + const config = policyConfig("local", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", {}); + config.providers.ollama = { adapter: "openai-chat", baseUrl: " " }; + config.routingProfiles!.guarded!.candidates.push({ provider: "ollama", model: "local-model" }); + + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe("local"); + expect(route.provider.baseUrl).toBe(loopback); + expect(route.routeDecision?.candidates[1]?.capability).toBeUndefined(); + }); + + test("an unresolved transport contributes no positive capability evidence", () => { + const config = policyConfig("ollama", { + adapter: "openai-chat", baseUrl: loopback, + modelInputModalities: { "local-model": ["text", "image"] }, + }, "local-model", { imageInput: true }); + config.providers.ollama!.baseUrl = " "; + + const evidence = assemblePolicyCandidateEvidence(config, getRoutingProfile(config, "guarded")!, Date.now(), { + routedProviderConfig, + }); + expect(evidence[0]?.capability).toBeUndefined(); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("missing and disabled providers are not resolved for capability evidence", () => { + const config = policyConfig("local", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", { tools: true }); + config.providers.disabled = { ...config.providers.local!, disabled: true }; + config.routingProfiles!.guarded!.candidates.push( + { provider: "missing", model: "model" }, + { provider: "disabled", model: "model" }, + ); + const resolved: string[] = []; + const evidence = assemblePolicyCandidateEvidence(config, getRoutingProfile(config, "guarded")!, Date.now(), { + routedProviderConfig: (name, provider) => { + resolved.push(name); + return routedProviderConfig(name, provider); + }, + }); + + expect(resolved).toEqual(["local"]); + expect(evidence[0]?.capability?.tools).toBe(true); + expect(evidence[1]?.capability).toBeUndefined(); + expect(evidence[2]?.capability).toBeUndefined(); + }); + + test("a selected invalid transport still fails the normal route validation", () => { + const config = policyConfig("ollama", { + adapter: "openai-chat", baseUrl: loopback, + }, "local-model", {}); + config.providers.ollama!.baseUrl = " "; + + expect(() => routeModel(config, "policy/guarded")).toThrow('Invalid baseUrl for provider "ollama"'); + }); +}); + describe("candidateCapabilityEvidence model matching", () => { test("a family entry covers its tagged siblings, as the resolver does", () => { const provider = providerWithFamilyEntries(); From 20ed03da1bcbe906782178c0e9829feb7dd7b247 Mon Sep 17 00:00:00 2001 From: Hako <25837994+devswha@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:52:06 +0900 Subject: [PATCH 034/236] fix(routing): exclude unresolved policy transports before selection (cherry picked from commit 7b1beb9c5eacd8dde22681a5df26804be52380b8) --- .../docs/fr/guides/routing-profile-editor.md | 6 +++-- .../docs/guides/routing-profile-editor.md | 2 ++ .../docs/tr/guides/routing-profile-editor.md | 3 ++- .../zh-tw/guides/routing-profile-editor.md | 1 + src/routing/compatibility/assemble.ts | 8 +++--- src/routing/evaluator.ts | 6 ++++- .../routing-capability-model-matching.test.ts | 25 +++++++++++++++++-- 7 files changed, 42 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md index f2577bc013..84b4410f77 100644 --- a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md @@ -39,8 +39,10 @@ résultat du plafond. Les capacités des candidats utilisent la configuration effective du fournisseur, après application du registre. Les exigences de localité (`localOnly` et -`remoteAllowed`) utilisent donc l'adresse amont effective. Si elle ne peut pas être -classée, `unknownEvidence.capability` détermine l'admissibilité du candidat. +`remoteAllowed`) utilisent donc l’adresse amont effective. Si elle ne peut pas être +classée, `unknownEvidence.capability` détermine l’admissibilité du candidat. +Une configuration de fournisseur invalide qui ne peut pas être résolue est toujours +exclue avec `route-unavailable`, même si les capacités inconnues sont autorisées. Sélectionnez un profil enregistré et utilisez **Évaluation à sec** pour ajouter des éléments propres à la requête, tels que la taille de la fenêtre de contexte, l’utilisation d’outils, l’entrée d’images ou la sortie structurée. La simulation évalue l’admissibilité et la notation, mais n’envoie jamais de requête à un modèle en amont. diff --git a/docs-site/src/content/docs/guides/routing-profile-editor.md b/docs-site/src/content/docs/guides/routing-profile-editor.md index 6bc7751cb7..7931f29ad6 100644 --- a/docs-site/src/content/docs/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/guides/routing-profile-editor.md @@ -42,6 +42,8 @@ Candidate capabilities use the effective provider configuration after registry overrides are applied. Locality requirements (`localOnly` and `remoteAllowed`) therefore use the effective upstream address. If that address cannot be classified, the profile's `unknownEvidence.capability` setting decides eligibility. +An invalid provider configuration that cannot be resolved is always excluded with +`route-unavailable`, even when unknown capabilities are allowed. Select a saved profile and use **Dry-run evaluation** to add request evidence such as context-window size, tool use, image input, or structured output. Dry-run evaluates eligibility and scoring but never sends an upstream model request. diff --git a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md index 6a02d786a7..ec75bdd17a 100644 --- a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md @@ -56,6 +56,8 @@ Aday yetenekleri, kayıt defteri kuralları uygulandıktan sonraki etkin sağlay yapılandırmasını kullanır. Yerellik gereksinimleri (`localOnly` ve `remoteAllowed`) bu nedenle etkin üst sunucu adresine göre değerlendirilir. Adres sınıflandırılamıyorsa, adayın uygunluğunu profilin `unknownEvidence.capability` ayarı belirler. +Çözümlenemeyen geçersiz sağlayıcı yapılandırmaları, bilinmeyen yeteneklere izin +verilse bile `route-unavailable` ile her zaman dışlanır. Kaydedilmiş bir profili seçin ve bağlam penceresi boyutu, araç kullanımı, görsel girişi veya yapılandırılmış çıktı gibi istek kanıtları eklemek için **Deneme @@ -104,4 +106,3 @@ Düzenleyici şu uç noktaları kullanır: } } ``` - diff --git a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md index 2dcf9d687b..0b54e70d57 100644 --- a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md @@ -33,6 +33,7 @@ OpenCodex 儀表板中的 **Models → Routing** 分頁可以直接管理 `confi ## 試跑已儲存的設定檔 候選能力使用套用 registry 覆寫後的有效供應商設定。因此,本地性需求(`localOnly` 與 `remoteAllowed`)會依據實際上游位址判定。若無法分類該位址,則由設定檔的 `unknownEvidence.capability` 決定候選是否合格。 +無法解析的無效供應商設定一律以 `route-unavailable` 排除,即使原則允許未知能力也是如此。 選取一個已儲存的設定檔,使用 **Dry-run evaluation** 加入請求證據,例如 context-window 大小、工具使用、圖片輸入或結構化輸出。試跑會評估資格與評分,但永遠不會送出上游模型請求。 diff --git a/src/routing/compatibility/assemble.ts b/src/routing/compatibility/assemble.ts index cbc036754f..d7cebc94bb 100644 --- a/src/routing/compatibility/assemble.ts +++ b/src/routing/compatibility/assemble.ts @@ -54,19 +54,21 @@ export function assemblePolicyCandidateEvidence( const compatibility = compatibilityByCandidate?.get(key); const provider = config.providers[candidate.provider]; let routed: OcxProviderConfig | undefined; + let routeResolutionFailed = false; if (provider && provider.disabled !== true) { try { routed = options.routedProviderConfig(candidate.provider, provider); } catch { - // An unresolved transport proves no capabilities. Do not abort healthy - // siblings: if this candidate is selected, normal route validation still - // reports its concrete error before dispatch. + // This is known unavailability, not unknown capability evidence. Keep + // the failure separate so permissive unknown policies cannot select it. + routeResolutionFailed = true; } } return { provider: candidate.provider, model: candidate.model, + ...(routeResolutionFailed ? { routeResolutionFailed: true } : {}), capability: routed ? candidateCapabilityEvidence(config, candidate.provider, candidate.model, routed) : undefined, diff --git a/src/routing/evaluator.ts b/src/routing/evaluator.ts index a07b833063..7cf801bfe9 100644 --- a/src/routing/evaluator.ts +++ b/src/routing/evaluator.ts @@ -54,6 +54,8 @@ export interface PolicyCandidateEvidence { accountRef?: string; /** Codex pool account id (provider "openai"); used to derive account-scoped quota evidence. */ codexAccountId?: string; + /** A failed effective-transport resolution excludes the candidate under every unknown policy. */ + routeResolutionFailed?: boolean; capability?: RouteCapabilityEvidence; health?: RouteHealthEvidence; quota?: RouteQuotaEvidence; @@ -278,6 +280,8 @@ export function evaluatePolicyProfile( ...requestRequirementFor(requestEvidence, evidence.capability), ]; const exclusions: RouteExclusionReason[] = []; + const routeUnavailable = evidence.routeResolutionFailed === true; + if (routeUnavailable) exclusions.push({ code: "route-unavailable" }); const bad = unsatisfiedOrUnknown(requirements); for (const requirement of bad) { if (requirement.outcome === "unsatisfied") { @@ -310,7 +314,7 @@ export function evaluatePolicyProfile( if (unknownCostBlocked) { exclusions.push({ code: "cost-limit-unknown", detail: "maxEstimatedCostUsd" }); } - let eligible = !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked; + let eligible = !routeUnavailable && !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked; // Trace/dry-run copy only: report the profile cap that was applied and the // operator-visible outcome. Do not feed this copy into costScore() — that diff --git a/tests/routing/routing-capability-model-matching.test.ts b/tests/routing/routing-capability-model-matching.test.ts index 5d85b715fd..509eeec2ea 100644 --- a/tests/routing/routing-capability-model-matching.test.ts +++ b/tests/routing/routing-capability-model-matching.test.ts @@ -231,13 +231,34 @@ describe("policy capability evidence uses the effective provider", () => { expect(evidence[2]?.capability).toBeUndefined(); }); - test("a selected invalid transport still fails the normal route validation", () => { + test.each(["allow", "penalize", "exclude"] as const)( + "an unresolved first candidate is excluded when unknown capabilities are %s", + capability => { + const config = policyConfig("ollama", { + adapter: "openai-chat", baseUrl: loopback, + }, "local-model", {}); + config.providers.ollama!.baseUrl = " "; + config.providers.local = { adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true }; + const profile = config.routingProfiles!.guarded!; + profile.candidates.push({ provider: "local", model: "local-model" }); + profile.unknownEvidence = { ...profile.unknownEvidence, capability }; + + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe("local"); + expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); + expect(route.routeDecision?.candidates[0]?.exclusions).toContainEqual({ code: "route-unavailable" }); + expect(JSON.stringify(route.routeDecision)).not.toContain("Invalid baseUrl"); + }, + ); + + test("all unresolved candidates produce a policy exclusion while explicit routing keeps validation", () => { const config = policyConfig("ollama", { adapter: "openai-chat", baseUrl: loopback, }, "local-model", {}); config.providers.ollama!.baseUrl = " "; - expect(() => routeModel(config, "policy/guarded")).toThrow('Invalid baseUrl for provider "ollama"'); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + expect(() => routeModel(config, "ollama/local-model")).toThrow('Invalid baseUrl for provider "ollama"'); }); }); From b72155e864b79070e7a80c447e01f098c878051c Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:12:18 +0900 Subject: [PATCH 035/236] fix(routing): reject missing and disabled policy providers --- docs-site/src/content/docs/fr/guides/routing-profile-editor.md | 1 + docs-site/src/content/docs/guides/routing-profile-editor.md | 1 + docs-site/src/content/docs/tr/guides/routing-profile-editor.md | 1 + .../src/content/docs/zh-tw/guides/routing-profile-editor.md | 1 + src/routing/compatibility/assemble.ts | 2 +- structure/01_runtime.md | 2 +- 6 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md index 84b4410f77..18575f21c8 100644 --- a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md @@ -43,6 +43,7 @@ après application du registre. Les exigences de localité (`localOnly` et classée, `unknownEvidence.capability` détermine l’admissibilité du candidat. Une configuration de fournisseur invalide qui ne peut pas être résolue est toujours exclue avec `route-unavailable`, même si les capacités inconnues sont autorisées. +Les fournisseurs absents ou désactivés sont également exclus avec `route-unavailable` avant le calcul des scores. Sélectionnez un profil enregistré et utilisez **Évaluation à sec** pour ajouter des éléments propres à la requête, tels que la taille de la fenêtre de contexte, l’utilisation d’outils, l’entrée d’images ou la sortie structurée. La simulation évalue l’admissibilité et la notation, mais n’envoie jamais de requête à un modèle en amont. diff --git a/docs-site/src/content/docs/guides/routing-profile-editor.md b/docs-site/src/content/docs/guides/routing-profile-editor.md index 7931f29ad6..d53e0d3616 100644 --- a/docs-site/src/content/docs/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/guides/routing-profile-editor.md @@ -44,6 +44,7 @@ therefore use the effective upstream address. If that address cannot be classifi the profile's `unknownEvidence.capability` setting decides eligibility. An invalid provider configuration that cannot be resolved is always excluded with `route-unavailable`, even when unknown capabilities are allowed. +Missing or disabled providers are also excluded with `route-unavailable` before scoring. Select a saved profile and use **Dry-run evaluation** to add request evidence such as context-window size, tool use, image input, or structured output. Dry-run evaluates eligibility and scoring but never sends an upstream model request. diff --git a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md index ec75bdd17a..74ab8a7bdc 100644 --- a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md @@ -58,6 +58,7 @@ bu nedenle etkin üst sunucu adresine göre değerlendirilir. Adres sınıfland adayın uygunluğunu profilin `unknownEvidence.capability` ayarı belirler. Çözümlenemeyen geçersiz sağlayıcı yapılandırmaları, bilinmeyen yeteneklere izin verilse bile `route-unavailable` ile her zaman dışlanır. +Eksik veya devre dışı sağlayıcılar da puanlama öncesinde `route-unavailable` ile dışlanır. Kaydedilmiş bir profili seçin ve bağlam penceresi boyutu, araç kullanımı, görsel girişi veya yapılandırılmış çıktı gibi istek kanıtları eklemek için **Deneme diff --git a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md index 0b54e70d57..4aa10fcaa7 100644 --- a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md @@ -34,6 +34,7 @@ OpenCodex 儀表板中的 **Models → Routing** 分頁可以直接管理 `confi 候選能力使用套用 registry 覆寫後的有效供應商設定。因此,本地性需求(`localOnly` 與 `remoteAllowed`)會依據實際上游位址判定。若無法分類該位址,則由設定檔的 `unknownEvidence.capability` 決定候選是否合格。 無法解析的無效供應商設定一律以 `route-unavailable` 排除,即使原則允許未知能力也是如此。 +缺少或停用的供應商也會在評分前以 `route-unavailable` 排除。 選取一個已儲存的設定檔,使用 **Dry-run evaluation** 加入請求證據,例如 context-window 大小、工具使用、圖片輸入或結構化輸出。試跑會評估資格與評分,但永遠不會送出上游模型請求。 diff --git a/src/routing/compatibility/assemble.ts b/src/routing/compatibility/assemble.ts index d7cebc94bb..5bcef26fc8 100644 --- a/src/routing/compatibility/assemble.ts +++ b/src/routing/compatibility/assemble.ts @@ -54,7 +54,7 @@ export function assemblePolicyCandidateEvidence( const compatibility = compatibilityByCandidate?.get(key); const provider = config.providers[candidate.provider]; let routed: OcxProviderConfig | undefined; - let routeResolutionFailed = false; + let routeResolutionFailed = !provider || provider.disabled === true; if (provider && provider.disabled !== true) { try { routed = options.routedProviderConfig(candidate.provider, provider); diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 26f501419a..7fb1c00997 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -15,7 +15,7 @@ | `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. | | `src/server/ports.ts` | Owns bind availability and ephemeral-port selection. Temporary probes dispose accepted peers and wait for listener close before reporting success. | | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | -| `src/router.ts` | Provider/model selection before adapter dispatch. | +| `src/router.ts` | Provider/model selection before adapter dispatch. Policy execution and ordinary management dry-run share effective-provider capability evidence; unresolved, missing, and disabled providers are excluded before scoring. | | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | | `src/codex/shim.ts` | Codex autostart shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and transactionally restores complete, stable external launcher replacements without a watcher or PATH rediscovery. | From 71c086bd600f7165a3a5587dc20fdcaa118197af Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:13:49 +0900 Subject: [PATCH 036/236] test(routing): exclude unavailable providers in runtime and dry-run --- .../routing-capability-model-matching.test.ts | 54 ++++++++++++++++ tests/routing/routing-profile.test.ts | 61 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/tests/routing/routing-capability-model-matching.test.ts b/tests/routing/routing-capability-model-matching.test.ts index 509eeec2ea..df5a6b22c2 100644 --- a/tests/routing/routing-capability-model-matching.test.ts +++ b/tests/routing/routing-capability-model-matching.test.ts @@ -231,6 +231,60 @@ describe("policy capability evidence uses the effective provider", () => { expect(evidence[2]?.capability).toBeUndefined(); }); + for (const unavailable of ["missing", "disabled"] as const) { + test.each(["allow", "penalize", "exclude"] as const)( + `${unavailable} first candidate is excluded under %s unknown policy`, + capability => { + // Empty requirements prevent another capability guard from masking availability. + const config = policyConfig("local", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", {}); + if (unavailable === "disabled") { + config.providers.disabled = { ...config.providers.local!, disabled: true }; + } + const profile = config.routingProfiles!.guarded!; + profile.candidates.unshift({ provider: unavailable, model: "local-model" }); + profile.unknownEvidence = { ...profile.unknownEvidence, capability }; + + for (const withSibling of [true, false]) { + if (!withSibling) profile.candidates.pop(); + const resolved: string[] = []; + const evidence = assemblePolicyCandidateEvidence( + config, getRoutingProfile(config, "guarded")!, Date.now(), { + routedProviderConfig: (name, provider) => { + resolved.push(name); + return routedProviderConfig(name, provider); + }, + }, + ); + expect(resolved).toEqual(withSibling ? ["local"] : []); + expect(evidence).toHaveLength(withSibling ? 2 : 1); + expect(evidence[0]?.routeResolutionFailed).toBe(true); + expect(evidence[0]?.capability).toBeUndefined(); + const evaluation = evaluatePolicyProfile(config, "guarded", {}, evidence); + expect(evaluation.selectedIndex).toBe(withSibling ? 1 : null); + expect(evaluation.candidates[0]).toMatchObject({ + provider: unavailable, + eligible: false, + requirements: [], + exclusions: [{ code: "route-unavailable" }], + }); + if (withSibling) { + expect(evidence[1]?.routeResolutionFailed).toBeUndefined(); + expect(evidence[1]?.capability?.tools).toBe(true); + expect(evaluation.candidates[1]?.eligible).toBe(true); + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe("local"); + expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); + expect(route.routeDecision?.candidates[0]?.exclusions).toEqual([{ code: "route-unavailable" }]); + } else { + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + } + } + }, + ); + } + test.each(["allow", "penalize", "exclude"] as const)( "an unresolved first candidate is excluded when unknown capabilities are %s", capability => { diff --git a/tests/routing/routing-profile.test.ts b/tests/routing/routing-profile.test.ts index aafca468dd..0736ec96fb 100644 --- a/tests/routing/routing-profile.test.ts +++ b/tests/routing/routing-profile.test.ts @@ -476,6 +476,67 @@ describe("routing profiles (RI-04)", () => { expect(body.candidates?.[1]).toMatchObject({ provider: "b", eligible: false }); }); + for (const unavailable of ["missing", "disabled"] as const) { + test.each(["allow", "penalize", "exclude"] as const)( + `API dry-run excludes ${unavailable} provider under %s unknown policy`, + async capability => { + const config = baseConfig({ + providers: { + local: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:11434/v1", allowPrivateNetwork: true }, + }, + defaultProvider: "local", + routingProfiles: { + guarded: { + candidates: [ + { provider: unavailable, model: "local-model" }, + { provider: "local", model: "local-model" }, + ], + require: {}, + unknownEvidence: { capability }, + }, + }, + }); + if (unavailable === "disabled") { + config.providers.disabled = { ...config.providers.local!, disabled: true }; + } + for (const withSibling of [true, false]) { + if (!withSibling) config.routingProfiles!.guarded!.candidates.pop(); + const req = new ManagementRequest("http://localhost/api/routing-profiles/dry-run", { + method: "POST", + headers: { "content-type": "application/json" }, + // No synthetic candidates: exercise the same assembly as runtime routing. + body: JSON.stringify({ profile: "guarded", evidence: {} }), + }); + const response = await handleManagementAPI(req, new URL(req.url), config, { + refreshCodexCatalog: async () => {}, + }); + expect(response).not.toBeNull(); + expect(response!.status).toBe(200); + const body = await response!.json() as { + selectedIndex: number | null; + candidates: Array<{ + provider: string; + eligible: boolean; + requirements: unknown[]; + exclusions: Array<{ code: string }>; + }>; + }; + expect(body.selectedIndex).toBe(withSibling ? 1 : null); + expect(body.candidates).toHaveLength(withSibling ? 2 : 1); + expect(body.candidates[0]).toMatchObject({ + provider: unavailable, + eligible: false, + requirements: [], + exclusions: [{ code: "route-unavailable" }], + }); + if (withSibling) { + expect(body.candidates[1]).toMatchObject({ provider: "local", eligible: true }); + } + } + }, + ); + } + test("API dry-run mirrors live codex cooldown for openai candidates", async () => { const { clearCodexUpstreamHealth, recordCodexUpstreamOutcome } = await import("../../src/codex/routing"); clearCodexUpstreamHealth(); From a22cd5d766f407a94d975bb93b058e129cf0039c Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:15:38 +0900 Subject: [PATCH 037/236] fix(codex): fence quota diagnostics at request dispatch --- src/codex/auth-api.ts | 22 +++- structure/05_gui-and-management-api.md | 3 + tests/cli/cli-account.test.ts | 26 ++-- .../codex-integration/codex-auth-api.test.ts | 121 ++++++++++++++++-- 4 files changed, 151 insertions(+), 21 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index b778cb91f8..51e3fed303 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -780,6 +780,8 @@ interface MainAccountInfoFetchResult { info: MainAccountInfo; /** Ephemeral result of this attempt, omitted when no WHAM request was made. */ quotaRefresh?: CodexQuotaRefreshOutcome; + /** Internal dispatch fence for diagnostics only; never copied into a public DTO or cache. */ + quotaRefreshGeneration?: number; /** Whether this attempt safely inspected the physical native-main credential. */ credentialChecked: boolean; /** Meaningful only when credentialChecked is true. */ @@ -802,7 +804,9 @@ export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promis const result = await fetchMainAccountInfoAttempt(forceRefresh, 1); return { info: result.info, - ...(result.quotaRefresh ? { quotaRefresh: result.quotaRefresh } : {}), + ...(result.quotaRefresh && result.quotaRefreshGeneration !== undefined + && isMainAccountIdentityGenerationLive(result.quotaRefreshGeneration) + ? { quotaRefresh: result.quotaRefresh } : {}), mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(), }; } @@ -908,6 +912,7 @@ async function fetchMainAccountInfoWhileOwned( // Keep diagnostics separate from authentication and freshness policy. Never serialize errors. const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS); let quotaPhase: "request" | "body" | "decode" | "publish" = "request"; + let quotaRefreshGeneration = captureMainAccountIdentityGeneration(); try { const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, @@ -919,12 +924,16 @@ async function fetchMainAccountInfoWhileOwned( const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); if (retried) return retried; if (terminalAuthFailure) { + // Account for this attempt's own synchronous invalidation, never prior external drift. + const diagnosticStillLive = isMainAccountIdentityGenerationLive(quotaRefreshGeneration); clearMainAccountInfoCache(); + if (diagnosticStillLive) quotaRefreshGeneration = captureMainAccountIdentityGeneration(); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration); } return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, quotaRefresh: { status: "http_error", httpStatus: resp.status }, + quotaRefreshGeneration, }; } quotaPhase = "body"; @@ -932,6 +941,11 @@ async function fetchMainAccountInfoWhileOwned( quotaPhase = "publish"; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); if (retried) return retried; + quotaPhase = "decode"; + if (data === null || typeof data !== "object" || Array.isArray(data)) { + throw new Error("Invalid WHAM usage object"); + } + quotaPhase = "publish"; // A delayed response from a replaced bearer cannot revoke a newer Reserve grant, // even in the same workspace or after an A→B→A credential transition. if (mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() @@ -974,6 +988,7 @@ async function fetchMainAccountInfoWhileOwned( return { info: result, quotaRefresh: { status: quota ? "ok" : "not_reported" }, + quotaRefreshGeneration, credentialChecked: true, hasCredential: true, ...(quota ? { freshQuota: quota } : {}), @@ -990,6 +1005,7 @@ async function fetchMainAccountInfoWhileOwned( return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, quotaRefresh: { status }, + quotaRefreshGeneration, }; } } @@ -1796,7 +1812,9 @@ export async function listCodexAuthAccountsSnapshot( id: MAIN_CODEX_ACCOUNT_ID, email: maskEmail(mainInfo.email) ?? "Codex App login", plan: mainInfo.plan, - ...(mainSnapshotLive && mainResult.quotaRefresh ? { quotaRefresh: mainResult.quotaRefresh } : {}), + ...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined + && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration) + ? { quotaRefresh: mainResult.quotaRefresh } : {}), logLabel: "main", isMain: true, paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index c8fb3bc101..f4e32adb35 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -365,6 +365,9 @@ completed check, and provider-report waiters are bound to the exact refresh epoc Main-account WHAM refresh diagnostics are an ephemeral `quotaRefresh` outcome carried from `fetchMainAccountInfoWhileOwned` to the generation-checked account DTO and the opt-in CLI quota JSON. They are not persisted or consumed by admission/rotation. +A private per-dispatch identity generation fences the diagnostic independently of ordinary +quota metadata. Both snapshot and account DTO publication omit externally invalidated +attempts; the generation itself is never serialized or stored in the quota cache. The CLI reconstructs the object using a fixed vocabulary and bounded numeric HTTP status, so an unexpected management response cannot add raw upstream material. diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 9f8acf3ab1..f6792933f1 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -621,16 +621,22 @@ describe("ocx account CLI (issue #180 matrix)", () => { ); test.each([ - undefined, null, [], "private-diagnostic-canary", 0, true, {}, - { status: "private-status-canary" }, - { status: "http_error" }, - { status: "http_error", httpStatus: NaN }, - { status: "http_error", httpStatus: Infinity }, - { status: "http_error", httpStatus: 99 }, - { status: "http_error", httpStatus: 600 }, - { status: "http_error", httpStatus: 403.5 }, - { status: "http_error", httpStatus: "403" }, - ])("diagnostic projector rejects invalid values: %j", value => { + { value: undefined }, + { value: null }, + { value: [] }, + { value: "private-diagnostic-canary" }, + { value: 0 }, + { value: true }, + { value: {} }, + { value: { status: "private-status-canary" } }, + { value: { status: "http_error" } }, + { value: { status: "http_error", httpStatus: NaN } }, + { value: { status: "http_error", httpStatus: Infinity } }, + { value: { status: "http_error", httpStatus: 99 } }, + { value: { status: "http_error", httpStatus: 600 } }, + { value: { status: "http_error", httpStatus: 403.5 } }, + { value: { status: "http_error", httpStatus: "403" } }, + ])("diagnostic projector rejects invalid values: %j", ({ value }) => { expect(projectCodexQuotaRefreshOutcome(value)).toBeUndefined(); }); diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 3191a8e6d2..04e19d49fa 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -28,6 +28,7 @@ import { } from "../../src/codex/account-store"; import * as accountStoreModule from "../../src/codex/account-store"; import * as reserveAvailabilityModule from "../../src/codex/reserve-availability"; +import { getMainAccountInfoCache, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; import { clearCodexUpstreamHealth, clearThreadAccountMap, @@ -257,10 +258,12 @@ function seedPoolAccount( } describe("main quota refresh diagnostics", () => { - function writeMain(): void { + function writeMain(accountId = "fixture-account"): string { + const accessToken = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), account_id: "fixture-account" }, + tokens: { access_token: accessToken, account_id: accountId }, })); + return accessToken; } test.each([401, 403, 429, 503])("HTTP %s is diagnostic, not proof of sign-out", async status => { @@ -363,16 +366,116 @@ describe("main quota refresh diagnostics", () => { } finally { observer.mockRestore(); timeout.mockRestore(); } }); - test("decoded invalid usage is distinct from a response body failure", async () => { - writeMain(); - const observer = spyOn(reserveAvailabilityModule, "observeMainReserveRevocation") - .mockImplementation(() => {}); - globalThis.fetch = (async () => Response.json(null)) as typeof fetch; - try { + test.each([false, true])("decoded null is invalid with a matching Reserve slot=%s", async matchingSlot => { + const accessToken = writeMain(); + reconcileMainCodexAccountRuntimeState(); + const token = { accessToken, chatgptAccountId: "fixture-account" }; + const writer = observeMainQuotaCredential(accessToken, token.chatgptAccountId); + let capabilityReads = 0; + let passiveReads = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + if (new Headers(init?.headers).get("x-openai-codex-luna-reserve") === "1") { + capabilityReads++; + return Response.json({ + rate_limit: { allowed: false }, + rate_limit_upsell: { banner_type: "luna_reserve" }, + additional_rate_limits: [{ limit_name: "gpt-reserve", rate_limit: { allowed: true } }], + }); + } + passiveReads++; + return Response.json(null); + }) as typeof fetch; + const authorization = matchingSlot + ? await reserveAvailabilityModule.getMainReserveAuthorization({ token, writer, observeOrdinaryQuota: () => {} }) + : undefined; + if (matchingSlot) { + expect(authorization).toBeDefined(); + expect(reserveAvailabilityModule.isMainReserveAuthorizationLive(authorization, token)).toBe(true); + } + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toEqual({ status: "invalid_response" }); + expect(result.info.quota).toBeNull(); + expect(capabilityReads).toBe(matchingSlot ? 1 : 0); + expect(passiveReads).toBe(1); + if (matchingSlot) { + expect(reserveAvailabilityModule.isMainReserveAuthorizationLive(authorization, token)).toBe(true); + } + }); + + test.each([{ value: [] }, { value: "invalid-usage" }, { value: 7 }, { value: false }])( + "decoded non-object usage is invalid: %j", async ({ value }) => { + writeMain(); + globalThis.fetch = (async () => Response.json(value)) as typeof fetch; const result = await fetchMainAccountInfoSnapshot(true); expect(result.quotaRefresh).toEqual({ status: "invalid_response" }); expect(result.info.quota).toBeNull(); - } finally { observer.mockRestore(); } + }, + ); + + test.each( + (["snapshot", "accounts"] as const).flatMap(surface => + (["none", "same_id", "round_trip"] as const).flatMap(invalidation => + (["http", "terminal_http", "body", "ok"] as const).map(outcome => ({ surface, invalidation, outcome })), + ), + ), + )("diagnostic dispatch fence: %j", async ({ surface, invalidation, outcome }) => { + writeMain(); + let started!: () => void; + const dispatched = new Promise(resolve => { started = resolve; }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let reads = 0; + globalThis.fetch = (async () => { + reads++; + started(); + await gate; + if (outcome === "http") return new Response("private-http-canary", { status: 503 }); + if (outcome === "terminal_http") { + return Response.json({ detail: { code: "invalid_workspace_selected" } }, { status: 403 }); + } + if (outcome === "body") return new Response(new ReadableStream({ + start(controller) { controller.error(new TypeError("private-body-canary")); }, + })); + return Response.json({ rate_limit: { primary_window: { used_percent: 37 } } }); + }) as typeof fetch; + const pending = surface === "snapshot" + ? fetchMainAccountInfoSnapshot(true) + : listCodexAuthAccounts(makeConfig(), true).then(rows => rows.find(row => row.isMain)!); + try { + await Promise.race([dispatched, pending.then(() => { throw new Error("Main WHAM never dispatched"); })]); + if (invalidation === "same_id") { + clearMainAccountInfoCache(); + } else if (invalidation === "round_trip") { + writeMain("other-account"); + reconcileMainCodexAccountRuntimeState(); + writeMain(); + reconcileMainCodexAccountRuntimeState(); + } + release(); + const result = await pending; + expect(reads).toBe(1); + if (invalidation === "none") { + const expected = outcome === "http" ? { status: "http_error", httpStatus: 503 } + : outcome === "terminal_http" ? { status: "http_error", httpStatus: 403 } + : { status: outcome === "body" ? "network_error" : "ok" }; + expect(result.quotaRefresh).toEqual(expected); + } else { + expect(result).not.toHaveProperty("quotaRefresh"); + } + // The existing terminal-auth decision still applies, independently of diagnostic freshness. + if (outcome === "terminal_http") expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + expect(result).not.toHaveProperty("quotaRefreshGeneration"); + expect(JSON.stringify(result)).not.toContain("quotaRefreshGeneration"); + expect(JSON.stringify(result)).not.toContain("canary"); + const cached = getMainAccountInfoCache(); + if (cached) { + expect(cached).not.toHaveProperty("quotaRefreshGeneration"); + expect(cached).not.toHaveProperty("quotaRefresh"); + } + } finally { + release(); + await pending; + } }); test("missing credentials omit diagnostics without issuing a request", async () => { From c23ac7e82e3015c15dea4181da747ca2c2647c02 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 6 Sep 2026 02:17:07 +0900 Subject: [PATCH 038/236] docs: plan attributed D integration stack (#3681) Owner explicitly authorized admin merges. This documentation-only head passed independent corrective review, the documentation validator and diff checks. GitHub reports no configured required checks; current administrative workflow checks remain queued, so this is not a claim that every check ran on this head. Squash retains the corrected current documentation without bringing superseded draft-plan commits into dev ancestry. --- .../000_plan.md | 46 ++++ .../001_roadmap_result.md | 9 + .../010_toml_guard.md | 122 ++++++++++ .../020_tool_aliases.md | 124 +++++++++++ .../030_cursor_schemas.md | 151 +++++++++++++ .../040_logs_filters.md | 210 ++++++++++++++++++ .../050_remote_aliases.md | 7 + 7 files changed, 669 insertions(+) create mode 100644 devlog/_plan/260906_d_integrations_delivery/000_plan.md create mode 100644 devlog/_plan/260906_d_integrations_delivery/001_roadmap_result.md create mode 100644 devlog/_plan/260906_d_integrations_delivery/010_toml_guard.md create mode 100644 devlog/_plan/260906_d_integrations_delivery/020_tool_aliases.md create mode 100644 devlog/_plan/260906_d_integrations_delivery/030_cursor_schemas.md create mode 100644 devlog/_plan/260906_d_integrations_delivery/040_logs_filters.md create mode 100644 devlog/_plan/260906_d_integrations_delivery/050_remote_aliases.md diff --git a/devlog/_plan/260906_d_integrations_delivery/000_plan.md b/devlog/_plan/260906_d_integrations_delivery/000_plan.md new file mode 100644 index 0000000000..57641fac6e --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/000_plan.md @@ -0,0 +1,46 @@ +# D delivery roadmap + +## Loop specification + +- Archetype: spec-satisfaction repair; C3 integration train. Alias routing is C4 where it affects upstream credential destinations; keep any undisclosed security analysis in ignored scratch. +- Trigger: owner assigned D: #3669, #3673, #3628, #3625, #3646. +- Goal: integrate these five bounded outcomes into dev with contributor attribution, current-head remote CI and immediate disposition of superseded originals/resolved issues. +- Non-goals: A/B/C implementation, main/preview/release, dogfood service, personal accounts/credentials, unrelated thinking/cache behavior. +- Verifier: GitHub Cross-platform CI gates and platform jobs for every layer; GUI lint/build plus real isolated browser smoke for Logs; targeted CI failures repaired without local tests. NEVER run local test suites, focused tests, test:changed, or typecheck. This owner instruction overrides local-gate defaults in AGENTS/skills. +- Stop: all five landed SHAs reachable from freshly fetched origin/dev, original PRs and genuinely resolved issues closed, independent reviews clear, evidence recorded. +- Memory: this unit, .tmp/d-delivery evidence and session-bound goalplan/ledger in this checkout. +- Tool/credential scope: local source/git, gh for this repository, inherited-model agents, isolated browser QA. No purchases or new credential/account actions. +- Bounds: no owner-set numerical token/cost cap or arbitrary delegation count; 12-hour per-phase wall-clock review bound, checkpoint and reassess if reached. Context compaction is not exhaustion. +- Escalation up: main reclaims any packet after two distinct agents fail it. Down: worker scopes must be fixed in the corresponding P document before B. No speculative next-phase implementation. +- Outcomes: DONE/NOOP only with fresh evidence; unresolved external conditions remain pending and do not weaken the final criteria. + +## Checkout and source snapshot + +Worktree is adopted in place. Initial dev is 81871b3fa7034250b8d5ba2cbbfde44e40f0e69c. Live source bodies/comments/commits and exact heads are saved in .tmp/d-delivery/pr-N.json. Source refs are origin/d-source-N. No source suites were executed during planning. + +## Structure and sequence + +1. roadmap: docs-only complete PABCD; lock 010–040 designs and the scratch-backed 050 work item. +2. toml / 010: config parse admission foundation; carry #3669. +3. toolalias / 020: stream argument identity; carry #3673. +4. cursor / 030: executable schema projection on current adapter layout; carry #3628. +5. logs / 040: expose existing filter predicate in actual UI; carry #3625. +6. remotealias / 050: bind generated client aliases to hub-owned routing; resolve #3646. +7. landing: bottom-up dev integration and original-item closeout. + +The five fixes are distinct functional units; the owner explicitly requested stacked PRs, so the delivery chain imposes an integration order, not a claim that TOML is a functional dependency of Cursor. Each layer has its own tests/docs and is independently reviewable. Create the documentation parent first, then stack the five item branches. Land eligible lower layers early when CI and review permit, immediately retarget remaining children and verify ancestry. Each implementation cycle certifies its current-head candidate; final landing criteria retain every dev-ancestry and closeout obligation. + +## Shared ownership + +- A owns shared Responses core integration; D #3673 modifies openai-chat.ts, not core.ts. +- B #3659 and D #3625 share locale modules; integrate both sets of keys. +- B #3649 and D #3646 may both touch Claude aliases/claude-messages.ts. Re-read remote dev before 050 and preserve Fable selector normalization. +- New tests must register both layout manifests where applicable. Existing test edits retain current paths. + +## Attribution and GitHub operations + +Original author commits or valid Co-authored-by trailers are retained. Every push uses --no-verify. Own rewritten stack refs use --force-with-lease only if required; never rewrite another active task branch. All PR bodies fill Summary/Verification/Checklist and show stack base, source PR, evidence and screenshot for visible GUI changes. Merge bottom-up; refresh head, CI, review and origin/dev immediately before each merge. After merge prove git merge-base --is-ancestor landed-sha origin/dev, then close superseded source PR and any fully solved issue. Partial issues stay open with exact remaining scope. + +## Verification route inspected + +.github/workflows/ci.yml uses pull_request without a base filter (stack support); src/tests/gui/docs changes are selected by changes job. gates executes Typecheck (lines 422–425), GUI tests (427–428), privacy (430–431), GUI lint/build when relevant; platform shards run the repository tests. Read-only git diff origin/dev...origin/d-source-3669 --check exited 0 and observes the source delta. Roadmap validation is a documentation-only Python check, not an application test suite. diff --git a/devlog/_plan/260906_d_integrations_delivery/001_roadmap_result.md b/devlog/_plan/260906_d_integrations_delivery/001_roadmap_result.md new file mode 100644 index 0000000000..ab86cff0e3 --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/001_roadmap_result.md @@ -0,0 +1,9 @@ +# Roadmap lock result + +Independent reviewer 01a0726c-c782-7701-962f-2607911a33af returned VERDICT: PASS, no actionable blockers. All five implementation designs, provenance, CI targets and separate final landing obligations were checked. + +The documentation-only verifier passed. An initial whitespace check flagged two blank context lines inside the embedded TOML diff; the command sequence did not stop and the B-to-C narrative incorrectly said the whitespace check passed. The whitespace was removed and the final complete roadmap diff was checked again successfully before closeout. No production test or typecheck ran locally. + +Next: enter the TOML cycle, refresh 010 against the current parent, carry the original authored commit, add the architecture contract, publish as a child of the documentation PR and obtain current-head hosted CI. The full delivery goal remains open. + +External review subsequently required correcting planning-artifact placement and tightening two future tool-contract designs. The detailed review synthesis is retained in ignored scratch. The public 050 entry now contains only a work-item pointer; its implementation is still pending. The independent initial PASS did not detect these issues and does not substitute for the corrective review. diff --git a/devlog/_plan/260906_d_integrations_delivery/010_toml_guard.md b/devlog/_plan/260906_d_integrations_delivery/010_toml_guard.md new file mode 100644 index 0000000000..e7014cf8ca --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/010_toml_guard.md @@ -0,0 +1,122 @@ +# 010 — TOML rewrite admission + +Depends on: roadmap lock. Class C2/C3 parser-admission preservation. Owner: main; bounded implementation reviewer during B, no future-phase code writes. + +Source: PR #3669, commit f6db9cae8e8854c6df06087288a074d767f9787d, Hako <25837994+devswha@users.noreply.github.com>. Preserve this author via cherry-pick and add Co-authored-by on carry PR. Existing review has no unresolved threads; reported local results are not our current-head CI proof. + +## File change map + +MODIFY src/integrations/config-io.ts: replace direct Bun.TOML.parse return with iterative document walk. Reject non-array objects with prototypes other than Object.prototype or null before JSON cloning can coerce typed date/time scalars to strings. Scalars/quoted dates/plain objects/arrays remain accepted. +MODIFY tests/clients/integrations-state.test.ts: exercise all supported TOML temporal kinds at root, nested tables and inline arrays; quoted equivalents stay accepted. +MODIFY tests/clients/integrations-writer.test.ts: real temp Kimi config produces unsafe state; apply refuses without changing original bytes, operation journal or ownership records. +MODIFY docs-site/src/content/docs/{guides,fr/guides,tr/guides,zh-tw/guides}/integrations.md: carry the source commit descriptions of refused date/time rewrites. +MODIFY structure/09_client-integrations.md: add typed TOML temporal values to the existing round-trip refusal contract after the classifier paragraph. +No new fields, enums, serializers, dependencies, runtime options or management endpoints. The parser is the existing common admission point for status and writers. Bypass is explicit manual editing outside managed rewrite; this guard does not control that user action. + +## Exact source patch + +```diff +diff --git a/src/integrations/config-io.ts b/src/integrations/config-io.ts +index 4f2a83482..9cb5f97ba 100644 +--- a/src/integrations/config-io.ts ++++ b/src/integrations/config-io.ts +@@ -162,7 +162,22 @@ export function parseConfig(text: string | null, format: ConfigFormat): unknown + * evidence is gone. + */ + if (/(^|[\s,[=])[-+]?(?:inf|nan)(?=[\s,\]]|$)/mi.test(text)) return PARSE_FAILED; +- return Bun.TOML.parse(text); ++ const document = Bun.TOML.parse(text); ++ // TOML date/time scalars are Temporal objects with toJSON methods. ++ // The merge layer JSON-clones documents, which silently turns these ++ // into strings. Refuse before either status or a writer can admit a ++ // lossy rewrite, including dates nested in arrays and inline tables. ++ const pending: unknown[] = [document]; ++ while (pending.length > 0) { ++ const value = pending.pop(); ++ if (value === null || typeof value !== "object") continue; ++ if (!Array.isArray(value)) { ++ const prototype = Object.getPrototypeOf(value); ++ if (prototype !== Object.prototype && prototype !== null) return PARSE_FAILED; ++ } ++ for (const child of Object.values(value)) pending.push(child); ++ } ++ return document; + } + } + } catch { +diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts +index 54ab80de1..872e9b382 100644 +--- a/tests/clients/integrations-state.test.ts ++++ b/tests/clients/integrations-state.test.ts +@@ -401,6 +401,25 @@ describe("classifier unit behavior", () => { + expect(parseConfig("{{{", "json")).toBe(PARSE_FAILED); + }); + ++ test("parseConfig refuses typed TOML dates before a JSON clone can turn them into strings", () => { ++ for (const literal of [ ++ "2026-09-05T10:00:00Z", ++ "2026-09-05T10:00:00-07:00", ++ "2026-09-05T10:00:00.123456", ++ "2026-09-05", ++ "10:00:00.123456", ++ ]) { ++ for (const text of [ ++ `expires = ${literal}\n`, ++ `[user]\nexpires = ${literal}\n`, ++ `items = [{ expires = ${literal} }]\n`, ++ ]) { ++ expect(parseConfig(text, "toml")).toBe(PARSE_FAILED); ++ } ++ expect(parseConfig(`expires = "${literal}"\n`, "toml")).toEqual({ expires: literal }); ++ } ++ }); ++ + test("parseConfig refuses json number literals a rewrite would change", () => { + // Overflow to Infinity — a rewrite would bake in null. + expect(parseConfig("{\"a\": 1e999}", "json")).toBe(PARSE_FAILED); +diff --git a/tests/clients/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts +index 0bf81fdb5..de2f16471 100644 +--- a/tests/clients/integrations-writer.test.ts ++++ b/tests/clients/integrations-writer.test.ts +@@ -141,6 +141,24 @@ function reverseJsonObjectKeys(value: unknown): unknown { + } + + describe("apply", () => { ++ test("refuses Kimi TOML date rewrites without changing the file or ownership store", () => { ++ const spec = INTEGRATION_CLIENTS.kimi; ++ mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); ++ const configPath = spec.configPath(TEST_ENV, home); ++ mkdirSync(dirname(configPath), { recursive: true }); ++ const original = "[user]\nexpires = 2026-09-05T10:00:00Z\n"; ++ writeFileSync(configPath, original); ++ const request = input({ clientId: "kimi" }); ++ ++ expect(readIntegrationState(request).state).toBe("unsafe"); ++ const result = applyIntegration(request); ++ expect(result.ok).toBe(false); ++ if (!result.ok) expect(result.reason).toBe("unsafe"); ++ expect(readFileSync(configPath, "utf8")).toBe(original); ++ expect(store.listOperations()).toHaveLength(0); ++ expect(store.readRecords().kimi).toBeUndefined(); ++ }); ++ + test("refuses a client that is not installed, and writes nothing", () => { + const result = applyIntegration(input()); + expect(result.ok).toBe(false); +``` + +## Additional structure diff + +After “Status and mutation must use the same classifier” paragraph add: + +> TOML temporal scalars cannot survive the JSON-cloned merge representation with their types intact. The common parser refuses documents containing them before either status or mutation proceeds, including nested arrays and inline tables. Quoted date strings remain supported. + +## Acceptance and activation + +- Unquoted offset/local date-time, local date, local time at every tested nesting returns PARSE_FAILED. +- Identical quoted values remain plain strings and can be managed. +- Kimi apply on typed temporal input activates unsafe classification and writes nothing, including bookkeeping. +- Existing special-float admission and other formats are unchanged. +- C consumes hosted current-head CI actual gates/platform jobs; no local suites/typecheck. Original focused paths named above are included in the CI repository tests. +- Independently review prototype traversal and actual parser shapes; unexpected compatibility gaps change the plan before implementation. +- Once integrated, refresh dev ancestry and close source #3669 immediately with attributed carry PR evidence. diff --git a/devlog/_plan/260906_d_integrations_delivery/020_tool_aliases.md b/devlog/_plan/260906_d_integrations_delivery/020_tool_aliases.md new file mode 100644 index 0000000000..54ecb53073 --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/020_tool_aliases.md @@ -0,0 +1,124 @@ +# 020 — Retain late Chat tool-call index aliases (#3673) + +## Loop specification + +- Class: C2 adapter repair; spec-satisfaction loop, one implementation PABCD cycle. +- Trigger: an upstream Chat stream introduces a call by ID, associates an index later, then sends index-only fragments. +- Goal: one complete call retains its original ID/name and argument budget ownership. +- Non-goals: guessing associations between unindexed calls, changing other malformed-field tolerance, changing budget limits, transport/core changes, unrelated adapter refactors. +- Verifier: exact-head hosted CI executes the focused cases below plus repository typecheck/full-suite gates. NO local tests, suites, typecheck, or test:changed; commands below are runner-only specifications. +- Stop: all acceptance rows and required hosted jobs pass on the delivered head, review findings resolved, and main proves delivery to dev. A docs-only result does not satisfy implementation criteria. +- Memory artifact: this decade document and main-owned 000/CI evidence ledger in the same unit. +- Outcomes: DONE after proof; NOOP only if current dev already has equivalent behavior and CI proof; otherwise BLOCKED/NEEDS_HUMAN with the concrete missing external evidence. Main controls orchestration and goals. +- Escalation: report upstream to main if the refreshed source no longer matches these contracts; main reclaims after two failed distinct delegates. Further delegated scope must be recorded during P, not improvised in B. +- Resources: local source/refs and supplied PR snapshot are read-only inputs; this planning delegate writes only this document and 030. Implementation write scope is the map below; main owns credentials, publication, CI dispatch, merge and its session-wide resource bound. No paid/provider calls are needed. + +## Provenance and stale check + +Baseline: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c`, inspected 2026-09-06 KST. +Source ref `origin/d-source-3673` resolves to `c8240c51d664f7cfb790b6d60679adfe0490b5c9`. +Original author: **Hako <25837994+devswha@users.noreply.github.com>** (`devswha`). +Source patch parent: `6585e6a70f42be8b6c81ff20d4fa0f39f7da03db`. +Snapshot: `.tmp/d-delivery/pr-3673.json` (`headRefOid`, body, comments, checks). +Read-only comparison `git diff c8240c51d^ 81871b3fa --` across the three source-PR paths returned no diff: source patch applies to the same relevant baseline. Recheck at this cycle's P because other lanes may land first. + +Preserve author identity when carrying the commit; include `Co-authored-by: Hako <25837994+devswha@users.noreply.github.com>` in the eventual squash description/commit. Main may carry with a cherry-pick or reimplementation; neither is performed by this document writer. + +## Exact change map + +| Operation | Path | Change | +|---|---|---| +| MODIFY | `src/adapters/openai-chat.ts` | Add first-observed index alias to pending call identity lookup; keep budget key immutable. | +| MODIFY | `tests/adapters/openai/openai-chat-parallel-stream.test.ts` | Port the complete original regression patch, extending T9b and adding collision/budget controls. | +| MODIFY | `docs-site/src/content/docs/reference/adapters.md` | Port the original five-line paragraph under openai-chat. | +| MODIFY | `structure/04_transports-and-sidecars.md` | Append the contract block below in C. | +| NEW | none | Existing test file already appears in both layout manifests; no new helper/module/manifest entry. | + +Read dependencies: `tests/helpers/translator-budget.ts`, `src/lib/translator-budget.ts`; reuse `createTestTranslatorBudget`, `withTestTranslatorBudget`, existing `collect`, `sse`, `chunkOf`, and `assembled`. No additional registry or identity map is necessary. Configuration cannot fix missing association state; deletion/NOOP would leave the observed sequence broken. + +## Concrete patch contract + +The exact original patch is the complete diff `git show c8240c51d664f7cfb790b6d60679adfe0490b5c9 -- src/adapters/openai-chat.ts tests/adapters/openai/openai-chat-parallel-stream.test.ts docs-site/src/content/docs/reference/adapters.md`. Preserve all hunks, including test import/helper changes; do not port just T9b. + +Current anchors: `src/adapters/openai-chat.ts:1661` pending interface, `:1856` identity lookup, `:1873` budget opening, `:1912` argument-byte accounting, `:1679` budget closing. Replace the lookup block with: + +```ts +if (typeof rawIndex === "number" + && (!Number.isInteger(rawIndex) || rawIndex < 0)) { + return yield* terminateWithError({ + ...invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage), + message: "upstream response contained invalid tool calls (invalid numeric index)", + }); +} +const indexKey = typeof rawIndex === "number" ? `i:${rawIndex}` : undefined; +const key = indexKey ?? (idDelta + ? `id:${idDelta}` + : pendingToolCalls[pendingToolCalls.length - 1]?.key); +let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined; +if (!call && indexKey !== undefined) call = pendingToolCalls.find(c => c.indexKey === indexKey); +if (!call && idDelta) call = pendingToolCalls.find(c => c.id === idDelta); +``` + +Add `indexKey?: string` after `PendingToolCall.key`. Immediately after the existing new-call allocation/openCall block, add the source comment and: + +```ts +if (indexKey !== undefined && call.indexKey === undefined) call.indexKey = indexKey; +``` + +Before: the ID+index delta finds the ID-owned call through ID fallback but does not retain its index; the next index-only delta allocates another unnamed call. After: direct key wins, then remembered index alias, then existing ID fallback. The original `call.key` never changes and alias registration does not call `budget.openCall` a second time. First observed index remains authoritative; no second alias is added for a repeated ID on a different index. Keep resolve-before-validation, `sawArgumentsString`, heartbeat emission, overflow conversion, flush and EOF logic unchanged. + +## Regression activation and oracle + +Port exact fixtures/assertions from the source commit; all paths are reachable through `createOpenAIChatAdapter(...).parseStream(new Response(sse(...)), budget)`. + +| Activation | Required observation | +|---|---| +| T9b ID-only `call_b/read/{"p"`, then index 0 + same ID + `:"x"`, then index 0 + `}` | Exactly `call_b/read/{"p":"x"}`; final done; budget activeCalls/currentBytes/overflows all zero. Current T9b at test line 214 ends at ID+index and misses the defect. | +| Two ID-only calls, learn indexes 9 and 4 in reverse order, index-only tails plus ID-only trailing space | Separate read/write calls and exact original fixture args; peak active calls 2, no duplicate owners, final zero retained bytes. | +| Two unindexed calls then unrelated index-only fragments without any ID/index association | Final error and no done; never guess by position. | +| Existing index with conflicting ID | Index ownership wins; neither call rebound. | +| Established indexed calls later share the same ID, followed by ID-only continuation | Existing first-match ID fallback stays intact. | +| Same ID repeats with a second index after index 0 was observed | Index 0 remains the alias; exact fixture completes one call. | +| `{"p":"é"}` split over ID/ID+index/index frames, maxCallArgumentBytes 9 then 10 | At 9: translation_buffer_limit, no tool_call_start, one overflow. At 10: exact completed args, done, zero overflow. Both release retained bytes/calls. | + +Optional additional mutation experiment (not a completion prerequisite): run the final regression file against the baseline adapter and observe T9b fail for split/unnamed calls; restore patched adapter and rerun the same file green. Store both outputs; until observed, describe RED as planned rather than proven. Do not disable original tests or change timeouts to mask failures. + +Runner-only focused command: + +```sh +bun test tests/adapters/openai/openai-chat-parallel-stream.test.ts tests/adapters/openai/openai-chat-hardening.test.ts tests/adapters/openai/openai-chat-eof.test.ts +``` + +Then hosted typecheck/full test jobs, privacy scan and docs build; `.github/workflows/ci.yml:255` owns test jobs, `:392` gates, `:422` typecheck. Record actual head SHA, run/job URLs and executed job conclusions; intake labels and skipped jobs do not prove tests. Main pushes with `--no-verify` as authorized, bypassing local prepush only. Do not attest that local CI ran. + +## Documentation and architecture sync + +Apply source paragraph before `## ollama-native` at adapters reference line 52. Reconcile this same-file edit with A's #3568 docs and 030's Cursor section without overwriting either. English is canonical; inspect translated adapter pages for contradictory identity claims, and enumerate any required locale changes in P before widening the map. + +Append to `structure/04_transports-and-sidecars.md`: + +```md +## Chat streamed tool-call identity + +`src/adapters/openai-chat.ts` retains a call's first observed numeric index as an +alias when the call started by ID. Lookup preserves direct-key precedence, then +index alias, then ID fallback. The initial key continues to own all translator +budget reservations and release; learning an alias creates no additional owner. +Unassociated index-only fragments are not guessed onto pending ID-only calls. +`tests/adapters/openai/openai-chat-parallel-stream.test.ts` covers late aliases, +parallel/colliding identities and UTF-8 byte-limit boundaries. +``` + +## Review blockers and integration exit + +Snapshot says MERGEABLE. Source body leaves draft/readiness open: 124 focused passes and 6,152 affected passes are author-reported, not delivered-head evidence; full baseline has reported timeout failures and does not establish green. CodeRabbit's latest comment reports no actionable comments; its docstring coverage warning is not product execution proof. No independent approval or review-thread completeness can be inferred solely from the empty `reviews` array. Main must refresh threads and CI at the candidate head. + +This layer follows 010 in the D stack as an integration sequence, not a runtime dependency. After lower-layer edits, main cascades refreshed descendants and revalidates changed heads. Main merges bottom-up, proves merge-commit ancestry on fetched dev, and immediately closes superseded #3673 only after that proof. A new PR's squash must retain the original trailer. This docs-only delivery neither merges nor closes anything. + +## Roadmap lock clarification + +The implementation cycle certifies its published current-head candidate. Every dev-ancestry and original-closeout obligation remains mandatory in the separate landing work-phase, allowing the owner-requested stack to exist without treating publication as dev integration. Eligible lower layers may land early and are closed immediately after ancestry proof. + +## External review amendment: numeric index contract + +Only non-negative integer indexes may become an alias. Immediately after reading rawIndex, if it is numeric but not an integer or is negative, terminate through the existing invalidToolCallsEvent/terminateWithError path; do not treat an invalid numeric index as absent and append its data to the last pending call. Other tolerated placeholder fields retain their existing rules. Add reachable negative/fractional numeric-index regressions with two distinct pending calls: one error, no done, no fragment reassignment, and all budget reservations released. Preserve all original positive and collision cases. This is an explicit source-patch amendment, not a claim the original commit already implements validation. diff --git a/devlog/_plan/260906_d_integrations_delivery/030_cursor_schemas.md b/devlog/_plan/260906_d_integrations_delivery/030_cursor_schemas.md new file mode 100644 index 0000000000..2150e7af91 --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/030_cursor_schemas.md @@ -0,0 +1,151 @@ +# 030 — Preserve Cursor executable tool schemas (#3628) + +## Loop specification + +- Class: C3 adapter contract carry across the current module split; spec-satisfaction, one implementation PABCD cycle. +- Trigger: bare exec_command advertisement omits supported execution fields, and freeform tools advertise an empty parameter object. +- Goal: preserve shell fields and a required string freeform input through advertisement and argument normalization, with reserved shell-name rejection. +- Non-goals: executing commands, changing approval/sandbox policy, nativeLocalExec defaults, OAuth or transport changes, changing generated protobuf code, rejoining split modules. +- Verifier: exact delivered-head hosted focused Cursor regressions, typecheck/full-suite, privacy and docs build. NO local tests, suites, typecheck or test:changed. Commands in this document run only on CI runners. +- Stop/outcomes: DONE only after acceptance rows, current-head required jobs and independent review pass and main proves dev integration. NOOP requires equivalent current-dev implementation plus evidence; external validation/permission gaps are BLOCKED/NEEDS_HUMAN, never success. +- Memory: this document plus the main-owned research/CI ledger. Main owns goals and FSM; this planning delegate does not alter either. +- Escalation: changed contracts/conflicts return to main at P; two failed distinct worker packets cause main reclaim. Further downward delegation is a P amendment. +- Resource/write scope: read local refs and supplied PR JSON, write only the two delegated roadmap files during this task; later implementation is restricted to the exact map below. Main owns authorized GitHub credentials, publication/merge and session resource bounds. No paid endpoint probes or tool execution are required. + +## Provenance, owner migration and blockers + +Inspected baseline `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c` on 2026-09-06 KST. +Source `origin/d-source-3628` is `37e6115c8a2ad3ffe20fee1e5a1e79a054625a56`. +Carry both original commits, in order: + +1. `1b29236c5bee9dd166b9d23983a2f1f1c2f0b793` — preserve executable tool schemas. +2. `37e6115c8a2ad3ffe20fee1e5a1e79a054625a56` — reject reserved freeform shell names. + +Both are authored by **SB Yoon <44089734+yansigit@users.noreply.github.com>** (`yansigit`). Preserve original authorship and add `Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com>` to the eventual squash commit/description. + +`.tmp/d-delivery/pr-3628.json:133` records source head, `:134` CONFLICTING. Its body reports 32 focused tests/full-suite success on source head; this is not candidate CI proof. The earlier reviewer finding at old `tool-definitions.ts:429` rejects bare freeform exec_command/shell_command. The author comment references pre-rebase `ae871bd19`; the fetched source's actual second commit above contains the correction. Carrying only the first commit would reintroduce the finding. Refresh actual current threads at integration; the supplied reviews/comments snapshot is not a complete unresolved-thread query. + +Current schema owner is **`src/adapters/cursor/tool-schemas.ts`**, moved by `3435d03983fdec305c6f2f4633650a15699a28e0` (split S04 L1/5). `tool-definitions.ts:6` imports schemas and `:8` preserves the public re-export facade. Do not cherry-pick a whole stale file over the split. Translate original schema hunks by symbol, retain current helpers, and add the new constant to the facade. + +## Exact file change map + +| Operation | Path | Change | +|---|---|---| +| MODIFY | `src/adapters/cursor/tool-schemas.ts` | All original production schema additions and both freeform guards, adapted from old tool-definitions.ts. | +| MODIFY | `src/adapters/cursor/tool-definitions.ts` | Add CURSOR_FREEFORM_INPUT_SCHEMA to the existing line-8 re-export only. | +| MODIFY | `tests/providers/cursor/cursor-tool-definitions.test.ts` | Port both source commits' complete regression hunks, preserving current file additions. | +| MODIFY | `docs-site/src/content/docs/reference/adapters.md` | Add exact Cursor contract bullet below under existing cursor section. | +| MODIFY | `structure/04_transports-and-sidecars.md` | Add schema ownership/normalization contract below. | +| NEW | none | Reuse existing file and test registration; no dependency or generated protobuf changes. | + +Read-only consumers: `tool-naming.ts:76` isBareCodexShellBridgeTool (`!namespace` plus reserved name), `tool-definitions.ts:80` buildCursorToolDefinitions and `:92` schema encoding, `live-transport.ts:672` toolSchemas normalization map, `arg-normalize.ts:69` normalizeArgKeys. Existing tool choice filtering and namespaced names must remain unchanged. Configuration/NOOP cannot supply missing schema declarations; reuse existing schema owners rather than add a parallel abstraction. + +## Exact patch references and adaptation + +The authoritative complete patch is: + +```sh +git diff 6b85485f32f783bafc61c79185d0cb937848859d 37e6115c8a2ad3ffe20fee1e5a1e79a054625a56 -- src/adapters/cursor/tool-definitions.ts tests/providers/cursor/cursor-tool-definitions.test.ts +``` + +Apply all production hunks from the old path to these current symbols in `tool-schemas.ts`: + +1. `CURSOR_EXEC_COMMAND_INPUT_SCHEMA` at line 4: after max_output_tokens, add the original sandbox_permissions string enum (`use_default`, `require_escalated`), justification string, prefix_rule string array, login boolean, including original descriptions. Preserve required `["cmd"]` and additionalProperties false. +2. Add immediately after that constant: + +```ts +/** Cursor represents a Responses freeform tool body as one string-valued input field. */ +export const CURSOR_FREEFORM_INPUT_SCHEMA = { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, +} as const; +``` + +3. `CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA` at line 64: add the same four property shapes from the source diff, keeping command as the canonical fallback and preserving max_output_chars. +4. At the start of BOTH `cursorToolInputSchema` (line 80) and `cursorToolArgNormalizeSchema` (line 89), insert this complete block before the current shell/function fallback: + +```ts +if (tool.freeform) { + if (isBareCodexShellBridgeTool(tool)) { + throw new Error(`freeform Cursor tools cannot use reserved shell bridge name ${tool.name}; use a namespace`); + } + return CURSOR_FREEFORM_INPUT_SCHEMA; +} +``` + +5. Add `CURSOR_FREEFORM_INPUT_SCHEMA` to the existing `export { ... } from "./tool-schemas"` facade at tool-definitions.ts:8. Existing tests import through that facade; do not introduce a second definition or silently change the public import surface. +6. Port original test imports for CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA and CURSOR_FREEFORM_INPUT_SCHEMA and the complete 94-line regression addition. Imports remain `../../../src/...` in the existing providers/cursor test directory. Existing manifests already register this file (layout.json:554, test-layout-expected.json:391). + +Before: bare exec advertisement lacks four fields; normal/freeform schema lookup falls through to `parameters ?? {}`. After: both normalization and advertisement use one input string for freeform; bare reserved freeform names are rejected before either can acquire shell semantics. Namespaced shell-like tools stay ordinary freeform. Ordinary shell_command converts cmd to command; caller-supplied cmd-only exec_command stays cmd-only via existing shellBridgeArgNormalizeSchema. Keep this helper and current required-command validation intact. + +## Regression activation scenarios + +| Constructible input | Observable assertion | +|---|---| +| Bare non-freeform exec_command passed to buildCursorToolDefinitions | Decode protobuf ValueSchema and verify cmd schema plus enum/string/array/boolean field shapes; required cmd and additionalProperties false remain. | +| Freeform apply_patch with parameters `{}` | Both schema functions and decoded protobuf require a string input. | +| Freeform bare exec code-mode tool without parameters | Both schema functions return the same required-input contract. | +| Bare freeform exec_command and shell_command, each through both schema functions and buildCursorToolDefinitions | Throw the explicit reserved-shell-name error; include both names, not one representative. | +| Namespaced freeform exec_command under mcp__custom | Accepted required-input schema; never interpreted as bare shell bridge. | +| Bare ordinary function exec_command with cmd-only parameters | Advertised Cursor exec schema; normalization retains original cmd-only schema. | +| shell_command declared with command, receive cmd plus sandbox_permissions=require_escalated, justification, prefix_rule, login=false | Only cmd rewrites to command; all four values survive exactly, especially false. | +| exec_command declared cmd-only, same fields | cmd remains cmd, other values survive; no added command key. | +| Existing canonical command and an alias simultaneously | Existing normalizeArgKeys canonical precedence remains covered by adjacent tests. | + +Use literal expected contracts and decoded protobuf values, not only equality against the newly added constant (both could be wrong together). Strengthen the ported freeform test with literal `{type:"object", properties:{input:{type:"string"}}, required:["input"], additionalProperties:false}`. Verify both ordinary shell directions already at current test lines 131 and 159. Existing code-mode/structured-edit tests later in the file protect unchanged routing and tool-choice behavior. + +Optional additional hosted mutation experiment (not a completion prerequisite): with final tests and baseline schema code, observe missing-property/freeform assertions fail; restore final schema code and obtain green. Separately remove only the reserved-name guard in an isolated runner checkout to prove both rejection tests fail, then restore and rerun. Do not claim RED before these logs exist. + +Runner-only commands: + +```sh +bun test tests/providers/cursor/cursor-tool-definitions.test.ts +bun test tests/providers/cursor +``` + +Follow with existing hosted typecheck, full-suite, privacy scan and docs build. Capture exact head and actual executed jobs; label/hygiene green or action_required does not establish validation. Preserve no-local policy even on failure; inspect CI artifacts and repair the specific defect. Main uses authorized --no-verify pushes to avoid local prepush, not server policy. No workflow edits are planned. + +## User and architecture documentation patch + +The source body's claim that no user documentation is needed is not adopted: the advertised tool contract changes and the root instructions require documentation sync. + +Append this bullet inside `## cursor` (`docs-site/src/content/docs/reference/adapters.md:304`), before `## azure-openai`: + +```md +- Codex-compatible shell schemas retain sandbox permissions, justification, reusable + prefix rules and login mode. Freeform tools expose one required string `input`; + bare `exec_command` and `shell_command` names are reserved for non-freeform shell + bridges. Namespace a custom freeform tool that uses either name. These schema + declarations do not grant approval or change execution policy. +``` + +Append this block to the existing transport SOT, preserving 020 and peer additions: + +```md +## Cursor executable tool schema ownership + +`src/adapters/cursor/tool-schemas.ts` owns advertised and argument-normalization +schemas; `tool-definitions.ts` remains the public facade and protobuf encoder. +Advertisement and normalization intentionally differ for shell bridges: Cursor may +emit `cmd`, while the declared Responses contract decides whether it becomes +`command`. Both paths preserve execution-control fields. Freeform tools use one +required string `input`; bare shell bridge names are rejected on the freeform path. +Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in +`tests/providers/cursor/cursor-tool-definitions.test.ts`. +``` + +Inspect directly affected translated adapter sections at P; add exact locale paths to this map if they contradict the English contract. No locale edit is justified solely by adding optional detail. Docs build remains CI-only. + +## Integration handoff + +030 follows 020 in the requested D stack; their runtime paths are independent, but the adapter reference and SOT are shared. Cascade stack updates after lower-layer changes, preserve each layer's review delta and attribution, and do not overwrite new split-owner behavior while resolving source conflicts. Main refreshes reviews and exact-head CI, merges bottom-up, verifies the landed commit is an ancestor of fetched dev, then promptly closes superseded #3628. Do not close on carry creation or CI success alone. This planning task writes no production code and performs no Git/GitHub mutations. + +## Roadmap lock clarification + +The implementation cycle certifies its published current-head candidate. Every dev-ancestry and original-closeout obligation remains mandatory in the separate landing work-phase, allowing the owner-requested stack to exist without treating publication as dev integration. Eligible lower layers may land early and are closed immediately after ancestry proof. + +## External review amendment: closed freeform object + +The advertised freeform schema must include additionalProperties:false, matching the existing custom-tool compatibility envelope. Include this literal property in schema and protobuf assertions; preserve ordinary named function schemas and reserved-name guards. diff --git a/devlog/_plan/260906_d_integrations_delivery/040_logs_filters.md b/devlog/_plan/260906_d_integrations_delivery/040_logs_filters.md new file mode 100644 index 0000000000..969ee2a876 --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/040_logs_filters.md @@ -0,0 +1,210 @@ +# 040 — Composable Logs filters (#3625) + +## Loop specification and scope + +- Class: C2 product slice, developer-console dashboard, global/i18n, existing dense visual language, feedback-only motion. This file is a docs-only deliverable in the main agent's roadmap P cycle; implementation is a later, separate PABCD work-phase. +- Archetype: spec-satisfaction repair and integration. Trigger: #3625 exposes the already-landed rich Logs predicate through usable controls. +- Goal: combine surface, intercepted-request, provider, exact model, time, speed, status and conversation filters over the loaded log ring, with clear result counts, reset, and accessible keyboard controls. +- Non-goals: new log API parameters, persistence/URL schema, retention/export, incremental polling #3250, request transport changes, provider/model configuration, new dependencies, redesign of the Logs table, unrelated locale cleanup. +- Verifier: current-head hosted CI covering the tests below, GUI lint/build/typecheck, privacy and repository gates; rendered screenshot/interaction evidence from an isolated same-head Vite preview, CI-built artifact or hosted preview. No local tests, suites, typecheck, or build that invokes typecheck. No verification was executed during this planning task. +- Stop: implemented behavior, all acceptance rows, docs sync, fresh screenshots, author credit, current-head CI and main-owned dev ancestry proof. An author comment or green intake check is not completion evidence. +- Outcomes: DONE only with those receipts; NOOP only if current dev independently contains equivalent behavior and evidence; BLOCKED for an unavailable CI/preview/required review; NEEDS_HUMAN for an unresolved external scope decision. Never mark an incomplete slice done. +- Memory artifact: this document plus main-owned 000 roadmap/evidence ledger. No goal or orchestration mutations by this document owner. +- Bounds: this delegate reads local source refs and the supplied metadata and writes only this document; zero paid provider requests, zero local test/build processes, zero Git/GitHub mutation. Implementation inherits main's resource bound and credentials; no separate cost allocation is invented here. +- Escalation: main reclaims a packet after two distinct failed workers. Further implementation delegation is a P amendment with inherited user model settings; no mid-B widening. + +## P stale check, provenance and exact source patch + +Planning tree: `81871b3fa7034250b8d5ba2cbbfde44e40f0e69c` (read 2026-09-06 KST). +Source ref: `origin/d-source-3625` = `4f79746b4cedffeb61700113977cd72adf25c51f`. +Source base: `be81013fab6d83ff630ca5f38e7881678a303871`. +Metadata: `.tmp/d-delivery/pr-3625.json`; recorded PR author `yansigit`, display name **SB Yoon**. The JSON head agrees with the source ref; its mergeable/readiness fields are a captured snapshot, not fresh merge authorization. Its body still cites `232e324...`; the later author comment cites `4f79746...`. Both test reports are contributor claims, not integration-head proof. + +The complete baseline implementation is the exact four-commit sequence below. Read/apply its patch at the later B, then apply the explicit amendments in this document. Do not restore whole historical files over current files. + +| Order | Source commit | Authored change | +| --- | --- | --- | +| 1 | `6602c5610c6d7d8a1179b05c9f86598c4acd8fee` | Initial composable controls, state wiring, locales, styles and tests | +| 2 | `e053045e9a2d49b8d70546223b0d02313d4031fe` | Exact identities, option invalidation, relative clock, keyboard navigation and review corrections | +| 3 | `232e324b45afa617ccabb97374137c9faf7654ae` | Turkish copy and assertion refinements | +| 4 | `4f79746b4cedffeb61700113977cd72adf25c51f` | Test-global cleanup in finally | + +All four commits identify `SB Yoon <44089734+yansigit@users.noreply.github.com>`. +Preserve authored commits where feasible. A carried/reimplemented or squash commit and its PR description must retain: + +```text +Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> +``` + +Read-only patch locator (not an instruction to execute tests or mutate Git): + +```sh +git diff be81013fab6d83ff630ca5f38e7881678a303871 4f79746b4cedffeb61700113977cd72adf25c51f -- gui +git log --format='%H %an <%ae> %s' be81013fab6d83ff630ca5f38e7881678a303871..4f79746b4cedffeb61700113977cd72adf25c51f +``` + +Current `Logs.tsx`, `logs-filter.ts`, `logs-auto-refresh.test.tsx`, and `logs-filter.test.ts` have no delta from that source base. All nine locale catalogs and `styles.css` do have intervening dev changes. Recheck these facts at implementation P and rebase the patch semantically against the actual stacked parent. Do not import source-base package versions, lockfiles, locale-wide rewrites or old CSS. + +## Exact implementation file map + +The immutable range above is the exact before/after source patch for all 17 carried files. “MODIFY” below means apply that path's hunks to the current parent, preserving unrelated edits; “NEW” means take the source blob and the test amendments specified below. + +| Path | Operation | Before → after and immutable source locator | +| --- | --- | --- | +| `gui/src/pages/Logs.tsx` | MODIFY | At current lines 376–380 replace five independent states with `filters: LogFilterState` and `filterClockNow`; import `useMemo`, bar and existing engine. Replace line 475's fresh empty array with module-level `EMPTY_LOGS`. Replace lines 502–521 with the source clock/hash/options/predicate block. Replace lines 600–659 toolbar with `LogsFilterBar`; distinguish filtered empty state at line 717; detail conversation action updates the shared state. Exact diff: the range above, this path. | +| `gui/src/pages/logs-filter-bar.tsx` | NEW | Source-head lines 1–127: controlled `LogsFilterBar`, no global store. Native labeled selects, intercepted checkbox, conversation input, active count/reset. Surface radios have roving tabIndex and keydown helper. Speed values map to `[−∞,15)`, `[15,50)`, `[50,+∞)` bounds. | +| `gui/src/pages/logs-surface-keydown.ts` | NEW | Source-head lines 1–24: ordered all/claude/codex/grok; wrapping ArrowLeft/Right/Up/Down, Home/End, preventDefault only for handled keys, select then focus matching radio id. | +| `gui/src/pages/logs-filter.ts` | MODIFY | Current lines 119–126: `value?.includes(modelQuery)` → `value === modelQuery` for requested, resolved and attempted model identities. Keep whitespace/case normalization. Standalone `logs-model-filter.ts` retains substring semantics. | +| `gui/src/styles.css` | MODIFY | Add source two selectors after current `.logs-toolbar` at 2145, then the bounded responsive amendment below. Keep table widths/clipping and all Models rules. | +| `gui/src/i18n/en.ts` | MODIFY | Add source's 20 keys after current `logs.filter.surface.label` at 700; English owns `TKey`. | +| `gui/src/i18n/de.ts` | MODIFY | Same 20 source locale keys after current line 667. | +| `gui/src/i18n/fr.ts` | MODIFY | Same keys after line 681; preserve final number-neutral `Affichage de {count} sur {total}`. | +| `gui/src/i18n/ja.ts` | MODIFY | Same keys after line 643. | +| `gui/src/i18n/ko.ts` | MODIFY | Same keys after line 686; source copy includes `필터 초기화`, `{total}개 중 {count}개 표시`. | +| `gui/src/i18n/ru.ts` | MODIFY | Same keys after line 684. | +| `gui/src/i18n/tr.ts` | MODIFY | Same keys after line 691, final `jeton/sn` speed wording; update `logs.metric.tokPerSecTitle` at 723 to `Tam istek süresince saniye başına çıktı jetonu`. | +| `gui/src/i18n/zh.ts` | MODIFY | Same keys after line 679 (GUI Simplified Chinese). | +| `gui/src/i18n/zh-TW.ts` | MODIFY | Same keys after line 536 (GUI Traditional Chinese). | +| `gui/tests/logs-filter.test.ts` | MODIFY | Source patch lines 49 onward replaces substring expectations with complete identities; adds partial/stale negative cases; preserve status/time/speed/malformed-attempt cases at current 80–143. | +| `gui/tests/logs-auto-refresh.test.tsx` | MODIFY | Source patch confines intercepted-row assertions at 542–570 to `.logs-table tbody`, since select options legitimately retain hidden model names. Add behavioral integration cases below using this file's existing renderer/cache/fake-clock harness. | +| `gui/tests/logs-filter-bar.test.ts` | NEW | Source-head 115-line file as baseline; replace its first three source-string “wiring” checks with observable controls/interaction coverage. Retain and expand the actual keyboard/reset tests, with cleanup on assertion failure. | + +New documentation changes beyond the source PR: `structure/05_gui-and-management-api.md`, and all eight existing `docs-site/src/content/docs/{,ko/,fr/,ja/,ru/,tr/,zh-cn/,zh-tw/}guides/web-dashboard.md` paths, specified below. No German dashboard page exists at this head; do not create an unrelated locale tree. No root test-layout manifest change is required for tests under `gui/tests/`; preserve the repository's `tests/` manifests unchanged. + +### Existing state and behavior to preserve + +- `gui/src/pages/Logs.tsx:463` owns the resource fetch, cache, 2-second poll and backoff. Filters consume this ring; they do not fetch a new dataset. Keep stale/cold/loading states at 482–499 and the table/details transport untouched. +- `Logs.tsx:526` virtualizes `filteredLogs`, rendering newest first by reverse indexing. Retain stable request keys, column schema and detail behavior. Do not sort the input merely for filter selection. +- `logs-filter.ts:95` remains the sole predicate. Model/provider option extraction at 162 includes failover attempts, normalized duplicate handling and stable code-point ordering. Options derive from the full loaded ring, not the filtered subset. +- All filters compose with AND at row level. A requested model and a provider appearing on another attempt can both match that same row; do not silently introduce same-attempt pairing. +- Clock refresh is 30 seconds only while `timeWindow !== 'all'` and tab is Logs. It is independent of auto-refresh, so paused network refresh does not freeze time-relative filtering. Cleanup on window changes, Debug tab and unmount. +- If the loaded ring loses the selected model/provider, clear only each vanished identity; leave status/time/conversation and still-present identities intact. No permanent state persistence is introduced. +- Conversation hashing retains cancellation against obsolete input; reset clears both input and hash. Preserve the existing opaque-id path, and verify delayed hash completion cannot restore a reset filter. + +### Responsive CSS amendment (bounded to this toolbar) + +The source's two rules alone do not address current `.logs-filter-field .input { min-width:220px; max-width:360px; }` at `styles.css:2174`, or the four 64px-minimum surface buttons. At narrow widths labels plus 220px controls can exceed the content area. Add these rules adjacent to the two source additions; never change global `.select-sm`, `.input` or `.btn`: + +```css +.logs-filter-container { min-width: 0; } +.logs-filter-container .logs-filter-field { min-width: 0; max-width: 100%; flex-wrap: wrap; } +.logs-filter-container .logs-filter-field .input { min-width: 0; max-width: 100%; } +.logs-filter-container .logs-filter-status { flex-wrap: wrap; max-width: 100%; } +.logs-filter-container .logs-segmented { max-width: 100%; flex-wrap: wrap; } +``` + +Keep source `.logs-filter-status` flex alignment/gap/margin-left and `.logs-toolbar-secondary` spacing. Controls may wrap by field and radios may wrap as a group; native select identity remains readable from its option menu. The table keeps its independent horizontal scroller and 1100px minimum width. Browser acceptance, not CSS-string presence, decides containment. If these narrowly scoped rules are insufficient in the measured screenshot, amend only this block and document the measured overflow before the repair. + +### Locale and B3659 ownership handshake + +D owns the 20 new Logs keys plus Turkish speed-title correction; B3659 owns its Models Hide/Delete/cleanup/sync keys. Exact D key set: + +```text +logs.filter.model.all +logs.filter.provider.label / .all +logs.filter.status.label / .all / .success / .errors +logs.filter.time.label / .all / .15m / .1h / .24h +logs.filter.speed.label / .all / .slow / .medium / .fast +logs.filter.reset +logs.filter.showingCount +logs.noMatchingRequests +``` + +The source range supplies the exact translated values for every key, including both `{count}` and `{total}` placeholders. Retain now-unused `logs.filter.model.placeholder` and conversation-clear keys; deleting them is unrelated churn. + +Before B starts, main exchanges the actual B3659 head and changed-key/selector inventory. No B3659 source ref was supplied to this delegate, so no fresh claim of hunk disjointness is made. Both lanes touch all nine catalog files and `styles.css`; merge by keys/selectors, never by choosing one whole file. B preserves `.logs-*`; D preserves `.models-*` and B's shared control fixes. Shared `.select-sm` or global token changes belong to main's integration review. Re-read the final union after either lower stack layer changes; CI must run against that union. Public dashboard docs can also overlap: D inserts the Logs subsection and preserves B's Models wording. + +## Behavioral regression amendments and acceptance + +Use existing `gui/tests/logs-auto-refresh.test.tsx:1–164`: Happy DOM, `mountLogs`, virtualizer layout stubs, isolated resource stores, mocked `/api/settings` and `/api/logs`, `act`, fake timers and explicit microtask settlement. All execution is CI-only. Do not add a new test runner or assert only source substrings. Expected row ids/counts must be hardcoded independently of `filterLogs`. + +| ID | Reachable activation | Required observable result / owner | +| --- | --- | --- | +| L01 defaults | Mock loaded ring containing Codex, Claude and Grok entries, no filters | All rows remain newest-first; no active count/reset; existing loading/error/detail tests still pass. `logs-auto-refresh.test.tsx`. | +| L02 composition | Rows differ independently in surface, provider, exact model, status and intercepted marker; select controls sequentially | Only the hand-selected intersection row remains; displayed count uses filtered length and unfiltered ring length. Model/provider options still include excluded rows. `logs-auto-refresh.test.tsx`. | +| L03 identities | Include `model-a`, `model-a-plus`, requested/resolved/fallback-only identities and case/space variants | `model-a` does not match `model-a-plus`; full fallback/resolved identity matches; normalized duplicates produce one stable option. Preserve standalone substring helper tests. `logs-filter.test.ts`. | +| L04 status/speed | Include 200, 299, 300, 400, 599 and malformed status; finite rates 14.99, 15, 49.99, 50 plus unavailable | Success only 2xx; errors only 4xx/5xx; slow <15, medium >=15 and <50, fast >=50; unavailable excluded only with speed bound. UI maps every speed option to these bounds. Engine file plus bar rendered events. | +| L05 time expiry | Fake now T; timestamp T−15m+1s; select 15m, disable auto-refresh, retain identical log snapshot | Row initially visible, disappears on first 30s clock tick; fetch count stays unchanged after pause. Repeat predicate boundaries for 1h and 24h with injected clock. No real sleep. `logs-auto-refresh.test.tsx` plus existing engine windows. | +| L06 clock lifecycle | Activate 15m then change 1h, switch Debug, return Logs, finally unmount | Track the 30,000ms interval handle via spies on window setInterval/clearInterval; old handle cleared on each deactivation; one live filter interval after reactivation; none after unmount. Do not count unrelated Happy DOM/virtualizer timers. | +| L07 ring rollover | Select model/provider from snapshot A; refresh with B lacking only selected model, then C lacking selected provider | Missing select resets to All; still-present selection and unrelated status/time/conversation remain. Labels never become blank while a hidden stale value excludes rows. | +| L08 reset/hash | Enter conversation, allow hash resolve; combine with status/time; click reset. Repeat with first hash resolution deferred until after reset | All controls default, full ring visible, count/reset disappear; late old hash does not resurrect filtering. Detail “filter conversation” action updates shared state and closes dialog. | +| L09 empty/error distinction | Cold empty API ring; separately populated ring excluded by status; separately cold failure and stale failed poll | Empty ring shows no-requests, filtered ring shows no-matching, cold failure retains error, stale failure keeps rows/banner. No empty-state flash during refresh. | +| L10 keyboard | Focus selected surface radio; ArrowRight from Grok, ArrowLeft from All, Up/Down, Home/End and unrelated key | Selection and focus wrap correctly; exactly one radio tab stop; unrelated key neither changes selection nor prevents default. Test actual rendered `aria-checked`/tabIndex plus helper; reset remains keyboard reachable. | +| L11 presentation | EN/KO/FR/DE, dark/light, widths 1440/768/390/320; long model and provider labels | Toolbar remains within page; count/reset wrap; focus visible; no clipped functional labels. Table scrolls inside its wrapper, not whole page. Browser receipts below. | + +In `logs-filter-bar.test.ts`, replace the source-oracle tests at source-head lines 10–36 with real rendered field/change assertions; move clock behavior proof to L05/L06. Expand reset fixture to several active fields rather than status only. Ensure every root unmounts in `finally` before restoring globals, including the existing rendered-reset test; restore property descriptors where practical. Keep the final source commit's keyboard-test `finally` fix. These changes strengthen observable oracles, not lower coverage to obtain green. + +## Documentation exact additions + +`docs-site/src/content/docs/guides/web-dashboard.md:87` retains the existing Logs overview row (it remains true); insert a new subsection immediately before `### Linking to a section` at line 92. Add the corresponding localized subsection immediately before the existing translated section-link heading in each of the seven translated guides. The following is the exact new English block: + +```md +### Filtering request logs + +Logs filters combine surface, intercepted requests, provider, exact model, status, time, +speed, and conversation ID over the currently loaded request ring. Provider and model +choices also include fallback attempts; model matching ignores case and surrounding spaces +but does not match partial names. Choices that disappear from the ring reset to All. + +Time windows cover the last 15 minutes, hour, or day and refresh every 30 seconds while the +Logs tab is active, even with auto-refresh off. Speed uses output tokens per second over the +full request duration: below 15, 15 to below 50, or at least 50. Unavailable speed values are +excluded when a speed filter is active. Success means 2xx; errors mean 4xx or 5xx. + +Active filters show the matching count out of the loaded total. Reset filters restores all +rows; “No matching requests” differs from an empty log ring. Use arrow keys or Home/End in +the surface selector. These controls do not query historical records beyond the loaded ring. +``` + +Translations must preserve every threshold, exact-identity rule and loaded-ring scope. Use the following exact localized summary blocks at the same insertion seam; they cover the same contract without rewriting the rest of each page: + +| Locale path segment | Heading and paragraph to insert | +| --- | --- | +| `ko/` | `### 요청 로그 필터` — `Logs에서 화면 종류, 가로챈 요청, 공급자, 정확한 모델명, 상태, 시간, 속도, 대화 ID를 함께 필터링합니다. 현재 불러온 로그만 대상이며 공급자·모델 선택지에는 폴백 시도도 포함됩니다. 모델명은 대소문자와 앞뒤 공백을 무시하지만 부분 이름은 일치하지 않습니다. 로그에서 사라진 선택지는 전체로 돌아갑니다. 시간 범위는 최근 15분·1시간·1일이며 Logs 탭에서는 자동 새로고침을 꺼도 30초마다 갱신됩니다. 속도는 전체 요청 시간 기준 초당 출력 토큰으로, 15 미만·15 이상 50 미만·50 이상입니다. 속도 필터를 켜면 측정값 없는 요청은 제외됩니다. 성공은 2xx, 오류는 4xx·5xx입니다. 일치 건수와 불러온 전체 건수를 표시하며 필터 초기화로 모든 행을 복원합니다. 일치하는 요청이 없는 상태와 빈 로그는 구분합니다. 화면 종류 선택은 방향키와 Home/End로 조작할 수 있습니다. 불러온 범위 밖의 과거 로그는 조회하지 않습니다.` | +| `fr/` | `### Filtrer les requêtes` — `Les filtres combinent interface, requêtes interceptées, fournisseur, modèle exact, statut, période, vitesse et identifiant de conversation dans le journal chargé. Les choix incluent les tentatives de repli ; les modèles ignorent la casse et les espaces externes, sans correspondance partielle. Un choix disparu revient à Tous. Les périodes de 15 minutes, une heure et un jour évoluent toutes les 30 secondes dans l’onglet Logs, même sans actualisation automatique. La vitesse mesure les jetons de sortie par seconde sur toute la durée : moins de 15, de 15 à moins de 50, ou au moins 50 ; les valeurs indisponibles sont exclues quand ce filtre est actif. Réussite : 2xx ; erreur : 4xx/5xx. Le compteur compare les résultats au total chargé ; la réinitialisation restaure toutes les lignes. Aucun résultat diffère d’un journal vide. Flèches et Home/End pilotent le sélecteur d’interface. Aucun historique au-delà du journal chargé n’est interrogé.` | +| `ja/` | `### リクエストログの絞り込み` — `Logsではサーフェス、インターセプトされたリクエスト、プロバイダー、完全なモデル名、ステータス、時間、速度、会話IDを組み合わせて、読み込み済みログを絞り込みます。選択肢にはフォールバック試行も含まれます。モデル名は大文字小文字と前後の空白を無視しますが、部分一致ではありません。ログから消えた選択肢は全件に戻ります。時間は直近15分・1時間・1日で、Logsタブでは自動更新をオフにしても30秒ごとに更新します。速度はリクエスト全体の時間あたりの毎秒出力トークン数で、15未満、15以上50未満、50以上です。速度フィルター中は測定不能な行を除外します。成功は2xx、エラーは4xx/5xxです。一致件数と読み込み総数を表示し、リセットで全行を復元します。一致なしと空ログを区別します。サーフェスは矢印キーとHome/Endで操作できます。読み込み範囲外の履歴は検索しません。` | +| `ru/` | `### Фильтрация запросов` — `Фильтры объединяют источник, перехваченные запросы, провайдера, точную модель, статус, время, скорость и ID диалога в загруженном журнале. Варианты включают резервные попытки; модель сравнивается без учёта регистра и крайних пробелов, но не по подстроке. Исчезнувший вариант сбрасывается на все записи. Периоды 15 минут, час и сутки обновляются каждые 30 секунд на вкладке Logs даже при выключенном автообновлении. Скорость — выходные токены в секунду за полную длительность запроса: меньше 15, от 15 до менее 50, не менее 50; недоступные значения исключаются при активном фильтре скорости. Успех — 2xx, ошибки — 4xx/5xx. Счётчик показывает совпадения из загруженного общего числа; сброс возвращает все строки. Нет совпадений и пустой журнал различаются. Источник выбирается стрелками и Home/End. История вне загруженного журнала не запрашивается.` | +| `tr/` | `### İstek günlüklerini filtreleme` — `Filtreler yüklü günlükte yüzey, yakalanan istekler, sağlayıcı, tam model adı, durum, zaman, hız ve konuşma kimliğini birleştirir. Seçenekler yedek denemeleri de içerir; model eşleşmesi büyük/küçük harfi ve dış boşlukları yok sayar, kısmi adları eşleştirmez. Kaybolan seçenek tüm kayıtlara döner. Son 15 dakika, saat ve gün pencereleri Logs sekmesinde otomatik yenileme kapalıyken de 30 saniyede bir güncellenir. Hız, tam istek süresindeki saniyelik çıktı jetonudur: 15 altı, 15 dahil 50 altı, en az 50; hız filtresi açıkken ölçülemeyenler dışlanır. Başarı 2xx, hata 4xx/5xx anlamındadır. Sayaç eşleşen ve yüklü toplam sayıları gösterir; sıfırlama tüm satırları geri getirir. Eşleşme olmaması boş günlükten ayrılır. Yüzey seçimi oklar ve Home/End ile çalışır. Yüklü günlüğün dışındaki geçmiş sorgulanmaz.` | +| `zh-cn/` | `### 筛选请求日志` — `Logs 可组合界面、被拦截请求、提供商、完整模型名、状态、时间、速度和会话 ID,筛选当前已加载的日志。选项包含回退尝试;模型匹配忽略大小写及首尾空格,但不做部分匹配。日志中消失的选项恢复为全部。时间范围为最近 15 分钟、1 小时或 1 天;Logs 标签页每 30 秒更新一次,即使关闭自动刷新也会更新。速度按完整请求耗时计算每秒输出 token,分为小于 15、15 至小于 50、至少 50;启用速度筛选时排除无测量值的请求。成功为 2xx,错误为 4xx/5xx。显示匹配数与已加载总数;重置恢复全部行,并区分无匹配与空日志。界面选择支持方向键及 Home/End,不查询已加载范围之外的历史记录。` | +| `zh-tw/` | `### 篩選請求日誌` — `Logs 可組合介面、被攔截請求、供應商、完整模型名稱、狀態、時間、速度和對話 ID,篩選目前已載入的日誌。選項包含回退嘗試;模型比對忽略大小寫及頭尾空白,但不做部分比對。日誌中消失的選項恢復為全部。時間範圍為最近 15 分鐘、1 小時或 1 天;Logs 分頁每 30 秒更新一次,即使關閉自動重新整理也會更新。速度按完整請求耗時計算每秒輸出 token,分為小於 15、15 至小於 50、至少 50;啟用速度篩選時排除無測量值的請求。成功為 2xx,錯誤為 4xx/5xx。顯示符合數與已載入總數;重設恢復全部列,並區分無符合結果與空日誌。介面選擇支援方向鍵及 Home/End,不查詢已載入範圍以外的歷史記錄。` | + +At `structure/05_gui-and-management-api.md:130`, in the Logs & Debug row replace only `Logs tab: request/runtime logs for local diagnosis.` with: + +```text +Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring without changing the log API. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. +``` + +Keep Debug/API/auth sections and Models content intact. The user guide is the behavior source of truth; the structure row records ownership rather than duplicating every label. + +## CI-only verification and screenshot receipt + +Commands below are a handoff to hosted CI, not permission to run locally. This roadmap task runs none of them. `gui build` runs `tsc -b`, so it is also prohibited locally. + +1. Current `.github/workflows/ci.yml:416–446` runs GUI lint, root typecheck, `cd gui && bun test --isolate tests`, privacy scan and GUI build. Require those steps to execute on the implementation head, plus required repository/platform jobs; an aggregate success with skipped tests is insufficient. +2. Focused CI receipts identify `gui/tests/logs-filter.test.ts`, `gui/tests/logs-filter-bar.test.ts`, `gui/tests/logs-auto-refresh.test.tsx`, `gui/tests/logs-model-filter.test.ts`, `gui/tests/logs-surface-filter.test.ts`, `gui/tests/logs-tab-keydown.test.ts`, and `gui/tests/logs-table-overflow.test.ts`. Full GUI test execution may supply these receipts; avoid redundant unchanged reruns. +3. Verify visible-copy checking in hosted CI: `cd gui && bun run lint:i18n`. This exact script is not a separate step in the inspected CI workflow; main must prove equivalent lint coverage or arrange a hosted run. Do not claim it ran merely because general CI is green. +4. Docs validation is `cd docs-site && bun install --frozen-lockfile && bun run build` on CI, per docs-site instructions. Confirm actual job/step coverage rather than assume a workflow filename. If absent, main arranges a narrowly scoped hosted verifier before readiness. +5. Screenshots come from the final candidate in an isolated Vite dev preview (bundling only; no typecheck/test command), a CI-built artifact, or a hosted preview. Do not use the existing live port 10100 to claim this patch works; that service is not this candidate. Use native in-app browser inspect → act → inspect; do not install Playwright or run any local suite. +6. Use synthetic request metadata in the isolated preview: distinct surface/provider/model/status/rate combinations and opaque conversation ids, no real accounts, secrets or request bodies. Capture 1440×1000 EN/light and KO/dark with combined filters and count/reset, 768×1024 DE, and 390×844 plus 320×800 FR/KO. Include one no-matches state, one empty-ring state and keyboard-focused radio/reset state. The 320px capture must show toolbar containment separately from intended table scrolling. +7. Main stores screenshots under its ignored evidence directory (suggested `.tmp/d-delivery/screenshots/3625/`), with a manifest naming head SHA, preview URL, viewport, locale/theme, scenario, observed result and file. Inspect each actual image. Publish a durable screenshot URL in the integration PR description; the old source PR screenshot is reference only, not final-head proof. +8. For L05/L06 use deterministic CI test output for elapsed-time proof, not a screenshot or timed sleep. For browser flows record console/network state and check filter changes add no new request parameters or extra fetches beyond existing polling. + +## Stack, attribution and closure handoff + +Main owns branch creation, cherry-pick/reimplementation, commit, `--no-verify` push, PR template, review, merge and closure. This delegate performs none. Place this logical slice after the preceding D stack layer chosen in 000; its GUI/source changes have no semantic dependency on the earlier Cursor/tool-call work, but inherit that parent for stack topology. Re-read the actual parent at P, and cascade lower-layer updates before pushing upper layers. + +Merge bottom-up. Before landing, require current-head CI, screenshot URL, contributor trailer and applicable maintainer review. Retarget children before deleting a parent branch. After merge, main fetches dev and proves the integration merge commit is its ancestor. Close original #3625 immediately after that proof if a carry PR superseded it, linking the landing. No issue is linked in the supplied source PR metadata; do not close another D issue or #3659 as a side effect. + +## Open gates and document verification + +- No blocker to writing this roadmap. Implementation remains pending. +- B3659's actual current changed-key/selector inventory must be checked by main before merging shared files; no coordination message was sent by this delegate. +- Source tests lack behavioral clock/rollover coverage; L05–L08 are required amendments, not verified results. +- Source PR has no docs-site delta; the explicit documentation additions above are required. +- Current integrated CI, hosted preview/screenshots, independent review and final dev ancestry are not yet available from this document's read-only snapshot. +- Author's green local reports and cached readiness state do not satisfy these gates. +- Verification for this docs-only deliverable: inspected the source range, current consumers/styles/test harness and supplied PR JSON; read back this document and checked only its own diff/paths. No production code, peer document, Git state, GitHub state, tests, typecheck, goal or orchestration was changed. + +## Roadmap lock clarification + +The implementation cycle certifies its current-head published candidate. Final dev-ancestry and original-closeout requirements remain in the separate landing work-phase; lower layers may land early after all gates pass. An isolated Vite preview is permitted for UI evidence; local test/typecheck commands remain forbidden. diff --git a/devlog/_plan/260906_d_integrations_delivery/050_remote_aliases.md b/devlog/_plan/260906_d_integrations_delivery/050_remote_aliases.md new file mode 100644 index 0000000000..99567f86b6 --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/050_remote_aliases.md @@ -0,0 +1,7 @@ +# 050 — Remote Desktop integration work item + +This numbered entry reserves the D-lane work item associated with public issue #3646. Implementation has not started. The detailed design and review notes are retained in ignored scratch at `.tmp/d-delivery/050_remote_aliases.md`, following AGENTS.md's security-working-notes policy. + +The main agent must read that local design at the work-phase's P transition, revalidate it against current source, and obtain an independent implementation review plus actual current-head CI before claiming completion. Relevant user documentation and regression coverage ship with the implementation. Only the verified published outcome belongs in this tracked unit. + +This pointer is not evidence of implementation, verification, or issue resolution. Full delivery and source-disposition obligations remain in the session-bound goalplan. From 08c7d3784d0cfa96c61467b8c7a581ea661378e3 Mon Sep 17 00:00:00 2001 From: Hako <25837994+devswha@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:29:36 +0900 Subject: [PATCH 039/236] fix(integrations): refuse lossy TOML date rewrites (cherry picked from commit f6db9cae8e8854c6df06087288a074d767f9787d) --- .../content/docs/fr/guides/integrations.md | 5 +++++ .../src/content/docs/guides/integrations.md | 5 +++++ .../content/docs/tr/guides/integrations.md | 5 +++++ .../content/docs/zh-tw/guides/integrations.md | 2 ++ src/integrations/config-io.ts | 17 ++++++++++++++++- tests/clients/integrations-state.test.ts | 19 +++++++++++++++++++ tests/clients/integrations-writer.test.ts | 18 ++++++++++++++++++ 7 files changed, 70 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index d54d941a2f..c65531a4d3 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -114,6 +114,11 @@ l'application s'arrête et le signale au lieu d'écrire une valeur modifiée en réussi. Le fichier concerné est indiqué et rien n'est déplacé sur le disque. Vous pouvez toujours modifier ce fichier manuellement ; seule la réécriture automatique est refusée. +Les dates et heures TOML empêchent également la réécriture automatique : la fusion +les convertirait en chaînes entre guillemets, y compris dans les tableaux et les +tables en ligne. Les dates déjà écrites entre guillemets restent prises en charge. +Pour conserver une date typée sans guillemets, modifiez manuellement la configuration. + **Pi, Kimi Code, Gajae Code, MiniMax Code et l'intégration DSH gérée fonctionnent uniquement avec une adresse de bouclage.** Les quatre premiers n'ont aucun champ de configuration pour l'en-tête `x-opencodex-api-key` qu'exige une liaison hors bouclage. DSH possède une table d'en-têtes générique, mais rc.6 ne documente pas diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index da1273ad9a..06a98ea33e 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -159,6 +159,11 @@ changed value and calling it success. You will see the file named and nothing on disk will have moved. Editing that file by hand still works; it is only our automatic rewrite that declines. +TOML dates and times also refuse managed rewrites: the merge step would turn these +typed values into quoted strings. This includes values inside arrays and inline +tables. Quoted date strings remain supported; an unquoted date must be preserved +by editing the configuration manually. + **Pi, Kimi Code, Gajae Code, MiniMax Code, Prime Agent and the managed DSH integration only work against a loopback bind.** The first four have no config field for the `x-opencodex-api-key` header a non-loopback bind requires. DSH has a generic headers map, but rc.6 does not document that dedicated admission diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index 636e86af79..fea4b37dd4 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -136,6 +136,11 @@ değişen bir değer yazıp buna başarı demek yerine durur ve bunu söyler. Do adlandırıldığını ve diskte hiçbir şeyin taşınmadığını görürsünüz. Bu dosyayı elle düzenlemek hala çalışır; yalnızca otomatik yeniden yazmamız reddeder. +TOML tarih ve saat değerleri de otomatik yeniden yazmayı engeller: birleştirme adımı, +diziler ve satır içi tablolar dahil bu türlenmiş değerleri tırnaklı metne dönüştürür. +Zaten tırnak içinde yazılmış tarihler desteklenir. Tırnaksız tarih türünü korumak +için yapılandırmayı elle düzenleyin. + **Pi, Kimi Code, Gajae Code, MiniMax Code ve yönetilen DSH entegrasyonu yalnızca geri döngü (loopback) bağlantısına karşı çalışır.** İlk dördünün yapılandırmasında geri döngü olmayan bir bağlantının gerektirdiği `x-opencodex-api-key` başlığı için alan yoktur. DSH genel bir headers haritası sunar, ancak rc.6 diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 426cdae162..54751d5620 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -60,6 +60,8 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil **如果某個值無法忠實重寫,開關會拒絕執行。** 往返覆蓋這些格式在實務上會用到的值種類;當它做不到時——例如使用 `inf` 或 `nan` 的 TOML 檔案,我們可用的 parser 無法準確讀回——套用會停止並說明,而不是寫入被改動的值然後宣稱成功。你會看到檔案被指名,磁碟上沒有任何東西被移動。手動編輯那個檔案仍然有效;只有我們的自動重寫會拒絕。 +TOML 日期與時間值也會阻止自動重寫:合併步驟會將這些帶有型別的值轉成加引號的字串,陣列和行內表格中的值也一樣。原本就加引號的日期字串仍受支援;若要保留不加引號的日期型別,請手動編輯設定。 + **Pi、Kimi Code、Gajae Code、MiniMax Code 與受管理 DSH 整合只能對 loopback bind 運作。** 前四者的設定沒有非 loopback bind 所需的 `x-opencodex-api-key` header 欄位。DSH 雖然提供通用 headers map,但 rc.6 並未把這個專用准入 header 記錄為受支援的整合契約,因此受管理 writer 會選擇安全拒絕,而不自行猜測。請改用 SSH tunnel,或由本機 forwarder 加上該 header 後再以 loopback 存取。 **產生的 OMP 整合也刻意只支援 loopback。** OMP 確實支援 provider 層級的 headers,但這個最初的整合不會發出遠端 `x-opencodex-api-key` 憑證連線。手動的遠端 OMP 設定目前不在受管理的整合範圍內。 diff --git a/src/integrations/config-io.ts b/src/integrations/config-io.ts index 4f2a834824..9cb5f97baa 100644 --- a/src/integrations/config-io.ts +++ b/src/integrations/config-io.ts @@ -162,7 +162,22 @@ export function parseConfig(text: string | null, format: ConfigFormat): unknown * evidence is gone. */ if (/(^|[\s,[=])[-+]?(?:inf|nan)(?=[\s,\]]|$)/mi.test(text)) return PARSE_FAILED; - return Bun.TOML.parse(text); + const document = Bun.TOML.parse(text); + // TOML date/time scalars are Temporal objects with toJSON methods. + // The merge layer JSON-clones documents, which silently turns these + // into strings. Refuse before either status or a writer can admit a + // lossy rewrite, including dates nested in arrays and inline tables. + const pending: unknown[] = [document]; + while (pending.length > 0) { + const value = pending.pop(); + if (value === null || typeof value !== "object") continue; + if (!Array.isArray(value)) { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return PARSE_FAILED; + } + for (const child of Object.values(value)) pending.push(child); + } + return document; } } } catch { diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index 54ab80de12..872e9b3824 100644 --- a/tests/clients/integrations-state.test.ts +++ b/tests/clients/integrations-state.test.ts @@ -401,6 +401,25 @@ describe("classifier unit behavior", () => { expect(parseConfig("{{{", "json")).toBe(PARSE_FAILED); }); + test("parseConfig refuses typed TOML dates before a JSON clone can turn them into strings", () => { + for (const literal of [ + "2026-09-05T10:00:00Z", + "2026-09-05T10:00:00-07:00", + "2026-09-05T10:00:00.123456", + "2026-09-05", + "10:00:00.123456", + ]) { + for (const text of [ + `expires = ${literal}\n`, + `[user]\nexpires = ${literal}\n`, + `items = [{ expires = ${literal} }]\n`, + ]) { + expect(parseConfig(text, "toml")).toBe(PARSE_FAILED); + } + expect(parseConfig(`expires = "${literal}"\n`, "toml")).toEqual({ expires: literal }); + } + }); + test("parseConfig refuses json number literals a rewrite would change", () => { // Overflow to Infinity — a rewrite would bake in null. expect(parseConfig("{\"a\": 1e999}", "json")).toBe(PARSE_FAILED); diff --git a/tests/clients/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts index 0bf81fdb54..de2f164710 100644 --- a/tests/clients/integrations-writer.test.ts +++ b/tests/clients/integrations-writer.test.ts @@ -141,6 +141,24 @@ function reverseJsonObjectKeys(value: unknown): unknown { } describe("apply", () => { + test("refuses Kimi TOML date rewrites without changing the file or ownership store", () => { + const spec = INTEGRATION_CLIENTS.kimi; + mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); + const configPath = spec.configPath(TEST_ENV, home); + mkdirSync(dirname(configPath), { recursive: true }); + const original = "[user]\nexpires = 2026-09-05T10:00:00Z\n"; + writeFileSync(configPath, original); + const request = input({ clientId: "kimi" }); + + expect(readIntegrationState(request).state).toBe("unsafe"); + const result = applyIntegration(request); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unsafe"); + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(store.listOperations()).toHaveLength(0); + expect(store.readRecords().kimi).toBeUndefined(); + }); + test("refuses a client that is not installed, and writes nothing", () => { const result = applyIntegration(input()); expect(result.ok).toBe(false); From e0b5f74b4db8551cf200aca4a9c05b2750375d48 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 01:38:48 +0900 Subject: [PATCH 040/236] docs(integrations): record TOML admission and carry evidence Co-authored-by: Hako <25837994+devswha@users.noreply.github.com> --- .../260906_d_integrations_delivery/011_toml_refresh.md | 8 ++++++++ structure/09_client-integrations.md | 4 ++++ 2 files changed, 12 insertions(+) create mode 100644 devlog/_plan/260906_d_integrations_delivery/011_toml_refresh.md diff --git a/devlog/_plan/260906_d_integrations_delivery/011_toml_refresh.md b/devlog/_plan/260906_d_integrations_delivery/011_toml_refresh.md new file mode 100644 index 0000000000..5c2850fa4d --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/011_toml_refresh.md @@ -0,0 +1,8 @@ +# TOML cycle P refresh + +Parent: cb75f49c9401e10f8bd37f4817cdef32b0a5cbe1, documentation PR #3681. +Source: f6db9cae8e8854c6df06087288a074d767f9787d by Hako. + +Read-only git comparison from source parent to the current parent returned no changes in config-io.ts and the two affected client regression files. The 010 diff remains applicable. The shared parser admits both status and writers; no caller-specific exception or new option is required. + +Implementation scope stays as 010. Main will cherry-pick the original commit and add the structure contract. An inherited independent reviewer audits the candidate; no local application tests or typecheck are permitted. Hosted CI supplies runtime verification; docs build may run in a fresh macmini-cf scratch checkout with no real credentials or service changes. diff --git a/structure/09_client-integrations.md b/structure/09_client-integrations.md index 2c2e42b419..0b1f7cc181 100644 --- a/structure/09_client-integrations.md +++ b/structure/09_client-integrations.md @@ -36,6 +36,10 @@ Status and mutation must use the same classifier. A special case added only to a would be misleading because refresh or disable could still reject the same file; a special case added only to a writer would let a mutation bypass the state users saw. +TOML temporal scalars cannot survive the JSON-cloned merge representation with their types +intact. The common parser refuses documents containing them before either status or mutation +proceeds, including nested arrays and inline tables. Quoted date strings remain supported. + ## Fast model selectors The serving proxy resolves `fastRowAvailable` on every management model row, including its From eca05648681da03ceb0082bc26391c75baecce8e Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:17:36 +0900 Subject: [PATCH 041/236] docs(plan): refresh context layer and incremental admin landing --- devlog/_plan/260906_lane_b_catalog_stack/000_plan.md | 6 ++++++ devlog/_plan/260906_lane_b_catalog_stack/020_context.md | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/devlog/_plan/260906_lane_b_catalog_stack/000_plan.md b/devlog/_plan/260906_lane_b_catalog_stack/000_plan.md index 98a74cce9a..d2be407c30 100644 --- a/devlog/_plan/260906_lane_b_catalog_stack/000_plan.md +++ b/devlog/_plan/260906_lane_b_catalog_stack/000_plan.md @@ -57,3 +57,9 @@ GitHub operations stay sequential. Bottom-up merge commits preserve ancestry; if - A #3568 and B #3571 share layout manifests and provider docs; preserve both additions. Independent review findings involving security stay in ignored scratch space. Public plans describe the already-public source changes, never unpublished vulnerability analysis. + +## Owner steering and verification checkpoints + +The owner explicitly authorized admin merges during execution. Once a child PR is open, land a verified parent with `--admin --merge`, prove dev ancestry and immediately close its completed source work; retain/retarget the child before any parent-ref cleanup. The final landing cycle reconciles all outcomes rather than delaying every already-ready parent until the end. + +An implementation preparation cycle may close after the exact-head functional CI jobs (Linux/macOS full tests, typecheck, GUI tests and privacy), independent review and applicable remote GUI/docs checks pass. Queued aggregate packaging/keyring jobs remain explicit PR merge gates; do not claim them passed or merge before resolving required checks. This allows the next stack layer to be prepared while ancillary jobs queue, without weakening final verification or source-closure requirements. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/020_context.md b/devlog/_plan/260906_lane_b_catalog_stack/020_context.md index 91254361d0..71f9519a10 100644 --- a/devlog/_plan/260906_lane_b_catalog_stack/020_context.md +++ b/devlog/_plan/260906_lane_b_catalog_stack/020_context.md @@ -634,3 +634,7 @@ index 35a7924ebe..36bf70be57 100644 + expect(loadConfig().providerContextCaps?.openai).toBe(350_000); +}); ``` + +## Consuming P refresh + +Source #3654 remains OPEN at 8facdb0d8c10109701015c0f6109fc67b1d9dd3c. Full binary-preserving patch applicability passes on verified visibility head e556cc9f7. The actual persistence field is providerContextCapValues. Preserve the prior visibility tests and all translated API paragraphs. A confirmed its config overlap is documentation-only; the executable proxy resolver changes live elsewhere. Owner admin steering and preparation-vs-merge gates are recorded in 000. From 08152b086c1a091221a4f0250d349e00f108754f Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:17:36 +0900 Subject: [PATCH 042/236] fix(models): preserve selected context limits while disabled Carry complete PR #3654 at 8facdb0d8c10109701015c0f6109fc67b1d9dd3c, including the setAll clarification and original screenshots. Co-authored-by: Robin Bially <7304732+RobinBially@users.noreply.github.com> --- .../screenshots/openai-context-cap-off.png | Bin 0 -> 72874 bytes .../screenshots/openai-context-cap-on.png | Bin 0 -> 69646 bytes .../content/docs/reference/management-api.md | 12 ++++++ gui/src/pages/Models.tsx | 32 ++++++--------- gui/src/pages/models-shared.ts | 1 + .../models-native-group-controls.test.ts | 11 ++---- gui/tests/models-status-toast.test.tsx | 37 ++++++++++++++++++ src/codex/catalog/metadata.ts | 2 - src/config.ts | 1 + src/providers/context-cap.ts | 29 ++++++++++++-- src/providers/provider-id-rewrite.ts | 18 +++++---- src/server/management/provider-routes.ts | 14 ++++--- src/types/config.ts | 2 + .../native-model-toggle.test.ts | 2 +- tests/providers/provider-id-rewrite.test.ts | 7 ++++ .../management-provider-validation.test.ts | 28 +++++++++++++ 16 files changed, 148 insertions(+), 48 deletions(-) create mode 100644 docs-site/public/screenshots/openai-context-cap-off.png create mode 100644 docs-site/public/screenshots/openai-context-cap-on.png diff --git a/docs-site/public/screenshots/openai-context-cap-off.png b/docs-site/public/screenshots/openai-context-cap-off.png new file mode 100644 index 0000000000000000000000000000000000000000..092f7377f9781e9ea6b52b04cee5b1069c59f9c6 GIT binary patch literal 72874 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 literal 0 HcmV?d00001 diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index c5973f3a77..a46849900e 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -244,6 +244,18 @@ keys are not returned to dashboard clients. | `GET, PUT /api/provider-context-caps` | Read or update global, all-provider, or one-provider context caps | 400 invalid request; 404 unknown provider | | `GET /api/provider-presets` | Return GUI provider presets derived from the runtime registry | — | +The provider context-cap response includes `caps` (active limits) and `values` (last selected +values, retained while disabled). Enabling a provider without `value` restores its selection, +or uses the global `contextCapValue` on first enable. This also applies to OpenAI: the switch +does not select a special 922k mode. An active cap bounds every native window; models with a +supported long-context window may expand only up to their own supported ceiling. +Updating the global value with `{ "value": 600000, "setAll": true }` changes only enabled +provider caps; disabled providers keep their remembered selections when later enabled. +In contrast, `{ "setAll": true }` without `value` enables every configured provider at the +current global value, replacing their remembered selections. Turning a cap off does not +activate its remembered value or erase the selection. + + `provider_has_dependent_combos` is a safety barrier: remove or edit the dependent combos before deleting their provider. diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 91971cf54d..5b90f29a73 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -51,8 +51,6 @@ import { fmtK, NATIVE_CAP_OPTIONS, NATIVE_CAP_OPTION_SET, - NATIVE_GPT56_DEFAULT_WINDOW, - NATIVE_GPT56_OPT_IN_WINDOW, PAGE, readCollapsedProviders, THREAD_OPTION_SET, @@ -75,6 +73,7 @@ type CachedModelsPage = { selectedModels: ProviderModelMap; disabled: string[]; contextCaps: Record; + contextCapValues?: Record; contextCapValue: number; }; @@ -213,6 +212,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const [search, setSearch] = useState>({}); const [limit, setLimit] = useState>({}); const [contextCaps, setContextCaps] = useState>(() => cached?.contextCaps ?? {}); + const [contextCapValues, setContextCapValues] = useState>(() => cached?.contextCapValues ?? {}); const [contextCapValue, setContextCapValue] = useState(() => cached?.contextCapValue ?? 350_000); const [customCap, setCustomCap] = useState(""); const [showCustom, setShowCustom] = useState(false); @@ -428,6 +428,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; selectedModels: selectionData, disabled: [...nextDisabled], contextCaps: capsData.caps ?? {}, + contextCapValues: capsData.values ?? capsData.caps ?? {}, contextCapValue: nextCapValue, } satisfies CachedModelsPage; writeSessionListCache(cacheKey, next); @@ -447,6 +448,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; setSelectedModels(next.selectedModels); setContextCapValue(next.contextCapValue); setContextCaps(next.contextCaps); + setContextCapValues(next.contextCapValues ?? next.contextCaps); }, []); const catalogResource = useDataSurface( @@ -722,7 +724,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; } }; - const toggleProviderCap = async (provider: string, nativeGroup = false) => { + const toggleProviderCap = async (provider: string) => { setBusy(true); busyRef.current = true; setStatus(""); @@ -733,13 +735,12 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const r = await fetch(`${apiBase}/api/provider-context-caps`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(enabled && nativeGroup - ? { provider, enabled, value: NATIVE_GPT56_OPT_IN_WINDOW } - : { provider, enabled }), + body: JSON.stringify({ provider, enabled }), }); try { const data = await readJsonOrThrow(r, t("models.capSaveFailed")); setContextCaps(data?.caps ?? {}); + setContextCapValues(data?.values ?? data?.caps ?? {}); setOk(true); setStatus(t("models.capApplied")); await load(true); @@ -784,6 +785,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const data = await readJsonOrThrow(r, t("models.capSaveFailed")); if (typeof data?.value === "number" && Number.isFinite(data.value) && data.value > 0) setContextCapValue(data.value); setContextCaps(data?.caps ?? {}); + setContextCapValues(data?.values ?? data?.caps ?? {}); setOk(true); setStatus(t("models.capApplied")); await load(true); @@ -828,7 +830,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const onSelectProviderCap = (provider: string, raw: string) => { if (raw === CUSTOM_OPTION) { setProviderCapCustomOpen(prev => ({ ...prev, [provider]: true })); - setProviderCapCustomDraft(prev => ({ ...prev, [provider]: String(contextCaps[provider] ?? contextCapValue) })); + setProviderCapCustomDraft(prev => ({ ...prev, [provider]: String(contextCaps[provider] ?? contextCapValues[provider] ?? contextCapValue) })); return; } setProviderCapCustomOpen(prev => ({ ...prev, [provider]: false })); @@ -1176,18 +1178,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const recentForProvider = modelDiscovery?.recentArrivals[provider] ?? []; const recentIds = new Set(recentForProvider.map(row => row.id)); const capOn = contextCaps[provider] !== undefined; - const providerCap = contextCaps[provider] ?? contextCapValue; - // With the cap off, `providerCap` is only the value a future toggle would apply — for the - // native group that is the 350k default, which says nothing true about what Codex sees. - // The honest number there is the largest window the rows actually advertise. - const widestRowWindow = rows.reduce((widest, row) => { - const window = typeof row.contextWindow === "number" && row.contextWindow > 0 ? row.contextWindow : undefined; - if (window === undefined) return widest; - return widest === undefined || window > widest ? window : widest; - }, undefined); - const capDisplayValue = capOn - ? providerCap - : (nativeProviderGroup ? NATIVE_GPT56_DEFAULT_WINDOW : (widestRowWindow ?? providerCap)); + // Show the value the next enable will actually use, including a remembered selection. + const capDisplayValue = contextCaps[provider] ?? contextCapValues[provider] ?? contextCapValue; // The native group offers only the three windows GPT-5.6 actually has contracts for // (272k live, 372k legacy, 1.05M measured); routed providers keep the generic ladder. // The set has to follow the list, or a saved value outside it loses its option. @@ -1348,7 +1340,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; screen-reader user was not told this governs the context window. The number belongs to the adjacent Select, which is where a value goes (020_control_affordances.md). */} - toggleProviderCap(provider, nativeProviderGroup)} disabled={busy} label={t("models.contextCapLabel")} showLabel /> + toggleProviderCap(provider)} disabled={busy} label={t("models.contextCapLabel")} showLabel /> {/* Always rendered, disabled when the cap is off. A cap-off provider used to drop this control entirely, which is the defect the user reported: openai showed 1.05M and anthropic showed nothing, so the two rows started at diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index 1575a52ac9..fdc487301c 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -56,6 +56,7 @@ export interface ProviderContextCapsResponse { cap?: number; value?: number; caps?: Record; + values?: Record; } export interface V2Status { diff --git a/gui/tests/models-native-group-controls.test.ts b/gui/tests/models-native-group-controls.test.ts index 14c2f2c6d8..c9b20f6c40 100644 --- a/gui/tests/models-native-group-controls.test.ts +++ b/gui/tests/models-native-group-controls.test.ts @@ -72,14 +72,9 @@ test("every provider keeps its window readable with the cap switched off", async // slot is occupied on every card (040_cap_cluster_and_occupied_slot.md), which makes the // property this test protects strictly wider than it was. expect(src).not.toContain("{(capOn || nativeProviderGroup) && ("); - // With the cap off the stored value is only what a future toggle would apply — the 350k - // default — so the display falls back to the widest window the rows actually advertise. - // Matched as separate fragments because the expression is wrapped across lines now, and - // it grew a native branch: with the cap off the native group shows its default window - // rather than the widest advertised row. A single-line literal pinned the formatting - // instead of the behaviour and broke on the reflow that introduced that branch. - expect(src).toContain("const capDisplayValue = capOn"); - expect(src).toContain("nativeProviderGroup ? NATIVE_GPT56_DEFAULT_WINDOW : (widestRowWindow ?? providerCap)"); + // The disabled select previews the persisted choice or global default used by enable. + expect(src).toContain("contextCaps[provider] ?? contextCapValues[provider] ?? contextCapValue"); + expect(src).not.toContain("value: NATIVE_GPT56_OPT_IN_WINDOW"); // The select is inert until the cap is actually on: showing a number is not the same as // offering to change one. expect(src).toContain("disabled={busy || !capOn}"); diff --git a/gui/tests/models-status-toast.test.tsx b/gui/tests/models-status-toast.test.tsx index 29c902c2cb..5ecadb078e 100644 --- a/gui/tests/models-status-toast.test.tsx +++ b/gui/tests/models-status-toast.test.tsx @@ -175,3 +175,40 @@ test("success toast expires after 6s and a repeated action re-arms it", async () await fireTimers(6000); expect(container.querySelector(".action-toast")).toBeNull(); }); + +test("OpenAI context switch restores the selected cap instead of forcing 922k", async () => { + testWindow.sessionStorage.clear(); + let caps: Record = {openai:128_000}; + const values = {openai:128_000}; + const bodies: unknown[] = []; + const fallback = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith("/api/models")) return Response.json([ + {provider:"openai",id:"gpt-5.5",namespaced:"gpt-5.5",native:true,disabled:false,contextWindow:caps.openai??272_000}, + ]); + if (url.endsWith("/api/providers")) return Response.json([{name:"openai",authMode:"forward",liveModels:false}]); + if (url.endsWith("/api/provider-context-caps")) { + if (init?.method === "PUT") { + const body=JSON.parse(String(init.body)); bodies.push(body); + caps=body.enabled ? {openai:values.openai} : {}; + } + return Response.json({caps,values,value:350_000}); + } + return fallback(input,init); + }) as typeof fetch; + const { createRoot } = await import("react-dom/client"); + await act(async () => { root=createRoot(container); root.render(); }); + const settle=async()=>{await new Promise(resolve=>testWindow.setTimeout(resolve,0));}; + await act(settle); + const cluster=()=>container.querySelector(".models-cap-cluster")!; + const toggle=()=>cluster().querySelector("button.switch")!; + expect(cluster().textContent).toContain("128k"); + await act(async()=>{toggle().click();await settle();}); + expect(toggle().getAttribute("aria-pressed")).toBe("false"); + expect(cluster().textContent).toContain("128k"); + await act(async()=>{toggle().click();await settle();}); + expect(toggle().getAttribute("aria-pressed")).toBe("true"); + expect(cluster().textContent).toContain("128k"); + expect(bodies).toEqual([{provider:"openai",enabled:false},{provider:"openai",enabled:true}]); +}); diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index a50dd9469f..f239ce48b1 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -299,8 +299,6 @@ function narrowToLimits(raw: number | undefined, slug: string, input: NativeCont return overlay !== undefined && cap !== undefined ? Math.min(window, cap) : window; } const narrowed = overlay === undefined ? raw : Math.min(raw, overlay); - // 922k is the GPT-5.6 1M opt-in, not a request to shrink gpt-5.4's 1M window. - if (cap === NATIVE_GPT56_MAX_INPUT_TOKENS) return narrowed; return applyProviderContextCap(narrowed, cap) ?? narrowed; } diff --git a/src/config.ts b/src/config.ts index 5d67275dce..ee1c83694a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1135,6 +1135,7 @@ const configSchema = z.object({ subagentModels: z.array(z.string().min(1)).optional().catch(undefined), clientIntegrations: clientIntegrationsSchema.optional().catch(undefined), providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), + providerContextCapValues: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), // Invalid optional recovery config must not discard unrelated provider/account state. diff --git a/src/providers/context-cap.ts b/src/providers/context-cap.ts index d10807ced2..9dd10126ad 100644 --- a/src/providers/context-cap.ts +++ b/src/providers/context-cap.ts @@ -43,13 +43,21 @@ export function globalContextCapValue(config: Pick return isValidContextCap(value) ? Math.floor(value) : DEFAULT_PROVIDER_CONTEXT_CAP; } +/** Active caps win over remembered values from an earlier switch-off. */ +export function selectedProviderContextCaps(config: Pick): Record { + return { ...providerContextCaps({ providerContextCaps: config.providerContextCapValues }), ...providerContextCaps(config) }; +} + export function setProviderContextCap(config: OcxConfig, provider: string, enabled: boolean, value?: number): void { const next = providerContextCaps(config); + const selected = selectedProviderContextCaps(config); if (enabled) { - next[provider] = isValidContextCap(value) ? Math.floor(value) : globalContextCapValue(config); + next[provider] = isValidContextCap(value) ? Math.floor(value) : (selected[provider] ?? globalContextCapValue(config)); + selected[provider] = next[provider]; } else { delete next[provider]; } + if (Object.keys(selected).length > 0) config.providerContextCapValues = selected; if (Object.keys(next).length > 0) config.providerContextCaps = next; else deleteConfigTopLevelKey(config, "providerContextCaps"); } @@ -66,18 +74,33 @@ export function setGlobalContextCapValue(config: OcxConfig, value: number, apply if (!applyToAll) return; const caps = providerContextCaps(config); for (const provider of Object.keys(caps)) caps[provider] = next; - if (Object.keys(caps).length > 0) config.providerContextCaps = caps; + if (Object.keys(caps).length > 0) { + config.providerContextCaps = caps; + config.providerContextCapValues = { ...selectedProviderContextCaps(config), ...caps }; + } } /** Enable the cap for every named provider at the current value, or clear all caps. */ export function setAllProviderContextCaps(config: OcxConfig, providerNames: string[], enabled: boolean): void { + const selected = selectedProviderContextCaps(config); if (!enabled) { + if (Object.keys(selected).length > 0) config.providerContextCapValues = selected; deleteConfigTopLevelKey(config, "providerContextCaps"); return; } const value = globalContextCapValue(config); const next: Record = {}; - for (const name of providerNames) next[name] = value; + for (const name of providerNames) { next[name] = value; selected[name] = value; } + if (Object.keys(selected).length > 0) config.providerContextCapValues = selected; if (Object.keys(next).length > 0) config.providerContextCaps = next; else deleteConfigTopLevelKey(config, "providerContextCaps"); } + +/** Provider removal clears both the active limit and its remembered selection. */ +export function forgetProviderContextCap(config: OcxConfig, provider: string): void { + setProviderContextCap(config, provider, false); + const values = { ...config.providerContextCapValues }; + delete values[provider]; + if (Object.keys(values).length > 0) config.providerContextCapValues = values; + else deleteConfigTopLevelKey(config, "providerContextCapValues"); +} diff --git a/src/providers/provider-id-rewrite.ts b/src/providers/provider-id-rewrite.ts index 10a6f3f211..0111a8674a 100644 --- a/src/providers/provider-id-rewrite.ts +++ b/src/providers/provider-id-rewrite.ts @@ -112,14 +112,16 @@ export function rewriteProviderReferences(config: OcxConfig, from: string, to: s // Keys. `providerContextCaps` is KEYED by provider id — a prefix rewrite would // silently orphan the cap — and a destination key may already be occupied. - const caps = config.providerContextCaps; - if (caps && Object.hasOwn(caps, from)) { - if (Object.hasOwn(caps, to)) { - collisions.push(`providerContextCaps.${to}`); - } else { - caps[to] = caps[from]!; - delete caps[from]; - changed += 1; + for (const field of ["providerContextCaps", "providerContextCapValues"] as const) { + const caps = config[field]; + if (caps && Object.hasOwn(caps, from)) { + if (Object.hasOwn(caps, to)) { + collisions.push(`${field}.${to}`); + } else { + caps[to] = caps[from]!; + delete caps[from]; + changed += 1; + } } } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 2268390b17..76dbe63b1f 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -60,7 +60,7 @@ import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache"; import { getCodexModelEntitlementStatus } from "../../codex/model-entitlements"; -import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; +import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, selectedProviderContextCaps, forgetProviderContextCap, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget"; import { resolveCodexHomeDir } from "../../codex/home"; import { readUsageEntries } from "../../usage/log"; @@ -268,7 +268,7 @@ function providerEditorCandidate( candidate.providers = providers; for (const name of removedProviders) { dropProviderCustomModels(candidate, name); - setProviderContextCap(candidate, name, false); + forgetProviderContextCap(candidate, name); } const validated = validateConfigCandidate(candidate); if (!validated.ok) { @@ -289,6 +289,8 @@ function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): vo else live.customModels = structuredClone(persisted.customModels); if (persisted.providerContextCaps === undefined) delete live.providerContextCaps; else live.providerContextCaps = structuredClone(persisted.providerContextCaps); + if (persisted.providerContextCapValues === undefined) delete live.providerContextCapValues; + else live.providerContextCapValues = structuredClone(persisted.providerContextCapValues); if (persisted.disabledModels === undefined) delete live.disabledModels; else live.disabledModels = [...persisted.disabledModels]; if (persisted.modelDiscovery === undefined) delete live.modelDiscovery; @@ -849,7 +851,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; + /** Last selected provider caps; retained while a cap is switched off. Not an active limit. */ + providerContextCapValues?: Record; /** Global Codex-visible context cap value (tokens). Falls back to DEFAULT_PROVIDER_CONTEXT_CAP. */ contextCapValue?: number; /** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */ diff --git a/tests/codex-integration/native-model-toggle.test.ts b/tests/codex-integration/native-model-toggle.test.ts index 52044cde5a..eac9ec0960 100644 --- a/tests/codex-integration/native-model-toggle.test.ts +++ b/tests/codex-integration/native-model-toggle.test.ts @@ -296,7 +296,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { const over = nativeModelRows({ providerContextCaps: { openai: 2_000_000 } }); expect(over.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(922_000); expect(raised.find(r => r.slug === "gpt-5.5")?.contextWindow).toBe(272_000); - expect(raised.find(r => r.slug === "gpt-5.4")?.contextWindow).toBe(1_000_000); + expect(raised.find(r => r.slug === "gpt-5.4")?.contextWindow).toBe(922_000); }); test("nativeModelRows applies providerContextCaps.openai as a ceiling (#1430)", () => { diff --git a/tests/providers/provider-id-rewrite.test.ts b/tests/providers/provider-id-rewrite.test.ts index 1df8753b82..cc87ae55fc 100644 --- a/tests/providers/provider-id-rewrite.test.ts +++ b/tests/providers/provider-id-rewrite.test.ts @@ -209,3 +209,10 @@ test("removal leaves the custom-model ownership marker untouched", () => { legacyOwnedSlugs: ["agnes-ai/agnes-2.5-flash", "huggingface/DeepSeek-V4-Flash-0731"], }); }); + + test("moves remembered provider caps without activating them", () => { + const config = { providerContextCapValues: { [FROM]: 128_000 } } as unknown as OcxConfig; + expect(rewriteProviderReferences(config, FROM, TO)).toEqual({ changed: 1, collisions: [] }); + expect(config.providerContextCapValues).toEqual({ [TO]: 128_000 }); + expect(providerContextCap(config, TO)).toBeUndefined(); +}); diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 386cc10931..622c3b37f0 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -4682,3 +4682,31 @@ describe("provider transport option management contract (#1668, #2816)", () => { }); }); }); + +test("OpenAI provider cap remembers an explicit window across off, reload, and on", async () => { + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + let live: OcxConfig = { + port: 0, defaultProvider: "openai", contextCapValue: 350_000, + providers: { openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", liveModels: false } }, + }; + saveConfig(live); + const put = async (body: unknown) => { + const url = new URL("http://localhost/api/provider-context-caps"); + const response = await handleManagementAPI(new Request(url, {method:"PUT", headers:{"content-type":"application/json"}, body:JSON.stringify(body)}), url, live, {createManagementConvergeCodex:catalogConvergenceFactory()}); + expect(response?.status).toBe(200); + return response!.json(); + }; + expect(await put({provider:"openai",enabled:true})).toMatchObject({caps:{openai:350_000}}); + await put({provider:"openai",enabled:true,value:128_000}); + expect(await put({provider:"openai",enabled:false})).toMatchObject({caps:{},values:{openai:128_000}}); + live = loadConfig(); + expect(live.providerContextCaps).toBeUndefined(); + expect(await put({provider:"openai",enabled:true})).toMatchObject({caps:{openai:128_000}}); + const {nativeModelRows} = await import("../../src/codex/catalog"); + expect(nativeModelRows(live).filter(row=>row.contextWindow !== undefined).every(row=>row.contextWindow! <= 128_000)).toBe(true); + await put({setAll:false}); + expect(loadConfig().providerContextCapValues?.openai).toBe(128_000); + await put({setAll:true}); + expect(loadConfig().providerContextCaps?.openai).toBe(350_000); +}); From e73e8e036038d4d5873fc7d029f12444a34fe4ba Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:18:06 +0900 Subject: [PATCH 043/236] docs(config): clarify active and remembered context contracts --- src/server/management/provider-routes.ts | 4 ++-- src/types/config.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 76dbe63b1f..37785ac17b 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -1434,8 +1434,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; /** Last selected provider caps; retained while a cap is switched off. Not an active limit. */ providerContextCapValues?: Record; From 64e74f26b43a280896ed33f221f322d08e5708f7 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:19:24 +0900 Subject: [PATCH 044/236] docs: plan deterministic Windows shutdown fixture repair --- .../260906_a_windows_fixture/000_plan.md | 9 ++ .../010_fixture_plan.md | 123 ++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 devlog/_plan/260906_a_windows_fixture/000_plan.md create mode 100644 devlog/_plan/260906_a_windows_fixture/010_fixture_plan.md diff --git a/devlog/_plan/260906_a_windows_fixture/000_plan.md b/devlog/_plan/260906_a_windows_fixture/000_plan.md new file mode 100644 index 0000000000..b858312f61 --- /dev/null +++ b/devlog/_plan/260906_a_windows_fixture/000_plan.md @@ -0,0 +1,9 @@ +# A Windows verifier foundation + +This is the independently reviewed foundation required to unblock A runtime-stack current-head verification. Failed Windows job101339545421 exposed non-hermetic shutdown spill fixtures; no production regression is inferred. The full diff-level specification is010_fixture_plan.md, copied from the audited A roadmap amendment and rechecked against currentdev. + +Loop: spec-satisfaction repair. Test-only fixture classC2; temporary CI verification workflow classC4 with explicit independent security audit. Main owns this branch, commits, --no-verify pushes, source preservation and owner-authorized --admin merges. No local suites/typecheck/build; remote pinnedBun1.4 only. Existing credentials permit repository GitHubActions, read-only-content hostedWindows verification and ownbranch writes; no release/service/account changes. No new credential inputs or command injection. Token/cost cap not specified;2h checkpoint reassesses evidence and progress. + +One workphase owns fixture correctness plus its verified testing foundation; no A feature implementation is included. Only tests/responses/responses-state.test.ts and numberedunitdocs belong in the product PR. A separate owner-only codex/a-verify-windows branch may add the concrete audited probe workflow, never merged todev. Feature/runtime/test blobs must match the product candidate; probe-only workflow excluded by explicit path equality proof. Final full CI on the exact product candidate remains mandatory. + +P refreshes failedfixtures and clock hooks. A audits both minimalfix and concrete CI workflow beforepush. B changes ordering/budget fixtures, pinsverificationartifacts and commits. C executes realWindows controls and fullrelevantgates, plus independent review. D records a preparedverified foundation; the next landing phase inserts/merges this foundation below A layers, refreshesheads and obtains allrequiredCI before eachadminmerge. No test skip, broader production budget, or unverified retry. Existing source and issue disposition criteria remainunchanged. diff --git a/devlog/_plan/260906_a_windows_fixture/010_fixture_plan.md b/devlog/_plan/260906_a_windows_fixture/010_fixture_plan.md new file mode 100644 index 0000000000..d5f6bb017f --- /dev/null +++ b/devlog/_plan/260906_a_windows_fixture/010_fixture_plan.md @@ -0,0 +1,123 @@ +# 010 — Deterministic Windows shutdown-spill fixtures + +Status: P amendment; documentation only. Future implementation is a separate C2 test-harness cycle after 050, before 080. C confirmed no ownership collision. Main owns the FSM, implementation, remote execution and insertion of this foundation beneath the runtime stack. + +## Evidence and boundary + +[Windows job 101339545421](https://github.com/lidge-jun/opencodex/actions/runs/33978547130/job/101339545421), head `4b34cbb8d3f308cd2b01e8d87784c65afb50a40f`, Bun 1.4.0: 3048 pass, 39 skip, 2 fail, 1 unhandled error. The two failures are in `tests/responses/responses-state.test.ts`: + +- Stable-tail (1297): the 500 ms drain timer selected synchronous fallback; its unmocked ACL runner failed with EICACLS. The actual async-delay cause is unmeasured. Global ACL call 7 is also an unreliable publication marker: snapshot and directory hardening share this runner. +- Reserved budget (1438): only the ACL clock is synthetic. `spill-store.ts:232` charged real serialization/filesystem elapsed time and exhausted the deadline before temp-file hardening. The expensive operation is not identified. + +Local evidence inputs: `.tmp/a-runtime-stack/ci-triage/report.md`, `sse-windows5.log:4038–4217`, and `prior-101262480176.log:3894–3897` in that same scratch directory. The earlier job passed the two cases; its overall run was not green. Unchanged source on the sampled dev is not an independently reproduced current-dev failure. Do not describe this as an SSE regression, a proven harmless transient, or a green Windows gate. + +Future edit set: **only `tests/responses/responses-state.test.ts`**. No production, workflow, manifest, shared fixture or budget changes. Reuse `forceWindowsAclLane`, `isSpillAclTarget`, `ICACLS_OK`, existing clock/runner setters, and spill event recording. Keep existing deadline, fallback, exhaustion and watchdog tests. No sleeps for synchronization, timeout increases, skips or relaxed assertions. + +Read-only owners inspected: + +| Owner | Contract retained | +|---|---| +| `src/responses/state.ts:595` | Drain races the observed tail against a real timer; `Date.now` alone cannot freeze that timer. | +| `src/responses/state.ts:771` and `:813` | Separate fallback reserve, remaining-budget forwarding, and repeated observation until the publication tail is stable. | +| `src/responses/spill-store.ts:100`, `:153`, `:225` | Existing I/O events and injectable spill clock; each harden gets min(per-call cap, remaining whole-write budget). | +| `src/lib/windows-secret-acl.ts:360`, `:410`, `:589` | Async runner timer; injected ACL clock; grant/inheritance/remove calls consume one harden deadline. | +| `tests/responses/ws-upstream.test.ts:725` | Existing Bun `jest.useFakeTimers` / `advanceTimersByTime` / `useRealTimers` convention. | + +## Hunk 1 — Stable-tail ordering, not elapsed disk time + +At the test import, add `jest`. Retain 1000/500 budgets. Use fake timers **only within this test**, with `Date.now` fixed to a captured real epoch and ACL/spill clocks fixed consistently. Capture native `setImmediate` before enabling fake timers for an event-loop checkpoint; this drains runnable promise work without a sleep or timer advance. No new shared helper. + +Replace the global `aclCalls === 1/7` runner with gates on the first two distinct spill temp paths at `/grant:r`: + +```ts +const gatedTemps = new Set(); +setAsyncIcaclsRunnerForTests(async args => { + const target = args[0] ?? ""; + if (!isSpillAclTarget(args) || !target.endsWith(".tmp") || args[1] !== "/grant:r") { + return ICACLS_OK; // includes snapshot, directory and later ACL steps + } + if (!gatedTemps.has(target)) { + gatedTemps.add(target); + if (gatedTemps.size === 1) { firstEntered(); await firstGate; } + if (gatedTemps.size === 2) { secondEntered(); await secondGate; } + } + return ICACLS_OK; +}); +let syncSpillCalls = 0; +setIcaclsRunnerForTests(args => { + if (isSpillAclTarget(args)) syncSpillCalls++; + return ICACLS_OK; +}); +``` + +Both principal resolvers remain synthetic through `forceWindowsAclLane`. Both ACL runners cover **every** target; filtering controls gating/counting, never whether a real subprocess is used. A fallback must fail the ordering oracle (`syncSpillCalls === 0`), rather than being hidden by the successful mock. + +Replace the current orchestration and 25 ms sleep with this exact ordering: + +1. Enter `try/finally` before the first enqueue/await. Enable fake timers and fixed epoch clock; install both clock setters. Enqueue first response and await its temp gate. +2. Start `flushResponseState`, immediately attach both settlement handlers, recording `flushed` and any error in a resolved outcome object. This avoids an unhandled rejection if an earlier assertion fails. +3. Enqueue second response **after** starting flush, then release first. Await second temp gate. Await a native `setImmediate` checkpoint, advance fake timers by 25 ms, then another native checkpoint. The drain timer stays below 500 ms; no real elapsed filesystem time can fire it. +4. Assert flush is still pending, exactly two distinct temp paths were gated, and no synchronous spill ACL calls occurred. Record `setSpillIoForTest({ record })` events and assert exactly one `stub-swap` so the first publication actually installed while the second is gated. +5. Release second, await the handled flush outcome and rethrow any captured error. Retain `{ residentCount: 0, spillStubCount: 2 }`; add pending `{ count: 0, bytes: 0 }`, two `stub-swap` events, and zero synchronous spill calls. Both stored response IDs must still expand to their distinct payloads. +6. `finally`: release **both** gates, await any started flush outcome and `flushPendingResponseSpillsForTests()` while mocks/clocks remain installed, then restore the Date spy and real timers in a nested `finally`. Existing `afterEach` restores setters. Never restore mocks while a gated async operation still owns work. + +Use a discriminated outcome (`{ ok: true } | { ok: false; error: unknown }`) rather than an undefined-error sentinel. Keep cleanup valid when either startup await/assertion fails. Fake-timer compatibility and the native checkpoint are remote Windows acceptance items, not assumed proof. Do not solve a failed fixture by globally suppressing timers or adding a production seam. + +## Hunk 2 — One logical fallback budget, actual drain timer + +At 1438, preserve `totalMs = 500`, `fallbackReserveMs = 300` and the pending async spill gate. Add the missing spill clock; scope a Date spy to the flush so outer fallback accounting and nested ACL accounting advance together. Keep native timers in this test: the unchanged 200 ms drain timer must expire while the async gate remains held. + +```diff + let aclClock = 0; + setNowForTests(() => aclClock); ++setResponseSpillNowForTests(() => aclClock); +``` + +Record `{ target, timeoutMs, spentBefore }` for **spill** synchronous ACL calls. Snapshot ACL calls return `ICACLS_OK` without charging the spill clock. For each spill call, record before incrementing `aclClock += 20`; preserve successful command results. + +```ts +const epoch = Date.now(); +const nowSpy = spyOn(Date, "now").mockImplementation(() => epoch + aclClock); +// Start only after the async spill gate announces entry. +try { + await flushResponseState(); // native 200 ms drain timer selects sync fallback +} finally { + release(); + try { await flushPendingResponseSpillsForTests(); } + finally { nowSpy.mockRestore(); } +} +``` + +An enclosing `try/finally` must also cover enqueue and `await started`, releasing the gate on early failure. Preserve all three original assertions: at least six spill commands, maximum deadline <= 150, and `200 + aclClock <= 500`. Add: + +- Every timeout is positive and <= `300 - spentBefore` (independent literal budget oracle). +- Within each target's grant/inheritance/remove sequence, each next timeout is exactly 20 ms smaller; do **not** assert global monotonicity across targets because a new harden has its own per-call cap. +- The async gate has not been released when synchronous spill work begins; fallback actually ran, pending count/bytes become zero, one spill stub remains, and replay contains the original payload. + +The Date spy prevents unmeasured real disk latency from consuming this logical-budget fixture. It does not disable the native drain timer. Real-time termination coverage remains in the unchanged cap-expiry test (1339) and `shutdown fallback budget exhaustion is contained by a child watchdog` (1613), using `tests/helpers/responses-state-shutdown-budget-child.ts`. Do not claim this test measures OS elapsed latency. + +## Windows red, control and proof + +Main executes these later on real Windows with the repository-pinned Bun, in isolated remote checkouts. Nothing below authorizes local tests in this documentation task. + +1. Preserve the failed root-head job/logs above. Run the original two tests on the pinned pre-fix baseline; record actual results, including a pass. Do not require random failure or accept retries as a fix. +2. In remote scratch only, force the old stable-tail drain to expire by holding the second gate until a recorded fallback entry. Use a counted synchronous sentinel that reports EICACLS instead of invoking native ACL tools. Confirm rejection and the fallback call; never infer the missing-mock path from elapsed time alone. This is a controlled mechanism probe, not proof that the same delay happened in CI. +3. In remote scratch only, use the existing spill `record("write")` event to advance a separate wall clock by 301 ms once synchronous fallback has begun. On the original reserved-budget fixture, spill uses that clock and fails before temp hardening; with the proposed shared logical spill clock, the same wall-clock perturbation cannot consume the ACL budget. Record entry and clock values. Keep this probe separate from production and from the committed passing fixture. +4. Prove oracle sensitivity with isolated remote mutations: (a) stop drain after its first observed tail, expecting the revised stable-tail pending/zero-fallback oracle to fail; (b) reset the ACL deadline for each command, expecting per-target 20 ms decrease assertions to fail. Separately advance the **injected spill clock** beyond 300 at the write event and require ETIMEDOUT, proving deadline enforcement remains active. Restore every mutation before green verification; retain diff and failing assertion for each probe. +5. Run the unchanged named cap-expiry and child-watchdog controls, then the whole focused file on the new exact head: + +```sh +# Remote Windows only; these commands are a future verifier recipe. +bun test --isolate --timeout 60000 tests/responses/responses-state.test.ts +bun run typecheck +``` + +6. Dispatch the actual Windows full-suite workflow on that exact head, including `bun test --isolate --timeout 60000 tests --shard=5/6` and every other required shard. Inspect job execution, not aggregate success with skipped tests. Record head SHA, Bun version, commands, job URLs, counts and absence of unhandled errors. Run current-head Linux/macOS gates and required scans as well. + +Implementation D means an independently reviewed prepared foundation draft with exact-head focused Windows evidence and remote typecheck; it is **not landing**. Main inserts the verified foundation beneath the stack, refreshes descendants bottom-up with original attribution intact, obtains required current-head gates, then admin-merges in dependency order. Verify each landed SHA is an ancestor of freshly fetched dev before closing a superseded PR or fully resolved issue. Partial issues retain their residual scope. See `080_landing.md`. + +Documentation acceptance: this file names both failed fixtures, all clock/timer boundaries, complete runner/cleanup coverage, executable negative controls, one-file implementation scope and separate landing gates. No test execution or implementation success is claimed here. + +## Remote execution fallback amendment + +The existing direct Windows SSH endpoint is unavailable; the reachable auxiliary host is Linux without Windows interop. Use GitHub Actions for actual Windows proof. If the existing full-suite workflow cannot execute focused causal probes, a separate owner-only `codex/a-verify-windows` branch may hold a temporary verification workflow triggered only by pushes to that exact branch. This workflow is never included in a product PR or merged to dev. It uses `windows-latest`, read-only contents permission, pinned checkout with `persist-credentials: false`, the existing pinned-Bun setup, fixed repository test commands and the exact carried fixture commit. No secrets, untrusted command inputs, self-hosted runner access or release permissions. It may execute the narrowly specified scratch mutations with guaranteed source restoration and upload logs. Independent security audit of the concrete workflow is required before pushing it. Standard per-head full CI remains the final gate; the temporary verifier cannot mark those checks green. From eed2b7c7e953e3b3e6877ce3c0b3477cee1e0a22 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:23:04 +0900 Subject: [PATCH 045/236] test(responses): make Windows shutdown spill fixtures deterministic --- tests/responses/responses-state.test.ts | 149 +++++++++++++++++++----- 1 file changed, 117 insertions(+), 32 deletions(-) diff --git a/tests/responses/responses-state.test.ts b/tests/responses/responses-state.test.ts index 831a8df29c..40601385e3 100644 --- a/tests/responses/responses-state.test.ts +++ b/tests/responses/responses-state.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:test"; import { BULK_DURABLE_IO_BUDGET_MS } from "../helpers/test-budget"; import { findDeadPid } from "../helpers/dead-pid"; import { @@ -1297,6 +1297,8 @@ describe("Responses previous_response_id state", () => { test("shutdown drain reaches a stable tail after a publication is appended mid-drain", async () => { forceWindowsAclLane(); setResponseSpillShutdownBudgetForTests({ totalMs: 1_000, fallbackReserveMs: 500 }); + const nativeSetImmediate = setImmediate; + const epoch = Date.now(); let releaseFirst!: () => void; let releaseSecond!: () => void; let firstEntered!: () => void; @@ -1305,35 +1307,88 @@ describe("Responses previous_response_id state", () => { const secondGate = new Promise(resolve => { releaseSecond = resolve; }); const firstStarted = new Promise(resolve => { firstEntered = resolve; }); const secondStarted = new Promise(resolve => { secondEntered = resolve; }); - let aclCalls = 0; - setAsyncIcaclsRunnerForTests(async () => { - aclCalls += 1; - if (aclCalls === 1) { - firstEntered(); - await firstGate; - } else if (aclCalls === 7) { - secondEntered(); - await secondGate; + const gatedTemps = new Set(); + setAsyncIcaclsRunnerForTests(async args => { + const target = args[0] ?? ""; + if (!isSpillAclTarget(args) || !target.endsWith(".tmp") || args[1] !== "/grant:r") { + return ICACLS_OK; } - return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + if (!gatedTemps.has(target)) { + gatedTemps.add(target); + if (gatedTemps.size === 1) { + firstEntered(); + await firstGate; + } else if (gatedTemps.size === 2) { + secondEntered(); + await secondGate; + } + } + return ICACLS_OK; + }); + let syncSpillCalls = 0; + setIcaclsRunnerForTests(args => { + if (isSpillAclTarget(args)) syncSpillCalls += 1; + return ICACLS_OK; + }); + let stubSwaps = 0; + setSpillIoForTest({ + record: event => { if (event === "stub-swap") stubSwaps += 1; }, }); setResponseStateByteCapForTests(1_024); - rememberLarge("resp_fixed_point_first", "a".repeat(8_000)); - await firstStarted; - let flushed = false; - const flushing = flushResponseState().then(() => { flushed = true; }); - rememberLarge("resp_fixed_point_second", "b".repeat(8_000)); - releaseFirst(); - await secondStarted; + let flushing: Promise<{ ok: true } | { ok: false; error: unknown }> | undefined; + let restoreClock: (() => void) | undefined; try { - await new Promise(resolve => setTimeout(resolve, 25)); + // This case proves publication ordering; real disk latency must not fire the drain timer. + jest.useFakeTimers(); + const nowSpy = spyOn(Date, "now").mockReturnValue(epoch); + restoreClock = () => { nowSpy.mockRestore(); }; + setNowForTests(() => epoch); + setResponseSpillNowForTests(() => epoch); + rememberLarge("resp_fixed_point_first", "a".repeat(8_000)); + await firstStarted; + + // Handle rejection immediately, including when a gate/assertion fails before this await. + flushing = flushResponseState().then( + () => { flushed = true; return { ok: true } as const; }, + (error: unknown) => { flushed = true; return { ok: false, error } as const; }, + ); + rememberLarge("resp_fixed_point_second", "b".repeat(8_000)); + releaseFirst(); + await secondStarted; + await new Promise(resolve => nativeSetImmediate(resolve)); + jest.advanceTimersByTime(25); + await new Promise(resolve => nativeSetImmediate(resolve)); expect(flushed).toBe(false); + expect(gatedTemps.size).toBe(2); + expect(stubSwaps).toBe(1); + expect(syncSpillCalls).toBe(0); + + releaseSecond(); + const outcome = await flushing; + if (!outcome.ok) throw outcome.error; + expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 2 }); + expect(pendingResponseSpillMetricsForTests()).toEqual({ count: 0, bytes: 0 }); + expect(stubSwaps).toBe(2); + expect(syncSpillCalls).toBe(0); + for (const [id, payload] of [["resp_fixed_point_first", "a"], ["resp_fixed_point_second", "b"]] as const) { + expect(JSON.stringify(expandPreviousResponseInput({ + previous_response_id: id, + input: "next", + }))).toContain(payload.repeat(8_000)); + } } finally { + releaseFirst(); releaseSecond(); + try { + await flushing; + await awaitResponseSpillPublicationTailForTests(); + await flushPendingResponseSpillsForTests(); + } finally { + restoreClock?.(); + jest.useRealTimers(); + } } - await flushing; - expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 2 }); }); test("shutdown drain cap expiry enters the synchronous spill fallback", async () => { @@ -1446,31 +1501,61 @@ describe("Responses previous_response_id state", () => { const started = new Promise(resolve => { entered = resolve; }); let aclClock = 0; setNowForTests(() => aclClock); + setResponseSpillNowForTests(() => aclClock); setAsyncIcaclsRunnerForTests(async args => { if (!isSpillAclTarget(args)) return ICACLS_OK; entered(); await gate; return ICACLS_OK; }); - const deadlines: number[] = []; - setIcaclsRunnerForTests((_args, timeoutMs) => { - deadlines.push(timeoutMs); + let released = false; + const deadlines: Array<{ target: string; timeoutMs: number; spentBefore: number; gateReleased: boolean }> = []; + setIcaclsRunnerForTests((args, timeoutMs) => { + if (!isSpillAclTarget(args)) return ICACLS_OK; + deadlines.push({ target: args[0]!, timeoutMs, spentBefore: aclClock, gateReleased: released }); aclClock += 20; - return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + return ICACLS_OK; }); setResponseStateByteCapForTests(1_024); - rememberLarge("resp_shutdown_budget", "b".repeat(2 * 1024 * 1024 + 4_096)); - await started; - + let restoreClock: (() => void) | undefined; try { + rememberLarge("resp_shutdown_budget", "b".repeat(2 * 1024 * 1024 + 4_096)); + await started; + // Keep the native 200 ms drain timer, but charge only logical ACL work to the reserve. + const epoch = Date.now(); + const nowSpy = spyOn(Date, "now").mockImplementation(() => epoch + aclClock); + restoreClock = () => { nowSpy.mockRestore(); }; await flushResponseState(); + const logicalElapsedMs = totalMs - fallbackReserveMs + aclClock; + expect(deadlines.length).toBeGreaterThanOrEqual(6); + expect(Math.max(...deadlines.map(call => call.timeoutMs))).toBeLessThanOrEqual(Math.floor(fallbackReserveMs / 2)); + expect(logicalElapsedMs).toBeLessThanOrEqual(totalMs); + const previousDeadlineByTarget = new Map(); + for (const { target, timeoutMs, spentBefore, gateReleased } of deadlines) { + expect(gateReleased).toBe(false); + expect(timeoutMs).toBeGreaterThan(0); + expect(timeoutMs).toBeLessThanOrEqual(300 - spentBefore); + const previous = previousDeadlineByTarget.get(target); + if (previous !== undefined) expect(timeoutMs).toBe(previous - 20); + previousDeadlineByTarget.set(target, timeoutMs); + } + expect(pendingResponseSpillMetricsForTests()).toEqual({ count: 0, bytes: 0 }); + expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 1 }); + expect(JSON.stringify(expandPreviousResponseInput({ + previous_response_id: "resp_shutdown_budget", + input: "next", + }))).toContain("b".repeat(2 * 1024 * 1024 + 4_096)); } finally { + released = true; release(); + try { + // Shutdown can clear pending ownership before the superseded async runner settles. + await awaitResponseSpillPublicationTailForTests(); + await flushPendingResponseSpillsForTests(); + } finally { + restoreClock?.(); + } } - const logicalElapsedMs = totalMs - fallbackReserveMs + aclClock; - expect(deadlines.length).toBeGreaterThanOrEqual(6); - expect(Math.max(...deadlines)).toBeLessThanOrEqual(Math.floor(fallbackReserveMs / 2)); - expect(logicalElapsedMs).toBeLessThanOrEqual(totalMs); }); test("late async spill completion cannot overwrite the shutdown fallback", async () => { From 7e7c7cb7f0eb6ccb267884d24a635449614d1d92 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:24:26 +0900 Subject: [PATCH 046/236] fix(context): validate own remembered selections and cover lifecycle edges --- .../021_context_build.md | 7 + gui/tests/models-status-toast.test.tsx | 47 +++++- src/providers/context-cap.ts | 3 +- .../native-model-toggle.test.ts | 10 ++ tests/providers/provider-id-rewrite.test.ts | 14 ++ .../management-provider-validation.test.ts | 148 +++++++++++++++++- 6 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/021_context_build.md diff --git a/devlog/_plan/260906_lane_b_catalog_stack/021_context_build.md b/devlog/_plan/260906_lane_b_catalog_stack/021_context_build.md new file mode 100644 index 0000000000..3327f7e5b2 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/021_context_build.md @@ -0,0 +1,7 @@ +# Context selection carry build + +Replacement #3695 carries #3654 final head `8facdb0d8c10109701015c0f6109fc67b1d9dd3c` with Robin Bially's author identity, coauthor trailer, source screenshots and setAll clarification. The new `providerContextCapValues` map preserves inactive selections without applying them to catalog metadata. + +Additional regression cases cover both setAll payload shapes, legacy active-only reloads, invalid-request memory/disk atomicity, removal/editor cleanup, rename collision, disabled native budgets and GUI remount/old-response fallback. The source/security review identified a numeric-selection lookup defect for valid inherited property names; the carry now requires an own numeric remembered value and covers first-enable/off/reload/on for toString and valueOf. Final review and remote/hosted execution remain pending at this checkpoint. Public API, provider config and routing documentation are synchronized across existing locales. + +Parent #3685 completed full exact-head CI 33978686258 and independent code/security plus remote GUI/docs verification. It was admin-merged into dev as `9115b179a29f1366561139b8502cebb17bf816e9`; source #3653 and issue #3650 were immediately closed after ancestry proof. #3695 was retargeted to dev before parent merge to preserve the stack safely. diff --git a/gui/tests/models-status-toast.test.tsx b/gui/tests/models-status-toast.test.tsx index 5ecadb078e..d64122352b 100644 --- a/gui/tests/models-status-toast.test.tsx +++ b/gui/tests/models-status-toast.test.tsx @@ -176,11 +176,12 @@ test("success toast expires after 6s and a repeated action re-arms it", async () expect(container.querySelector(".action-toast")).toBeNull(); }); -test("OpenAI context switch restores the selected cap instead of forcing 922k", async () => { +test("OpenAI context switch restores the selected cap after a disabled-page reload instead of forcing 922k", async () => { testWindow.sessionStorage.clear(); let caps: Record = {openai:128_000}; const values = {openai:128_000}; const bodies: unknown[] = []; + let capReads = 0; const fallback = globalThis.fetch; globalThis.fetch = (async (input, init) => { const url = String(input); @@ -192,7 +193,7 @@ test("OpenAI context switch restores the selected cap instead of forcing 922k", if (init?.method === "PUT") { const body=JSON.parse(String(init.body)); bodies.push(body); caps=body.enabled ? {openai:values.openai} : {}; - } + } else capReads += 1; return Response.json({caps,values,value:350_000}); } return fallback(input,init); @@ -207,8 +208,50 @@ test("OpenAI context switch restores the selected cap instead of forcing 922k", await act(async()=>{toggle().click();await settle();}); expect(toggle().getAttribute("aria-pressed")).toBe("false"); expect(cluster().textContent).toContain("128k"); + const readsBeforeReload = capReads; + await act(async () => { root!.unmount(); root = null; }); + clearClientResourceStoresForTests(); + testWindow.sessionStorage.clear(); + await act(async () => { + root = createRoot(container); + root.render(); + }); + await act(settle); + expect(capReads).toBeGreaterThan(readsBeforeReload); + expect(toggle().getAttribute("aria-pressed")).toBe("false"); + expect(cluster().textContent).toContain("128k"); + expect(bodies).toEqual([{ provider: "openai", enabled: false }]); await act(async()=>{toggle().click();await settle();}); expect(toggle().getAttribute("aria-pressed")).toBe("true"); expect(cluster().textContent).toContain("128k"); expect(bodies).toEqual([{provider:"openai",enabled:false},{provider:"openai",enabled:true}]); }); + +test.each([ + { response: { caps: { anthropic: 128_000 }, value: 350_000 }, label: "128k", enabled: true }, + { response: { caps: {}, value: 600_000 }, label: "600k", enabled: false }, + { response: { caps: {}, cap: 350_000 }, label: "350k", enabled: false }, +])("legacy cap response $response falls back without remembered values", async ({ response, label, enabled }) => { + // The existing seed is also the old cache shape, without contextCapValues. + const fallback = globalThis.fetch; + let capReads = 0; + globalThis.fetch = (async (input, init) => { + if (String(input).endsWith("/api/provider-context-caps")) { + capReads += 1; + return Response.json(response); + } + return fallback(input, init); + }) as typeof fetch; + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(container); + root.render(); + }); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + expect(capReads).toBeGreaterThan(0); + const cluster = container.querySelector(".models-cap-cluster")!; + expect(cluster.textContent).toContain(label); + expect(cluster.querySelector("button.switch")?.getAttribute("aria-pressed")).toBe(String(enabled)); +}); diff --git a/src/providers/context-cap.ts b/src/providers/context-cap.ts index 9dd10126ad..15e11be599 100644 --- a/src/providers/context-cap.ts +++ b/src/providers/context-cap.ts @@ -52,7 +52,8 @@ export function setProviderContextCap(config: OcxConfig, provider: string, enabl const next = providerContextCaps(config); const selected = selectedProviderContextCaps(config); if (enabled) { - next[provider] = isValidContextCap(value) ? Math.floor(value) : (selected[provider] ?? globalContextCapValue(config)); + const remembered = Object.hasOwn(selected, provider) ? selected[provider] : undefined; + next[provider] = isValidContextCap(value) ? Math.floor(value) : (isValidContextCap(remembered) ? remembered : globalContextCapValue(config)); selected[provider] = next[provider]; } else { delete next[provider]; diff --git a/tests/codex-integration/native-model-toggle.test.ts b/tests/codex-integration/native-model-toggle.test.ts index eac9ec0960..0ac18f1ad2 100644 --- a/tests/codex-integration/native-model-toggle.test.ts +++ b/tests/codex-integration/native-model-toggle.test.ts @@ -313,6 +313,16 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(other.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(272_000); }); + test("remembered disabled caps do not narrow native windows or input budgets", () => { + const config = makeConfig({ providerContextCapValues: { openai: 128_000 } }); + expect(nativeContextLimits(config)).toEqual({}); + expect(nativeModelRows(config)).toEqual(nativeModelRows(makeConfig())); + expect(nativeModelRows(config).find(row => row.slug === "gpt-5.6-sol")).toMatchObject({ + contextWindow: 272_000, + maxInputTokens: 272_000, + }); + }); + test("native aliases suppress their native dashboard row and activate Desktop allowlist pruning", () => { const config = makeConfig({ disabledModels: ["gpt-5.6-sol", "gpt-5.5"], diff --git a/tests/providers/provider-id-rewrite.test.ts b/tests/providers/provider-id-rewrite.test.ts index cc87ae55fc..6f69f2cb6b 100644 --- a/tests/providers/provider-id-rewrite.test.ts +++ b/tests/providers/provider-id-rewrite.test.ts @@ -216,3 +216,17 @@ test("removal leaves the custom-model ownership marker untouched", () => { expect(config.providerContextCapValues).toEqual({ [TO]: 128_000 }); expect(providerContextCap(config, TO)).toBeUndefined(); }); + +test("a remembered cap rename collision preserves both disabled selections", () => { + const config = { + providerContextCapValues: { [FROM]: 128_000, [TO]: 256_000 }, + } as unknown as OcxConfig; + const before = structuredClone(config); + expect(rewriteProviderReferences(config, FROM, TO)).toEqual({ + changed: 0, + collisions: [`providerContextCapValues.${TO}`], + }); + expect(config).toEqual(before); + expect(providerContextCap(config, FROM)).toBeUndefined(); + expect(providerContextCap(config, TO)).toBeUndefined(); +}); diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 622c3b37f0..09bfb1e67c 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -29,7 +29,7 @@ import { startServer, } from "../../src/server"; import { handleManagementAPI } from "../../src/server/management-api"; -import { providerManagementConfigError } from "../../src/server/auth-cors"; +import { providerEditorConfigDTO, providerManagementConfigError } from "../../src/server/auth-cors"; import { providerEmptyToolOutputConfigError } from "../../src/config/provider-validation"; import { providerServiceTierConfigError, withProviderServiceTierDTO } from "../../src/server/management/provider-capability-config"; import { clearModelCache, markProviderDiscoveryFailed, markProviderDiscoveryOk } from "../../src/codex/model-cache"; @@ -4128,6 +4128,13 @@ describe("provider management validation", () => { }); expect(disabled.status).toBe(200); expect(await disabled.json()).toMatchObject({ ok: true, caps: {} }); + expect(loadConfig().providerContextCapValues?.["test-openai"]).toBe(350_000); + const uncapped = await fetch(new URL("/api/models", server.url)); + expect(uncapped.status).toBe(200); + const uncappedRows = await uncapped.json() as Array<{ id: string; contextWindow?: number; contextCap?: number }>; + const wide = uncappedRows.find(row => row.id === "wide-model"); + expect(wide).toMatchObject({ contextWindow: 500_000 }); + expect(wide?.contextCap).toBeUndefined(); } finally { await server.stop(true); } @@ -4710,3 +4717,142 @@ test("OpenAI provider cap remembers an explicit window across off, reload, and o await put({setAll:true}); expect(loadConfig().providerContextCaps?.openai).toBe(350_000); }); + +describe("remembered provider context selections", () => { + function selectionConfig(remembered = true): OcxConfig { + const live: OcxConfig = { + port: 0, + defaultProvider: "alpha", + contextCapValue: 350_000, + providers: { + alpha: { adapter: "openai-chat", baseUrl: "https://alpha.example.test/v1", liveModels: false }, + beta: { adapter: "openai-chat", baseUrl: "https://beta.example.test/v1", liveModels: false }, + }, + providerContextCaps: { alpha: 128_000 }, + ...(remembered ? { providerContextCapValues: { alpha: 128_000, beta: 256_000 } } : {}), + }; + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(live); + return loadConfig(); + } + + async function request(live: OcxConfig, path: string, method: string, body?: unknown): Promise { + const url = new URL(path, "http://localhost"); + const response = await handleManagementAPI(new Request(url, { + method, + ...(body === undefined ? {} : { + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + }), url, live, { createManagementConvergeCodex: catalogConvergenceFactory() }); + if (!response) throw new Error(`unhandled management route: ${path}`); + return response; + } + + test.each(["toString", "valueOf"])("first enable ignores inherited remembered values for %s", async (provider) => { + let live = selectionConfig(); + live.providers[provider] = { adapter: "openai-chat", baseUrl: "https://context.example.test/v1", liveModels: false }; + saveConfig(live); + live = loadConfig(); + + const first = await request(live, "/api/provider-context-caps", "PUT", { provider, enabled: true }); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ caps: { [provider]: 350_000 }, values: { [provider]: 350_000 } }); + expect(loadConfig().providerContextCaps?.[provider]).toBe(350_000); + expect(Object.hasOwn(loadConfig().providerContextCapValues ?? {}, provider)).toBe(true); + + expect((await request(live, "/api/provider-context-caps", "PUT", { provider, enabled: true, value: 128_000 })).status).toBe(200); + expect((await request(live, "/api/provider-context-caps", "PUT", { provider, enabled: false })).status).toBe(200); + live = loadConfig(); + expect(Object.hasOwn(live.providerContextCaps ?? {}, provider)).toBe(false); + expect((await request(live, "/api/provider-context-caps", "PUT", { provider, enabled: true })).status).toBe(200); + expect(loadConfig().providerContextCaps?.[provider]).toBe(128_000); + }); + + test.each([ + { body: { value: 600_000, setAll: true }, caps: { alpha: 600_000 }, values: { alpha: 600_000, beta: 256_000 }, restored: 256_000 }, + { body: { setAll: true }, caps: { alpha: 350_000, beta: 350_000 }, values: { alpha: 350_000, beta: 350_000 }, restored: 350_000 }, + ])("setAll payload $body preserves or replaces a disabled selection as documented", async ({ body, caps, values, restored }) => { + let live = selectionConfig(); + const response = await request(live, "/api/provider-context-caps", "PUT", body); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ caps, values }); + live = loadConfig(); + expect(live.providerContextCaps).toEqual(caps); + expect(live.providerContextCapValues).toEqual(values); + const enabled = await request(live, "/api/provider-context-caps", "PUT", { provider: "beta", enabled: true }); + expect(enabled.status).toBe(200); + expect(await enabled.json()).toMatchObject({ caps: { ...caps, beta: restored } }); + expect(loadConfig().providerContextCaps?.beta).toBe(restored); + }); + + test("an active-only legacy selection survives off, reload and implicit enable", async () => { + let live = selectionConfig(false); + expect(live.providerContextCapValues).toBeUndefined(); + const initial = await request(live, "/api/provider-context-caps", "GET"); + expect(initial.status).toBe(200); + expect(await initial.json()).toMatchObject({ caps: { alpha: 128_000 }, values: { alpha: 128_000 } }); + const off = await request(live, "/api/provider-context-caps", "PUT", { provider: "alpha", enabled: false }); + expect(off.status).toBe(200); + expect(await off.json()).toMatchObject({ caps: {}, values: { alpha: 128_000 } }); + live = loadConfig(); + expect(live.providerContextCaps).toBeUndefined(); + expect(live.providerContextCapValues).toEqual({ alpha: 128_000 }); + const on = await request(live, "/api/provider-context-caps", "PUT", { provider: "alpha", enabled: true }); + expect(on.status).toBe(200); + expect(await on.json()).toMatchObject({ caps: { alpha: 128_000 }, values: { alpha: 128_000 } }); + expect(loadConfig().providerContextCaps).toEqual({ alpha: 128_000 }); + }); + + test("rejected cap requests leave active and disabled selections untouched in memory and on disk", async () => { + const live = selectionConfig(); + const before = structuredClone(live); + const beforeBytes = readFileSync(join(TEST_DIR, "config.json"), "utf8"); + for (const [body, status] of [ + [{ provider: "beta", enabled: true, value: 0.5 }, 400], + [{ provider: "beta", enabled: "yes", value: 700_000 }, 400], + [{ provider: "beta", enabled: true, setAll: true }, 400], + [{ value: 700_000, setAll: "yes" }, 400], + [{ value: 0.5 }, 400], + [{ provider: "missing", enabled: true }, 404], + [[1, 2, 3], 400], + [null, 400], + ] as const) { + const response = await request(live, "/api/provider-context-caps", "PUT", body); + expect(response.status).toBe(status); + expect(live).toEqual(before); + expect(readFileSync(join(TEST_DIR, "config.json"), "utf8")).toBe(beforeBytes); + } + }); + + test.each(["DELETE", "editor"] as const)("%s removal forgets active and disabled selections in persisted and live state", async mode => { + const live = selectionConfig(); + live.providers.retained = { adapter: "openai-chat", baseUrl: "https://retained.example.test/v1", liveModels: false }; + live.defaultProvider = "retained"; + saveConfig(live); + const resolved = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + for (const name of ["alpha", "beta"]) { + const baseline = providerEditorConfigDTO(loadConfig()); + const next = structuredClone(baseline); + delete next.providers[name]; + const response = mode === "DELETE" + ? await request(live, `/api/providers?name=${name}`, "DELETE") + : await request(live, "/api/providers", "PUT", { baseline, next }); + expect(response.status).toBe(200); + for (const snapshot of [live, loadConfig()]) { + expect(snapshot.providers[name]).toBeUndefined(); + expect(snapshot.providers.retained).toBeDefined(); + expect(snapshot.providerContextCaps).toBeUndefined(); + expect(snapshot.providerContextCapValues).toEqual(name === "alpha" ? { beta: 256_000 } : undefined); + } + const caps = await request(live, "/api/provider-context-caps", "GET"); + expect(caps.status).toBe(200); + expect(await caps.json()).toMatchObject({ caps: {}, values: name === "alpha" ? { beta: 256_000 } : {} }); + } + } finally { + resolved.mockRestore(); + } + }); +}); From c97b9e10057e31574d9226a2af4406ba1d7941d2 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:24:26 +0900 Subject: [PATCH 047/236] docs(context): synchronize remembered selections and setAll semantics --- .../content/docs/fr/guides/model-routing.md | 18 +++++++++++------- .../fr/reference/configuration/providers.md | 5 +++-- .../docs/fr/reference/management-api.md | 12 ++++++++++++ .../src/content/docs/guides/model-routing.md | 10 +++++++--- .../content/docs/ja/guides/model-routing.md | 14 +++++++++----- .../ja/reference/configuration/providers.md | 5 +++-- .../docs/ja/reference/management-api.md | 10 ++++++++++ .../content/docs/ko/guides/model-routing.md | 15 +++++++++------ .../ko/reference/configuration/providers.md | 5 +++-- .../docs/ko/reference/management-api.md | 9 +++++++++ .../docs/reference/configuration/providers.md | 5 +++-- .../content/docs/ru/guides/model-routing.md | 16 ++++++++++------ .../ru/reference/configuration/providers.md | 5 +++-- .../docs/ru/reference/management-api.md | 12 ++++++++++++ .../content/docs/tr/guides/model-routing.md | 19 ++++++++++--------- .../tr/reference/configuration/providers.md | 5 +++-- .../docs/tr/reference/management-api.md | 11 +++++++++++ .../docs/zh-cn/guides/model-routing.md | 9 ++++++--- .../reference/configuration/providers.md | 5 +++-- .../docs/zh-cn/reference/management-api.md | 8 ++++++++ .../docs/zh-tw/guides/model-routing.md | 10 +++++++--- .../reference/configuration/providers.md | 5 +++-- .../docs/zh-tw/reference/management-api.md | 8 ++++++++ 23 files changed, 163 insertions(+), 58 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/model-routing.md b/docs-site/src/content/docs/fr/guides/model-routing.md index 927d2ef863..273e2d1901 100644 --- a/docs-site/src/content/docs/fr/guides/model-routing.md +++ b/docs-site/src/content/docs/fr/guides/model-routing.md @@ -90,13 +90,17 @@ Le routage et la visibilité dans le catalogue sont deux mécanismes distincts : catalogue et `/v1/models` sont restreintes. - `provider.disabled: true` retire ce fournisseur de la découverte du catalogue. Les requêtes explicites `provider/model` échouent, et les recherches dans `defaultModel` et `models[]` l'ignorent. -- `providerContextCaps` applique des plafonds de contexte visibles par Codex, fournisseur par fournisseur. - `contextCapValue` est la valeur par défaut du tableau de bord (350 000 par défaut), mais n'a aucun effet à - lui seul tant qu'un fournisseur ne figure pas dans `providerContextCaps`. La modification de la valeur dans - le tableau de bord réaffecte tous les fournisseurs activés uniquement lorsque l'option « appliquer à tous les - fournisseurs routés » est activée ; sinon, chaque fournisseur conserve son propre plafond. Un plafond peut - seulement réduire une fenêtre de contexte connue : il ne peut ni l'augmenter ni modifier la limite réelle du - modèle en amont. +- `providerContextCaps` définit les plafonds de contexte visibles par Codex pour chaque fournisseur. + `contextCapValue` est la valeur par défaut du tableau de bord (350 000) ; elle n’applique aucun + plafond tant que le fournisseur ne figure pas dans `providerContextCaps`. Modifier cette valeur + ne met à jour les plafonds actifs que si « appliquer à tous les fournisseurs routés » est activé ; + sinon, chaque fournisseur conserve son plafond. Les fenêtres ordinaires connues ne peuvent + qu’être réduites ; les modèles natifs prenant en charge une fenêtre longue peuvent être étendus + jusqu’à leur propre plafond pris en charge, sans modifier la limite réelle du modèle en amont. + Désactiver un plafond conserve sa sélection dans `providerContextCapValues`, même après + rechargement ; le réactiver restaure cette sélection. Une sélection mémorisée n’impose aucune + limite tant que le plafond est désactivé. `{ "setAll": true }` sans `value` active tous les + fournisseurs configurés à la valeur globale actuelle et remplace leurs sélections mémorisées. ```json { diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index bec7932c09..24b9dcba77 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -28,8 +28,9 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `providers` | `Record` | — | Mappage du nom du fournisseur avec la configuration du fournisseur. | | `openaiProviderTierVersion?` | `2` | défini par la migration | Marque la projection OpenAI prenant en compte les options uniques comme terminée. | | `disabledModels?` | `string[]` | — | Modèles masqués du catalogue de Codex et de `/v1/models`, mais non bloqués des appels proxy directs. Un identifiant acheminé est supprimé des listes. Un identifiant natif qualifié de compte masque uniquement cette ligne de sélecteur ; un identifiant GPT natif nu masque la ligne nue et chaque ligne de sélecteur de compte pour ce modèle. La page Modèles du tableau de bord expose uniquement les lignes natives routées et nues ; utilisez ce champ de configuration directement pour masquer une ligne qualifiée par le sélecteur. | -| `providerContextCaps?` | `Record` | `{}` | Limites de contexte Codex-visibles par fournisseur. Un plafond abaisse uniquement une fenêtre de contexte connue. | -| `contextCapValue?` | `number` | `350000` | Valeur par défaut utilisée par les contrôles de plafond de contexte du tableau de bord. La modifier applique la valeur à chaque fournisseur routé — y compris ceux qui ne possèdent aucune entrée `providerContextCaps` — uniquement lorsque l'option « appliquer à chaque fournisseur routé » est activée ; sinon, chaque fournisseur conserve son propre plafond. | +| `providerContextCaps?` | `Record` | `{}` | Limites de contexte actives par fournisseur. Les fenêtres ordinaires sont réduites ; les modèles natifs prenant en charge une fenêtre longue peuvent être étendus uniquement jusqu’à leur propre plafond pris en charge. | +| `providerContextCapValues?` | `Record` | `{}` | Dernières limites sélectionnées par fournisseur, conservées après désactivation. Ces valeurs n’activent aucun plafond. Une valeur active est prioritaire sur une valeur mémorisée. | +| `contextCapValue?` | `number` | `350000` | Valeur par défaut lors de la première activation. Les activations suivantes restaurent la sélection du fournisseur. Modifier la valeur globale avec `setAll: true` ne modifie que les plafonds actifs ; `setAll: true` sans valeur active tous les fournisseurs configurés à la valeur globale actuelle. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Métadonnées du compte pool ChatGPT/Codex gérées par Codex Auth. Les secrets vivent séparément dans `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Comptes exclus de la sélection du pool jusqu'à la reprise, y compris le compte principal `__main__` lorsqu'il est mis en pause. | | `codexAccountNamespaces?` | `Record` | — | Mappage facultatif d’un sélecteur de modèle public arbitraire vers une cible de compte Codex stockée. Lorsque les lignes du sélecteur qualifié par compte sont activées, chaque sélecteur dont la cible est présente ajoute des lignes `/` distinctes au sélecteur Codex ; chaque ligne utilise uniquement ce compte. Dès qu'un sélecteur est actif, les lignes natives non qualifiées sont masquées dans le sélecteur, mais leurs identifiants restent routables et figurent toujours dans la réponse brute de `/v1/models`, sauf désactivation explicite. | diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index 0ae564647f..0732df6dca 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -220,6 +220,18 @@ fournisseurs ne sont pas renvoyés aux clients du tableau de bord. | `GET, PUT /api/provider-context-caps` | Lire ou mettre à jour les plafonds de contexte globaux, communs à tous les fournisseurs ou propres à un fournisseur | 400 requête invalide ; 404 fournisseur inconnu | | `GET /api/provider-presets` | Renvoyer les préréglages de fournisseur de l'interface graphique dérivés du registre d'exécution | — | +La réponse des plafonds de contexte comprend `caps` (limites actives) et `values` (dernières +sélections, conservées après désactivation). Activer un fournisseur sans `value` restaure sa +sélection, ou utilise la valeur globale `contextCapValue` lors de la première activation. +Cela vaut aussi pour OpenAI : le commutateur ne sélectionne pas un mode spécial à 922k. +Un plafond actif borne chaque fenêtre native ; les modèles prenant en charge un contexte long +peuvent être étendus uniquement jusqu’à leur propre plafond pris en charge. +`{ "value": 600000, "setAll": true }` modifie la valeur globale et uniquement les plafonds actifs ; +les fournisseurs dont le plafond est désactivé conservent leur sélection pour une réactivation ultérieure. +`{ "setAll": true }` sans `value` active tous les fournisseurs configurés à la valeur globale +actuelle et remplace leurs sélections mémorisées. La désactivation conserve la sélection, +même après rechargement, sans l’appliquer comme limite. + `provider_has_dependent_combos` est une barrière de sécurité : supprimez ou modifiez les combinaisons dépendantes avant de supprimer leur fournisseur. diff --git a/docs-site/src/content/docs/guides/model-routing.md b/docs-site/src/content/docs/guides/model-routing.md index 59198f6815..b9f1a6b34d 100644 --- a/docs-site/src/content/docs/guides/model-routing.md +++ b/docs-site/src/content/docs/guides/model-routing.md @@ -93,10 +93,14 @@ Routing and catalog visibility are separate controls: `provider/model` requests fail, and `defaultModel` / `models[]` scans skip it. - `providerContextCaps` applies per-provider Codex-visible context caps. `contextCapValue` is the dashboard default (350,000 by default), but it does nothing by itself until a provider is - present in `providerContextCaps`. Changing the dashboard value re-points every enabled provider + present in `providerContextCaps`. Changing the dashboard value updates every enabled cap only when "apply to every routed provider" is toggled on; otherwise each provider keeps its own - cap. Caps only lower a known context window; they never raise one or change the upstream model's - actual limit. + cap. Ordinary known windows can only be lowered; native models that support a longer window + can expand up to their own supported ceiling. Caps never change the upstream model's actual limit. + Switching a cap off retains its selection in `providerContextCapValues`, including after reload; + switching it on restores that selection. A remembered selection never applies a limit while disabled. + Sending `{ "setAll": true }` without `value` enables all configured providers at the current + global value and replaces their remembered selections. ```json { diff --git a/docs-site/src/content/docs/ja/guides/model-routing.md b/docs-site/src/content/docs/ja/guides/model-routing.md index 3fb9e5f55f..7dda1864c0 100644 --- a/docs-site/src/content/docs/ja/guides/model-routing.md +++ b/docs-site/src/content/docs/ja/guides/model-routing.md @@ -86,11 +86,15 @@ model ID は変更しません。`openai-apikey/` は API key transport - `provider.disabled: true` のプロバイダーはカタログ探索から除外されます。明示的 `provider/model` リクエストは 失敗し、`defaultModel` / `models[]` 検査でもスキップします。 - `providerContextCaps` はプロバイダーごとに Codex に表示するコンテキスト上限を指定します。 - `contextCapValue` はダッシュボードが併用する値でデフォルトは 350,000 です。ただしこの値だけを設定しても - 変化はなく、`providerContextCaps` にプロバイダーが含まれていて初めて適用されます。ダッシュボードの値を - 変更すると、「すべてのルーティング対象プロバイダーに適用」がオンになっている場合にのみ、有効なすべての - プロバイダーに再適用されます。それ以外の場合、各プロバイダーは独自の上限を維持します。既知のコンテキスト - サイズを下げるだけで、上げたり上流モデルの実際の上限を変えたりはしません。 + `contextCapValue` はダッシュボードの既定値(350,000)です。この値だけでは上限は適用されず、 + `providerContextCaps` にプロバイダーが含まれている必要があります。ダッシュボードの値を変更すると、 + 「すべてのルーティング対象プロバイダーに適用」がオンの場合に限り、有効な上限をすべて更新します。 + オフの場合は各プロバイダーの上限を保持します。通常の既知のウィンドウは縮小のみ可能ですが、 + 長いウィンドウに対応したネイティブモデルは、そのモデルが対応する上限まで拡張できます。 + 上流モデルの実際の制限は変わりません。上限を無効にしても選択値は `providerContextCapValues` に + 保存され、再読み込み後も残ります。再び有効にすると選択値を復元します。無効な間は保存値を制限として + 適用しません。`value` なしの `{ "setAll": true }` は、設定済みの全プロバイダーの上限を現在の + グローバル値で有効にし、保存された選択値も置き換えます。 ```json { diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index bd33d34a3f..117591e118 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -27,8 +27,9 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `providers` | `Record` | — |プロバイダー名からプロバイダー設定へのマップ。 | | `openaiProviderTierVersion?` | `2` |移行によって設定される |単一のオプション対応 OpenAI プロジェクションを完了としてマークします。 | | `disabledModels?` | `string[]` | — | Codex catalog と `/v1/models` から非表示にする model。直接の proxy 呼び出しはブロックしません。routed id は一覧から削除されます。account-qualified native id は該当する selector row だけを非表示にし、bare native GPT id は bare row とその model の全 account-selector row を非表示にします。Models ページに表示されるのは bare native 行と routed 行だけです。selector-qualified 行を 1 つだけ非表示にするには、この設定フィールドを直接編集してください。 | -| `providerContextCaps?` | `Record` | `{}` |プロバイダーごとの Codex に表示されるコンテキストの上限。キャップは既知のコンテキスト ウィンドウを下げるだけです。 | -| `contextCapValue?` | `number` | `350000` |ダッシュボードのコンテキストキャップ コントロールで使用される既定値。「すべてのルーティング済みプロバイダーに適用」がオンになっている場合のみ、変更によってすべてのルーティング済みプロバイダー(`providerContextCaps` エントリがまだないプロバイダーを含む)に値が適用されます。それ以外では各プロバイダーは独自のキャップを保持します。 | +| `providerContextCaps?` | `Record` | `{}` | プロバイダーごとの有効なコンテキスト上限。通常のウィンドウは縮小されます。長いウィンドウに対応したネイティブモデルは、そのモデルが対応する上限まで拡張できます。 | +| `providerContextCapValues?` | `Record` | `{}` | プロバイダーごとに最後に選択した上限。無効にしても保持され、この値だけで上限が有効になることはありません。有効な値が保存済みの値より優先されます。 | +| `contextCapValue?` | `number` | `350000` | 初回の有効化で使う既定値。再び有効にすると、そのプロバイダーの選択値を復元します。`setAll: true` とともにグローバル値を変更すると、有効な上限だけを更新します。値を指定せずに `setAll: true` を送ると、設定済みの全プロバイダーの上限を現在のグローバル値で有効にします。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex プール アカウントのメタデータは Codex Auth によって管理されます。秘密は`codex-accounts.json`に別に住んでいます。 | | `pausedCodexAccountIds?` | `string[]` | `[]` |再開するまでプールの選択から除外されるアカウント (一時停止時のメイン `__main__` アカウントを含む)。 | | `codexAccountNamespaces?` | `Record` | — | 任意の公開 model selector を保存済み Codex アカウント target に対応付ける任意の map。account-qualified picker row が有効な場合、target が存在する各 selector は Codex picker に個別の `/` row を追加し、各 row はそのアカウントだけを使用します。selector が 1 つでも有効な場合、bare native row は picker で非表示になりますが、明示的に無効化されない限り id は引き続き routing でき、raw `/v1/models` にも表示されます。 | diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 2c6270ef17..fecada7216 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -191,6 +191,16 @@ Authorization: Bearer | `GET, PUT /api/provider-context-caps` |グローバル、全プロバイダー、または 1 つのプロバイダーのコンテキスト キャップを読み取りまたは更新します。 400 無効なリクエスト。 404 不明なプロバイダ | | `GET /api/provider-presets` |ランタイム レジストリから派生した GUI プロバイダー プリセットを返します。 — | +コンテキスト上限のレスポンスには `caps`(有効な上限)と `values`(無効化後も保持される最後の選択値)が +含まれます。`value` を指定せずにプロバイダーの上限を有効にすると選択値を復元し、初回はグローバルの +`contextCapValue` を使います。OpenAI でも同様で、スイッチが特別な 922k モードを選ぶことはありません。 +有効な上限はすべてのネイティブウィンドウに適用されます。長いコンテキストに対応したモデルは、 +そのモデルが対応する上限まで拡張できます。 +`{ "value": 600000, "setAll": true }` はグローバル値と有効な上限だけを更新します。 +上限が無効なプロバイダーは選択値を保持し、後で有効にすると復元します。 +`value` なしの `{ "setAll": true }` は、設定済みの全プロバイダーの上限を現在のグローバル値で有効にし、 +保存された選択値も置き換えます。無効化しても選択値は再読み込み後まで保持されますが、制限としては適用されません。 + `provider_has_dependent_combos` は安全バリアです。プロバイダーを削除する前に、依存するコンボを削除または編集してください。 ### サイドバーと同意に基づくアクション diff --git a/docs-site/src/content/docs/ko/guides/model-routing.md b/docs-site/src/content/docs/ko/guides/model-routing.md index f9b1eff92c..ac6425f342 100644 --- a/docs-site/src/content/docs/ko/guides/model-routing.md +++ b/docs-site/src/content/docs/ko/guides/model-routing.md @@ -84,12 +84,15 @@ fallback하지 않습니다. 직접 라우팅은 그대로 두고, 카탈로그와 `/v1/models`에 내보낼 모델만 줄입니다. - `provider.disabled: true`인 프로바이더는 카탈로그 탐색에서 제외됩니다. 명시적 `provider/model` 요청은 실패하고, `defaultModel` / `models[]` 검사에서도 건너뜁니다. -- `providerContextCaps`는 프로바이더별로 Codex에 표시할 컨텍스트 상한을 지정합니다. - `contextCapValue`는 대시보드가 함께 쓰는 값이며 기본값은 350,000입니다. 다만 이 값만 설정해서는 - 아무 변화가 없고 `providerContextCaps`에 프로바이더가 들어 있어야 적용됩니다. 대시보드 값을 변경하면 - '모든 라우팅 대상 프로바이더에 적용' 토글이 켜져 있을 때만 모든 활성 프로바이더에 다시 적용되며, - 그렇지 않으면 각 프로바이더는 자체 한도를 유지합니다. 이미 알려진 컨텍스트 크기를 낮추기만 하며, - 더 키우거나 업스트림 모델의 실제 한도를 바꾸지는 않습니다. +- `providerContextCaps`는 공급자별로 Codex에 표시할 컨텍스트 상한을 지정합니다. + `contextCapValue`는 대시보드의 기본값이며 기본 설정은 350,000입니다. 이 값만으로는 상한이 적용되지 않고, + `providerContextCaps`에 공급자가 있어야 적용됩니다. '모든 라우팅 대상 공급자에 적용' 토글을 켠 상태에서 + 대시보드 값을 바꾸면 활성 상한만 갱신합니다. 토글이 꺼져 있으면 각 공급자의 상한을 유지합니다. + 일반적인 기존 윈도는 줄일 수만 있지만, 장문 윈도를 지원하는 네이티브 모델은 해당 모델의 지원 상한까지 + 확장할 수 있습니다. 업스트림 모델의 실제 한도는 바뀌지 않습니다. 상한을 꺼도 선택값은 + `providerContextCapValues`에 남고 다시 불러와도 유지됩니다. 다시 켜면 이 선택값을 복원하며, + 꺼져 있는 동안에는 저장된 값을 제한으로 적용하지 않습니다. `value` 없이 `{ "setAll": true }`를 보내면 + 설정된 모든 공급자의 상한을 현재 전역 값으로 켜고, 저장된 선택값도 이 값으로 바꿉니다. ```json { diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 3d65dbfb4d..33874a8749 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -27,8 +27,9 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `providers` | `Record` | — | 공급자 이름을 공급자 설정에 매핑합니다. | | `openaiProviderTierVersion?` | `2` | 마이그레이션으로 설정됨 | 옵션을 인식하는 단일 OpenAI 투영이 완료되었음을 표시합니다. | | `disabledModels?` | `string[]` | — | Codex catalog와 `/v1/models`에서는 숨기지만 직접 proxy 호출은 차단하지 않습니다. routed id는 목록에서 제거됩니다. account-qualified native id는 해당 selector row만 숨기고, bare native GPT id는 bare row와 그 model의 모든 account-selector row를 숨깁니다. Models 페이지에는 bare native 행과 routed 행만 표시됩니다. selector-qualified 행 하나만 숨기려면 이 설정 필드에 직접 추가하세요. | -| `providerContextCaps?` | `Record` | `{}` | 공급자별 Codex 표시 컨텍스트 상한입니다. 상한은 이미 알려진 컨텍스트 윈도만 낮춥니다. | -| `contextCapValue?` | `number` | `350000` | 대시보드의 컨텍스트 상한 컨트롤이 사용하는 기본값입니다. "모든 라우팅된 공급자에 적용" 토글이 켜져 있을 때만 값을 변경하면 기존 `providerContextCaps` 항목이 없는 공급자를 포함해 모든 라우팅된 공급자에 값이 적용됩니다. 그렇지 않으면 각 공급자는 자체 상한을 유지합니다. | +| `providerContextCaps?` | `Record` | `{}` | 공급자별 활성 컨텍스트 상한입니다. 일반 윈도는 줄어들며, 장문 윈도를 지원하는 네이티브 모델은 해당 모델의 지원 상한까지만 확장할 수 있습니다. | +| `providerContextCapValues?` | `Record` | `{}` | 공급자별로 마지막에 선택한 상한입니다. 꺼도 선택값이 남으며, 저장된 값만으로는 상한이 활성화되지 않습니다. 활성 값이 저장된 선택값보다 우선합니다. | +| `contextCapValue?` | `number` | `350000` | 처음 켤 때 쓰는 기본값입니다. 다시 켜면 공급자별 선택값을 복원합니다. `setAll: true`와 함께 전역 값을 바꾸면 활성 상한만 갱신합니다. 값 없이 `setAll: true`를 보내면 설정된 모든 공급자의 상한을 현재 전역 값으로 켭니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth가 관리하는 ChatGPT/Codex 풀 계정 메타데이터입니다. 비밀 정보는 `codex-accounts.json`에 따로 저장됩니다. | | `pausedCodexAccountIds?` | `string[]` | `[]` | 일시 중지된 `__main__` 계정을 포함해, 재개될 때까지 Pool 선택에서 제외되는 계정입니다. | | `codexAccountNamespaces?` | `Record` | — | 임의의 공개 model selector를 저장된 Codex 계정 target에 연결하는 선택적 map입니다. 계정 한정 선택기 행이 활성화되어 있으면 target이 존재하는 각 selector는 Codex picker에 별도의 `/` row를 추가하며, 각 row는 해당 계정만 사용합니다. selector가 하나라도 활성화되면 bare native row는 picker에서 숨겨지지만, 명시적으로 비활성화하지 않는 한 해당 id는 계속 routing 가능하고 raw `/v1/models`에 표시됩니다. | diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index 503df00925..a9a54c69c3 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -194,6 +194,15 @@ Authorization: Bearer | `GET, PUT /api/provider-context-caps` | 전역, 모든 provider, 또는 하나의 provider context cap을 읽거나 업데이트합니다 | 400 잘못된 요청; 404 알 수 없는 provider | | `GET /api/provider-presets` | 런타임 registry에서 파생된 GUI provider preset을 반환합니다 | — | +컨텍스트 상한 응답에는 `caps`(활성 상한)와 `values`(꺼도 유지되는 마지막 선택값)가 포함됩니다. +`value` 없이 공급자의 상한을 켜면 선택값을 복원하고, 처음 켤 때는 전역 `contextCapValue`를 씁니다. +OpenAI도 같은 규칙을 따르며, 스위치를 켠다고 별도의 922k 모드가 선택되지는 않습니다. +활성 상한은 모든 네이티브 윈도에 적용됩니다. 장문 컨텍스트를 지원하는 모델은 해당 모델의 지원 상한까지만 +확장할 수 있습니다. `{ "value": 600000, "setAll": true }`는 전역 값과 활성 상한만 갱신합니다. +상한이 꺼진 공급자는 선택값을 유지하고, 나중에 켜면 그 값을 복원합니다. +`value` 없이 `{ "setAll": true }`를 보내면 설정된 모든 공급자의 상한을 현재 전역 값으로 켜고, +저장된 선택값도 바꿉니다. 상한을 꺼도 선택값은 다시 불러온 뒤까지 유지되지만 제한으로 적용되지는 않습니다. + `provider_has_dependent_combos`는 안전 장치입니다. provider를 삭제하기 전에 종속된 combo를 제거하거나 수정하십시오. ### 사이드바 및 동의가 필요한 작업 diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d6adbdac9c..2578c648ec 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -28,8 +28,9 @@ After GUI registration or OAuth login, the confirmation dialog lets you open the | `providers` | `Record` | — | Map of provider name to provider config. | | `openaiProviderTierVersion?` | `2` | set by migration | Marks the single option-aware OpenAI projection as complete. | | `disabledModels?` | `string[]` | — | Models hidden from Codex's catalog and `/v1/models`, but not blocked from direct proxy calls. A routed id is removed from listings. An account-qualified native id hides only that selector row; a bare native GPT id hides the bare row and every account-selector row for that model. The dashboard Models page exposes only routed and bare native rows; use this configuration field directly to hide one selector-qualified row. | -| `providerContextCaps?` | `Record` | `{}` | Per-provider Codex-visible context caps. A cap only lowers a known context window. | -| `contextCapValue?` | `number` | `350000` | Default value used by the dashboard context-cap controls. Changing it applies the value to every routed provider — including providers without an existing `providerContextCaps` entry — only when "apply to every routed provider" is toggled on; otherwise each provider keeps its own cap. | +| `providerContextCaps?` | `Record` | `{}` | Active provider context limits. Ordinary windows are lowered; native models with a supported long window can expand only up to their own supported ceiling. | +| `providerContextCapValues?` | `Record` | `{}` | Last selected provider limits, retained while disabled. These values do not activate a cap. An enabled value takes precedence over a remembered value. | +| `contextCapValue?` | `number` | `350000` | Default used on first enable. A later enable restores the selected provider value. Updating the global value with `setAll: true` changes enabled caps only; `setAll: true` without a value enables all configured providers at the current global value. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by Codex Auth. Secrets live separately in `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. | | `codexQuotaAutoRefresh?` | `Record` | `{}` | Per-Codex-login-account opt-in for automatic `fiveHour` and `weekly` window activation in Pool mode; Direct mode does not run this worker. In Providers/Codex Auth **Advanced settings**, one control enables or disables both supported windows across all current main and added accounts. New accounts are not opted in automatically. Enable skips windows absent from live WHAM data; disable also clears stale enabled windows. The UI reuses granular `/api/settings` writes, reconciles partial failures, and retries the original ON/OFF intent without replacing unrelated settings or completed reset markers. The API still rejects enabling an unavailable window with HTTP 409. At a reported reset time, opencodex sends one minimal non-stored Codex message using that account's quota and persists the activated timestamp. This does not apply to API-key providers. | diff --git a/docs-site/src/content/docs/ru/guides/model-routing.md b/docs-site/src/content/docs/ru/guides/model-routing.md index eb4178cd3f..ce96fc95b8 100644 --- a/docs-site/src/content/docs/ru/guides/model-routing.md +++ b/docs-site/src/content/docs/ru/guides/model-routing.md @@ -91,12 +91,16 @@ description: Как opencodex решает, какой провайдер буд - `provider.disabled: true` убирает провайдера из обнаружения каталога. Явные запросы `provider/model` завершаются ошибкой, а проверки `defaultModel` / `models[]` его пропускают. - `providerContextCaps` задаёт видимые для Codex лимиты контекста по провайдерам. - `contextCapValue` — значение по умолчанию для дашборда (по умолчанию 350 000), но сам по себе он - ничего не делает, пока провайдер не указан в `providerContextCaps`. Изменение значения на дашборде - переназначает все включённые провайдеры только при включённом переключателе «применить ко всем - маршрутизируемым провайдерам»; в противном случае каждый провайдер сохраняет собственный лимит. - Лимиты только понижают известное контекстное окно; они никогда не повышают его и не меняют - фактический предел вышестоящей модели. + `contextCapValue` — значение по умолчанию для дашборда (350 000); само по себе оно не применяет + ограничение, пока провайдер не указан в `providerContextCaps`. Изменение значения в дашборде + обновляет только активные лимиты и только при включённом переключателе «применить ко всем + маршрутизируемым провайдерам»; иначе каждый провайдер сохраняет свой лимит. Обычные известные + окна можно только уменьшать; нативные модели с поддержкой длинного контекста могут расширять + окно до собственного поддерживаемого предела. Фактический предел вышестоящей модели не меняется. + При отключении лимита выбор сохраняется в `providerContextCapValues`, в том числе после + перезагрузки. Повторное включение восстанавливает выбор. Сохранённое значение не ограничивает + окно, пока лимит отключён. `{ "setAll": true }` без `value` включает лимиты всех настроенных + провайдеров с текущим глобальным значением и заменяет их сохранённые значения. ```json { diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index a058fdb842..bd670b2ea0 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -28,8 +28,9 @@ ocx models provider openrouter on | `providers` | `Record` | — | Map вида provider name → provider config. | | `openaiProviderTierVersion?` | `2` | set by migration | Отмечает, что единая projection OpenAI с учётом режима уже завершена. | | `disabledModels?` | `string[]` | — | Модели, скрытые из каталога Codex и `/v1/models`, но не заблокированные для прямых вызовов прокси. Routed-id удаляются из списков. Account-qualified native-id скрывает только строку этого селектора; bare native GPT-id скрывает bare-строку и строки всех селекторов аккаунтов для этой модели. Страница Models показывает только bare native- и routed-строки; чтобы скрыть одну selector-qualified строку, задайте это поле конфигурации напрямую. | -| `providerContextCaps?` | `Record` | `{}` | Context cap'ы, видимые Codex, по каждому провайдеру. Cap может только понижать известное context window. | -| `contextCapValue?` | `number` | `350000` | Значение по умолчанию для элементов управления context-cap в дашборде. При изменении значение применяется ко всем маршрутизируемым провайдерам — включая провайдеров без существующей записи `providerContextCaps` — только при включённом переключателе «применить ко всем маршрутизируемым провайдерам»; в противном случае каждый провайдер сохраняет собственный лимит. | +| `providerContextCaps?` | `Record` | `{}` | Активные лимиты контекста по провайдерам. Обычные окна уменьшаются; нативные модели с поддержкой длинного контекста могут расширять окно только до собственного поддерживаемого предела. | +| `providerContextCapValues?` | `Record` | `{}` | Последние выбранные лимиты по провайдерам, сохраняемые после отключения. Эти значения сами по себе не включают ограничение. Активное значение имеет приоритет над сохранённым. | +| `contextCapValue?` | `number` | `350000` | Значение по умолчанию при первом включении. При повторном включении восстанавливается выбранное значение провайдера. Изменение глобального значения с `setAll: true` обновляет только активные лимиты; `setAll: true` без значения включает лимиты всех настроенных провайдеров с текущим глобальным значением. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, которыми управляет Codex Auth. Секреты живут отдельно в `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Аккаунты, исключённые из выбора Pool до снятия паузы, включая основной аккаунт `__main__`, если он поставлен на паузу. | | `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление произвольного публичного селектора модели с сохранённым аккаунтом Codex. Когда строки picker'а с указанием аккаунта включены, каждый селектор с существующей целью добавляет в model picker Codex отдельные строки `/`; каждая строка использует только этот аккаунт. Если активен хотя бы один селектор, bare native-строки скрываются в picker, но их id остаются маршрутизируемыми и перечисляются raw `/v1/models`, если они не отключены явно. | diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index a02f3664f8..aefc4e09cf 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -214,6 +214,18 @@ Endpoint'ы storage cleanup могут перемещать или навсег | `GET, PUT /api/provider-context-caps` | Прочитать или обновить context cap глобально, для всех провайдеров или для одного провайдера | 400 invalid request; 404 unknown provider | | `GET /api/provider-presets` | Вернуть GUI-presets провайдеров, выведенные из runtime registry | — | +Ответ API лимитов контекста содержит `caps` (активные лимиты) и `values` (последние выбранные +значения, сохраняемые после отключения). Включение лимита провайдера без `value` восстанавливает +его выбор, а при первом включении использует глобальное значение `contextCapValue`. +Это относится и к OpenAI: переключатель не выбирает специальный режим 922k. Активный лимит +ограничивает каждое нативное окно; модели с поддержкой длинного контекста могут расширять окно +только до собственного поддерживаемого предела. +`{ "value": 600000, "setAll": true }` меняет глобальное значение и только активные лимиты. +Провайдеры с отключённым лимитом сохраняют свой выбор для последующего включения. +`{ "setAll": true }` без `value` включает лимиты всех настроенных провайдеров с текущим глобальным +значением и заменяет сохранённый выбор. Отключение сохраняет выбор даже после перезагрузки, +но не применяет его как ограничение. + `provider_has_dependent_combos` — это safety-барьер: сначала удалите или отредактируйте зависящие combo, и лишь потом удаляйте их провайдера. diff --git a/docs-site/src/content/docs/tr/guides/model-routing.md b/docs-site/src/content/docs/tr/guides/model-routing.md index 50f3ca4ccc..c3183bdcb6 100644 --- a/docs-site/src/content/docs/tr/guides/model-routing.md +++ b/docs-site/src/content/docs/tr/guides/model-routing.md @@ -102,15 +102,16 @@ Yönlendirme ve katalog görünürlüğü ayrı kontrollerdir: - `provider.disabled: true`, bu sağlayıcıyı katalog keşfinden kaldırır. Açık `sağlayıcı/model` istekleri başarısız olur ve `defaultModel` / `models[]` taramaları bunu atlar. -- `providerContextCaps`, sağlayıcı başına Codex tarafından görülebilen bağlam - sınırlarını uygular. `contextCapValue` kontrol paneli varsayılanıdır - (varsayılan olarak 350.000), ancak bir sağlayıcı `providerContextCaps` içinde - yer alana kadar tek başına hiçbir şey yapmaz. Kontrol paneli değerini - değiştirmek, yalnızca "tüm yönlendirilen sağlayıcılara uygula" açık olduğunda - etkinleştirilmiş her sağlayıcıyı yeniden yönlendirir; aksi takdirde her - sağlayıcı kendi sınırını korur. Sınırlar yalnızca bilinen bir bağlam - penceresini düşürür; asla bir pencereyi yükseltmez veya yukarı akış modelinin - gerçek sınırını değiştirmez. +- `providerContextCaps`, sağlayıcı başına Codex tarafından görülebilen bağlam sınırlarını belirler. + `contextCapValue`, kontrol panelinin varsayılan değeridir (350.000); sağlayıcı `providerContextCaps` + içinde bulunmadıkça tek başına sınır uygulamaz. Kontrol paneli değerini değiştirmek, yalnızca + "tüm yönlendirilen sağlayıcılara uygula" açıkken etkin sınırları günceller; aksi halde her sağlayıcı + kendi sınırını korur. Bilinen normal pencereler yalnızca küçültülebilir; uzun pencereyi destekleyen + yerel modeller kendi desteklenen üst sınırlarına kadar genişletilebilir. Yukarı akış modelinin + gerçek sınırı değişmez. Sınır kapatıldığında seçim `providerContextCapValues` içinde saklanır + ve yeniden yüklemeden sonra da korunur. Yeniden açıldığında bu seçim geri yüklenir; kapalıyken + saklanan değer bir sınır uygulamaz. `value` olmadan `{ "setAll": true }`, yapılandırılmış tüm + sağlayıcıların sınırlarını geçerli genel değerle etkinleştirir ve saklanan seçimlerini değiştirir. ```json { diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 4213ab6001..e475582187 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -28,8 +28,9 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `providers` | `Record` | — | Sağlayıcı adından sağlayıcı yapılandırmasına eşleme haritası. | | `openaiProviderTierVersion?` | `2` | geçiş tarafından ayarlanır | Tek seçenek duyarlı OpenAI projeksiyonunu tamamlandı olarak işaretler. | | `disabledModels?` | `string[]` | — | Codex kataloğundan ve `/v1/models` listesinden gizlenen, ancak doğrudan proxy çağrılarından engellenmeyen modeller. Yönlendirilen bir kimlik listelerden kaldırılır. Hesap nitelikli bir yerel kimlik yalnızca o seçici satırını gizler; yalın bir yerel GPT kimliği, yalın satırı ve o model için her hesap seçici satırını gizler. Kontrol paneli Modeller sayfası yalnızca yönlendirilen ve yalın yerel satırları gösterir; seçici nitelikli bir satırı gizlemek için doğrudan bu yapılandırma alanını kullanın. | -| `providerContextCaps?` | `Record` | `{}` | Sağlayıcı başına Codex tarafından görülebilen bağlam sınırları. Bir sınır yalnızca bilinen bir bağlam penceresini düşürür. | -| `contextCapValue?` | `number` | `350000` | Kontrol paneli bağlam sınırı kontrolleri tarafından kullanılan varsayılan değer. Değiştirilmesi, yalnızca "tüm yönlendirilen sağlayıcılara uygula" açık olduğunda değeri mevcut bir `providerContextCaps` girdisi olmayan sağlayıcılar da dahil olmak üzere yönlendirilen her sağlayıcıya uygular; aksi takdirde her sağlayıcı kendi sınırını korur. | +| `providerContextCaps?` | `Record` | `{}` | Sağlayıcı başına etkin bağlam sınırları. Normal pencereler küçültülür; uzun pencereyi destekleyen yerel modeller yalnızca kendi desteklenen üst sınırlarına kadar genişletilebilir. | +| `providerContextCapValues?` | `Record` | `{}` | Sağlayıcı başına son seçilen sınırlar; devre dışı bırakıldığında da saklanır. Bu değerler tek başına sınırı etkinleştirmez. Etkin değer, saklanan değerden önceliklidir. | +| `contextCapValue?` | `number` | `350000` | İlk etkinleştirmede kullanılan varsayılan değer. Sonraki etkinleştirmelerde sağlayıcının seçimi geri yüklenir. Genel değeri `setAll: true` ile güncellemek yalnızca etkin sınırları değiştirir; değer olmadan `setAll: true`, yapılandırılmış tüm sağlayıcıların sınırlarını geçerli genel değerle etkinleştirir. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth tarafından yönetilen ChatGPT/Codex havuz hesabı meta verileri. Sırlar ayrı olarak `codex-accounts.json` içinde yer alır. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Duraklatıldığında ana `__main__` hesabı da dahil olmak üzere, devam ettirilene kadar Havuz seçiminden hariç tutulan hesaplar. | | `codexAccountNamespaces?` | `Record` | — | İsteğe bağlı olarak rastgele bir genel model seçiciden saklanan bir Codex hesap hedefine eşleme. Hesap nitelikli seçici satırları etkinleştirildiğinde, hedefi mevcut olan her seçici, Codex seçicisine ayrı `/` satırları ekler; her satır yalnızca o hesabı kullanır. Herhangi bir seçici etkinken, yalın yerel satırlar seçicide gizlenir, ancak açıkça devre dışı bırakılmadıkça kimlikleri yönlendirilebilir kalır ve ham `/v1/models` tarafından listelenir. | diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index d210aeafd6..afdb835e9c 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -233,6 +233,17 @@ döndürülmez. | `GET, PUT /api/provider-context-caps` | Küresel, tüm sağlayıcılar veya tek sağlayıcı bağlam sınırlarını okuyun veya güncelleyin | 400 geçersiz istek; 404 bilinmeyen sağlayıcı | | `GET /api/provider-presets` | Çalışma zamanı kayıt defterinden türetilen GUI sağlayıcı önayarlarını döndürün | — | +Bağlam sınırı yanıtı `caps` (etkin sınırlar) ve `values` (devre dışıyken de saklanan son seçimler) +alanlarını içerir. Sağlayıcının sınırını `value` olmadan etkinleştirmek seçimini geri yükler; +ilk etkinleştirmede genel `contextCapValue` kullanılır. Bu kural OpenAI için de geçerlidir: +anahtar özel bir 922k modu seçmez. Etkin sınır tüm yerel pencereleri sınırlar; uzun bağlamı +destekleyen modeller yalnızca kendi desteklenen üst sınırlarına kadar genişletilebilir. +`{ "value": 600000, "setAll": true }`, genel değeri ve yalnızca etkin sınırları günceller. +Sınırı kapalı olan sağlayıcılar, daha sonra yeniden etkinleştirildiğinde kullanılacak seçimlerini korur. +`value` olmadan `{ "setAll": true }`, yapılandırılmış tüm sağlayıcıların sınırlarını geçerli genel +değerle etkinleştirir ve saklanan seçimlerini değiştirir. Devre dışı bırakmak seçimi silmez; +yeniden yüklemeden sonra da saklar, ancak bir sınır olarak uygulamaz. + `provider_has_dependent_combos` bir güvenlik engelidir: sağlayıcılarını silmeden önce bağımlı komboları kaldırın veya düzenleyin. diff --git a/docs-site/src/content/docs/zh-cn/guides/model-routing.md b/docs-site/src/content/docs/zh-cn/guides/model-routing.md index 70f798b9d0..5bfe9d28bc 100644 --- a/docs-site/src/content/docs/zh-cn/guides/model-routing.md +++ b/docs-site/src/content/docs/zh-cn/guides/model-routing.md @@ -75,9 +75,12 @@ transport;这些凭证路径互不 fallback。 - `provider.disabled: true` 会把该提供商排除在目录发现之外。显式 `provider/model` 请求会失败, `defaultModel` / `models[]` 扫描也会跳过它。 - `providerContextCaps` 为各提供商设置 Codex 可见的上下文上限。`contextCapValue` 是仪表盘的默认值, - 默认为 350,000;但只有 `providerContextCaps` 中列出了提供商时才会生效。仅当勾选“应用到所有已路由的 - 提供方”时,修改仪表盘值才会重新指向所有已启用提供商;否则每个提供商保留自己的上限。上限只能降低 - 已知上下文,不会把它调高,也不会改变上游模型的实际限制。 + 默认为 350,000;仅设置这个值不会应用上限,提供商必须出现在 `providerContextCaps` 中才会生效。 + 勾选“应用到所有已路由的提供方”后,修改仪表盘值只会更新已开启的上限;未勾选时,各提供商保留自己的上限。 + 普通的已知窗口只能缩小;支持长窗口的原生模型可以扩展到该模型支持的上限,但不会改变上游模型的实际限制。 + 关闭上限后,选择值保存在 `providerContextCapValues` 中,重新加载后仍保留;再次开启时恢复该选择值。 + 关闭期间不会把保存的值作为限制应用。不带 `value` 的 `{ "setAll": true }` 会按当前全局值开启所有 + 已配置提供商的上限,并替换它们保存的选择值。 ```json { diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index f2d245b5ec..9009e892ed 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -27,8 +27,9 @@ ocx models provider openrouter on | `providers` | `Record` | — | 提供者名称到提供者配置的映射。 | | `openaiProviderTierVersion?` | `2` | 由迁移设置 | 标记单一、可感知选项的 OpenAI 投影已完成。 | | `disabledModels?` | `string[]` | — | 从 Codex catalog 和 `/v1/models` 中隐藏、但不阻止直接 proxy 调用的 model。routed id 会从列表中移除。account-qualified native id 只隐藏对应 selector row;bare native GPT id 会隐藏 bare row 以及该 model 的所有 account-selector row。Models 页面只显示裸原生行和路由行;若只隐藏一个 selector-qualified 行,请直接设置此配置字段。 | -| `providerContextCaps?` | `Record` | `{}` | 按提供者设置、对 Codex 可见的上下文上限。上限只会降低已知的上下文窗口。 | -| `contextCapValue?` | `number` | `350000` | 仪表板上下文上限控件使用的默认值。仅当勾选“应用到所有已路由的提供方”时,修改它才会把值应用到所有已路由提供方(包括没有现有 `providerContextCaps` 条目的提供方);否则每个提供方保留自己的上限。 | +| `providerContextCaps?` | `Record` | `{}` | 按提供商设置的有效上下文上限。普通窗口只能缩小;支持长窗口的原生模型可以扩展到该模型支持的上限。 | +| `providerContextCapValues?` | `Record` | `{}` | 各提供商最后选择的上限,关闭后仍保留。仅保存这些值不会启用上限。有效值优先于保存的选择值。 | +| `contextCapValue?` | `number` | `350000` | 首次开启时使用的默认值。再次开启时恢复该提供商的选择值。修改全局值时附带 `setAll: true` 只会更新已开启的上限;不带值的 `setAll: true` 会按当前全局值开启所有已配置提供商的上限。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池账户元数据。密钥单独存放在 `codex-accounts.json` 中。 | | `pausedCodexAccountIds?` | `string[]` | `[]` | 在恢复之前从 Pool 选择中排除的账户,包括被暂停时的主 `__main__` 账户。 | | `codexAccountNamespaces?` | `Record` | — | 将任意公开 model selector 映射到已保存 Codex account target 的可选配置。启用账户限定的选择器行后,target 存在的每个 selector 都会在 Codex picker 中添加独立的 `/` row,且每个 row 只使用对应账户。只要有 selector 生效,bare native row 就会在 picker 中隐藏;但除非显式禁用,其 id 仍可路由,并继续列在 raw `/v1/models` 中。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 340d2ee94d..9391afb5fd 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -191,6 +191,14 @@ Authorization: Bearer | `GET, PUT /api/provider-context-caps` | 读取或更新全局、全部 provider,或单个 provider 的上下文上限 | 400 请求无效;404 未知 provider | | `GET /api/provider-presets` | 返回从运行时注册表派生的 GUI provider 预设 | — | +上下文上限响应包含 `caps`(当前有效的上限)和 `values`(关闭后仍保留的最后选择值)。 +开启提供商的上限时,如果未指定 `value`,则恢复其选择值;首次开启时使用全局 `contextCapValue`。 +OpenAI 也遵循此规则:开关不会选择特殊的 922k 模式。有效上限约束每个原生窗口;支持长上下文的模型 +只能扩展到该模型支持的上限。 +`{ "value": 600000, "setAll": true }` 修改全局值,并且只更新已开启的上限;上限已关闭的提供商保留 +自己的选择值,供之后开启时恢复。不带 `value` 的 `{ "setAll": true }` 会按当前全局值开启所有 +已配置提供商的上限,并替换保存的选择值。关闭上限不会清除选择值,重新加载后仍保留,但不会将其作为限制应用。 + `provider_has_dependent_combos` 是一个安全屏障:在删除 provider 之前,先移除或编辑依赖它的 combos。 ### 侧边栏与基于同意的动作 diff --git a/docs-site/src/content/docs/zh-tw/guides/model-routing.md b/docs-site/src/content/docs/zh-tw/guides/model-routing.md index a3cb08885f..0a716351ee 100644 --- a/docs-site/src/content/docs/zh-tw/guides/model-routing.md +++ b/docs-site/src/content/docs/zh-tw/guides/model-routing.md @@ -55,9 +55,13 @@ OpenAI 的 bare `gpt-*` 使用單一 `openai` provider。`codexAccountMode` 在 目錄和 `/v1/models` 輸出的模型範圍。 - `provider.disabled: true` 會把該供應商排除在目錄發現之外。顯式 `provider/model` 請求會失敗, `defaultModel` / `models[]` 掃描也會跳過它。 -- `providerContextCaps` 為各供應商設定 Codex 可見的上下文上限。`contextCapValue` 是儀表板共用的值, - 預設為 350,000;但只有 `providerContextCaps` 中列出了供應商時才會生效。上限只能降低已知上下文, - 不會把它調高,也不會改變上游模型的實際限制。 +- `providerContextCaps` 為各供應商設定 Codex 可見的上下文上限。`contextCapValue` 是儀表板的預設值, + 預設為 350,000;僅設定此值不會套用上限,供應商必須列在 `providerContextCaps` 中才會生效。 + 勾選「套用至所有路由供應商」後,修改儀表板值只會更新已啟用的上限;未勾選時,各供應商保留自己的上限。 + 一般已知視窗只能縮小;支援長視窗的原生模型可以擴展到該模型支援的上限,但不會改變上游模型的實際限制。 + 停用上限後,選擇值儲存在 `providerContextCapValues` 中,重新載入後仍保留;再次啟用時恢復該選擇值。 + 停用期間不會將儲存值套用為限制。不帶 `value` 的 `{ "setAll": true }` 會以目前全域值啟用所有 + 已設定供應商的上限,並取代其儲存的選擇值。 ```json { diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 7a27de4f61..8a3217e3e6 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -27,8 +27,9 @@ ocx models provider openrouter on | `providers` | `Record` | — | 供應商名稱到供應商設定的映射。 | | `openaiProviderTierVersion?` | `2` | 由遷移設定 | 標記單一選項感知的 OpenAI projection 已完成。 | | `disabledModels?` | `string[]` | — | 對 Codex 目錄與 `/v1/models` 隱藏的模型,但不阻擋直接代理呼叫。路由 id 從清單中移除;裸原生 GPT id 取得 `visibility: "hide"`。 | -| `providerContextCaps?` | `Record` | `{}` | Per-供應商的 Codex 可見 context 上限。上限只會降低已知的 context window。 | -| `contextCapValue?` | `number` | `350000` | 儀表板 context-cap 控制使用的值;變更它會更新每個啟用的 `providerContextCaps` 項目。 | +| `providerContextCaps?` | `Record` | `{}` | 各供應商目前生效的上下文上限。一般視窗只能縮小;支援長視窗的原生模型可以擴展到該模型支援的上限。 | +| `providerContextCapValues?` | `Record` | `{}` | 各供應商最後選擇的上限,停用後仍保留。僅儲存這些值不會啟用上限。生效中的值優先於儲存的選擇值。 | +| `contextCapValue?` | `number` | `350000` | 首次啟用時使用的預設值。再次啟用時恢復該供應商的選擇值。修改全域值時附帶 `setAll: true` 只會更新已啟用的上限;不帶值的 `setAll: true` 會以目前全域值啟用所有已設定供應商的上限。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池帳號中繼資料。秘密分別存在 `codex-accounts.json`。 | | `pausedCodexAccountIds?` | `string[]` | `[]` | 被排除於池選擇直到恢復的帳號,包含暫停時的 main `__main__` 帳號。 | | `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 3aaf9bf997..ca899bc730 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -191,6 +191,14 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `GET, PUT /api/provider-context-caps` | 讀取或更新全域、所有供應商或單一供應商的 context 上限 | 400 無效請求;404 未知供應商 | | `GET /api/provider-presets` | 回傳從 runtime registry 衍生的 GUI 供應商預設 | — | +上下文上限回應包含 `caps`(目前生效的上限)和 `values`(停用後仍保留的最後選擇值)。 +啟用供應商的上限時,若未指定 `value`,便會恢復其選擇值;首次啟用時使用全域 `contextCapValue`。 +OpenAI 也遵循此規則:開關不會選擇特殊的 922k 模式。生效中的上限會限制每個原生視窗;支援長上下文 +的模型只能擴展到該模型支援的上限。 +`{ "value": 600000, "setAll": true }` 會修改全域值,並且只更新已啟用的上限;上限已停用的供應商會 +保留自己的選擇值,供之後啟用時恢復。不帶 `value` 的 `{ "setAll": true }` 會以目前全域值啟用所有 +已設定供應商的上限,並取代儲存的選擇值。停用不會清除選擇值,重新載入後仍保留,但不會將其套用為限制。 + `provider_has_dependent_combos` 是安全屏障:在刪除其供應商前,先移除或編輯相依的組合。 ### 側邊欄與同意約束動作 From b2f24cada35ff998096cf7f3c83b95eef4ee55af Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:26:22 +0900 Subject: [PATCH 048/236] test(responses): observe shutdown drain before snapshot persistence --- .../260906_a_windows_fixture/011_review_resolution.md | 3 +++ tests/responses/responses-state.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+) create mode 100644 devlog/_plan/260906_a_windows_fixture/011_review_resolution.md diff --git a/devlog/_plan/260906_a_windows_fixture/011_review_resolution.md b/devlog/_plan/260906_a_windows_fixture/011_review_resolution.md new file mode 100644 index 0000000000..5b230ea087 --- /dev/null +++ b/devlog/_plan/260906_a_windows_fixture/011_review_resolution.md @@ -0,0 +1,3 @@ +# Fixture review correction + +Independent implementation reviewer found that full flush settlement includes later snapshot I/O, which could hide an incorrect first-tail drain return. Accepted: the ordering test now starts the existing drain-only helper before appending the second publication and observes its settlement independently. Both drain and full-flush promises attach rejection handlers immediately and are joined during cleanup. Existing publication, replay and zero-fallback assertions remain. No production changes. A remote first-tail mutation must fail this direct drain oracle before completion. diff --git a/tests/responses/responses-state.test.ts b/tests/responses/responses-state.test.ts index 40601385e3..5442910aec 100644 --- a/tests/responses/responses-state.test.ts +++ b/tests/responses/responses-state.test.ts @@ -1336,6 +1336,8 @@ describe("Responses previous_response_id state", () => { }); setResponseStateByteCapForTests(1_024); let flushed = false; + let drained = false; + let draining: Promise<{ ok: true } | { ok: false; error: unknown }> | undefined; let flushing: Promise<{ ok: true } | { ok: false; error: unknown }> | undefined; let restoreClock: (() => void) | undefined; try { @@ -1349,6 +1351,10 @@ describe("Responses previous_response_id state", () => { await firstStarted; // Handle rejection immediately, including when a gate/assertion fails before this await. + draining = flushPendingResponseSpillsForTests().then( + () => { drained = true; return { ok: true } as const; }, + (error: unknown) => { drained = true; return { ok: false, error } as const; }, + ); flushing = flushResponseState().then( () => { flushed = true; return { ok: true } as const; }, (error: unknown) => { flushed = true; return { ok: false, error } as const; }, @@ -1359,12 +1365,16 @@ describe("Responses previous_response_id state", () => { await new Promise(resolve => nativeSetImmediate(resolve)); jest.advanceTimersByTime(25); await new Promise(resolve => nativeSetImmediate(resolve)); + // Snapshot I/O after draining must not mask a premature drain return. + expect(drained).toBe(false); expect(flushed).toBe(false); expect(gatedTemps.size).toBe(2); expect(stubSwaps).toBe(1); expect(syncSpillCalls).toBe(0); releaseSecond(); + const drainOutcome = await draining; + if (!drainOutcome.ok) throw drainOutcome.error; const outcome = await flushing; if (!outcome.ok) throw outcome.error; expect(responseStateMetrics()).toMatchObject({ residentCount: 0, spillStubCount: 2 }); @@ -1381,6 +1391,7 @@ describe("Responses previous_response_id state", () => { releaseFirst(); releaseSecond(); try { + await draining; await flushing; await awaitResponseSpillPublicationTailForTests(); await flushPendingResponseSpillsForTests(); From 3ed95a43563de7d0e12b1e0c106d8d6724cb603e Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:41:45 +0900 Subject: [PATCH 049/236] docs: plan Grok catalog and Codex patch stabilization --- .../000_research.md | 17 +++++++++++++++++ .../010_export_selection.md | 10 ++++++++++ .../020_owned_refresh.md | 17 +++++++++++++++++ .../030_responses_patch.md | 13 +++++++++++++ 4 files changed, 57 insertions(+) create mode 100644 devlog/_plan/260906_grok_catalog_and_patch/000_research.md create mode 100644 devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md create mode 100644 devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md create mode 100644 devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md diff --git a/devlog/_plan/260906_grok_catalog_and_patch/000_research.md b/devlog/_plan/260906_grok_catalog_and_patch/000_research.md new file mode 100644 index 0000000000..7b97dc7bee --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/000_research.md @@ -0,0 +1,17 @@ +# Grok catalog selection and Codex patch parity + +Class C3, spec-satisfaction repair. The user clarified that filtering means enabled model visibility in Pi/Aside, not assistant output filtering. No output-filter changes are authorized by this unit. Prior Chat patch fixes must remain effective at every Codex Responses tool completion boundary. + +Scope: export catalog selection, refresh of already-owned Pi/Aside integrations, native Responses custom-tool repair, focused regression tests, matching docs. No live user config changes, deployment, release, unrelated cleanup, local test suites or local typecheck. Push --no-verify and admin merge are explicitly authorized. Probes and hosted CI are authorized. + +Evidence: src/clients/config-export/constants.ts selects openai-completions for Pi; Aside uses the same builder. src/server/management/model-rows.ts:218 filters disabled only. src/cli/opencode.ts:373 likewise ignores selectedModels. src/codex/catalog/provider-fetch.ts:1992 is canonical for allowlist plus disabled and pending selection. src/server/management/model-routes.ts visibility writes converge Codex only. Both explicit sync paths refresh only MCode among owned file integrations. Live read-only snapshot: /v1/models and existing Aside managed file currently contain xai/grok-4.6 only; do not claim that the live snapshot reproduced a full catalog leak. Synthetic allowlist and stale-owned-file scenarios will establish the gaps. + +Independent patch analysis: native custom exec is skipped by repairable||aliased in responses-custom-tool-repair.ts:206. Its item.done/input.done preserve raw patch while response.completed repairs it. Function apply_patch helper aliases can stream raw patch before compiling final JS. Existing arbitrary JS and foreign namespace boundaries stay byte-exact. + +Dependencies: 010 export selection -> 020 owned file convergence -> 030 Codex patch completion parity. Each has its own PABCD and reviewable PR. The patch layer is a separate user-requested stabilization concern published after catalog layers in the requested stack. + +Resource scope: existing GitHub repo credentials, read-only local configuration with no secret output; at most 24 synthetic live-provider calls, each <=120 seconds; six-hour execution window. No explicit token budget. Probe scripts stay ignored .tmp; public notes contain no credentials or private requests. DONE = regression probes, actual hosted exact-head test/typecheck CI, independent review, registered stack merged and fetched-dev ancestry. BLOCKED only for a persistent external dependency; no stopping on CI queueing. Each later P rechecks current source and carries earlier evidence. Escalation: reclaim failed delegated scope; new write delegation requires P amendment. + +## Baseline probes + +`bun .tmp/grok-stabilization/catalog-probe.ts` exit 0: management/CLI × pi/aside each emitted grok-4.3, grok-4.5, grok-4.6 despite selectedModels=[grok-4.6]; full management roster was three. `bun .tmp/grok-stabilization/patch-probe.ts` exit 0: native custom exec emitted one raw delta, input.done uncompiled and item.done uncompiled; function apply_patch alias emitted one raw preview despite compiled final. Both are observation probes before repair, not passing acceptance assertions. `python3 .tmp/grok-stabilization/verify-roadmap.py` exit 0 checks numbered roadmap artifacts and actual existing source target paths. No local suite or typecheck was run. diff --git a/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md b/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md new file mode 100644 index 0000000000..a34b3184b6 --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md @@ -0,0 +1,10 @@ +# 010 Export catalog visibility + +Loop: spec-satisfaction repair; trigger: selectedModels ignored by export projection. Goal: Pi/Aside export obeys the same provider allowlist, blocklist, pending selection as routed catalog. No change to full management catalog or routing authorization. + +MODIFY src/server/management/model-rows.ts: import filterCatalogVisibleModels. In loadExportModels compute visible routed row identities from filterCatalogVisibleModels(rows.filter(row => !row.native), config); return only !row.disabled and (row.native || visible set contains row), then map toExportModel. Preserve native visibility semantics. +MODIFY src/cli/opencode.ts: use same canonical filter once for rows with non-native provider/id identity, then exclude those not retained before seen.add. Do not infer provider identities for legacy rows missing them; keep existing disabled and Direct-native checks. Preserve order, custom/combo aliases and per-row metadata; do not duplicate allowlist matching. +MODIFY tests/server/management-client-config-route.test.ts and tests/cli/cli-export-command.test.ts: fixtures with xai selectedModels=[grok-4.6], full three-model roster, blocklist override, empty allowlist, slash-bearing ids, disabled duplicate. Render both pi and aside through production loader and CLI projection. Expect only selected ids; management still offers all ids. +MODIFY docs-site/src/content/docs/guides/integrations.md: explain selected list applies to generated catalogs. + +Verifier: standalone synthetic imports of loadExportModels/exportModelsFromProxyRows plus Pi/Aside serializers; no bun:test. CI runs existing focused regressions, typecheck and full platform suite. Before C record exact source SHA and probe output. Stop after export boundaries agree; next cycle refreshes old owned files. diff --git a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md new file mode 100644 index 0000000000..83f13e0879 --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md @@ -0,0 +1,17 @@ +# 020 Converge already-owned Pi/Aside catalogs + +Depends on 010 filtered loader. Loop spec-satisfaction repair. Goal: a model visibility/selection change and explicit sync refresh existing connected Pi/Aside files. No adoption of unowned/manual files, no recreation of removed blocks, no override of drift. + +NEW src/integrations/catalog-refresh.ts: bounded helper refreshOwnedCatalogIntegrations(input) iterates explicit client list [pi, aside] (sync may include mcode), passes lazy cached models loader to refreshOwnedIntegration, catches per-client errors and returns existing outcome shape. Use existing ownership store, mutation flight and coordinated writer; never bypass fingerprints. +MODIFY src/server/management/model-routes.ts: local async convergence helper calls existing convergeCodexCatalog then new owned refresh for pi/aside with port from URL/config and lazy loadExportModels(config); attach clientIntegrations outcome to disabled-models, model-visibility, selected-models and model-preset writes. Keep successful config persistence even when one file refuses refresh; return warning outcome. +MODIFY src/server/management/config-routes.ts and src/cli/dispatch.ts: expand current MCode-only owned refresh to mcode/pi/aside via helper; preserve native Grok/Desktop gates and refused-sync behavior. +MODIFY existing tests/clients/sync-client-integrations.test.ts and tests/server/management-integration-routes.test.ts: fake IO/store or isolated home seeds owned pi/aside with two models, refresh with selected one, assert hidden row removed and other provider fields preserved. Prove unowned, removed and drifted configs untouched; one failure does not block other client. Add route-driven visibility refresh coverage using injected convergence. +UPDATE structure/09_client-integrations.md and owning docs page with ownership/refusal semantics. + +Verification: standalone isolated writer probe using synthetic models and temp homes, then exact-head hosted CI. C4 care for automatic owned-file writes: independent review must confirm ownership/no-clobber and per-client failure boundaries. Final enforcement is existing coordinated writer; refresh helper is an early caller, not a permission boundary. Known bypass: manually calling writer with explicit adoption; no such call in this unit. Stop when file projection converges or produces truthful refusal. + +## Audit amendment: overlapping refreshes + +The existing constant refresh mutation-flight key incorrectly joins different model selections. MODIFY src/integrations/owned-refresh.ts to use a unique per-refresh operation key (crypto.randomUUID), making overlapping refreshes explicitly busy rather than reporting another desired catalog as success. Implicit refresh never joins an explicit HTTP mutation. Add controlled overlap with distinct old/new rosters: second call reports integration_mutation_busy; first result describes only its own write. Subsequent retry applies the new roster. Return per-client failures; never retry stale snapshots automatically. + +Add a ManagementApiDeps refreshOwnedCatalogIntegrations seam for route verification, defaulting to the real helper. Creation: exported helper/deps type; consumption: model routes and explicit sync. No serialization/deserialization: runtime-only dependency injection. Tests use fake IO/store or temporary home, never actual user-owned files. diff --git a/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md b/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md new file mode 100644 index 0000000000..84743c0fa9 --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md @@ -0,0 +1,13 @@ +# 030 Codex native Responses patch completion parity + +Depends on recorded export layers for stack delivery; runtime independent. Class C3, spec-satisfaction repair. Goal: same repaired executable input at deltas/input.done/item.done/response.completed for complete patches misrouted as exec. No arbitrary JavaScript rewriting. + +MODIFY src/server/responses-custom-tool-repair.ts: register same-name routed custom calls in addition to aliases. Track original wire name and target; hold custom input deltas when code-mode exec may be a patch envelope or when helper alias requires compilation. Accumulate under TranslatorBudget, release on done/dispose. Run restoreRoutedCustomCalls for same-name custom items, and use existing resolveCodeModeHelperName/compileCodeModeHelperInput at input.done. Do not place exec in repairNames. Preserve ordinary JavaScript streaming where monotonic; once raw prefix would diverge, withhold to authoritative completion. Suppress function helper-alias progressive previews rather than emitting raw patch before compiled JS. +MODIFY tests/responses/responses-custom-tool-repair.test.ts: native custom exec raw/wrapped complete patch, fragmented marker, input.done and output_item.done plus terminal snapshots; function apply_patch wrapper alias; invalid/incomplete envelopes and valid JS remain exact; flat catalogs and foreign namespaces do not retarget; cancellation frees retained buffers. +UPDATE existing patch compatibility docs and structure/11_compatibility-contracts.md to describe completion-boundary parity. + +Verifier: pure standalone synthetic SSE-block imports, compare outputs at each lifecycle edge and execute generated JS against a recording tools.apply_patch stub (no filesystem writes). Probe must assert monotonic preview or held preview, one call, exact canonical patch data. CI runs added regressions and existing bridge/native compatibility tests plus full suite/typecheck. Complete only after independent review and exact-head CI; register requested stack and admin merge after verified heads. Fetch dev and prove every merge SHA ancestor. D records parity inventory and public PR links. + +## Audit amendment + +Executable repair is limited to authorized code-mode exec and recognized helper aliases; unrelated same-name native custom tools keep raw input byte-for-byte. Explicit negative: render_diagram input JSON string {"input":"literal"} is not unwrapped. Separate scenarios cover missing input.done, terminal-only completion, failed/incomplete after held deltas, and disposal. Authoritative completion wins over previews. Failure never synthesizes successful completion. All retained buffers release. One simulated execution means choose the client-consumed completed item once, not execute every redundant lifecycle representation. From b8010aebdc8dbd0a972a368260dc59dbfa26c94f Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:43:02 +0900 Subject: [PATCH 050/236] fix(clients): honor provider selections in exported catalogs --- .../src/content/docs/guides/integrations.md | 4 +++ src/cli/opencode.ts | 7 +++- src/server/management/model-rows.ts | 6 +++- structure/09_client-integrations.md | 7 ++++ tests/cli/cli-export-command.test.ts | 32 +++++++++++++++++++ .../management-client-config-route.test.ts | 31 +++++++++++++++++- 6 files changed, 84 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index da1273ad9a..8f41b22fec 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -21,6 +21,10 @@ file, and removes it again. Twelve clients work this way, each with a switch: | ZCode | `~/.zcode/v2/config.json` | JSON | on restart | loopback placeholder | | Aside | `~/.aside/u//models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder | +Generated catalogs include only enabled models from each provider selection. This applies to both +downloads and managed integrations, including Pi and Aside. The management model list still shows +the full roster so you can enable additional models. + The managed OpenCode integration owns two fragments: `provider.opencodex` (opencode V1) and `providers.opencodex` (opencode V2). Only the V2 block carries the per-model reasoning-effort variants, so both are written and kept in sync; they name the same provider and model ids, and diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index d56f5745e5..9e4ed8246c 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -39,7 +39,7 @@ import type { OpencodeProviderBlocks, OpencodeV2ProviderBlock, } from "../clients/config-export"; -import { visibleNativeSlugs } from "../codex/catalog"; +import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog"; import { commandInvocation } from "../lib/win-exec"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; import { providerCodexAccountMode } from "../providers/registry"; @@ -376,12 +376,17 @@ export function opencodeCatalogFromProxyRows( config: OcxConfig, ): OpencodeCatalogModel[] { const omitNative = providerCodexAccountMode("openai", config.providers?.openai) === "direct"; + const routedRows = rows.filter((row): row is OpencodeProxyModelRow & { provider: string; id: string } => + row.native !== true && typeof row.provider === "string" && typeof row.id === "string"); + const visibleRouted = new Set(filterCatalogVisibleModels(routedRows, config)); const seen = new Set(); const catalog: OpencodeCatalogModel[] = []; for (const row of rows) { const namespaced = row.namespaced?.trim(); if (!namespaced || row.disabled === true) continue; if (omitNative && row.native === true) continue; + if (row.native !== true && typeof row.provider === "string" && typeof row.id === "string" + && !visibleRouted.has(row)) continue; if (seen.has(namespaced)) continue; seen.add(namespaced); catalog.push({ diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 4635a9fbfd..6d9ec08853 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -11,6 +11,7 @@ import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, + filterCatalogVisibleModels, accountBoundNativeOpenAiSlugsBySelector, nativeDefaultReasoningEffort, NATIVE_OPENAI_MODELS, @@ -217,5 +218,8 @@ export function toExportModel(row: ManagementModelRow): ExportModel { */ export async function loadExportModels(config: OcxConfig): Promise { const rows = await listManagementModelRows(config); - return rows.filter(row => !row.disabled).map(toExportModel); + // Management deliberately lists the full roster so hidden models can be enabled. + // A client picker must also honor the provider selection, not just its blocklist. + const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), config)); + return rows.filter(row => !row.disabled && (row.native || visibleRouted.has(row))).map(toExportModel); } diff --git a/structure/09_client-integrations.md b/structure/09_client-integrations.md index 2c2e42b419..4132d7a60a 100644 --- a/structure/09_client-integrations.md +++ b/structure/09_client-integrations.md @@ -36,6 +36,13 @@ Status and mutation must use the same classifier. A special case added only to a would be misleading because refresh or disable could still reject the same file; a special case added only to a writer would let a mutation bypass the state users saw. +## Catalog visibility + +Management export and CLI export apply the canonical routed catalog visibility filter before +serialization: provider selections, disabled models, and pending initial selection all constrain +the client roster. The full management list remains available for selection. Native rows retain +their existing visibility rules. + ## Fast model selectors The serving proxy resolves `fastRowAvailable` on every management model row, including its diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts index 28fce31d44..3e8fcbfcfd 100644 --- a/tests/cli/cli-export-command.test.ts +++ b/tests/cli/cli-export-command.test.ts @@ -430,3 +430,35 @@ describe("export row filtering", () => { expect(model?.defaultReasoningEffort).toBe("high"); }); }); + +describe("export allowlist parity", () => { + test("filters the full management roster before deduplication and keeps other providers", () => { + const cfg = config(); + cfg.providers.xai = { + adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", + selectedModels: ["grok-4.6"], + }; + const rows = [ + { provider: "xai", id: "grok-4.5", namespaced: "xai/grok-4.5", disabled: false }, + { provider: "xai", id: "grok-4.6", namespaced: "xai/grok-4.6", disabled: true }, + { provider: "xai", id: "grok-4.6", namespaced: "xai/grok-4.6", reasoningEfforts: ["high"] }, + { provider: "other", id: "model", namespaced: "other/model" }, + ]; + const exported = exportModelsFromProxyRows(rows, cfg); + expect(exported.map(row => row.namespaced)).toEqual(["xai/grok-4.6", "other/model"]); + expect(exported[0]!.reasoningEfforts).toEqual(["high"]); + cfg.disabledModels = ["xai/grok-4.6"]; + expect(exportModelsFromProxyRows(rows, cfg).map(row => row.namespaced)).toEqual(["other/model"]); + }); + + test("uses the catalog's encoded-id selection equivalence", () => { + const cfg = config(); + cfg.providers.slash = { + adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", selectedModels: ["org-model"], + }; + expect(exportModelsFromProxyRows([ + { provider: "slash", id: "org/model", namespaced: "slash/org-model" }, + { provider: "slash", id: "other", namespaced: "slash/other" }, + ], cfg).map(row => row.namespaced)).toEqual(["slash/org-model"]); + }); +}); diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index 1f3f2b2908..7ed7fd17fe 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -8,7 +8,7 @@ import { seedCodexModelEntitlementsForTests, } from "../../src/codex/model-entitlements"; import { handleManagementAPI } from "../../src/server/management-api"; -import { loadExportModels } from "../../src/server/management/model-rows"; +import { listManagementModelRows, loadExportModels } from "../../src/server/management/model-rows"; import { OPENCODE_API_KEY_ENV, OPENCODE_CONFIG_SCHEMA, @@ -645,3 +645,32 @@ describe("default Fast availability reaches external exports", () => { expect(result.providers.opencodex.models.map(model => model.id)).not.toContain("fixture/m--fast"); }); }); + +describe("Pi and Aside provider selection", () => { + test.each(["pi", "aside"] as const)("%s exports selected Grok models while management retains the full roster", async client => { + const config = baseConfig({ + fastRows: false, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "key", + liveModels: false, models: ["grok-4.6", "grok-4.5", "grok-4.3"], + selectedModels: ["grok-4.6"], + }, + }, + }); + const ids = async () => { + const models = await loadExportModels(config); + const doc = buildClientConfig(client, { baseUrl: "http://127.0.0.1:10100/v1", config, models }) as PiGeneratedConfig; + return doc.providers.opencodex!.models.map(model => model.id).filter(id => id.startsWith("xai/")); + }; + const management = await listManagementModelRows(config); + expect(management.filter(row => row.provider === "xai")).toHaveLength(3); + expect(await ids()).toEqual(["xai/grok-4.6"]); + config.disabledModels = ["xai/grok-4.6"]; + expect(await ids()).toEqual([]); + config.disabledModels = []; + config.providers.xai!.selectedModels = []; + expect(await ids()).toEqual(["xai/grok-4.3", "xai/grok-4.5", "xai/grok-4.6"]); + }); +}); From fef024a69cfbe735d3ce0a6d33e65e911461bd2d Mon Sep 17 00:00:00 2001 From: Hako <25837994+devswha@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:42:15 +0900 Subject: [PATCH 051/236] fix(adapters): retain late chat tool-call index aliases (cherry picked from commit c8240c51d664f7cfb790b6d60679adfe0490b5c9) --- .../src/content/docs/reference/adapters.md | 5 + src/adapters/openai-chat.ts | 15 ++- .../openai-chat-parallel-stream.test.ts | 126 +++++++++++++++++- 3 files changed, 135 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 1db98357d3..930c4d8d15 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -49,6 +49,11 @@ provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local), and mor tiers, accepts reasoning deltas from either `delta.reasoning_content` or `delta.reasoning`, requests streamed usage with `stream_options.include_usage`, and reads usage from non-stream response envelopes. +Streaming tool calls retain their identity when a provider first sends an ID, +then associates that ID with an index, and later sends index-only argument +fragments. Those fragments assemble into one call with the original name and +complete arguments; parallel calls retain separate identities. + ## `ollama-native` **Targets:** Ollama's own **Chat API** (`POST /api/chat`) rather than its OpenAI-compatible diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 8b7d9c8614..2620ce36f6 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1660,6 +1660,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd let bufferBytes = 0; interface PendingToolCall { key: string; + indexKey?: string; id: string; name: string; args: string; @@ -1853,12 +1854,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // streamers repeat an already-sent field as a non-string placeholder on a // continuation delta; judging first meant the whole stream died with a 502 even // though the value being repeated was already held in canonical form. - const key = typeof rawIndex === "number" - ? `i:${rawIndex}` - : idDelta - ? `id:${idDelta}` - : pendingToolCalls[pendingToolCalls.length - 1]?.key; + const indexKey = typeof rawIndex === "number" ? `i:${rawIndex}` : undefined; + const key = indexKey ?? (idDelta + ? `id:${idDelta}` + : pendingToolCalls[pendingToolCalls.length - 1]?.key); let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined; + if (!call && indexKey !== undefined) call = pendingToolCalls.find(c => c.indexKey === indexKey); if (!call && idDelta) call = pendingToolCalls.find(c => c.id === idDelta); if (!call) { call = { @@ -1872,6 +1873,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd pendingToolCalls.push(call); budget.openCall(call.key); } + // An ID-only call may learn its index from a later ID+index fragment. Retain that + // alias without changing the key that owns its argument budget. Only the first + // observed index binds: a repeated ID on a different index must not alias both. + if (indexKey !== undefined && call.indexKey === undefined) call.indexKey = indexKey; // Tolerance is per FIELD, keyed on that field's own provenance. A canonical name // says nothing about whether `arguments` was ever sent as a string, so it cannot diff --git a/tests/adapters/openai/openai-chat-parallel-stream.test.ts b/tests/adapters/openai/openai-chat-parallel-stream.test.ts index 238faa9082..ec1e3d55cd 100644 --- a/tests/adapters/openai/openai-chat-parallel-stream.test.ts +++ b/tests/adapters/openai/openai-chat-parallel-stream.test.ts @@ -1,16 +1,17 @@ import { describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../../../src/adapters/openai-chat"; +import type { TranslatorBudget } from "../../../src/lib/translator-budget"; import type { AdapterEvent } from "../../../src/types"; -import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "../../helpers/translator-budget"; const createOpenAIChatAdapter = (...args: Parameters) => withTestTranslatorBudget(createOpenAIChatAdapterProduction(...args)); const provider = { adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "key" }; -async function collect(body: string): Promise { +async function collect(body: string, budget?: TranslatorBudget): Promise { const out: AdapterEvent[] = []; - for await (const e of createOpenAIChatAdapter(provider).parseStream(new Response(body))) out.push(e); + for await (const e of createOpenAIChatAdapter(provider).parseStream(new Response(body), budget)) out.push(e); return out; } @@ -211,12 +212,125 @@ describe("openai-chat parallel tool call stream assembly", () => { expect(assembled(events)).toEqual([{ id: "call_a", name: "shell", args: "{\"cmd\":\"ls\"}" }]); }); - test("T9b: id-only first chunk followed by index+id continuation stays ONE call", async () => { + test("T9b: id-only call retains a later index for index-only continuation", async () => { + const budget = createTestTranslatorBudget(); const events = await collect(sse([ chunkOf([{ id: "call_b", function: { name: "read", arguments: "{\"p\"" } }]), - chunkOf([{ index: 0, id: "call_b", function: { arguments: ":\"x\"}" } }]), + chunkOf([{ index: 0, id: "call_b", function: { arguments: ":\"x\"" } }]), + chunkOf([{ index: 0, function: { arguments: "}" } }]), chunkOf([], "tool_calls"), - ])); + ]), budget); expect(assembled(events)).toEqual([{ id: "call_b", name: "read", args: "{\"p\":\"x\"}" }]); + expect(events.at(-1)?.type).toBe("done"); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + + test("late indexes keep interleaved calls separate without adding budget owners", async () => { + const budget = createTestTranslatorBudget(); + const response = new Response(sse([ + chunkOf([ + { id: "call_a", function: { name: "read", arguments: "{\"p\":" } }, + { id: "call_b", function: { name: "write", arguments: "{\"p\":" } }, + ]), + chunkOf([{ index: 9, id: "call_b", function: { arguments: "\"b\"" } }]), + chunkOf([{ index: 4, id: "call_a", function: { arguments: "\"a\"" } }]), + chunkOf([{ index: 9, function: { arguments: "}" } }]), + chunkOf([{ index: 4, function: { arguments: "}" } }]), + chunkOf([{ id: "call_a", function: { arguments: " " } }]), + chunkOf([], "tool_calls"), + ])); + const events: AdapterEvent[] = []; + let maxActiveCalls = 0; + for await (const event of createOpenAIChatAdapter(provider).parseStream(response, budget)) { + events.push(event); + maxActiveCalls = Math.max(maxActiveCalls, budget.snapshot().activeCalls); + } + expect(assembled(events)).toEqual([ + { id: "call_a", name: "read", args: "{\"p\":\"a\"} " }, + { id: "call_b", name: "write", args: "{\"p\":\"b\"}" }, + ]); + expect(events.at(-1)?.type).toBe("done"); + expect(maxActiveCalls).toBe(2); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + + test("index-only fragments do not guess an association between unindexed calls", async () => { + const events = await collect(sse([ + chunkOf([ + { id: "call_a", function: { name: "read", arguments: "{\"p\":" } }, + { id: "call_b", function: { name: "write", arguments: "{\"p\":" } }, + ]), + chunkOf([{ index: 0, function: { arguments: "\"a\"}" } }]), + chunkOf([{ index: 1, function: { arguments: "\"b\"}" } }]), + chunkOf([], "tool_calls"), + ])); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(event => event.type === "done")).toBe(false); + }); + + test("an observed index wins over a conflicting ID without rebinding either call", async () => { + const events = await collect(sse([ + chunkOf([{ id: "call_a", function: { name: "read", arguments: "{\"p\":" } }]), + chunkOf([{ index: 1, id: "call_b", function: { name: "write", arguments: "{\"p\":" } }]), + chunkOf([{ index: 0, id: "call_a", function: { arguments: "\"a\"" } }]), + chunkOf([{ index: 1, id: "call_a", function: { arguments: "\"b\"}" } }]), + chunkOf([{ index: 0, id: "call_b", function: { arguments: "}" } }]), + chunkOf([{ index: 0, function: { arguments: " " } }]), + chunkOf([], "tool_calls"), + ])); + expect(assembled(events)).toEqual([ + { id: "call_a", name: "read", args: "{\"p\":\"a\"} " }, + { id: "call_b", name: "write", args: "{\"p\":\"b\"}" }, + ]); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("duplicate IDs on established indexed calls keep first-match ID fallback", async () => { + const events = await collect(sse([ + chunkOf([ + { index: 0, function: { name: "read", arguments: "{\"p\":" } }, + { index: 1, function: { name: "write", arguments: "{\"p\":" } }, + ]), + chunkOf([{ index: 0, id: "shared", function: { arguments: "\"a\"" } }]), + chunkOf([{ index: 1, id: "shared", function: { arguments: "\"b\"" } }]), + chunkOf([{ id: "shared", function: { arguments: "}" } }]), + chunkOf([{ index: 1, function: { arguments: "}" } }]), + chunkOf([], "tool_calls"), + ])); + expect(assembled(events)).toEqual([ + { id: "shared", name: "read", args: "{\"p\":\"a\"}" }, + { id: "shared", name: "write", args: "{\"p\":\"b\"}" }, + ]); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("a repeated ID on a different index does not replace the first observed alias", async () => { + const events = await collect(sse([ + chunkOf([{ id: "call_a", function: { name: "read", arguments: "{\"p\":" } }]), + chunkOf([{ index: 0, id: "call_a", function: { arguments: "\"a\"" } }]), + chunkOf([{ index: 1, id: "call_a", function: { arguments: "" } }]), + chunkOf([{ index: 0, function: { arguments: "}" } }]), + chunkOf([], "tool_calls"), + ])); + expect(assembled(events)).toEqual([{ id: "call_a", name: "read", args: "{\"p\":\"a\"}" }]); + expect(events.at(-1)?.type).toBe("done"); + }); + + test.each([9, 10])("late index preserves a %i-byte argument limit across all fragments", async limit => { + const budget = createTestTranslatorBudget({ maxCallArgumentBytes: limit }); + const events = await collect(sse([ + chunkOf([{ id: "call_a", function: { name: "read", arguments: "{\"p\":" } }]), + chunkOf([{ index: 0, id: "call_a", function: { arguments: "\"é\"" } }]), + chunkOf([{ index: 0, function: { arguments: "}" } }]), + chunkOf([], "tool_calls"), + ]), budget); + if (limit === 9) { + expect(events.at(-1)).toMatchObject({ type: "error", code: "translation_buffer_limit" }); + expect(events.some(event => event.type === "tool_call_start")).toBe(false); + } else { + expect(assembled(events)).toEqual([{ id: "call_a", name: "read", args: "{\"p\":\"é\"}" }]); + expect(events.at(-1)?.type).toBe("done"); + } + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: limit === 9 ? 1 : 0 }); }); }); From 6d93d0abe3465351559c3efa9dc7a70b23fae70e Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:50:30 +0900 Subject: [PATCH 052/236] fix(clients): refresh owned Pi and Aside model selections --- .../020_owned_refresh.md | 6 + .../src/content/docs/guides/integrations.md | 9 +- src/cli/dispatch.ts | 17 +- src/integrations/catalog-refresh.ts | 32 +++ src/integrations/owned-refresh.ts | 4 +- src/server/management/config-routes.ts | 36 +-- src/server/management/context.ts | 3 + src/server/management/model-routes.ts | 24 +- structure/09_client-integrations.md | 9 + .../clients/sync-client-integrations.test.ts | 225 +++++++++++++++++- .../management-client-config-route.test.ts | 35 +++ 11 files changed, 344 insertions(+), 56 deletions(-) create mode 100644 src/integrations/catalog-refresh.ts diff --git a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md index 83f13e0879..26909e5427 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md @@ -15,3 +15,9 @@ Verification: standalone isolated writer probe using synthetic models and temp h The existing constant refresh mutation-flight key incorrectly joins different model selections. MODIFY src/integrations/owned-refresh.ts to use a unique per-refresh operation key (crypto.randomUUID), making overlapping refreshes explicitly busy rather than reporting another desired catalog as success. Implicit refresh never joins an explicit HTTP mutation. Add controlled overlap with distinct old/new rosters: second call reports integration_mutation_busy; first result describes only its own write. Subsequent retry applies the new roster. Return per-client failures; never retry stale snapshots automatically. Add a ManagementApiDeps refreshOwnedCatalogIntegrations seam for route verification, defaulting to the real helper. Creation: exported helper/deps type; consumption: model routes and explicit sync. No serialization/deserialization: runtime-only dependency injection. Tests use fake IO/store or temporary home, never actual user-owned files. + +## P revalidation and implementation interface + +010 b8010aebd passes the four standalone visibility probes and source review; all original hosted-CI/merge criteria are retained under the terminal stack cycle, not marked complete. Helper signature: refreshOwnedCatalogIntegrations(input: Omit, clientIds: readonly IntegrationClientId[] = ["pi", "aside"]): Promise. Memoize the lazy model load per fan-out; no owned record means no catalog load. Catch and redact each failure. Explicit sync passes [mcode,pi,aside]. Visibility routes attach both catalogRefresh and clientIntegrations; native Codex failure does not undo an already persisted selection. + +Delegate tests only to one worker: tests/clients/sync-client-integrations.test.ts owns helper refresh+overlap coverage; main owns implementation and route regression tests. The worker has no production writes, suite execution, FSM or git mutations. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 8f41b22fec..fcfcd366b0 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -213,9 +213,12 @@ ocx integration client enable --client mcode ocx mcode ``` -Once connected, `ocx sync` also refreshes the owned MCode block with current context -windows and reasoning-effort ladders. It leaves missing, foreign-edited, unsafe, and -never-owned blocks untouched; re-enable explicitly when you intend to reconnect one. +Once connected, `ocx sync` refreshes owned MCode, Pi, and Aside catalogs with the current +model selection, context windows, and reasoning-effort ladders. Changes to model visibility, +provider selection, or presets also refresh connected Pi and Aside catalogs. Missing, +foreign-edited, unsafe, and never-owned blocks stay untouched; reconnect them explicitly. +A refused or overlapping refresh is reported separately for each client. Start a new Pi +session or fully quit and reopen Aside to load the updated file. The separate MiniMax platform CLI (`mmx`) is not a file-toggle integration. Its text commands use MiniMax's Anthropic-compatible endpoint, so OpenCodex provides a diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 48a1f44be3..6fef425ad5 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -387,25 +387,26 @@ const commandRunners: Record = { if (restartDesktopApp) await handleDesktopAppRestart(console); } // `ocx sync` is a direct CLI path; it does not call the management - // `/api/sync` route. Refresh the already-connected MCode block here too, + // `/api/sync` route. Refresh already-connected file integrations here too, // after Codex has published the catalog that supplies its capabilities. if (synced.status !== "refused" && live) { try { const config = deps.loadConfig(); - const { refreshOwnedIntegration } = await import("../integrations/owned-refresh"); - const result = await refreshOwnedIntegration({ - clientId: "mcode", + const { refreshOwnedCatalogIntegrations } = await import("../integrations/catalog-refresh"); + const results = await refreshOwnedCatalogIntegrations({ models: async () => { const { loadExportModels } = await import("../server/management/model-rows"); return loadExportModels(config); }, config, port: live.port, - }); - if (result?.changed) console.log("MCode integration refreshed from the current catalog."); - else if (result?.reason) console.warn(`MCode integration was not refreshed: ${result.reason}`); + }, ["mcode", "pi", "aside"]); + for (const result of results) { + if (result.changed) console.log(`${result.client} integration refreshed from the current catalog.`); + else if (result.reason) console.warn(`${result.client} integration was not refreshed: ${result.reason}`); + } } catch (error) { - console.warn(`MCode integration was not refreshed: ${error instanceof Error ? error.message : String(error)}`); + console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } } return code; diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts new file mode 100644 index 0000000000..6ace895e00 --- /dev/null +++ b/src/integrations/catalog-refresh.ts @@ -0,0 +1,32 @@ +import { redactSecretString } from "../lib/redact"; +import type { ExportModel } from "../clients/config-export"; +import type { IntegrationClientId } from "./registry"; +import { + refreshOwnedIntegration, + type OwnedIntegrationRefreshInput, + type OwnedIntegrationRefreshOutcome, +} from "./owned-refresh"; + +/** Refresh only previously connected clients; a refused file never blocks its peers. */ +export async function refreshOwnedCatalogIntegrations( + input: Omit, + clientIds: readonly IntegrationClientId[] = ["pi", "aside"], +): Promise { + let models: Promise | undefined; + const loadModels = () => models ??= Promise.resolve().then(() => + typeof input.models === "function" ? input.models() : input.models); + const outcomes: OwnedIntegrationRefreshOutcome[] = []; + for (const clientId of clientIds) { + try { + const result = await refreshOwnedIntegration({ ...input, clientId, models: loadModels }); + if (result) outcomes.push(result); + } catch (error) { + outcomes.push({ + client: clientId, + ok: false, + reason: redactSecretString(error instanceof Error ? error.message : String(error)), + }); + } + } + return outcomes; +} diff --git a/src/integrations/owned-refresh.ts b/src/integrations/owned-refresh.ts index 9af6ad5e3c..6167c1aa35 100644 --- a/src/integrations/owned-refresh.ts +++ b/src/integrations/owned-refresh.ts @@ -59,7 +59,9 @@ export async function refreshOwnedIntegration( const bound = { ...rest, models, store }; const result = await runIntegrationMutationFlight( input.clientId, - "refresh", + // Separate catalog snapshots must not inherit another refresh's success. + // The shared flight owner returns busy for overlapping operations instead. + `refresh:${crypto.randomUUID()}`, input.io?.now ?? Date.now, () => refreshIntegrationCoordinated(bound, options), ); diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 885fe10408..cb226314ea 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -1,3 +1,4 @@ +import type { IntegrationClientId } from "../../integrations/registry"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; @@ -151,7 +152,7 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{ /** One client's outcome from a fan-out sync. Absent from the list means "left alone". */ interface ClientIntegrationSyncOutcome { - readonly client: "grok" | "claude-desktop" | "mcode"; + readonly client: "grok" | "claude-desktop" | IntegrationClientId; readonly ok: boolean; readonly changed?: boolean; readonly reason?: string; @@ -215,30 +216,15 @@ async function syncEnabledClientIntegrations( } } - try { - const { refreshOwnedIntegration } = await import("../../integrations/owned-refresh"); - const result = await refreshOwnedIntegration({ - clientId: "mcode", - models: async () => { - const { loadExportModels } = await import("./model-rows"); - return loadExportModels(config); - }, - config, - port, - }); - if (result) { - out.push(result.ok - ? { - client: "mcode", - ok: true, - changed: result.changed === true, - ...(result.reason ? { reason: result.reason } : {}), - } - : { client: "mcode", ok: false, reason: result.reason }); - } - } catch (error) { - out.push({ client: "mcode", ok: false, reason: error instanceof Error ? error.message : String(error) }); - } + const { refreshOwnedCatalogIntegrations } = await import("../../integrations/catalog-refresh"); + out.push(...await refreshOwnedCatalogIntegrations({ + models: async () => { + const { loadExportModels } = await import("./model-rows"); + return loadExportModels(config); + }, + config, + port, + }, ["mcode", "pi", "aside"])); return out; } diff --git a/src/server/management/context.ts b/src/server/management/context.ts index 13c922e575..f516dc00d9 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -6,6 +6,7 @@ import type { StartupHealth } from "../../codex/autostart-health"; import type { StartupInstallAction } from "../startup-action-control"; import type { ManagementPrincipal, ManagementSessionControl } from "../management-auth"; import type { CatalogModel } from "../../codex/catalog"; +import type { refreshOwnedCatalogIntegrations } from "../../integrations/catalog-refresh"; import type { Paths as CodexPromptPaths } from "../../codex/prompt-layers"; import type { injectGrokConfig } from "../../grok/inject"; import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; @@ -20,6 +21,8 @@ import type { } from "../../codex/app-server-restart-service"; export interface ManagementApiDeps { + /** Isolates automatic owned-client writes in route tests. */ + refreshOwnedCatalogIntegrations?: typeof refreshOwnedCatalogIntegrations; /** Platform seam for capability projections; does not alter host-level startup behavior. */ platform?: NodeJS.Platform; toggleCodexMultiAgentV2?: (enabled: boolean) => void; diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index c3e9d58cf9..28d1bef0ec 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -183,6 +183,17 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { + const catalogRefresh = await convergeCodexCatalog(); + const refresh = deps.refreshOwnedCatalogIntegrations + ?? (await import("../../integrations/catalog-refresh")).refreshOwnedCatalogIntegrations; + const clientIntegrations = await refresh({ + config, + port: Number(url.port) || config.port, + models: () => loadExportModels(config), + }); + return { catalogRefresh, clientIntegrations }; + }; if (url.pathname === "/api/model-discovery" && req.method === "GET") { const providers = Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => [ @@ -518,8 +529,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise typeof m === "string") : []; config.disabledModels = disabled; persistConfig(config); - const catalogRefresh = await convergeCodexCatalog(); - return jsonResponse({ ok: true, disabled, catalogRefresh }); + return jsonResponse({ ok: true, disabled, ...await convergeVisibleCatalogs() }); } // One user-facing visibility switch spans two persisted filters: a provider allowlist and the @@ -644,8 +654,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { }); }); -test("the direct ocx sync command refreshes MCode instead of relying on /api/sync", async () => { +describe("owned Pi/Aside catalogs follow filtered model selections", () => { + const clients = ["pi", "aside"] as const; + const env: NodeJS.ProcessEnv = {}; + const config = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as OcxConfig; + const oldModels: ExportModel[] = [ + { namespaced: "mock/visible", provider: "mock", id: "visible", contextWindow: 128_000 }, + { namespaced: "mock/hidden", provider: "mock", id: "hidden", contextWindow: 64_000 }, + ]; + const filteredModels = oldModels.slice(0, 1); + const sibling = { baseUrl: "http://user.invalid/v1", models: [{ id: "personal" }] }; + let root: string; + let home: string; + let store: IntegrationStateStore; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-owned-catalog-refresh-")); + home = join(root, "home"); + store = createIntegrationStateStore(join(root, "state", "integrations")); + mkdirSync(join(home, ".aside"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ currentAccountId: 0 })); + for (const client of clients) { + mkdirSync(INTEGRATION_CLIENTS[client].detectDir(env, home), { recursive: true }); + mkdirSync(dirname(INTEGRATION_CLIENTS[client].configPath(env, home)), { recursive: true }); + writeFileSync(INTEGRATION_CLIENTS[client].configPath(env, home), JSON.stringify({ + theme: "dark", providers: { personal: sibling }, + })); + } + }); + + afterEach(() => { + removeTreeWithRetry(root); + }); + + function input(models: readonly ExportModel[] | (() => Promise)) { + return { models, config, port: 10100, env, home, store }; + } + + function document(client: typeof clients[number]) { + return JSON.parse(readFileSync(INTEGRATION_CLIENTS[client].configPath(env, home), "utf8")) as { + theme: string; + providers: { + personal: typeof sibling; + opencodex?: { baseUrl: string; api: string; apiKey: string; models: Array<{ id: string }> }; + }; + }; + } + + test("refreshes both owned catalogs from one lazy load and preserves unrelated settings", async () => { + for (const clientId of clients) { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + expect(document(clientId).providers.opencodex?.models.map(model => model.id)) + .toEqual(["mock/hidden", "mock/visible"]); + } + let loads = 0; + const outcomes = await refreshOwnedCatalogIntegrations(input(async () => { + loads += 1; + return filteredModels; + })); + expect(outcomes).toEqual(clients.map(client => ({ client, ok: true, changed: true }))); + expect(loads).toBe(1); + for (const client of clients) { + expect(document(client)).toMatchObject({ theme: "dark", providers: { personal: sibling } }); + expect(document(client).providers.opencodex).toMatchObject({ + baseUrl: "http://127.0.0.1:10100/v1", api: "openai-completions", apiKey: "opencodex-loopback", + }); + expect(document(client).providers.opencodex?.models.map(model => model.id)).toEqual(["mock/visible"]); + expect(store.listOperations(client).map(row => row.kind)).toEqual(["refresh", "apply"]); + } + }); + + test("never loads or writes unowned manual catalogs", async () => { + const before = JSON.stringify({ providers: { personal: sibling, opencodex: { models: [{ id: "manual" }] } } }); + for (const client of clients) writeFileSync(INTEGRATION_CLIENTS[client].configPath(env, home), before); + let loads = 0; + const outcomes = await refreshOwnedCatalogIntegrations(input(async () => { + loads += 1; + return filteredModels; + })); + expect(outcomes).toEqual([]); + expect(loads).toBe(0); + for (const client of clients) { + expect(readFileSync(INTEGRATION_CLIENTS[client].configPath(env, home), "utf8")).toBe(before); + } + expect(store.readRecords()).toEqual({}); + expect(store.listOperations()).toEqual([]); + expect(existsSync(store.root)).toBe(false); + }); + + test.each(clients)("does not reconnect a removed %s block", async clientId => { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const recordBefore = store.readRecords()[clientId]; + const before = JSON.stringify({ theme: "dark", providers: { personal: sibling } }); + const path = INTEGRATION_CLIENTS[clientId].configPath(env, home); + writeFileSync(path, before); + expect(await refreshOwnedCatalogIntegrations(input(filteredModels))).toEqual([{ + client: clientId, ok: true, changed: false, + reason: "managed block is absent; refresh did not reconnect it", + }]); + expect(readFileSync(path, "utf8")).toBe(before); + expect(store.readRecords()[clientId]).toEqual(recordBefore); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["apply"]); + }); + + test.each(clients)("does not recreate an uninstalled %s client", async clientId => { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const recordBefore = store.readRecords()[clientId]; + const detectDir = INTEGRATION_CLIENTS[clientId].detectDir(env, home); + removeTreeWithRetry(detectDir); + const outcomes = await refreshOwnedCatalogIntegrations(input(filteredModels)); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ client: clientId, ok: false }); + expect(outcomes[0]?.reason).toContain(`${clientId} is not installed`); + expect(existsSync(detectDir)).toBe(false); + expect(store.readRecords()[clientId]).toEqual(recordBefore); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["apply"]); + }); + + test.each(clients)("preserves a drifted %s provider and its ownership record", async clientId => { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const recordBefore = store.readRecords()[clientId]; + const edited = document(clientId); + edited.providers.opencodex!.baseUrl = "http://user-edited.invalid/v1"; + const before = JSON.stringify(edited); + const path = INTEGRATION_CLIENTS[clientId].configPath(env, home); + writeFileSync(path, before); + const outcomes = await refreshOwnedCatalogIntegrations(input(filteredModels)); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ client: clientId, ok: false }); + expect(outcomes[0]?.reason).toContain("changed after opencodex wrote it"); + expect(readFileSync(path, "utf8")).toBe(before); + expect(store.readRecords()[clientId]).toEqual(recordBefore); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["apply"]); + }); + + test("a thrown Pi filesystem error does not prevent the owned Aside refresh", async () => { + for (const clientId of clients) expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const path = INTEGRATION_CLIENTS.pi.configPath(env, home); + const before = readFileSync(path, "utf8"); + const recordBefore = store.readRecords().pi; + const io = store.io(); + const outcomes = await refreshOwnedCatalogIntegrations({ + ...input(filteredModels), + io: { ...io, statKind: candidate => { + if (candidate === path) throw new Error("synthetic Pi stat failure"); + return io.statKind(candidate); + } }, + }); + expect(outcomes).toEqual([ + { client: "pi", ok: false, reason: "synthetic Pi stat failure" }, + { client: "aside", ok: true, changed: true }, + ]); + expect(readFileSync(path, "utf8")).toBe(before); + expect(store.readRecords().pi).toEqual(recordBefore); + expect(store.listOperations("pi").map(row => row.kind)).toEqual(["apply"]); + expect(document("aside").providers.opencodex?.models.map(model => model.id)).toEqual(["mock/visible"]); + expect(store.listOperations("aside").map(row => row.kind)).toEqual(["refresh", "apply"]); + }); + + test.each(clients)("overlapping %s selections report busy and a later retry applies the new roster", async clientId => { + expect(applyIntegration({ ...input(oldModels), clientId }).ok).toBe(true); + const nextModels = oldModels.slice(1); + let release!: () => void; + let observeFirst!: () => void; + let observeSecond!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { observeFirst = resolve; }); + const contended = new Promise(resolve => { observeSecond = resolve; }); + setIntegrationMutationFlightTestHook(async operation => { + observeFirst(); + await gate; + return operation(); + }); + const first = refreshOwnedCatalogIntegrations(input(filteredModels), [clientId]); + let second: ReturnType | undefined; + try { + await started; + second = refreshOwnedCatalogIntegrations({ + ...input(nextModels), + io: { ...store.io(), now: () => { observeSecond(); return Date.now(); } }, + }, [clientId]); + await contended; + release(); + expect(await first).toEqual([{ client: clientId, ok: true, changed: true }]); + expect(await second).toEqual([{ client: clientId, ok: false, reason: "integration_mutation_busy" }]); + expect(document(clientId).providers.opencodex?.models.map(model => model.id)).toEqual(["mock/visible"]); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["refresh", "apply"]); + } finally { + release(); + await Promise.allSettled([first, ...(second ? [second] : [])]); + setIntegrationMutationFlightTestHook(null); + } + expect(await refreshOwnedCatalogIntegrations(input(nextModels), [clientId])) + .toEqual([{ client: clientId, ok: true, changed: true }]); + expect(document(clientId).providers.opencodex?.models.map(model => model.id)).toEqual(["mock/hidden"]); + expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["refresh", "refresh", "apply"]); + }); +}); + +test("the direct ocx sync command refreshes MCode, Pi and Aside instead of relying on /api/sync", async () => { const src = await Bun.file(new URL("../../src/cli/dispatch.ts", import.meta.url)).text(); const start = src.indexOf("sync: async deps =>"); const command = src.slice(start, src.indexOf("v2: async deps =>", start)); - expect(command).toContain("refreshOwnedIntegration"); - expect(command).toContain('clientId: "mcode"'); - expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedIntegration")); + expect(command).toContain("refreshOwnedCatalogIntegrations"); + expect(command).toContain('["mcode", "pi", "aside"]'); + expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations")); expect(command).toContain('synced.status !== "refused"'); }); -test("refresh joins refresh but cannot swallow an explicit apply or disable", async () => { +test("identical explicit mutation keys join but cannot swallow a different apply or disable", async () => { let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); let refreshRuns = 0; diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index 7ed7fd17fe..d3d91aebdf 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -674,3 +674,38 @@ describe("Pi and Aside provider selection", () => { expect(await ids()).toEqual(["xai/grok-4.3", "xai/grok-4.5", "xai/grok-4.6"]); }); }); + +describe("visibility changes refresh connected client catalogs", () => { + test.each([ + ["/api/selected-models", { provider: "a", models: ["m1"] }, ["a/m1"]], + ["/api/disabled-models", { models: ["a/m2"] }, ["a/m1"]], + ["/api/model-visibility", { scope: "models", provider: "a", targets: [{ id: "m2" }], enabled: false }, ["a/m1"]], + ["/api/model-presets", { provider: "a", mode: "all" }, ["a/m1", "a/m2"]], + ] as const)("%s refreshes from the persisted selection and reports refused clients", async (path, body, expected) => { + const config = baseConfig({ fastRows: false }); + let saved = false; + let refreshCalls = 0; + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { + method: "PUT", headers: { Host: url.host, "content-type": "application/json" }, body: JSON.stringify(body), + }), url, config, { + saveConfigPreservingClaudeCode: () => { saved = true; }, + createManagementConvergeCodex: catalogConvergenceFactory(), + refreshOwnedCatalogIntegrations: async input => { + expect(saved).toBe(true); + expect(input.config).toBe(config); + expect(input.port).toBe(10100); + const models = typeof input.models === "function" ? await input.models() : input.models; + expect(models.filter(row => row.provider === "a").map(row => row.namespaced)).toEqual([...expected]); + refreshCalls += 1; + return [{ client: "pi", ok: false, reason: "integration_mutation_busy" }, { client: "aside", ok: true, changed: true }]; + }, + }); + expect(response?.status).toBe(200); + expect(refreshCalls).toBe(1); + expect(await response!.json()).toMatchObject({ + ok: true, + clientIntegrations: [{ client: "pi", ok: false, reason: "integration_mutation_busy" }, { client: "aside", ok: true, changed: true }], + }); + }); +}); From ff388977a11dbf911defffcc51ac5f0cc29b4eee Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:51:28 +0900 Subject: [PATCH 053/236] test(clients): preserve convergence inventory through shared refresh --- .../020_owned_refresh.md | 4 ++++ .../codex-convergence-contract.test.ts | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md index 26909e5427..dd7266a694 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md @@ -21,3 +21,7 @@ Add a ManagementApiDeps refreshOwnedCatalogIntegrations seam for route verificat 010 b8010aebd passes the four standalone visibility probes and source review; all original hosted-CI/merge criteria are retained under the terminal stack cycle, not marked complete. Helper signature: refreshOwnedCatalogIntegrations(input: Omit, clientIds: readonly IntegrationClientId[] = ["pi", "aside"]): Promise. Memoize the lazy model load per fan-out; no owned record means no catalog load. Catch and redact each failure. Explicit sync passes [mcode,pi,aside]. Visibility routes attach both catalogRefresh and clientIntegrations; native Codex failure does not undo an already persisted selection. Delegate tests only to one worker: tests/clients/sync-client-integrations.test.ts owns helper refresh+overlap coverage; main owns implementation and route regression tests. The worker has no production writes, suite execution, FSM or git mutations. + +## Implementation audit synthesis + +Averroes found an indirect source-oracle dependency: codex-convergence-contract.test.ts counts direct convergence calls and two preset calls. The shared visibility helper changes direct count but preserves fourteen logical paths. Update the inventory to subtract the helper definition and add its five callers, assert exactly one Codex convergence inside the helper, and preserve the marker-only custom preset negative. Run that affected file remotely in addition to the writer/route tests. No runtime blockers in the ownership audit. diff --git a/tests/codex-integration/codex-convergence-contract.test.ts b/tests/codex-integration/codex-convergence-contract.test.ts index db37963095..9644523163 100644 --- a/tests/codex-integration/codex-convergence-contract.test.ts +++ b/tests/codex-integration/codex-convergence-contract.test.ts @@ -374,7 +374,7 @@ test("a failure cause never carries message text, paths or identifiers (#1784)", expect(body).not.toContain("failed writing"); }); -test("the route inventory contains exactly the specified 8 + 14 + 2 + 2 convergence calls", () => { +test("the route inventory contains exactly the specified 8 + 14 + 2 + 2 convergence paths", () => { const counts = Object.fromEntries([ ["provider-routes.ts", 8], ["model-routes.ts", 14], @@ -382,7 +382,14 @@ test("the route inventory contains exactly the specified 8 + 14 + 2 + 2 converge ["agent-settings-routes.ts", 2], ].map(([file, expected]) => { const source = readFileSync(repoPath("src", "server", "management", file as string), "utf8"); - const count = source.match(/await convergeCodexCatalog\(\)/g)?.length ?? 0; + const direct = source.match(/await convergeCodexCatalog\(\)/g)?.length ?? 0; + const shared = source.match(/await convergeVisibleCatalogs\(\)/g)?.length ?? 0; + if (file === "model-routes.ts") { + const helper = source.slice(source.indexOf("const convergeVisibleCatalogs ="), source.indexOf('if (url.pathname ===')); + expect(helper.match(/await convergeCodexCatalog\(\)/g)?.length).toBe(1); + expect(shared).toBe(5); + } + const count = file === "model-routes.ts" ? direct - 1 + shared : direct; expect(count).toBe(expected); expect(source).not.toContain("refreshCodexCatalogBestEffort"); return [file, count]; @@ -435,7 +442,9 @@ test("both model-preset write paths converge the Codex catalog", () => { const handlerBody = source.slice(handlerStart, source.indexOf("url.pathname ===", handlerStart + 1)); // The "all" branch and the materialize branch each converge; "custom" only moves the marker, // so it deliberately does not. - expect(handlerBody.match(/await convergeCodexCatalog\(\)/g)?.length).toBe(2); + expect(handlerBody.match(/await convergeVisibleCatalogs\(\)/g)?.length).toBe(2); + const customBranch = handlerBody.slice(handlerBody.indexOf('if (mode === "custom")'), handlerBody.indexOf("const preset =")); + expect(customBranch).not.toMatch(/await converge(?:CodexCatalog|VisibleCatalogs)\(\)/); }); /** From 8daae009079a1e2986a02ea3ab4bf1403ce36134 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:52:42 +0900 Subject: [PATCH 054/236] docs(plan): refresh OpenCode Go ordering and locale parity --- devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md b/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md index d4eff0de6f..0a323a2997 100644 --- a/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md +++ b/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md @@ -663,3 +663,11 @@ index 114c699eaf..8dba5cfb55 100644 "catalog-oauth-observation.test.ts": "codex-integration", ```` + +## Consuming P refresh + +Parent preparation head is 29f98462c4a63cf217347c26668733169fd65736. Source #3571 remains OPEN at 0a935c5694229760c8c1cd5a62072107d8ae6696, and its full patch passes applicability on this parent. All four non-merge source commits identify voiys . The existing modelPickerOrder field survives config loading through the established root passthrough schema; no new persistence field is introduced. Preserve providerContextCapValues from 020. + +The initial roadmap listed source English/French edits, but six other existing model-ordering guides also contain the legacy native-order contract. MODIFY docs-site/src/content/docs/{ja,ko,ru,tr,zh-cn,zh-tw}/guides/model-ordering.md with the same complete-order opt-in, exact/equivalent matching, unchanged spawn roster and existing-list migration warning. Do not create new locales or alter unrelated routing semantics. The runtime/template output remains separately verified from any native client capture; a synthetic rendering must never be described as an actual client capture. + +Delegation: main carries the final source diff and owns SoT/commits; catalog worker supplies caller-level coverage and a captured generated-list comparison; docs worker owns the six translated guides; independent code reviewer checks priorities/retained paths; remote verifier uses isolated exact-head tests/docs plus a native client capture if the installed client can be run safely with synthetic state. No local test/build/typecheck and no real personal proxy/account calls. Final merge gates remain unchanged. From 8b7ef5f1008ea92bdfba9506fea44f7eb0ec4655 Mon Sep 17 00:00:00 2001 From: voiys Date: Sun, 6 Sep 2026 02:52:42 +0900 Subject: [PATCH 055/236] fix(catalog): preserve OpenCode Go efforts and complete picker order Carry the full four-commit source PR #3571 at 0a935c5694229760c8c1cd5a62072107d8ae6696, including retained display and spawn rank corrections. Source commits: e57a57d5f, d745d8a41, 90eaaddd8, 0a935c569. Co-authored-by: voiys --- .../content/docs/fr/guides/model-ordering.md | 29 ++++- .../src/content/docs/guides/model-ordering.md | 26 +++- .../docs/reference/configuration/providers.md | 18 +++ scripts/test-layout/layout.json | 2 + src/codex/catalog/sync.ts | 66 ++++++++++- src/codex/convergence.ts | 2 + src/types/config.ts | 19 ++- .../catalog-full-picker-order.test.ts | 111 ++++++++++++++++++ .../catalog-go-exact-efforts.test.ts | 39 ++++++ tests/codex-integration/codex-catalog.test.ts | 10 +- tests/codex-integration/codex-v2-gate.test.ts | 5 +- tests/fixtures/test-layout-expected.json | 2 + 12 files changed, 299 insertions(+), 30 deletions(-) create mode 100644 tests/codex-integration/catalog-full-picker-order.test.ts create mode 100644 tests/codex-integration/catalog-go-exact-efforts.test.ts diff --git a/docs-site/src/content/docs/fr/guides/model-ordering.md b/docs-site/src/content/docs/fr/guides/model-ordering.md index cac2b0667c..64408efa53 100644 --- a/docs-site/src/content/docs/fr/guides/model-ordering.md +++ b/docs-site/src/content/docs/fr/guides/model-ordering.md @@ -23,7 +23,7 @@ priorités `i * N + j`, où `j` est la position du sélecteur en base zéro ; un sont déplacées hors de ces groupes de sélecteurs. Codex continue de n’annoncer que les cinq premières lignes visibles dans le sélecteur. -Les priorités sans sélecteur pertinentes sont : +Sans ordre global du sélecteur, les priorités sans sélecteur pertinentes sont : | Entrée du catalogue | Priorité | Source | | --- | --- : | --- | @@ -134,8 +134,31 @@ au-delà de ce bloc mis en avant : Les lignes routées indiquées apparaissent dans l’ordre configuré. Une ligne absente du tableau conserve sa priorité normale et reste donc devant la bande d’affichage de `modelPickerOrder` ; indiquez toutes les lignes routées dont vous souhaitez contrôler l’ordre relatif. Une ligne également présente dans -`subagentModels` conserve sa priorité de mise en avant. `modelPickerOrder` ne réorganise ni les lignes -natives non qualifiées ni celles qualifiées par un compte ; utilisez `subagentModels` pour celles-ci. +`subagentModels` conserve sa priorité de mise en avant. Une liste contenant uniquement des identifiants +routés conserve la position normale des lignes natives. + +Pour ordonner tout le sélecteur, incluez un identifiant natif non qualifié : + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +Les lignes indiquées apparaissent d’abord dans l’ordre du tableau, puis les lignes absentes +selon leur priorité naturelle. La correspondance est exacte : `gpt-5.6-sol` et +`openai/gpt-5.6-sol` désignent deux lignes distinctes. Pour une ligne qualifiée par un compte, +indiquez son identifiant complet, sélecteur inclus. Les formes brute et encodée du même +identifiant routé sont acceptées, avec priorité aux correspondances exactes. Les entrées +vides sont ignorées. + +### Migration : identifiants natifs dans les listes existantes + +Auparavant, les identifiants natifs dans `modelPickerOrder` étaient ignorés. Une liste +existante contenant un identifiant natif non qualifié ordonne désormais tout le sélecteur, +y compris les lignes mises en avant. Supprimez ces identifiants pour conserver l’ancien +comportement limité aux lignes routées. Les listes absentes, vides ou uniquement routées +conservent leur comportement ; les priorités des candidats sous-agents ne changent pas. `modelPickerOrder` ne modifie jamais l’ensemble des candidats de `spawn_agent`. Il change uniquement la priorité visible par Codex dans le sélecteur, tandis qu’OpenCodex conserve la priorité naturelle de chaque diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md index 696f631a58..352c8ddb12 100644 --- a/docs-site/src/content/docs/guides/model-ordering.md +++ b/docs-site/src/content/docs/guides/model-ordering.md @@ -23,7 +23,7 @@ priorities `i * N + j`, where `j` is the selector's zero-based position; a route rows are moved outside those selector groups. Codex still advertises only the first five picker-visible rows. -The relevant no-selector priorities are: +Without complete-picker ordering, the relevant no-selector priorities are: | Catalog entry | Priority | Source | | --- | ---: | --- | @@ -133,8 +133,28 @@ featured block: Listed routed rows appear in the configured order. A routed row omitted from the array keeps its normal priority, so it remains ahead of the `modelPickerOrder` display band; list every routed row whose relative position you want to control. A row also present in `subagentModels` keeps its -featured priority. Bare native and account-qualified native rows are not reordered by -`modelPickerOrder`; use `subagentModels` for those rows. +featured priority. With a routed-only list, native rows keep their normal positions. + +To order the complete picker, include a bare native id: + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +Listed rows appear first in array order, followed by unlisted rows in natural priority +order. Matching uses exact catalog ids: `gpt-5.6-sol` and `openai/gpt-5.6-sol` are separate +rows. Raw and encoded spellings of the same routed id are also accepted, with exact +matches taking precedence. Empty entries are ignored. Account-qualified rows need +their selector-qualified id in the list. + +### Migration note: native ids in existing orders + +Previously, native ids in `modelPickerOrder` were ignored. An existing list containing +a bare native id now activates complete-picker ordering, including featured rows. +Remove bare native ids to keep the previous routed-only behavior. Unset, empty and +routed-only lists retain their behavior; subagent candidate priorities are unchanged. `modelPickerOrder` never changes the `spawn_agent` candidate set. It changes only the Codex-visible picker priority while opencodex retains each moved row's natural priority for diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 2578c648ec..aaa0bf2bd1 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -811,3 +811,21 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 "visionSidecar": { "enabled": true } } ``` + + +## OpenCode Go reasoning efforts + +Go catalog rows preserve their configured reasoning efforts exactly, including during +catalog sync. OpenCodex does not append synthetic `max` or `ultra` choices to these rows. +Use `modelReasoningEfforts` and `modelDefaultReasoningEfforts` for each model's accepted +upstream values. Key these per-provider maps by upstream model ID, not the routed +`opencode-go/` catalog slug. For example, Omen Alpha (`omen-alpha`) accepts `low`, `high`, +and `max`; Muse Spark 1.3 Contributor (`muse-spark-1.3-contributor`) accepts `minimal`, `low`, `medium`, `high`, and `xhigh` (Go endpoint validation, 2026-09-05). +See the [OpenCode Go model list](https://opencode.ai/docs/go/#models) for the current roster. +A configured subset can exclude the lower tiers. Other providers retain their existing behavior. + +For a native-first picker, include native ids in `modelPickerOrder` followed by the +routed ids. This orders the complete picker while preserving the separate subagent +candidate priorities. Routed-only orders keep their previous behavior. See the +[ordering migration note](/guides/model-ordering/#migration-note-native-ids-in-existing-orders). +`modelDisplayNames` on a provider controls readable labels without changing wire ids. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 29dd2c5f1c..7f3fa69999 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -255,6 +255,8 @@ "bun-stream-caps.test.ts": "lib", "cancel-body-on-abort.test.ts": "server", "catalog-cursor-search.test.ts": "codex-integration", + "catalog-full-picker-order.test.ts": "codex-integration", + "catalog-go-exact-efforts.test.ts": "codex-integration", "catalog-input-modality-enum.test.ts": "codex-integration", "catalog-llamacpp-capabilities.test.ts": "codex-integration", "catalog-oauth-observation.test.ts": "codex-integration", diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 3f5f472baf..7b0be6e19b 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -315,6 +315,8 @@ export function deriveEntry( contextCap?: NativeContextLimitsInput, ): RawEntry { const preserveExact = isExactComboCatalogModel(model, exactComboSlugs); + // Go exposes model-specific upstream enums; synthetic tiers mislead subagent overrides. + const preserveExactReasoning = preserveExact || model?.provider === "opencode-go"; const codexForwardNativeCapabilityAlias = model?.codexForwardNativeCapabilityAlias === true ? upstreamNativeEntry(model.id) : null; @@ -359,7 +361,7 @@ export function deriveEntry( e, model?.reasoningEfforts, model?.defaultReasoningEffort, - preserveExact || codexForwardNativeCapabilityAlias !== null, + preserveExactReasoning || codexForwardNativeCapabilityAlias !== null, ); // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned // native tool/search/responses-lite contract while preserving the routed slug and wire id. @@ -409,7 +411,7 @@ export function deriveEntry( }; if (isRouted) { applyRoutedCodexToolMode(entry, model?.codexToolMode); - applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); + applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning); } else { applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]); @@ -518,7 +520,7 @@ export function buildCatalogEntriesFromObservedState({ // before. The spawn_agent candidate window is derived separately from SPAWN_PRIORITY_FIELD, so // this display reorder cannot change which rows are spawn candidates. const pickerOrder = Array.isArray(modelPickerOrder) - ? modelPickerOrder.filter((id): id is string => typeof id === "string" && id.length > 0) + ? modelPickerOrder.filter((id): id is string => typeof id === "string" && id.trim().length > 0) : []; const pickerOrderRank = new Map(pickerOrder.map((slug, i) => [slug, i] as const)); const pickerOrderActive = pickerOrder.length > 0; @@ -779,12 +781,33 @@ export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly< unsupportedNativeEntries: "drop", }); +/** Preserve exact-id precedence while accepting the existing raw/encoded slug spellings. */ +function modelPickerRank(order: readonly string[]): (slug: string) => number | undefined { + const exact = new Map(order.map((slug, index) => [slug, index])); + const equivalent = new Map(order.map((slug, index) => [slugEquivalenceKey(slug), index])); + return slug => exact.get(slug) ?? equivalent.get(slugEquivalenceKey(slug)); +} + +/** A picker order containing native ids orders the whole list, without changing spawn ranks. */ +export function applyFullModelPickerOrder(entries: RawEntry[], order: readonly string[]): void { + const pickerOrder = order.filter(slug => slug.trim().length > 0); + if (!pickerOrder.some(slug => !slug.includes("/"))) return; + const rankOf = modelPickerRank(pickerOrder); + for (const entry of entries) { + const natural = entry[SPAWN_PRIORITY_FIELD] ?? entry.priority ?? 9; + entry[SPAWN_PRIORITY_FIELD] = natural; + entry.priority = rankOf(String(entry.slug)) ?? pickerOrder.length + Number(natural); + } +} + export interface ObservedCatalogMergeInput { readonly catalogModels: readonly RawEntry[]; readonly baselineCatalogModels: readonly RawEntry[]; readonly routedEntries: readonly RawEntry[]; readonly baseline: ReadonlyMap; readonly featured: readonly string[]; + readonly modelPickerOrder?: readonly string[]; + readonly accountSelectors?: readonly string[]; readonly wsEnabled: boolean; readonly template: RawEntry | null; readonly disabledModels: ReadonlySet; @@ -817,6 +840,8 @@ export function mergeCatalogEntriesFromObservedState({ routedEntries, baseline, featured, + modelPickerOrder = [], + accountSelectors = [], wsEnabled, template, disabledModels, @@ -975,7 +1000,9 @@ export function mergeCatalogEntriesFromObservedState({ finished.priority = nativePriority(slug, upstream.priority); return finished; } - const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m.priority) }); + const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m[SPAWN_PRIORITY_FIELD] ?? m.priority) }); + // Recompute spawn rank from current featured models, not a prior picker override. + delete preserved[SPAWN_PRIORITY_FIELD]; // Older natives kept from disk still need the mock top tiers (max + ultra always // for subagent max spawns; wire-clamped to the model's real top rung). if (!isGpt56NativeSlug(slug) && slug !== NATIVE_RESERVE_MODEL) ensureUltraReasoningLevel(preserved); @@ -1060,6 +1087,32 @@ export function mergeCatalogEntriesFromObservedState({ // remain outside provider ownership and survive unless a fresh row replaces their exact slug. return !isOcxAuthoredRoutedEntry(entry); }); + // Retained rows bypass the builder. Recompute managed spawn ranks from current config + // before either display-order mode; a saved display override is not current roster authority. + const pickerOrder = modelPickerOrder.filter(slug => slug.trim().length > 0); + const fullPickerOrder = pickerOrder.some(slug => !slug.includes("/")); + const rankOf = modelPickerRank(pickerOrder); + const featuredRankOf = modelPickerRank(featured); + const priorityStride = Math.max(accountSelectors.length, 1); + for (const entry of preservedRoutedEntries) { + const natural = entry[SPAWN_PRIORITY_FIELD]; + if (typeof natural === "number") { + entry.priority = natural; + delete entry[SPAWN_PRIORITY_FIELD]; + } + const slug = String(entry.slug); + if (!isOcxAuthoredRoutedEntry(entry) || isNativeAliasCatalogEntry(entry)) continue; + const featuredRank = featuredRankOf(slug); + entry.priority = featuredRank !== undefined + ? featuredRank * priorityStride + : (accountSelectors.length > 0 ? 1_000 : 0) + 5; + if (featuredRank !== undefined || fullPickerOrder) continue; + const pickerIndex = rankOf(slug); + if (pickerIndex !== undefined) { + entry[SPAWN_PRIORITY_FIELD] = entry.priority; + entry.priority = PICKER_ORDER_PRIORITY_BASE + pickerIndex * priorityStride; + } + } let finalRoutedEntries = [...admittedRoutedEntries, ...preservedRoutedEntries]; finalRoutedEntries = finalRoutedEntries.filter(entry => { const slug = typeof entry.slug === "string" ? entry.slug : ""; @@ -1134,7 +1187,7 @@ export function mergeCatalogEntriesFromObservedState({ // Mock-max universality (260709): preserved routed entries from disk may predate // the max rung — ensure it here so subagent max spawns validate on every // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact. - if (!exactCombo && !reserveProjection) { + if (!exactCombo && !reserveProjection && !String(e.slug ?? "").startsWith("opencode-go/")) { const levels = Array.isArray(e.supported_reasoning_levels) ? e.supported_reasoning_levels as Array<{ effort?: string }> : []; @@ -1161,6 +1214,7 @@ export function mergeCatalogEntriesFromObservedState({ multiAgentV2Enabled, { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection }, ); + applyFullModelPickerOrder(versionedEntries, modelPickerOrder); for (const entry of versionedEntries) { const kind = entry.opencodex_catalog_kind; if (trustedAccountBoundNativeCatalogSlug(entry) === undefined @@ -1762,6 +1816,8 @@ function writeRetainedCatalogSync({ }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) : []; catalog.models = mergeCatalogEntriesFromObservedState({ + modelPickerOrder, + accountSelectors, catalogModels: catalogModelsForMerge, baselineCatalogModels: baselineCatalog?.models ?? [], routedEntries: goEntries, diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index df765a7853..8b30bb9eb2 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -342,6 +342,8 @@ function prepareCatalog( )), ); const mergedModels = mergeCatalogEntriesFromObservedState({ + modelPickerOrder, + accountSelectors, catalogModels, baselineCatalogModels, routedEntries, diff --git a/src/types/config.ts b/src/types/config.ts index 34e2d385d6..2593dba84f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -419,17 +419,14 @@ export interface OcxConfig { /** One-time featured-roster upgrade marker; later user ordering is preserved. */ subagentModelsVersion?: number; /** - * Optional full picker ordering for the Codex model catalog, independent of the - * 5-slot `subagentModels` spawn_agent cap. DISPLAY-ONLY: it controls the visual order of - * the Codex model picker for large routed catalogs (10-20+ models) that would otherwise sort - * arbitrarily and reshuffle on every rebuild. Values are routed `/` catalog - * slugs (matched by exact slug or `provider/id`); native OpenAI passthrough rows and - * account-qualified native rows are not reordered (order native rows via `subagentModels`). - * Listed routed rows appear in array order; rows not listed keep their normal display order. - * `subagentModels`-featured rows keep their top position. When unset or empty, catalog - * priority is unchanged. This changes ONLY what the user sees in the picker: the spawn_agent - * candidate set is derived from each row's natural priority and is provably unaffected, even - * when every routed row is listed (see opencodex_spawn_priority / effectiveSubagentRoster). + * Display-only order for the Codex picker, independent of subagentModels. + * Routed-only lists order non-featured routed rows; featured and native rows keep + * their normal positions. Including a bare native id opts into ordering the complete + * picker: listed ids appear first in array order, followed by unlisted rows in their + * natural priority order. Exact catalog ids take precedence over equivalent raw/encoded + * routed ids; empty entries are ignored. The separate natural spawn + * priority is preserved, so display order does not change subagent candidates. + * Unset or empty leaves catalog priorities unchanged. */ modelPickerOrder?: string[]; /** diff --git a/tests/codex-integration/catalog-full-picker-order.test.ts b/tests/codex-integration/catalog-full-picker-order.test.ts new file mode 100644 index 0000000000..1dbda27090 --- /dev/null +++ b/tests/codex-integration/catalog-full-picker-order.test.ts @@ -0,0 +1,111 @@ +import { routedSlug } from "../../src/providers/slug-codec"; +import { expect, test } from "bun:test"; +import { buildCatalogEntriesFromObservedState, mergeCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, applyFullModelPickerOrder, deriveEntry, mergeCatalogEntriesForSync, SPAWN_PRIORITY_FIELD } from "../../src/codex/catalog/sync"; + +test("native-first picker order preserves Go subagent ranks and is repeatable", () => { + const rows: any[] = [ + { slug: "opencode-go/glm-5.3", priority: 0 }, + { slug: "gpt-5.6-sol", priority: 9 }, + { slug: "gpt-6-astra", priority: 9 }, + ]; + const order = ["gpt-6-astra", "gpt-5.6-sol", "opencode-go/glm-5.3"]; + applyFullModelPickerOrder(rows, order); + expect([...rows].sort((a,b) => a.priority-b.priority).map(r => r.slug)).toEqual(order); + expect(rows.map(r => r[SPAWN_PRIORITY_FIELD])).toEqual([0,9,9]); + const once = structuredClone(rows); + applyFullModelPickerOrder(rows, order); + expect(rows).toEqual(once); +}); + +test("existing routed-only ordering retains its behavior", () => { + const rows: any[] = [{ slug: "opencode-go/glm-5.3", priority: 1000 }]; + applyFullModelPickerOrder(rows, ["opencode-go/glm-5.3"]); + expect(rows).toEqual([{ slug: "opencode-go/glm-5.3", priority: 1000 }]); +}); + + +test("sync refreshes native spawn rank when featured models change", () => { + const sol = deriveEntry(null, "gpt-5.6-sol", "Sol", 105); + const order = ["gpt-5.6-sol"]; + applyFullModelPickerOrder([sol], order); + expect(sol[SPAWN_PRIORITY_FIELD]).toBe(105); + + const baseline = new Map([["gpt-5.6-sol", 9]]); + const promoted = mergeCatalogEntriesForSync([sol], [], baseline, ["gpt-5.6-sol"], false); + applyFullModelPickerOrder(promoted, order); + expect(promoted.find(entry => entry.slug === sol.slug)?.[SPAWN_PRIORITY_FIELD]).toBe(0); + + const demoted = mergeCatalogEntriesForSync(promoted, [], baseline, ["opencode-go/glm-5.3"], false); + applyFullModelPickerOrder(demoted, order); + expect(demoted.find(entry => entry.slug === sol.slug)?.[SPAWN_PRIORITY_FIELD]).toBe(101); +}); + + +test("bare native ids and routed slugs match exactly, without suffix aliases", () => { + const rows: any[] = [ + { slug: "openai/gpt-5.6-sol", priority: 2 }, + { slug: "gpt-5.6-sol", priority: 9 }, + { slug: "other/gpt-5.6-sol", priority: 3 }, + ]; + applyFullModelPickerOrder(rows, ["gpt-5.6-sol", "openai/gpt-5.6-sol"]); + expect(rows.map(row => row.priority)).toEqual([1, 0, 5]); + expect(rows.map(row => row[SPAWN_PRIORITY_FIELD])).toEqual([2, 9, 3]); +}); + +test.each([ + { order: [] as string[] }, + { order: ["gpt-5.6-sol", "opencode-go/glm-5.3"], after: ["opencode-go/glm-5.3"] }, + { order: ["gpt-5.6-sol", "opencode-go/glm-5.3"], before: ["opencode-go/glm-5.3"], after: [] }, + { order: ["gpt-5.6-sol", "opencode-go/team/model"], modelId: "team/model", before: ["other/model", "opencode-go/team/model"], after: ["opencode-go/team/model", "other/model"] }, + + { order: ["", "opencode-go/glm-5.3"] }, + { order: [" ", "opencode-go/glm-5.3"] }, + { order: [""] }, + { order: ["opencode-go/team/model"], modelId: "team/model" }, + { order: ["opencode-go/glm-5.3"] }, + { order: ["other/model", "opencode-go/glm-5.3"] }, +])("degraded discovery refreshes ranks and remains stable for %j", ({ order, modelId = "glm-5.3", before = [], after = [] }) => { + for (const accountSelectors of [[], ["account-a", "account-b"]]) { + const slug = routedSlug("opencode-go", modelId); + const fresh = (modelPickerOrder: readonly string[], featured: readonly string[] = []) => buildCatalogEntriesFromObservedState({ + template: null, gptSlugs: [], + goModels: [{ id: modelId, provider: "opencode-go", displayName: "GLM 5.3", reasoningEfforts: ["high", "max"] }], + featured, modelPickerOrder, wsEnabled: false, multiAgentMode: "default", + exactComboSlugs: new Set(), accountSelectors, suppressedBareNativeSlugs: new Set(), + disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled: false, + }); + const merge = (catalogModels: Record[], routedEntries: Record[], modelPickerOrder: readonly string[], degraded: boolean, featured: readonly string[] = []) => + mergeCatalogEntriesFromObservedState({ + catalogModels, routedEntries, modelPickerOrder, accountSelectors, + baselineCatalogModels: [], baseline: new Map(), featured, wsEnabled: false, + template: null, disabledModels: new Set(), selectedModelsByProvider: new Map(), + gatheredProviderNames: new Set(["opencode-go"]), + degradedProviderNames: new Set(degraded ? ["opencode-go"] : []), + legacyCustomModelSlugs: new Set(), multiAgentMode: "default", multiAgentV2Enabled: false, + exactComboSlugs: new Set(), hasPhysicalComboProvider: false, includeNativeOpenAi: true, + accountBoundEntries: [], + policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "suppress" }, + }); + const fullOrder = ["gpt-5.6-sol", slug]; + const previous = merge([], fresh(fullOrder, before), fullOrder, false, before); + const saved = structuredClone(previous); + const healthy = merge(previous, fresh(order, after), order, false, after); + const degraded = merge(previous, [], order, true, after); + const row = (entries: Record[]) => entries.find(entry => entry.slug === slug)!; + expect(row(degraded).priority).toBe(row(healthy).priority); + expect(row(degraded)[SPAWN_PRIORITY_FIELD]).toBe(row(healthy)[SPAWN_PRIORITY_FIELD]); + expect(merge(degraded, [], order, true, after)).toEqual(degraded); + expect(previous).toEqual(saved); + } +}); + + +test("full ordering ignores empty entries and accepts raw upstream ids with slashes", () => { + const slug = routedSlug("vendor", "team/model"); + const rows = [{ slug, priority: 1000 }, { slug: "gpt-5.6-sol", priority: 9 }]; + applyFullModelPickerOrder(rows, ["", "gpt-5.6-sol", "vendor/team/model"]); + expect(rows.map(row => row.priority)).toEqual([1, 0]); + const exact = [{ slug, priority: 5 }]; + applyFullModelPickerOrder(exact, ["gpt-5.6-sol", slug, "vendor/team/model"]); + expect(exact[0]!.priority).toBe(1); +}); diff --git a/tests/codex-integration/catalog-go-exact-efforts.test.ts b/tests/codex-integration/catalog-go-exact-efforts.test.ts new file mode 100644 index 0000000000..5fa4da816b --- /dev/null +++ b/tests/codex-integration/catalog-go-exact-efforts.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; +import { deriveEntry, mergeCatalogEntriesForSync } from "../../src/codex/catalog/sync"; + +for (const template of [null, { slug: "gpt-5.6-sol", supported_reasoning_levels: [{ effort: "ultra" }] }]) { + test(`Go preserves exact configured efforts (${template ? "template" : "fallback"})`, () => { + for (const [id, efforts] of [ + ["glm-5.3", ["high", "max"]], + ["glm-5.3-flash", ["high", "max"]], + ["omen-alpha", ["high", "max"]], + ["deepseek-v4-flash-vision-exp", ["high", "max"]], + ["muse-spark-1.3-contributor", ["high", "xhigh"]], + ] as const) { + const entry = deriveEntry(template, `opencode-go/${id}`, "Go", 1, { + provider: "opencode-go", id, reasoningEfforts: [...efforts], defaultReasoningEffort: efforts[1], + }); + expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual([...efforts]); + expect(entry.default_reasoning_level).toBe(efforts[1]); + } + }); +} + +test("other providers retain their existing virtual tiers", () => { + const entry = deriveEntry(null, "other/model", "Other", 1, { + provider: "other", id: "model", reasoningEfforts: ["high"], + }); + expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(["high", "max", "ultra"]); +}); + +test("sync does not reintroduce max for Muse", () => { + const muse = deriveEntry(null, "opencode-go/muse-spark-1.3-contributor", "Muse", 1, { + provider: "opencode-go", id: "muse-spark-1.3-contributor", + reasoningEfforts: ["high", "xhigh"], defaultReasoningEffort: "xhigh", + }); + for (const [disk, fresh] of [[[muse], []], [[], [muse]]]) { + const entries = mergeCatalogEntriesForSync(disk, fresh, new Map(), [], false); + const entry = entries.find(e => e.slug === muse.slug)!; + expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(["high", "xhigh"]); + } +}); diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 37d8c69798..cc394a548d 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -5445,11 +5445,11 @@ describe("Codex catalog routed normalization", () => { const expected = [ { slug: "deepseek/deepseek-v4-flash", efforts: ["low", "high", "max", "ultra"] }, { slug: "deepseek/deepseek-v4-pro", efforts: ["low", "high", "max", "ultra"] }, - { slug: "opencode-go/deepseek-v4-flash", efforts: ["low", "high", "max", "ultra"] }, - { slug: "opencode-go/deepseek-v4-pro", efforts: ["low", "high", "max", "ultra"] }, - { slug: "opencode-go/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, - { slug: "opencode-go/glm-5.1", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, - { slug: "opencode-go/glm-5", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "opencode-go/deepseek-v4-flash", efforts: ["low", "high", "max"] }, + { slug: "opencode-go/deepseek-v4-pro", efforts: ["low", "high", "max"] }, + { slug: "opencode-go/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max"] }, + { slug: "opencode-go/glm-5.1", efforts: ["low", "medium", "high", "xhigh", "max"] }, + { slug: "opencode-go/glm-5", efforts: ["low", "medium", "high", "xhigh", "max"] }, { slug: "zai/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, { slug: "zai/glm-5.2[1m]", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, { slug: "zhipu-bigmodel/glm-4.6", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, diff --git a/tests/codex-integration/codex-v2-gate.test.ts b/tests/codex-integration/codex-v2-gate.test.ts index 8d3e2d9dc5..6e8b6a18c1 100644 --- a/tests/codex-integration/codex-v2-gate.test.ts +++ b/tests/codex-integration/codex-v2-gate.test.ts @@ -100,14 +100,13 @@ function installModeHintRuntime(supported = true): string { describe("catalog ultra (always-on)", () => { const routed = [{ id: "glm-5.2", provider: "opencode-go", reasoningEfforts: ["low", "medium", "high", "xhigh"] }]; - test("routed + old natives always advertise mock max AND ultra", () => { + test("Go keeps declared efforts while old natives retain mock tiers", () => { const entries = buildCatalogEntries(template(), ["gpt-5.5"], routed as never, [], false); const native = entries.find(e => e.slug === "gpt-5.5")!; const glm = entries.find(e => e.slug === "opencode-go/glm-5.2")!; expect(efforts(native)).toContain("ultra"); expect(efforts(native)).toContain("max"); - expect(efforts(glm)).toContain("ultra"); - expect(efforts(glm)).toContain("max"); // mock max: adapters/wire clamp keep it honest + expect(efforts(glm)).toEqual(["low", "medium", "high", "xhigh"]); }); test("gpt-5.6-sol keeps native ultra + max; luna has max but no native ultra (upstream ladder)", () => { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 114c699eaf..8dba5cfb55 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -92,6 +92,8 @@ "bun-stream-caps.test.ts": "lib", "cancel-body-on-abort.test.ts": "server", "catalog-cursor-search.test.ts": "codex-integration", + "catalog-full-picker-order.test.ts": "codex-integration", + "catalog-go-exact-efforts.test.ts": "codex-integration", "catalog-input-modality-enum.test.ts": "codex-integration", "catalog-llamacpp-capabilities.test.ts": "codex-integration", "catalog-oauth-observation.test.ts": "codex-integration", From c605260c718f5239de026a946c3d56fee5adda8e Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:53:40 +0900 Subject: [PATCH 056/236] docs(catalog): state complete picker and configured Go ladder contracts --- .../content/docs/reference/configuration/providers.md | 5 ++--- structure/03_catalog-and-subagents.md | 9 +++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index aaa0bf2bd1..ebb35d28d1 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -812,15 +812,14 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 } ``` - ## OpenCode Go reasoning efforts Go catalog rows preserve their configured reasoning efforts exactly, including during catalog sync. OpenCodex does not append synthetic `max` or `ultra` choices to these rows. Use `modelReasoningEfforts` and `modelDefaultReasoningEfforts` for each model's accepted upstream values. Key these per-provider maps by upstream model ID, not the routed -`opencode-go/` catalog slug. For example, Omen Alpha (`omen-alpha`) accepts `low`, `high`, -and `max`; Muse Spark 1.3 Contributor (`muse-spark-1.3-contributor`) accepts `minimal`, `low`, `medium`, `high`, and `xhigh` (Go endpoint validation, 2026-09-05). +`opencode-go/` catalog slug. For example, a configured `["high", "max"]` list +remains exactly those two choices; a configured `["high", "xhigh"]` list does not gain `max`. See the [OpenCode Go model list](https://opencode.ai/docs/go/#models) for the current roster. A configured subset can exclude the lower tiers. Other providers retain their existing behavior. diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 2b067f0b4c..b1401e0a22 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -249,6 +249,15 @@ sorts by, so a display reorder can never change candidate membership. That diver upstream's own ordering is the feature's purpose, not a defect — `tests/codex-integration/codex-catalog-model-picker-order.test.ts` pins it. +A nonblank bare id in `modelPickerOrder` opts into complete-picker display ordering. Exact +ids take precedence over raw/encoded equivalents; routed-only and empty lists keep the legacy +ordering behavior. This does not change the separate `opencodex_spawn_priority` contract. +Retained rows recompute their natural ranks from the current featured roster and account-selector +stride before display order is applied, so a discovery outage cannot preserve an obsolete +featured or picker rank. Canonical `opencode-go` rows retain their configured reasoning ladder +both when generated and when merged from retained catalog state; synthetic max/ultra choices +are not added to that provider's declared ladder. + Full derivation with per-line citations: `devlog/_plan/260816_codexrs_multiagent_v2_and_history_perf/013_five_cap_v1_vs_v2.md`. ## Routed tool discovery and hosted search From 318d8fdf53b97e24b03ddd9c48fc89a776a73efc Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:56:30 +0900 Subject: [PATCH 057/236] fix(responses): repair native exec patches before tool completion --- .../030_responses_patch.md | 4 +++ .../040_native_tool_parity.md | 12 +++++++ .../content/docs/guides/codex-integration.md | 6 ++++ src/responses/custom-tool-compat.ts | 10 ++++++ src/server/responses-custom-tool-repair.ts | 36 ++++++++++++++----- structure/11_compatibility-contracts.md | 8 +++++ 6 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md diff --git a/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md b/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md index 84743c0fa9..264cf9c105 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md @@ -11,3 +11,7 @@ Verifier: pure standalone synthetic SSE-block imports, compare outputs at each l ## Audit amendment Executable repair is limited to authorized code-mode exec and recognized helper aliases; unrelated same-name native custom tools keep raw input byte-for-byte. Explicit negative: render_diagram input JSON string {"input":"literal"} is not unwrapped. Separate scenarios cover missing input.done, terminal-only completion, failed/incomplete after held deltas, and disposal. Authoritative completion wins over previews. Failure never synthesizes successful completion. All retained buffers release. One simulated execution means choose the client-consumed completed item once, not execute every redundant lifecycle representation. + +## P revalidation + +Consume 020 ff388977a with isolated route/writer proof and remote75+19 tests. 030 remains scoped to the two patch lifecycle gaps. Append040 for independently confirmed ordinary function and dotted-namespace parity gaps; all terminal CI/merge obligations move there unchanged. Main owns production030; a disjoint worker may add tests only in tests/responses/responses-custom-tool-repair.test.ts. Native code-mode exec previews may be held until final when they could be complete raw/wrapped envelopes; unrelated native custom JSON bodies remain raw. diff --git a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md new file mode 100644 index 0000000000..c19a1e2233 --- /dev/null +++ b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md @@ -0,0 +1,12 @@ +# 040 Native function and namespace parity + +Depends on 030 custom-call restoration. Class C3 spec-satisfaction repair. User requested all Chat-era tool repairs be checked. Independent source/probe inventory finds native ordinary calls retain integer-as-float and numeric-as-string mismatches, completed empty arguments, and dotted namespace names that bridge already repairs. These are in scope; assistant output filtering is not. + +NEW src/responses/function-call-compat.ts: collect original current-turn ordinary function declarations using collectResponsesToolGroups, preserving namespace/kind and original parameter schema. Lookup exact declared identity, including reserved functions children as bare and authorized canonical namespace aliases. Do not consume historical-only declarations or provider-normalized schemas. Pure completed-item transform calls coerceIntegerToolArguments(raw||"{}", original.parameters, original.namespace ? undefined : original.name). Only explicit completed empty payload becomes {}; unknown/missing, malformed nonempty, fractions, numeric unions, unsafe integers, custom/hosted/helper calls remain unchanged. +NEW src/server/responses-function-tool-repair.ts: SseBlockRewrite tracks item identity by item_id/output_index and uses same pure completion transform at arguments.done, item.done and terminal snapshots. Preserve in-progress placeholders. Budget any buffered data and release on done/terminal/dispose. Original declaration is authority; attempt wire alias is transport spelling only. Final representations must agree and failed/incomplete never synthesize successful executable input. Revalidate whether delta holding is necessary against existing bridge closeCurrentToolCall (which already repairs authoritative final arguments after streamed numeric previews); use one compatible completion contract rather than introducing arbitrary JSON rewriting. +MODIFY src/server/responses/core.ts: derive ordinary schemas from currentTurnWireToolCatalogBody before lowering; compose native function repair after namespace/custom restores and before undeclared guard. Apply pure repair in JSON, SSE final snapshots, bounded JSON-to-SSE, and rememberPassthroughResponseChecked so client and replay state agree. Canonical forward auth remains byte-pass-through. Rebuild only attempt-specific aliases on retries. +MODIFY src/responses/namespace-tool-compat.ts and, if needed, responses-undeclared-tool-guard.ts: reuse existing collectAmbiguousDottedAliases ownership algorithm rather than duplicate. Add unambiguous dotted aliases after canonical authorization, collisions computed from whole original current-turn catalog including bare spellings before selection. Never reinterpret explicit conflicting namespaces or different kinds; canonical identities retain precedence. +MODIFY existing native Responses repair and namespace tests, or register new domain tests in both layout manifests: integer/string and no-arg scenarios at JSON/each SSE completion/replay, namespace wait exception boundaries, same-inner-name schemas, forbidden selectors/replay-only names, early/interleaved events, terminal/dispose cleanup, dotted collision order independence, unchanged030 code/patch semantics. +UPDATE structure/11_compatibility-contracts.md and guides/codex-integration.md with completion parity boundary and inventory. + +C: standalone synthetic imports with stub tools (no real execution) and remote focused tests/typecheck; all stack PR exact-head hosted CI must pass before merge. Keep original pi-filter/owned-refresh/responses-patch terminal criteria unchanged and satisfy them at final D with PR heads/CI/merge ancestry. Register native GitHub stack, merge approved prefix using async REST and SHA guard, wait for actual merged status, fetch dev and prove all merged SHAs ancestors. No release/deploy/local suites. Stop only verified DONE or actual external blocking evidence. Resource bounds inherited from000. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index b2ae7fcb91..c015b71ebb 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -324,6 +324,12 @@ encodes that declaration and its history as an upstream function tool, then rest function-call lifecycle to `custom_tool_call` before Codex sees it. Native OpenAI forward routing and the supported `apply_patch` custom tool stay unchanged. +If a routed model sends a complete patch as the entire code-mode `exec` input, opencodex +converts it to the nested `tools.apply_patch` call before the tool-completion events reach +Codex. Native custom calls and converted function calls use the same completion rule; +patch previews are held while their executable form is unresolved. JavaScript that merely +contains patch text and unrelated native custom payloads stay unchanged. + The selected provider must support function/tool calling. A text-only provider without tool-call support cannot use `exec`, Browser, or Computer Use. Native OpenAI rows keep their upstream tool mode unchanged. diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 4e4bcad2ab..ce8f9591d1 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -287,6 +287,16 @@ export function restoreRoutedCustomCalls( const helper = aliased && sourceInput !== "" ? item.name : resolveCodeModeHelperName(undefined, targetName, sourceInput, itemNamespace, declaredNames); + // Native custom input is already the tool's raw grammar. Only a recognized + // helper/envelope may reinterpret it; a JSON-looking native body is not a wrapper. + if (item.type === "custom_tool_call" && !aliased && !helper) { + const input = repairNames.has(wireName) && typeof sourceInput === "string" + ? normalizeApplyPatchDelimiters(sourceInput) + : sourceInput; + return input !== sourceInput + ? { value: { ...item, input }, changed: true } + : { value: item, changed: false }; + } const restored: Record = { ...item, type: "custom_tool_call", diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index 4177e03426..87bee011c5 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -93,7 +93,8 @@ export function createRoutedCustomToolRestoreBlockRewrite( declaredNames?: ReadonlySet, ): SseBlockRewrite { const itemNames = new Map(); - const customAliasItemNames = new Map(); + // Native helper aliases and genuine bare code-mode exec calls share completion repair. + const customExecItemNames = new Map(); const repairItemNames = new Map(); const ordinaryItemIds = new Set(); const openCalls = new Map(); @@ -119,7 +120,7 @@ export function createRoutedCustomToolRestoreBlockRewrite( } pendingArguments = []; itemNames.clear(); - customAliasItemNames.clear(); + customExecItemNames.clear(); repairItemNames.clear(); ordinaryItemIds.clear(); }; @@ -195,15 +196,17 @@ export function createRoutedCustomToolRestoreBlockRewrite( const wireName = routedCustomToolWireName(parsed.item); const targetName = routedCustomToolTargetName(parsed.item, names, declaredNames); const aliased = targetName !== undefined && targetName !== wireName; - if (upstreamItemId && aliased) { - customAliasItemNames.set(upstreamItemId, parsed.item.name); + const codeModeExec = targetName === "exec" && parsed.item.name === "exec" + && parsed.item.namespace === undefined && declaresCodeModeExec(declaredNames); + if (upstreamItemId && (aliased || codeModeExec)) { + customExecItemNames.set(upstreamItemId, parsed.item.name); if (type === "response.output_item.added") { openCalls.set(upstreamItemId, { argumentsText: "", emittedInput: "", retainedBytes: 0 }); } } const repairable = wireName !== undefined && repairNames.has(wireName); if (upstreamItemId && repairable) repairItemNames.set(upstreamItemId, parsed.item.name); - const restored = repairable || aliased + const restored = repairable || aliased || codeModeExec ? restoreRoutedCustomCalls(parsed, names, repairNames, declaredNames) : { value: parsed, changed: false }; if (type === "response.output_item.done" && upstreamItemId) releaseCall(upstreamItemId); @@ -259,7 +262,7 @@ export function createRoutedCustomToolRestoreBlockRewrite( if ( type === "response.custom_tool_call_input.delta" && upstreamItemId - && customAliasItemNames.has(upstreamItemId) + && customExecItemNames.has(upstreamItemId) ) { const open = openCalls.get(upstreamItemId) ?? { argumentsText: "", emittedInput: "", retainedBytes: 0 }; const delta = typeof parsed.delta === "string" ? parsed.delta : ""; @@ -268,19 +271,32 @@ export function createRoutedCustomToolRestoreBlockRewrite( open.argumentsText += delta; open.retainedBytes += deltaBytes; openCalls.set(upstreamItemId, open); - return []; + if (customExecItemNames.get(upstreamItemId) !== "exec" + || mayBecomePatchEnvelope(open.argumentsText) + || FREEFORM_WRAP_PREFIX.startsWith(open.argumentsText.trimStart()) + || FREEFORM_WRAP_PREFIX_RE.test(open.argumentsText)) return []; + // If a held prefix turns out to be ordinary JavaScript, release the entire + // un-emitted suffix. Native custom input remains byte-exact. + const inputDelta = open.argumentsText.slice(open.emittedInput.length); + open.emittedInput = open.argumentsText; + return inputDelta ? [replaceSseDataPayload(block, JSON.stringify({ ...parsed, delta: inputDelta }))] : []; } if ( type === "response.custom_tool_call_input.done" && upstreamItemId - && customAliasItemNames.has(upstreamItemId) + && customExecItemNames.has(upstreamItemId) ) { const source = typeof parsed.input === "string" ? parsed.input : openCalls.get(upstreamItemId)?.argumentsText ?? ""; + const name = customExecItemNames.get(upstreamItemId)!; + const helper = name === "exec" + ? resolveCodeModeHelperName(undefined, name, source, undefined, declaredNames) + : name; + releaseCall(upstreamItemId); return [replaceSseDataPayload(block, JSON.stringify({ ...parsed, - input: compileCodeModeHelperInput(source, customAliasItemNames.get(upstreamItemId)!), + input: helper ? compileCodeModeHelperInput(source, helper) : source, }))]; } if ( @@ -312,6 +328,8 @@ export function createRoutedCustomToolRestoreBlockRewrite( open.argumentsText += delta; open.retainedBytes += deltaBytes; openCalls.set(upstreamItemId, open); + // A helper alias will become JavaScript at completion, never raw patch/JSON. + if (itemNames.get(upstreamItemId)?.aliased) return []; // Still accumulating toward the compact wrapper, or an unrecognized shape: // suppress progressive emission and let the done event carry input. if (FREEFORM_WRAP_PREFIX.startsWith(open.argumentsText)) return []; diff --git a/structure/11_compatibility-contracts.md b/structure/11_compatibility-contracts.md index 27a446eb2a..295590788a 100644 --- a/structure/11_compatibility-contracts.md +++ b/structure/11_compatibility-contracts.md @@ -67,3 +67,11 @@ them independently. - 선택한 방식: Add a passive versioned schema and one exact `openai`/canonical Codex URL/forward/`gpt-5.6-sol` manifest whose claims reference assertion-level fixtures executed against the production adapter. - 다른 대안 대신 이 방식을 선택한 이유: Registry flags do not capture transformations such as local continuation expansion or orphan-output degradation. A broad first matrix would turn unverified assumptions into public promises. - 장점, 단점 및 영향: The first contract is small but trustworthy and can feed future CLI/GUI surfaces. Coverage expands only as fixtures are added; no request behavior changes in this slice. + +## Routed code-mode patch completion + +Native Responses custom exec and function helper aliases apply the same complete-envelope +resolver at input.done, output_item.done and terminal snapshots. Potential raw/wrapped patch +previews are withheld before compilation; ordinary native custom payloads retain their raw +grammar. A string merely containing patch markers remains executable caller input and is +never rewritten. Completion and disposal release retained preview buffers. From b477b731e7c16bf22911a794693274a4809d0947 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:57:21 +0900 Subject: [PATCH 058/236] test(responses): cover native patch completion and ambiguous wrappers --- .../030_responses_patch.md | 4 + src/server/responses-custom-tool-repair.ts | 6 +- .../responses-custom-tool-repair.test.ts | 209 ++++++++++++++++++ 3 files changed, 217 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md b/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md index 264cf9c105..a7778f6dc0 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md @@ -15,3 +15,7 @@ Executable repair is limited to authorized code-mode exec and recognized helper ## P revalidation Consume 020 ff388977a with isolated route/writer proof and remote75+19 tests. 030 remains scoped to the two patch lifecycle gaps. Append040 for independently confirmed ordinary function and dotted-namespace parity gaps; all terminal CI/merge obligations move there unchanged. Main owns production030; a disjoint worker may add tests only in tests/responses/responses-custom-tool-repair.test.ts. Native code-mode exec previews may be held until final when they could be complete raw/wrapped envelopes; unrelated native custom JSON bodies remain raw. + +## Implementation audit synthesis + +A fragmented pretty JSON wrapper beginning with brace-newline escaped the compact-prefix guard, so preview bytes could contradict compiled completion. The completion parser accepts arbitrary whitespace, escaped property names and property order; native exec now conservatively holds all object-leading inputs to completion. Ordinary JavaScript stays byte-exact, though a block-leading program waits for completion. Added per-character pretty-wrapper and escaped-key regressions. diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index 87bee011c5..c9d8527ebc 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -273,8 +273,10 @@ export function createRoutedCustomToolRestoreBlockRewrite( openCalls.set(upstreamItemId, open); if (customExecItemNames.get(upstreamItemId) !== "exec" || mayBecomePatchEnvelope(open.argumentsText) - || FREEFORM_WRAP_PREFIX.startsWith(open.argumentsText.trimStart()) - || FREEFORM_WRAP_PREFIX_RE.test(open.argumentsText)) return []; + // JSON.parse accepts whitespace, escaped keys and arbitrary property order. + // Any object prefix may still wrap a patch; keep it until authoritative completion. + || open.argumentsText.trimStart() === "" + || open.argumentsText.trimStart().startsWith("{")) return []; // If a held prefix turns out to be ordinary JavaScript, release the entire // un-emitted suffix. Native custom input remains byte-exact. const inputDelta = open.argumentsText.slice(open.emittedInput.length); diff --git a/tests/responses/responses-custom-tool-repair.test.ts b/tests/responses/responses-custom-tool-repair.test.ts index ac5995d912..98336d0241 100644 --- a/tests/responses/responses-custom-tool-repair.test.ts +++ b/tests/responses/responses-custom-tool-repair.test.ts @@ -188,6 +188,215 @@ describe("routed Responses custom-tool compatibility", () => { rewrite.dispose?.(); }); + test.each([ + { label: "native raw exec", native: true, name: "exec", input: DECORATED_PATCH }, + { label: "native wrapped exec", native: true, name: "exec", input: WRAPPED_DECORATED_PATCH }, + { label: "native pretty wrapper", native: true, name: "exec", input: JSON.stringify({ input: DECORATED_PATCH }, null, 2) }, + { label: "native escaped-key wrapper", native: true, name: "exec", input: `{ "\\u0069nput": ${JSON.stringify(DECORATED_PATCH)} }` }, + { label: "function apply_patch wrapper alias", native: false, name: "apply_patch", input: WRAPPED_DECORATED_PATCH }, + ])("holds fragmented $label previews and completes with executable patch input", async ({ native, name, input }) => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + new Set(["exec"]), budget, new Set(), new Set(["exec"]), + ); + const id = native ? "ctc_patch_lifecycle" : "fc_patch_lifecycle"; + const item = { type: native ? "custom_tool_call" : "function_call", id, call_id: "call_patch_lifecycle", name }; + const payloadKey = native ? "input" : "arguments"; + const eventPrefix = native ? "response.custom_tool_call_input" : "response.function_call_arguments"; + // Independent oracle: do not compute expected source with the production compiler. + const expected = `const result = await tools.apply_patch(${JSON.stringify(CANONICAL_PATCH)});\ntext(result);`; + try { + const added = rewrite(frame("response.output_item.added", { + output_index: 0, item: { ...item, [payloadKey]: "", status: "in_progress" }, + })); + expect(added).toHaveLength(1); + expect(dataPayload(added[0]!).item).toMatchObject({ + type: "custom_tool_call", id: "ctc_patch_lifecycle", call_id: item.call_id, name: "exec", input: "", + }); + // Split both the JSON wrapper and patch markers, including escaped newlines. + for (const delta of input) { + expect(rewrite(frame(`${eventPrefix}.delta`, { output_index: 0, item_id: id, delta }))).toEqual([]); + } + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + const inputDone = rewrite(frame(`${eventPrefix}.done`, { + output_index: 0, item_id: id, [payloadKey]: input, + })); + expect(inputDone).toHaveLength(1); + expect(dataPayload(inputDone[0]!)).toMatchObject({ + type: "response.custom_tool_call_input.done", item_id: "ctc_patch_lifecycle", input: expected, + }); + if (native) expect(budget.snapshot().currentBytes).toBe(0); + const completedItem = { ...item, [payloadKey]: input, status: "completed" }; + const itemDone = rewrite(frame("response.output_item.done", { output_index: 0, item: completedItem })); + expect(itemDone).toHaveLength(1); + expect(dataPayload(itemDone[0]!).item).toMatchObject({ + type: "custom_tool_call", id: "ctc_patch_lifecycle", call_id: item.call_id, name: "exec", input: expected, + }); + expect(dataPayload(itemDone[0]!).item).not.toHaveProperty("arguments"); + expect(budget.snapshot().currentBytes).toBe(0); + const terminal = rewrite(frame("response.completed", { + response: { id: "resp_patch_lifecycle", status: "completed", output: [completedItem] }, + })); + expect(terminal).toHaveLength(1); + const response = dataPayload(terminal[0]!).response as { output: Array> }; + expect(response.output).toHaveLength(1); + expect(response.output[0]).toMatchObject({ + type: "custom_tool_call", id: "ctc_patch_lifecycle", call_id: item.call_id, name: "exec", input: expected, + }); + expect(response.output[0]).not.toHaveProperty("arguments"); + // Execute the client-consumed terminal item once, not each redundant representation. + const calls: unknown[] = []; + const output: unknown[] = []; + const run = new Function("tools", "text", `return (async () => { ${response.output[0]!.input} })();`); + await run({ apply_patch: async (patch: unknown) => { calls.push(patch); return "patched"; } }, + (value: unknown) => output.push(value)); + expect(calls).toEqual([CANONICAL_PATCH]); + expect(output).toEqual(["patched"]); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { + rewrite.dispose?.(); + } + }); + + test.each([ + { label: "raw item.done without input.done", input: DECORATED_PATCH, started: true, itemDone: true }, + { label: "wrapped item.done without input.done", input: WRAPPED_DECORATED_PATCH, started: true, itemDone: true }, + { label: "terminal after held deltas without either done event", input: WRAPPED_DECORATED_PATCH, started: true, itemDone: false }, + { label: "raw terminal-only", input: DECORATED_PATCH, started: false, itemDone: false }, + { label: "wrapped terminal-only", input: WRAPPED_DECORATED_PATCH, started: false, itemDone: false }, + ])("repairs native exec at $label completion", ({ input, started, itemDone }) => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + new Set(["exec"]), budget, new Set(), new Set(["exec"]), + ); + const item = { type: "custom_tool_call", id: "ctc_missing_done", call_id: "call_missing_done", name: "exec" }; + const expected = `const result = await tools.apply_patch(${JSON.stringify(CANONICAL_PATCH)});\ntext(result);`; + try { + if (started) { + rewrite(frame("response.output_item.added", { + output_index: 0, item: { ...item, input: "", status: "in_progress" }, + })); + // The authoritative item must win even when only a prefix was previewed upstream. + expect(rewrite(frame("response.custom_tool_call_input.delta", { + output_index: 0, item_id: item.id, delta: input.slice(0, 12), + }))).toEqual([]); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + } + if (itemDone) { + const done = rewrite(frame("response.output_item.done", { + output_index: 0, item: { ...item, input, status: "completed" }, + })); + expect(done).toHaveLength(1); + expect(dataPayload(done[0]!).item).toEqual({ ...item, input: expected, status: "completed" }); + expect(budget.snapshot().currentBytes).toBe(0); + } + const terminal = rewrite(frame("response.completed", { + response: { id: "resp_missing_done", status: "completed", output: [{ ...item, input, status: "completed" }] }, + })); + expect(terminal).toHaveLength(1); + expect(dataPayload(terminal[0]!).response).toMatchObject({ + status: "completed", output: [{ ...item, input: expected, status: "completed" }], + }); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { + rewrite.dispose?.(); + } + }); + + test.each([ + { label: "arbitrary JavaScript mentioning a patch", name: "exec", input: `const patch = ${JSON.stringify(DECORATED_PATCH)};\ntext(patch);` }, + { label: "JavaScript block with an ambiguous brace prefix", name: "exec", input: '{ const value = "literal"; text(value); }' }, + { label: "unrelated custom JSON input", name: "render_diagram", input: '{"input":"literal"}' }, + { label: "incomplete patch envelope", name: "exec", input: "*** Begin Patch ***\n*** Add File: note.txt\n+unfinished" }, + { label: "envelope without an operation", name: "exec", input: "*** Begin Patch ***\nnot an operation\n*** End Patch ***" }, + { label: "flat exec catalog", name: "exec", input: DECORATED_PATCH, flat: true }, + { label: "foreign exec namespace", name: "exec", input: DECORATED_PATCH, namespace: "mcp" }, + { label: "foreign helper namespace", name: "apply_patch", input: DECORATED_PATCH, namespace: "mcp" }, + ])("preserves native $label across completion boundaries", ({ name, input, ...options }) => { + const namespace = "namespace" in options ? options.namespace : undefined; + const flat = "flat" in options && options.flat; + const names = new Set(["exec", "render_diagram", "mcp__exec", "mcp__apply_patch"]); + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + names, undefined, new Set(), new Set([...names, ...(flat ? ["exec_command"] : [])]), + ); + const item = { + type: "custom_tool_call", id: "ctc_preserved", call_id: "call_preserved", name, + ...(namespace ? { namespace } : {}), + }; + try { + rewrite(frame("response.output_item.added", { + output_index: 0, item: { ...item, input: "", status: "in_progress" }, + })); + let preview = ""; + for (const delta of input) { + for (const block of rewrite(frame("response.custom_tool_call_input.delta", { + output_index: 0, item_id: item.id, delta, + }))) { + const payload = dataPayload(block); + expect(payload.type).toBe("response.custom_tool_call_input.delta"); + expect(typeof payload.delta).toBe("string"); + preview += payload.delta; + expect(input.startsWith(preview)).toBe(true); + } + } + // Ordinary JS and unrelated tools retain progressive input; ambiguous exec may be held. + if (input.startsWith("const ") || name === "render_diagram") expect(preview).toBe(input); + const inputDone = rewrite(frame("response.custom_tool_call_input.done", { + output_index: 0, item_id: item.id, input, + })); + expect(inputDone).toHaveLength(1); + expect(dataPayload(inputDone[0]!)).toMatchObject({ type: "response.custom_tool_call_input.done", input }); + const completedItem = { ...item, input, status: "completed" }; + const itemDone = rewrite(frame("response.output_item.done", { output_index: 0, item: completedItem })); + expect(itemDone).toHaveLength(1); + expect(dataPayload(itemDone[0]!).item).toEqual(completedItem); + const terminal = rewrite(frame("response.completed", { + response: { id: "resp_preserved", status: "completed", output: [completedItem] }, + })); + expect(terminal).toHaveLength(1); + expect(dataPayload(terminal[0]!).response).toEqual({ + id: "resp_preserved", status: "completed", output: [completedItem], + }); + } finally { + rewrite.dispose?.(); + } + }); + + test.each(["failed", "incomplete", "dispose"])("releases held native exec input on %s without synthesizing success", outcome => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + new Set(["exec"]), budget, new Set(), new Set(["exec"]), + ); + try { + rewrite(frame("response.output_item.added", { + output_index: 0, + item: { type: "custom_tool_call", id: "ctc_cancelled", call_id: "call_cancelled", name: "exec", input: "", status: "in_progress" }, + })); + expect(rewrite(frame("response.custom_tool_call_input.delta", { + output_index: 0, item_id: "ctc_cancelled", delta: WRAPPED_DECORATED_PATCH, + }))).toEqual([]); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + if (outcome === "dispose") { + expect(rewrite.dispose?.()).toBeUndefined(); + } else { + const terminal = frame(`response.${outcome}`, { + response: { id: "resp_cancelled", status: outcome, output: [] }, + }); + expect(rewrite(terminal)).toEqual([terminal]); + } + expect(budget.snapshot().currentBytes).toBe(0); + // Late provider bytes cannot reopen a cancelled collector or flush a successful item. + const lateDelta = frame("response.custom_tool_call_input.delta", { + output_index: 0, item_id: "ctc_cancelled", delta: "late", + }); + expect(rewrite(lateDelta)).toEqual([lateDelta]); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { + rewrite.dispose?.(); + } + }); + test("restores streamed exec_command arguments through unified exec", () => { const rewrite = createRoutedCustomToolRestoreBlockRewrite( new Set(["exec"]), From c79db69d4f384bc4bcdaec24946da287fcac0b0f Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 02:57:45 +0900 Subject: [PATCH 059/236] fix(adapters): reject invalid numeric indexes before tool matching Co-authored-by: Hako <25837994+devswha@users.noreply.github.com> --- .../021_tool_alias_refresh.md | 7 +++ .../src/content/docs/reference/adapters.md | 2 + src/adapters/openai-chat.ts | 11 ++++- structure/04_transports-and-sidecars.md | 16 +++++++ .../openai-chat-parallel-stream.test.ts | 45 +++++++++++++++++++ 5 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260906_d_integrations_delivery/021_tool_alias_refresh.md diff --git a/devlog/_plan/260906_d_integrations_delivery/021_tool_alias_refresh.md b/devlog/_plan/260906_d_integrations_delivery/021_tool_alias_refresh.md new file mode 100644 index 0000000000..667aae674e --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/021_tool_alias_refresh.md @@ -0,0 +1,7 @@ +# Tool-call alias cycle P refresh + +Current parent: 22da7a4bc80040f66b819239c5028e578f9a1ede, after TOML delivery. Original source c8240c51d664f7cfb790b6d60679adfe0490b5c9 remains open and authored by Hako. Relevant baseline comparison is retained in scratch; implementation uses the current tree and preserves adjacent changes. + +Apply the original commit, then the independently reviewed 020 numeric-index amendment. Missing/non-numeric placeholders keep existing tolerance; negative/fractional numeric indexes terminate before matching. Preserve the immutable reservation key and first observed valid index alias. Add direct malformed-index activation coverage alongside all original positive/collision/UTF-8 budget cases. Update the transport structure contract as planned. + +Main owns cherry-pick/commits/PR/CI/merge. An inherited worker may edit only src/adapters/openai-chat.ts, tests/adapters/openai/openai-chat-parallel-stream.test.ts, and structure/04_transports-and-sidecars.md after A passes. Main owns this document and all other files. Independent reviewer checks resulting code; all tests/typechecks execute remotely or in GitHub Actions. Full-suite readiness remains remote; no local application checks. macmini shared test lock is respected. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 930c4d8d15..62440679b2 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -53,6 +53,8 @@ Streaming tool calls retain their identity when a provider first sends an ID, then associates that ID with an index, and later sends index-only argument fragments. Those fragments assemble into one call with the original name and complete arguments; parallel calls retain separate identities. +Numeric streamed tool-call indexes must be non-negative integers; malformed numeric +indexes terminate the stream with an upstream error before identity matching. ## `ollama-native` diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 2620ce36f6..76c9d7e931 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1849,8 +1849,17 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const rawId = rawToolCall.id; const idDelta = typeof rawId === "string" ? rawId : ""; const rawIndex = rawToolCall.index; + // Invalid numeric indexes must not fall through to ID or last-call matching. + // Reject before an alias can bind or any pending call can consume the fragment. + if (typeof rawIndex === "number" + && (!Number.isInteger(rawIndex) || rawIndex < 0)) { + return yield* terminateWithError({ + ...invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage), + message: "upstream response contained invalid tool calls (invalid numeric index)", + }); + } - // Resolve the pending call BEFORE judging the fields. Some OpenAI-compatible + // Resolve the pending call BEFORE judging repeated string fields. Some OpenAI-compatible // streamers repeat an already-sent field as a non-string placeholder on a // continuation delta; judging first meant the whole stream died with a 502 even // though the value being repeated was already held in canonical form. diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 4ee22c114c..fa4258a800 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -1502,6 +1502,22 @@ shares the 12-image active cap. Bounded source labels are emitted in active user root pruning cannot erase attachment provenance; the same text participates in token estimation. Native Composer/MCP behavior and text-only historical replay remain unchanged. +## Chat streamed tool-call identity + +`src/adapters/openai-chat.ts` retains a call's first observed non-negative integer +index as an alias when the call started by ID. Numeric indexes that are negative +or non-integer terminate the stream before any key, alias, ID or last-call matching. +The invalid-index error releases all pending call reservations without emitting +those calls or a successful completion; invalid indexes are never treated as absent. +Missing and non-numeric index placeholders retain their existing tolerance. + +For valid indexes, lookup preserves direct-key precedence, then index alias, then +ID fallback. The initial key continues to own all translator budget reservations +and release; learning an alias creates no additional owner. Unassociated index-only +fragments are not guessed onto pending ID-only calls. +`tests/adapters/openai/openai-chat-parallel-stream.test.ts` covers late aliases, +parallel/colliding identities, invalid numeric indexes and UTF-8 byte-limit boundaries. + ## Sidecars Web search and vision sidecars run only when the main request needs that capability and a usable diff --git a/tests/adapters/openai/openai-chat-parallel-stream.test.ts b/tests/adapters/openai/openai-chat-parallel-stream.test.ts index ec1e3d55cd..3e65b8534e 100644 --- a/tests/adapters/openai/openai-chat-parallel-stream.test.ts +++ b/tests/adapters/openai/openai-chat-parallel-stream.test.ts @@ -268,6 +268,51 @@ describe("openai-chat parallel tool call stream assembly", () => { expect(events.some(event => event.type === "done")).toBe(false); }); + test.each([ + [-1, undefined], + [-1, "call_a"], + [0.5, undefined], + [0.5, "call_a"], + ] as const)("invalid numeric index %s with ID %s aborts without reassigning pending calls", async (index, id) => { + const budget = createTestTranslatorBudget(); + const response = new Response(sse([ + chunkOf([ + { id: "call_a", function: { name: "read", arguments: '{"p":"a"}' } }, + { id: "call_b", function: { name: "write", arguments: '{"p":"b"}' } }, + ]), + chunkOf([{ index: 0, id: "call_a", function: { arguments: "" } }]), + // Whitespace keeps either complete JSON argument valid if the invalid index is + // mistakenly ignored and this fragment falls back to its ID or the last call. + chunkOf([{ index, id, function: { arguments: " " } }]), + chunkOf([{ index: 0, function: { arguments: " " } }]), + chunkOf([], "tool_calls"), + ])); + const events: AdapterEvent[] = []; + let sawBothPendingReservations = false; + for await (const event of createOpenAIChatAdapter(provider).parseStream(response, budget)) { + events.push(event); + const snapshot = budget.snapshot(); + // Each ASCII JSON argument is nine bytes; the valid alias heartbeat observes + // both retained reservations before the malformed continuation arrives. + sawBothPendingReservations ||= snapshot.activeCalls === 2 && snapshot.currentBytes === 18; + if (event.type === "error") { + expect(snapshot).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + } + } + expect(sawBothPendingReservations).toBe(true); + expect(events.filter(event => event.type === "error")).toEqual([expect.objectContaining({ + type: "error", + status: 502, + errorType: "upstream_error", + message: "upstream response contained invalid tool calls (invalid numeric index)", + })]); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(event => event.type === "done")).toBe(false); + expect(events.some(event => event.type === "tool_call_start" + || event.type === "tool_call_delta" || event.type === "tool_call_end")).toBe(false); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + test("an observed index wins over a conflicting ID without rebinding either call", async () => { const events = await collect(sse([ chunkOf([{ id: "call_a", function: { name: "read", arguments: "{\"p\":" } }]), From 890413a7104fbeca3b01cabb54140d501534465e Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:01:12 +0900 Subject: [PATCH 060/236] docs: specify native function completion parity interfaces --- .../040_native_tool_parity.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md index c19a1e2233..afb85c5d50 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md @@ -10,3 +10,11 @@ MODIFY existing native Responses repair and namespace tests, or register new dom UPDATE structure/11_compatibility-contracts.md and guides/codex-integration.md with completion parity boundary and inventory. C: standalone synthetic imports with stub tools (no real execution) and remote focused tests/typecheck; all stack PR exact-head hosted CI must pass before merge. Keep original pi-filter/owned-refresh/responses-patch terminal criteria unchanged and satisfy them at final D with PR heads/CI/merge ancestry. Register native GitHub stack, merge approved prefix using async REST and SHA guard, wait for actual merged status, fetch dev and prove all merged SHAs ancestors. No release/deploy/local suites. Stop only verified DONE or actual external blocking evidence. Resource bounds inherited from000. + +## P revalidation at b477b731e + +030 lifecycle+raw payload boundaries are independently reviewed and55 remote tests pass. The existing bridge streams ordinary function argument previews then uses coerceIntegerToolArguments at authoritative arguments.done/item.done;040 mirrors that contract. Unlike executable exec source compilation, numeric representation repair does not require withholding previews. Do not synthesize corrected deltas; correct the authoritative completion events and JSON snapshots, and verify downstream Chat collector consumes those finals. Buffer only early identity-less completion events if correlation needs them; all retention remains budgeted. + +Implementation interfaces locked for disjoint delegation: function-call-compat.ts exports collectFunctionCallRepairSchemas(body), repairFunctionCalls(value, schemas): {value,changed}, repairFunctionCallsInJson(text,schemas). responses-function-tool-repair.ts exports createResponsesFunctionToolRepairBlockRewrite(schemas,budget?). The collector reads only original current-turn ordinary declarations and honors original function-kind/namespace selector restrictions; it can reuse pure namespace lowering to resolve selectors while preserving original schema values. Native forward routing receives an empty repair map. Empty ordinary completed arguments become{}; custom/native wrappers never enter. + +Main owns namespace-tool-compat.ts, extraction of existing ambiguity helpers to new responses/tool-name-aliases.ts, guard import updates, core integration, namespace tests, layout registration, docs. Worker owns only the two new function repair modules and one new tests/responses/responses-function-tool-repair.test.ts. No shared write paths, no worker commits/FSM/local suites. Core captures schemas after successful adapter buildRequest from the previously captured clientToolAuthorizationBody, then uses same pure repair in remembered continuation and clientJSON, and block repair after custom/tool-search restores before final declaration guard. Each attempt receives fresh block state. From 7b5cb0dc1c67fbe50521f5aa07038f72e3c5b615 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:11:04 +0900 Subject: [PATCH 061/236] fix(responses): restore native function argument and namespace parity --- .../040_native_tool_parity.md | 6 + .../content/docs/guides/codex-integration.md | 6 + scripts/test-layout/layout.json | 1 + src/responses/function-call-compat.ts | 152 +++++++++ src/responses/namespace-tool-compat.ts | 18 +- src/responses/tool-name-aliases.ts | 79 +++++ src/server/responses-function-tool-repair.ts | 178 +++++++++++ src/server/responses-undeclared-tool-guard.ts | 69 +--- src/server/responses/core.ts | 19 +- structure/11_compatibility-contracts.md | 15 + tests/fixtures/test-layout-expected.json | 1 + tests/responses/namespace-tool-compat.test.ts | 39 ++- .../responses-function-tool-repair.test.ts | 301 ++++++++++++++++++ 13 files changed, 806 insertions(+), 78 deletions(-) create mode 100644 src/responses/function-call-compat.ts create mode 100644 src/responses/tool-name-aliases.ts create mode 100644 src/server/responses-function-tool-repair.ts create mode 100644 tests/responses/responses-function-tool-repair.test.ts diff --git a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md index afb85c5d50..e1c0c13c91 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md @@ -18,3 +18,9 @@ C: standalone synthetic imports with stub tools (no real execution) and remote f Implementation interfaces locked for disjoint delegation: function-call-compat.ts exports collectFunctionCallRepairSchemas(body), repairFunctionCalls(value, schemas): {value,changed}, repairFunctionCallsInJson(text,schemas). responses-function-tool-repair.ts exports createResponsesFunctionToolRepairBlockRewrite(schemas,budget?). The collector reads only original current-turn ordinary declarations and honors original function-kind/namespace selector restrictions; it can reuse pure namespace lowering to resolve selectors while preserving original schema values. Native forward routing receives an empty repair map. Empty ordinary completed arguments become{}; custom/native wrappers never enter. Main owns namespace-tool-compat.ts, extraction of existing ambiguity helpers to new responses/tool-name-aliases.ts, guard import updates, core integration, namespace tests, layout registration, docs. Worker owns only the two new function repair modules and one new tests/responses/responses-function-tool-repair.test.ts. No shared write paths, no worker commits/FSM/local suites. Core captures schemas after successful adapter buildRequest from the previously captured clientToolAuthorizationBody, then uses same pure repair in remembered continuation and clientJSON, and block repair after custom/tool-search restores before final declaration guard. Each attempt receives fresh block state. + +## Implementation observations + +Namespace aliases are built after custom lowering (openai-responses.ts2410-2432), so an original custom tool can carry lowered kind=function. Preserve that existing namespace restoration kind behavior; only original-schema function repair enforces ordinary function kind. Dotted restoration adds spelling parity, not a new kind conversion. Explicit conflicting namespaces stay untouched. Reserved functions children participate in the shared collision inventory as bare names. The existing namespace tests are updated for additional alias entries rather than weakening their authorization assertions. + +Review-size exception: keep original-schema collection, native SSE/JSON/replay wiring and their end-to-end regressions in one layer because they jointly define the completion contract. Roughly half the added lines are focused regressions; the alias inventory is moved, not reimplemented. Prior catalog and patch concerns are already separate PRs. Additional Aside profile work remains separate future cycles. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index c015b71ebb..a5d864e673 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -330,6 +330,12 @@ Codex. Native custom calls and converted function calls use the same completion patch previews are held while their executable form is unresolved. JavaScript that merely contains patch text and unrelated native custom payloads stay unchanged. +Ordinary routed Responses function calls also use the original declared parameter schema at +completion: integral floats in integer fields and integral numbers in string-only fields are +normalized, while fractions and numeric unions stay unchanged. An explicitly empty completed +argument string becomes `{}`. Final events and locally stored continuation history agree. +Unambiguous dotted namespace spellings are restored to the declared namespace and tool name. + The selected provider must support function/tool calling. A text-only provider without tool-call support cannot use `exec`, Browser, or Computer Use. Native OpenAI rows keep their upstream tool mode unchanged. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 29dd2c5f1c..999863fbc0 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1005,6 +1005,7 @@ "responses-context-overflow.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", + "responses-function-tool-repair.test.ts": "responses", "responses-fetch-helpers-boundary.test.ts": "responses", "responses-field-backfill.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", diff --git a/src/responses/function-call-compat.ts b/src/responses/function-call-compat.ts new file mode 100644 index 0000000000..bba6a8c8ea --- /dev/null +++ b/src/responses/function-call-compat.ts @@ -0,0 +1,152 @@ +import { coerceIntegerToolArguments } from "../lib/tool-argument-integers"; +import { namespacedToolName } from "../types/tools"; +import { rewriteRoutedNamespaceToolsForUpstream } from "./namespace-tool-compat"; +import { collectResponsesToolGroups } from "./tool-groups"; + +export interface FunctionCallRepairSchema { + name: string; + namespace?: string; + parameters?: Record; +} + +/** Keys are canonical original identities, never a bare-name fallback for a namespace. */ +export type FunctionCallRepairSchemas = ReadonlyMap; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function namespaceOf(value: unknown): string | undefined { + return typeof value === "string" && value !== "functions" ? value : undefined; +} + +function selectorAllows( + selector: unknown, + lowered: unknown, + wireName: string, + identity: FunctionCallRepairSchema, +): boolean { + if (!isObject(selector) || selector.type !== "function" || typeof selector.name !== "string") return false; + if ("namespace" in selector) { + if (typeof selector.namespace !== "string" || selector.namespace.length === 0) return false; + return namespaceOf(selector.namespace) === identity.namespace && selector.name === identity.name; + } + return isObject(lowered) && lowered.type === "function" && lowered.name === wireName; +} + +/** Caller supplies currentTurnWireToolCatalogBody BEFORE provider schema lowering. */ +export function collectFunctionCallRepairSchemas(body: unknown): Map { + const schemas = new Map(); + if (!isObject(body)) return schemas; + // Reuse namespace selector resolution, retaining schemas from the original objects below. + const lowered = rewriteRoutedNamespaceToolsForUpstream(body).body; + const choice = body.tool_choice; + const loweredChoice = isObject(lowered) ? lowered.tool_choice : undefined; + const occupied = new Map(); + const register = (tool: unknown, namespace?: string): void => { + if (!isObject(tool) || typeof tool.name !== "string" || tool.name.length === 0) return; + const identity: FunctionCallRepairSchema = { + name: tool.name, + ...(namespace ? { namespace } : {}), + ...(isObject(tool.parameters) ? { parameters: tool.parameters } : {}), + }; + const key = namespacedToolName(namespace, tool.name); + if (occupied.has(key)) { + const previous = occupied.get(key); + // Conflicting duplicate declarations cannot choose a schema by insertion order. + if (!previous || previous.kind !== tool.type + || previous.identity.namespace !== namespace + || previous.identity.name !== tool.name + || JSON.stringify(previous.identity.parameters) !== JSON.stringify(identity.parameters)) { + occupied.set(key, null); + } + } else occupied.set(key, { kind: tool.type, identity }); + }; + for (const group of collectResponsesToolGroups(body)) { + for (const tool of group) { + if (!isObject(tool)) continue; + if (tool.type === "namespace") { + if (typeof tool.name !== "string" || !tool.name || !Array.isArray(tool.tools)) continue; + for (const child of tool.tools) register(child, namespaceOf(tool.name)); + } else if (tool.type === "function" && isObject(tool.function)) { + register({ ...tool.function, type: "function" }); + } else register(tool); + } + } + for (const [key, entry] of occupied) { + if (!entry || entry.kind !== "function") continue; + let allowed = choice === undefined || choice === "auto" || choice === "required"; + if (isObject(choice)) { + if (choice.type === "allowed_tools" && Array.isArray(choice.tools)) { + const selectors = isObject(loweredChoice) && Array.isArray(loweredChoice.tools) ? loweredChoice.tools : []; + allowed = choice.tools.some((selector, index) => selectorAllows(selector, selectors[index], key, entry.identity)); + } else allowed = selectorAllows(choice, loweredChoice, key, entry.identity); + } + if (allowed) schemas.set(key, entry.identity); + } + return schemas; +} + +function repairItem(item: unknown, schemas: FunctionCallRepairSchemas, completed: boolean): unknown { + if (!isObject(item) || item.type !== "function_call" || typeof item.name !== "string" + || typeof item.arguments !== "string") return item; + if (item.status !== "completed" && !(item.status === undefined && completed)) return item; + if ("namespace" in item && (typeof item.namespace !== "string" || !item.namespace)) return item; + const namespace = namespaceOf(item.namespace); + const schema = schemas.get(namespacedToolName(namespace, item.name)); + if (!schema) return item; + // An explicit namespace is an identity coordinate, not another spelling to guess at. + if ("namespace" in item && (schema.namespace !== namespace || schema.name !== item.name)) return item; + const raw = item.arguments; + if (raw !== "") { + try { + let unsafe = false; + JSON.parse(raw, (_key, value: unknown) => { + if (typeof value === "number" && (!Number.isFinite(value) + || (Number.isInteger(value) && !Number.isSafeInteger(value)))) unsafe = true; + return value; + }); + // Re-stringifying another repaired field must not round an unsafe sibling number. + if (unsafe) return item; + } catch { return item; } + } + const argumentsText = coerceIntegerToolArguments(raw || "{}", schema.parameters, schema.namespace ? undefined : schema.name); + return argumentsText === raw ? item : { ...item, arguments: argumentsText }; +} + +/** Only executable completion slots are visited; metadata and custom input are opaque. */ +export function repairFunctionCalls( + value: unknown, + schemas: FunctionCallRepairSchemas, +): { value: unknown; changed: boolean } { + if (schemas.size === 0 || !isObject(value)) return { value, changed: false }; + if (typeof value.status === "string" && ["failed", "incomplete", "cancelled", "in_progress", "queued"].includes(value.status)) return { value, changed: false }; + let next: unknown = value; + if (value.type === "function_call") next = repairItem(value, schemas, false); + else if (value.type === "response.output_item.done") { + const item = repairItem(value.item, schemas, true); + if (item !== value.item) next = { ...value, item }; + } else if (value.type === "response.completed" && isObject(value.response)) { + const response = value.response; + if ((response.status === undefined || response.status === "completed") && Array.isArray(response.output)) { + const original = response.output; + const output = original.map(item => repairItem(item, schemas, true)); + if (output.some((item, index) => item !== original[index])) next = { ...value, response: { ...response, output } }; + } + } else if (typeof value.type !== "string" || !value.type.startsWith("response.")) { + if (Array.isArray(value.output)) { + const original = value.output; + const output = original.map(item => repairItem(item, schemas, value.status === "completed")); + if (output.some((item, index) => item !== original[index])) next = { ...value, output }; + } + } + return { value: next, changed: next !== value }; +} + +export function repairFunctionCallsInJson(text: string, schemas: FunctionCallRepairSchemas): string { + if (schemas.size === 0) return text; + let payload: unknown; + try { payload = JSON.parse(text); } catch { return text; } + const repaired = repairFunctionCalls(payload, schemas); + return repaired.changed ? JSON.stringify(repaired.value) : text; +} diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 3088ab4c5d..d13e19422a 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -1,5 +1,6 @@ -import { namespacedToolName } from "../types"; +import { dottedToolName, namespacedToolName } from "../types"; import { collectResponsesToolGroups } from "./tool-groups"; +import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous } from "./tool-name-aliases"; export interface RoutedNamespaceToolIdentity { namespace: string; @@ -365,6 +366,16 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { } const toolChoice = rewriteToolChoice(body.tool_choice, plan); + const aliases = authorizedAliases(plan.aliases, toolChoice); + const ambiguousDotted = collectAmbiguousDottedAliases(groups); + // Authorize canonical identities first, then add only unambiguous spellings. + // Selection cannot hide a collision elsewhere in the original declaration set. + for (const identity of [...aliases.values()]) { + const dotted = dottedToolName(identity.namespace, identity.name); + if (dottedAliasIsUnambiguous(identity.namespace, identity.name) + && !ambiguousDotted.has(dotted) && !plan.bareWireNames.has(dotted) + && !aliases.has(dotted)) aliases.set(dotted, identity); + } return { body: { ...body, @@ -372,7 +383,7 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { ...(input !== body.input ? { input } : {}), ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}), }, - aliases: authorizedAliases(plan.aliases, toolChoice), + aliases, }; } @@ -404,7 +415,8 @@ export function restoreRoutedNamespaceCalls( && typeof value.name === "string" ) { const identity = aliases.get(value.name); - if (identity) { + if (identity + && (!Object.hasOwn(value, "namespace") || value.namespace === identity.namespace)) { restored.name = identity.name; restored.namespace = identity.namespace; changed = true; diff --git a/src/responses/tool-name-aliases.ts b/src/responses/tool-name-aliases.ts new file mode 100644 index 0000000000..1943a686bf --- /dev/null +++ b/src/responses/tool-name-aliases.ts @@ -0,0 +1,79 @@ +import { dottedToolName, namespacedToolName } from "../types"; + +const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** + * A dotted spelling is a safe alias only when it cannot ALSO be read as some other identity's + * canonical `ns__name`. + * + * `{namespace: "x__y", name: "z"}` produces the dotted spelling "x__y.z", which is exactly the + * canonical wire name of `{namespace: "x", name: "y.z"}`. If only the latter is declared, an + * echoed call for the former would still find "x__y.z" in the declared set and be authorized as + * a tool the caller never granted. Requiring both halves to be free of the `__` separator keeps + * a dotted alias from ever impersonating a canonical name. + */ +export function dottedAliasIsUnambiguous(namespace: string, name: string): boolean { + return !namespace.includes("__") && !name.includes("__"); +} + +export function wireToolInnerName(tool: unknown): string | undefined { + if (!isPlainObject(tool)) return undefined; + const nestedFunction = tool.type === "function" && isPlainObject(tool.function) + ? tool.function + : undefined; + return typeof tool.name === "string" && tool.name.length > 0 + ? tool.name + : typeof nestedFunction?.name === "string" && nestedFunction.name.length > 0 + ? nestedFunction.name + : undefined; +} + +/** + * Dotted aliases that more than one declared identity would claim, plus dotted aliases that + * collide with a canonical or bare declared name. + * + * Resolved over the WHOLE catalog before any name is registered, so which identity "wins" can + * never depend on declaration order -- an order the caller controls. + */ +export function collectAmbiguousDottedAliases(specGroups: readonly unknown[]): Set { + const owners = new Map(); + const claim = (alias: string, identity: string): void => { + const owner = owners.get(alias); + if (owner === undefined) owners.set(alias, identity); + else if (owner !== identity) owners.set(alias, null); + }; + for (const specs of specGroups) { + if (!Array.isArray(specs)) continue; + for (const spec of specs) { + if (!isPlainObject(spec)) continue; + if (spec.type === "namespace" && Array.isArray(spec.tools)) { + const namespace = typeof spec.name === "string" ? spec.name : undefined; + if (!namespace) continue; + for (const inner of spec.tools) { + const name = wireToolInnerName(inner); + if (!name) continue; + if (namespace === BUILTIN_FUNCTIONS_NAMESPACE) { + claim(name, JSON.stringify([undefined, name])); + continue; + } + const identity = JSON.stringify([namespace, name]); + claim(dottedToolName(namespace, name), identity); + // A canonical or bare name already owned by a different identity poisons the dotted + // alias that would shadow it. + claim(namespacedToolName(namespace, name), identity); + claim(name, identity); + } + continue; + } + const name = wireToolInnerName(spec); + if (name) claim(name, JSON.stringify([undefined, name])); + } + } + const ambiguous = new Set(); + for (const [alias, owner] of owners) if (owner === null) ambiguous.add(alias); + return ambiguous; +} + diff --git a/src/server/responses-function-tool-repair.ts b/src/server/responses-function-tool-repair.ts new file mode 100644 index 0000000000..8041e431cc --- /dev/null +++ b/src/server/responses-function-tool-repair.ts @@ -0,0 +1,178 @@ +import { + TRANSLATOR_MAX_TURN_BYTES, + TranslatorBudgetExceededError, + type TranslatorBudget, +} from "../lib/translator-budget"; +import { repairFunctionCalls, type FunctionCallRepairSchemas } from "../responses/function-call-compat"; +import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; + +type Identity = { + itemId?: string; + outputIndex?: number; + item: Record; + bytes: number; +}; +type PendingCompletion = { + block: string; + itemId?: string; + outputIndex?: number; + bytes: number; +}; +const ENTRY_OVERHEAD_BYTES = 64; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function outputIndexOf(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + +/** Ordinary deltas remain upstream previews; only authoritative completions are repaired. */ +export function createResponsesFunctionToolRepairBlockRewrite( + schemas: FunctionCallRepairSchemas, + budget?: TranslatorBudget, +): SseBlockRewrite { + if (schemas.size === 0) return block => [block]; + const byId = new Map(); + const byIndex = new Map(); + let pending: PendingCompletion[] = []; + let retainedBytes = 0; + let disposed = false; + + const retain = (bytes: number): void => { + if (retainedBytes + bytes > TRANSLATOR_MAX_TURN_BYTES) { + throw new TranslatorBudgetExceededError("retained_collectors", TRANSLATOR_MAX_TURN_BYTES); + } + budget?.chargeRetained(bytes, { kind: "retained_collectors" }); + retainedBytes += bytes; + }; + const release = (bytes: number): void => { + budget?.releaseRetained(bytes, { kind: "retained_collectors" }); + retainedBytes -= bytes; + }; + const releaseIdentity = (identity: Identity): void => { + if (identity.itemId !== undefined) byId.delete(identity.itemId); + if (identity.outputIndex !== undefined) byIndex.delete(identity.outputIndex); + release(identity.bytes); + }; + const dispose = (): void => { + if (disposed) return; + disposed = true; + release(retainedBytes); + byId.clear(); + byIndex.clear(); + pending = []; + }; + const lookup = (itemId: string | undefined, index: number | undefined): Identity | undefined => { + const identity = itemId === undefined ? undefined : byId.get(itemId); + if (identity) return index === undefined || identity.outputIndex === undefined || identity.outputIndex === index ? identity : undefined; + const indexed = index === undefined ? undefined : byIndex.get(index); + return indexed && (itemId === undefined || indexed.itemId === undefined || indexed.itemId === itemId) ? indexed : undefined; + }; + const register = (item: Record, index: number | undefined): Identity | undefined => { + const itemId = typeof item.id === "string" && item.id ? item.id : undefined; + if (itemId === undefined && index === undefined) return undefined; + // Repeated snapshots replace metadata; never retain provider argument bodies here. + const previous = new Set(); + if (itemId !== undefined && byId.has(itemId)) previous.add(byId.get(itemId)!); + if (index !== undefined && byIndex.has(index)) previous.add(byIndex.get(index)!); + for (const identity of previous) releaseIdentity(identity); + const metadata = { + type: item.type, + name: item.name, + ...("namespace" in item ? { namespace: item.namespace } : {}), + ...(item.status !== undefined && item.status !== "in_progress" && item.status !== "completed" ? { status: item.status } : {}), + }; + const bytes = ENTRY_OVERHEAD_BYTES + Buffer.byteLength(JSON.stringify([itemId, index, metadata]), "utf8"); + retain(bytes); + const identity = { itemId, outputIndex: index, item: metadata, bytes }; + if (itemId !== undefined) byId.set(itemId, identity); + if (index !== undefined) byIndex.set(index, identity); + return identity; + }; + const repairCompletion = (block: string, event: Record, identity: Identity): string => { + if (typeof event.arguments !== "string") return block; + const repaired = repairFunctionCalls({ ...identity.item, status: identity.item.status ?? "completed", arguments: event.arguments }, schemas); + if (!repaired.changed || !isObject(repaired.value)) return block; + return replaceSseDataPayload(block, JSON.stringify({ ...event, arguments: repaired.value.arguments })); + }; + const flushPending = (identity: Identity): string[] => { + const output: string[] = []; + const remaining: PendingCompletion[] = []; + for (const completion of pending) { + const matches = completion.itemId !== undefined + ? completion.itemId === identity.itemId + && (completion.outputIndex === undefined || identity.outputIndex === undefined || completion.outputIndex === identity.outputIndex) + : completion.outputIndex !== undefined && completion.outputIndex === identity.outputIndex; + if (!matches) { remaining.push(completion); continue; } + release(completion.bytes); + const payload = sseDataPayload(completion.block); + const event: unknown = payload === null ? undefined : JSON.parse(payload); + output.push(isObject(event) ? repairCompletion(completion.block, event, identity) : completion.block); + } + pending = remaining; + return output; + }; + + const rewrite: SseBlockRewrite = block => { + if (disposed) return [block]; + const payload = sseDataPayload(block); + if (payload === null) return [block]; + if (payload === "[DONE]") { + const unfinished = pending.map(entry => entry.block); + dispose(); + return [...unfinished, block]; + } + let event: unknown; + try { event = JSON.parse(payload); } catch { return [block]; } + if (!isObject(event)) return [block]; + const index = outputIndexOf(event.output_index); + const itemId = typeof event.item_id === "string" && event.item_id ? event.item_id : undefined; + try { + if ((event.type === "response.output_item.added" || event.type === "response.output_item.done") && isObject(event.item)) { + const identity = register(event.item, index); + const replayed = identity ? flushPending(identity) : []; + const repaired = repairFunctionCalls(event, schemas); + const output = repaired.changed ? replaceSseDataPayload(block, JSON.stringify(repaired.value)) : block; + if (identity && (event.type === "response.output_item.done" || replayed.length > 0)) releaseIdentity(identity); + return event.type === "response.output_item.added" ? [output, ...replayed] : [...replayed, output]; + } + if (event.type === "response.function_call_arguments.done" && typeof event.arguments === "string") { + const identity = lookup(itemId, index); + if (identity) { + const output = repairCompletion(block, event, identity); + releaseIdentity(identity); + return [output]; + } + if (itemId !== undefined || index !== undefined) { + const bytes = ENTRY_OVERHEAD_BYTES + Buffer.byteLength(block, "utf8"); + retain(bytes); + pending.push({ block, itemId, outputIndex: index, bytes }); + return []; + } + } + if (typeof event.type === "string" && ["response.completed", "response.failed", "response.incomplete", "response.cancelled"].includes(event.type)) { + const replayed: string[] = []; + if (event.type === "response.completed" && isObject(event.response) && Array.isArray(event.response.output) + && (event.response.status === undefined || event.response.status === "completed")) { + for (const [slot, item] of event.response.output.entries()) { + if (!isObject(item)) continue; + const identity = register(item, slot); + if (identity) replayed.push(...flushPending(identity)); + } + } + replayed.push(...pending.map(entry => entry.block)); + const repaired = repairFunctionCalls(event, schemas); + dispose(); + return [...replayed, repaired.changed ? replaceSseDataPayload(block, JSON.stringify(repaired.value)) : block]; + } + return [block]; + } catch (error) { + dispose(); + throw error; + } + }; + rewrite.dispose = dispose; + return rewrite; +} diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index acdd282b07..cad1b39011 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -1,3 +1,4 @@ +import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous, wireToolInnerName } from "../responses/tool-name-aliases"; import { CODE_MODE_EXEC_TOOL_NAME, dottedToolName, @@ -75,32 +76,6 @@ function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } -/** - * A dotted spelling is a safe alias only when it cannot ALSO be read as some other identity's - * canonical `ns__name`. - * - * `{namespace: "x__y", name: "z"}` produces the dotted spelling "x__y.z", which is exactly the - * canonical wire name of `{namespace: "x", name: "y.z"}`. If only the latter is declared, an - * echoed call for the former would still find "x__y.z" in the declared set and be authorized as - * a tool the caller never granted. Requiring both halves to be free of the `__` separator keeps - * a dotted alias from ever impersonating a canonical name. - */ -function dottedAliasIsUnambiguous(namespace: string, name: string): boolean { - return !namespace.includes("__") && !name.includes("__"); -} - -function wireToolInnerName(tool: unknown): string | undefined { - if (!isPlainObject(tool)) return undefined; - const nestedFunction = tool.type === "function" && isPlainObject(tool.function) - ? tool.function - : undefined; - return typeof tool.name === "string" && tool.name.length > 0 - ? tool.name - : typeof nestedFunction?.name === "string" && nestedFunction.name.length > 0 - ? nestedFunction.name - : undefined; -} - function addWireToolName( names: Set, tool: unknown, @@ -171,48 +146,6 @@ function addWireToolSpecs( } } -/** - * Dotted aliases that more than one declared identity would claim, plus dotted aliases that - * collide with a canonical or bare declared name. - * - * Resolved over the WHOLE catalog before any name is registered, so which identity "wins" can - * never depend on declaration order -- an order the caller controls. - */ -function collectAmbiguousDottedAliases(specGroups: readonly unknown[]): Set { - const owners = new Map(); - const claim = (alias: string, identity: string): void => { - const owner = owners.get(alias); - if (owner === undefined) owners.set(alias, identity); - else if (owner !== identity) owners.set(alias, null); - }; - for (const specs of specGroups) { - if (!Array.isArray(specs)) continue; - for (const spec of specs) { - if (!isPlainObject(spec)) continue; - if (spec.type === "namespace" && Array.isArray(spec.tools)) { - const namespace = typeof spec.name === "string" ? spec.name : undefined; - if (!namespace || namespace === BUILTIN_FUNCTIONS_NAMESPACE) continue; - for (const inner of spec.tools) { - const name = wireToolInnerName(inner); - if (!name) continue; - const identity = JSON.stringify([namespace, name]); - claim(dottedToolName(namespace, name), identity); - // A canonical or bare name already owned by a different identity poisons the dotted - // alias that would shadow it. - claim(namespacedToolName(namespace, name), identity); - claim(name, identity); - } - continue; - } - const name = wireToolInnerName(spec); - if (name) claim(name, JSON.stringify([undefined, name])); - } - } - const ambiguous = new Set(); - for (const [alias, owner] of owners) if (owner === null) ambiguous.add(alias); - return ambiguous; -} - /** * Tool names the OUTBOUND Responses body actually declared. * diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 9e087bcc90..d992c07856 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -380,6 +380,8 @@ import { } from "../sse-payload-rewrite"; import { restoreRoutedCustomCalls, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair"; +import { collectFunctionCallRepairSchemas, repairFunctionCalls, repairFunctionCallsInJson } from "../../responses/function-call-compat"; +import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-function-tool-repair"; import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair"; import { @@ -3976,6 +3978,9 @@ async function handleResponsesInner( } throw error; } + const functionRepairSchemas = isCanonicalOpenAiForwardProvider(route.provider) + ? new Map() + : collectFunctionCallRepairSchemas(clientToolAuthorizationBody); if (!isCanonicalOpenAiForwardProvider(route.provider)) { for (const name of request.convertedRoutedCustomToolNames ?? []) { if ( @@ -4153,12 +4158,12 @@ async function handleResponsesInner( const rememberPassthroughResponseChecked = rememberPassthroughResponse ? (response: { id?: unknown; output?: unknown; status?: unknown }) => { if (inspectionSawUndeclaredTool) return; - const restoredResponse = restoreRoutedCustomCalls( - restoreAuthorizedBareNamespaceToolCalls(response), + const restoredResponse = repairFunctionCalls(restoreRoutedCustomCalls( + restoreAuthorizedBareNamespaceToolCalls(restoreRoutedNamespaceCalls(response, routedNamespaceToolAliases).value), routedCustomToolNames, routedCustomToolRepairNames, declaredWireToolNames, - ).value as { id?: unknown; output?: unknown; status?: unknown }; + ).value, functionRepairSchemas).value as { id?: unknown; output?: unknown; status?: unknown }; if ( undeclaredToolGuardActive && undeclaredToolCallNameInResponse( @@ -5056,6 +5061,9 @@ async function handleResponsesInner( ? createResponsesSnapshotBlockRewrite(outboundRequestBody, translatorBudget) : undefined, createResponsesFieldBackfillBlockRewrite(), + functionRepairSchemas.size > 0 + ? createResponsesFunctionToolRepairBlockRewrite(functionRepairSchemas, translatorBudget) + : undefined, // Last: every rewrite above can still rename or reshape a call item, so the guard must // compare the names the client will actually receive against the declared catalog. undeclaredToolGuardActive @@ -5273,9 +5281,10 @@ async function handleResponsesInner( restored, routedToolSearchNames, ); + const restoredFunctionCalls = repairFunctionCallsInJson(restoredToolSearch, functionRepairSchemas); const repaired = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair) - ? repairResponsesSnapshotJson(restoredToolSearch, outboundRequestBody) - : restoredToolSearch; + ? repairResponsesSnapshotJson(restoredFunctionCalls, outboundRequestBody) + : restoredFunctionCalls; const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId ? rewriteResponsesModelJson(backfillResponsesFieldsJson(repaired), parsed._responseModelId) : backfillResponsesFieldsJson(repaired); diff --git a/structure/11_compatibility-contracts.md b/structure/11_compatibility-contracts.md index 295590788a..c503cc0991 100644 --- a/structure/11_compatibility-contracts.md +++ b/structure/11_compatibility-contracts.md @@ -75,3 +75,18 @@ resolver at input.done, output_item.done and terminal snapshots. Potential raw/w previews are withheld before compilation; ordinary native custom payloads retain their raw grammar. A string merely containing patch markers remains executable caller input and is never rewritten. Completion and disposal release retained preview buffers. + +## Native ordinary function completion + +The native Responses lane captures ordinary function schemas from the current caller-owned +catalog before provider lowering; historical replay catalogs cannot add repair authority. +Completion events, JSON responses and stored continuation output share schema-aware argument +repair. Preview deltas retain the existing bridge contract; authoritative completed arguments +carry representation fixes. Custom tool wrappers and native forward traffic are excluded. + +Namespace restoration and the undeclared-name guard share one dotted-alias collision inventory, +including bare declarations inside the reserved functions group. Canonical authorization happens +before dotted aliases are added. A conflicting explicit namespace is never overwritten. Namespace +restoration retains the existing lowered-kind handling because custom tools are lowered to +functions before the adapter constructs its alias map; ordinary argument repair independently +checks the original declaration kind. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 114c699eaf..cfab75fefb 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -842,6 +842,7 @@ "responses-context-overflow.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", + "responses-function-tool-repair.test.ts": "responses", "responses-fetch-helpers-boundary.test.ts": "responses", "responses-field-backfill.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", diff --git a/tests/responses/namespace-tool-compat.test.ts b/tests/responses/namespace-tool-compat.test.ts index 7724661246..622fe09411 100644 --- a/tests/responses/namespace-tool-compat.test.ts +++ b/tests/responses/namespace-tool-compat.test.ts @@ -67,6 +67,7 @@ describe("Responses namespace tool compatibility", () => { ]); expect([...rewritten.aliases]).toEqual([ ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], + ["collaboration.spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], ]); }); @@ -124,6 +125,7 @@ describe("Responses namespace tool compatibility", () => { }); expect([...allowed.aliases]).toEqual([ ["collaboration__safe", { namespace: "collaboration", name: "safe", kind: "function" }], + ["collaboration.safe", { namespace: "collaboration", name: "safe", kind: "function" }], ]); expect(restoreRoutedNamespaceCalls({ type: "function_call", @@ -237,7 +239,7 @@ describe("Responses namespace tool compatibility", () => { ], }, }); - expect([...aliases.keys()]).toEqual([wireName]); + expect([...aliases.keys()]).toEqual([wireName, "collaboration.safe"]); }); }); @@ -253,7 +255,7 @@ describe("Responses namespace tool compatibility", () => { const { aliases } = rewriteRoutedNamespaceToolsForUpstream( choice === undefined ? { tools } : { tools, tool_choice: choice }, ); - expect(aliases.size).toBe(2); + expect(aliases.size).toBe(4); } // A top-level selector for another tool kind states a restriction that no // namespace call satisfies, so it authorizes nothing. @@ -482,6 +484,7 @@ describe("Responses namespace tool compatibility", () => { test("restores only aliases authorized by this request in JSON and SSE payloads", () => { const aliases = new Map([ ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], + ["collaboration.spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], ]); const payload = { type: "response.completed", @@ -517,3 +520,35 @@ describe("Responses namespace tool compatibility", () => { expect(restoreRoutedNamespaceCallsInJson("not-json", aliases)).toBe("not-json"); }); }); + +describe("dotted namespace restoration uses the declaration collision boundary", () => { + const ping = { type: "namespace", name: "mcp", tools: [{ type: "function", name: "ping", parameters: {} }] }; + test("restores the dotted spelling after canonical tool-choice authorization", () => { + const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools: [ping], tool_choice: { type: "function", namespace: "mcp", name: "ping" } }); + expect(restoreRoutedNamespaceCalls({ type: "function_call", name: "mcp.ping", arguments: "{}" }, aliases).value) + .toEqual({ type: "function_call", name: "ping", namespace: "mcp", arguments: "{}" }); + const conflicting = { type: "function_call", name: "mcp.ping", namespace: "other", arguments: "{}" }; + expect(restoreRoutedNamespaceCalls(conflicting, aliases).value).toEqual(conflicting); + }); + test.each([ + { type: "function", name: "mcp.ping", parameters: {} }, + { type: "namespace", name: "functions", tools: [{ type: "function", name: "mcp.ping", parameters: {} }] }, + ])("a bare canonical declaration prevents dotted shadowing in either order", collision => { + for (const tools of [[ping, collision], [collision, ping]]) { + const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools, tool_choice: { type: "function", namespace: "mcp", name: "ping" } }); + expect(aliases.has("mcp.ping")).toBe(false); + expect(aliases.has("mcp__ping")).toBe(true); + } + }); + test("different dotted coordinates remain ambiguous and canonical forms remain distinct", () => { + for (const tools of [ + [{ type: "namespace", name: "a.b", tools: [{ type: "function", name: "c" }] }, { type: "namespace", name: "a", tools: [{ type: "function", name: "b.c" }] }], + [{ type: "namespace", name: "a", tools: [{ type: "function", name: "b.c" }] }, { type: "namespace", name: "a.b", tools: [{ type: "function", name: "c" }] }], + ]) { + const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools }); + expect(aliases.has("a.b.c")).toBe(false); + expect(aliases.has("a.b__c")).toBe(true); + expect(aliases.has("a__b.c")).toBe(true); + } + }); +}); diff --git a/tests/responses/responses-function-tool-repair.test.ts b/tests/responses/responses-function-tool-repair.test.ts new file mode 100644 index 0000000000..2b8b129fb6 --- /dev/null +++ b/tests/responses/responses-function-tool-repair.test.ts @@ -0,0 +1,301 @@ +import { handleResponses } from "../../src/server/responses"; +import type { OcxConfig } from "../../src/types"; +import { describe, expect, test } from "bun:test"; +import { + collectFunctionCallRepairSchemas, + repairFunctionCalls, + repairFunctionCallsInJson, +} from "../../src/responses/function-call-compat"; +import { createResponsesFunctionToolRepairBlockRewrite } from "../../src/server/responses-function-tool-repair"; +import { createTranslatorBudget, TranslatorBudgetExceededError } from "../../src/lib/translator-budget"; +import { sseDataPayload } from "../../src/server/sse-payload-rewrite"; + +const parameters = { type: "object", properties: { + cell_id: { type: "string" }, yield_time_ms: { type: "integer" }, + union: { type: ["number", "string"] }, +} }; +const wait = { type: "function", name: "wait", parameters }; +const schemas = collectFunctionCallRepairSchemas({ tools: [wait, { type: "function", name: "get_state" }] }); +const raw = '{"cell_id":4,"yield_time_ms":120000.0}'; +const canonical = '{"cell_id":"4","yield_time_ms":120000}'; + +function item(argumentsText: unknown = raw, overrides: Record = {}) { + return { type: "function_call", id: "fc_one", call_id: "call_one", name: "wait", arguments: argumentsText, status: "completed", ...overrides }; +} +function frame(type: string, fields: Record) { + return `event: ${type}\ndata: ${JSON.stringify({ type, ...fields })}`; +} +function payload(block: string): Record { + return JSON.parse(sseDataPayload(block)!) as Record; +} +function repairedItem(argumentsText: unknown, overrides: Record = {}) { + return repairFunctionCalls(item(argumentsText, overrides), schemas).value; +} + +describe("original function declaration authority", () => { + const groups = [ + { type: "namespace", name: "left", tools: [wait] }, + { type: "namespace", name: "right", tools: [{ ...wait, parameters: { type: "object", properties: { cell_id: { type: "number" } } } }] }, + { type: "custom", name: "exec", description: "JavaScript" }, + { type: "web_search" }, + ]; + + test("preserves schema references and distinct same-inner-name identities", () => { + const map = collectFunctionCallRepairSchemas({ tools: groups }); + expect([...map.keys()]).toEqual(["left__wait", "right__wait"]); + expect(map.get("left__wait")?.parameters).toBe(parameters); + expect(repairFunctionCalls(item('{"cell_id":4}', { namespace: "left" }), map).value) + .toMatchObject({ arguments: '{"cell_id":"4"}' }); + expect(repairFunctionCalls(item('{"cell_id":4}', { namespace: "right" }), map).changed).toBe(false); + expect(repairFunctionCalls(item(raw), map).changed).toBe(false); + expect(repairFunctionCalls(item(raw, { name: "left__wait", namespace: "right" }), map).changed).toBe(false); + expect(repairFunctionCalls(item(raw, { name: "left__wait", namespace: "functions" }), map).changed).toBe(false); + }); + + test.each([ + [{ type: "function", namespace: "left", name: "wait" }, ["left__wait"]], + [{ type: "function", name: "left__wait" }, ["left__wait"]], + [{ type: "function", name: "left.wait" }, ["left__wait"]], + [{ type: "function", name: "wait" }, []], + [{ type: "custom", namespace: "left", name: "wait" }, []], + [{ type: "function", namespace: "right", name: "left__wait" }, []], + [{ type: "function", namespace: "", name: "left__wait" }, []], + [{ type: "function", namespace: null, name: "left__wait" }, []], + [{ type: "file_search", name: "left__wait" }, []], + ["none", []], + [null, []], + [{ type: "allowed_tools", tools: [{ type: "function", namespace: "right", name: "wait" }, { type: "custom", name: "left__wait" }] }, ["right__wait"]], + ])("honors exact selector %j", (tool_choice, keys) => { + expect([...collectFunctionCallRepairSchemas({ tools: groups, tool_choice }).keys()]).toEqual(keys); + }); + + test("reserved functions remain bare and support explicit namespace selectors", () => { + const map = collectFunctionCallRepairSchemas({ + tools: [{ type: "namespace", name: "functions", tools: [wait] }], + tool_choice: { type: "function", namespace: "functions", name: "wait" }, + }); + expect(map.get("wait")).toMatchObject({ name: "wait", parameters }); + expect(map.get("wait")).not.toHaveProperty("namespace"); + expect(repairFunctionCalls(item(raw, { namespace: "functions" }), map).value).toMatchObject({ arguments: canonical }); + }); + + test("reads supplied current-turn groups, not declarations nested in replay messages or metadata", () => { + const map = collectFunctionCallRepairSchemas({ + input: [{ type: "additional_tools", tools: [wait] }, { type: "message", tools: [{ type: "function", name: "old" }] }], + metadata: { tools: [{ type: "function", name: "shadow" }] }, + }); + expect([...map.keys()]).toEqual(["wait"]); + expect(collectFunctionCallRepairSchemas({ input: [{ type: "message", tools: [wait] }] }).size).toBe(0); + }); + + test("conflicting same-wire schemas cannot win by declaration order", () => { + const other = { ...wait, parameters: { type: "object", properties: { cell_id: { type: "number" } } } }; + for (const tools of [[wait, other], [other, wait], [wait, { type: "custom", name: "wait" }]]) { + expect(collectFunctionCallRepairSchemas({ tools }).size).toBe(0); + } + }); + + test("namespace wait does not inherit bare wait number-field exceptions", () => { + const numberWait = { ...wait, parameters: { type: "object", properties: { yield_time_ms: { type: "number" } } } }; + const map = collectFunctionCallRepairSchemas({ tools: [numberWait, { type: "namespace", name: "remote", tools: [numberWait] }] }); + expect(repairFunctionCalls(item('{"yield_time_ms":1000.0}'), map).value).toMatchObject({ arguments: '{"yield_time_ms":1000}' }); + expect(repairFunctionCalls(item('{"yield_time_ms":1000.0}', { namespace: "remote" }), map).changed).toBe(false); + }); +}); + +describe("pure function completion repair", () => { + test("repairs integer/string arguments and explicit completed empty arguments", () => { + expect(repairedItem(raw)).toEqual(item(canonical)); + expect(repairedItem("", { name: "get_state" })).toEqual(item("{}", { name: "get_state" })); + const missing = { type: "function_call", name: "get_state", status: "completed" }; + expect(repairFunctionCalls(missing, schemas).value).toBe(missing); + }); + + test.each([" ", "{", '{"cell_id":4.5}', '{"yield_time_ms":1.5}', '{"union":4.0}', + '{"cell_id":9007199254740993}', '{"cell_id":4,"unknown":9007199254740993}', '{"cell_id":4,"unknown":1e400}']) + ("preserves invalid/disagreeing/unsafe payload %s", argumentsText => { + const value = item(argumentsText); + expect(repairFunctionCalls(value, schemas)).toEqual({ value, changed: false }); + }); + + test("preserves custom/helper/unknown calls, previews and failed/incomplete snapshots", () => { + for (const overrides of [{ type: "custom_tool_call", input: raw }, { name: "exec_command" }, { name: "exec" }, + { status: "in_progress" }, { status: "incomplete" }, { namespace: "missing" }]) { + const value = item(raw, overrides); + expect(repairFunctionCalls(value, schemas).value).toBe(value); + } + for (const status of ["failed", "incomplete", "in_progress"]) { + const value = { status, output: [item()] }; + expect(repairFunctionCalls(value, schemas).value).toBe(value); + } + const added = { type: "response.output_item.added", item: item("") }; + expect(repairFunctionCalls(added, schemas).value).toBe(added); + }); + + test("repairs JSON/item/terminal completions without visiting metadata or adding status", () => { + const shadow = item(); + const value = { status: "completed", output: [item()], metadata: { shadow } }; + const json = repairFunctionCallsInJson(JSON.stringify(value), schemas); + expect(JSON.parse(json)).toEqual({ ...value, output: [item(canonical)] }); + expect(repairFunctionCallsInJson(json, schemas)).toBe(json); + expect(repairFunctionCallsInJson("not JSON", schemas)).toBe("not JSON"); + const noStatus = { ...item("", { name: "get_state" }), status: undefined }; + expect(repairFunctionCalls({ type: "response.output_item.done", item: noStatus }, schemas).value) + .toEqual({ type: "response.output_item.done", item: { ...noStatus, arguments: "{}" } }); + expect(repairFunctionCalls({ type: "response.completed", response: { output: [noStatus] } }, schemas).value) + .toEqual({ type: "response.completed", response: { output: [{ ...noStatus, arguments: "{}" }] } }); + expect(repairFunctionCalls(value, new Map()).value).toBe(value); + }); +}); + +describe("native function completion SSE", () => { + test("keeps previews exact and repairs every authoritative completion without synthetic deltas", () => { + const budget = createTranslatorBudget(); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas, budget); + try { + const added = frame("response.output_item.added", { output_index: 0, item: item("", { status: "in_progress" }) }); + expect(rewrite(added)).toEqual([added]); + const delta = frame("response.function_call_arguments.delta", { item_id: "fc_one", delta: raw }); + expect(rewrite(delta)).toEqual([delta]); + const done = rewrite(frame("response.function_call_arguments.done", { item_id: "fc_one", arguments: raw })); + expect(done).toHaveLength(1); + expect(payload(done[0]!)).toMatchObject({ type: "response.function_call_arguments.done", arguments: canonical }); + expect(budget.snapshot().currentBytes).toBe(0); + const itemDone = rewrite(frame("response.output_item.done", { output_index: 0, item: item() })); + expect(payload(itemDone[0]!).item).toEqual(item(canonical)); + const terminal = rewrite(frame("response.completed", { response: { status: "completed", output: [item()] } })); + expect(payload(terminal[0]!).response).toEqual({ status: "completed", output: [item(canonical)] }); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { rewrite.dispose?.(); budget.dispose(); } + }); + + test.each(["response.output_item.done", "response.completed"])("no-arg %s works without arguments.done", type => { + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); + try { + const call = item("", { name: "get_state" }); + const fields = type === "response.completed" ? { response: { status: "completed", output: [call] } } : { output_index: 0, item: call }; + const result = rewrite(frame(type, fields)); + expect(result).toHaveLength(1); + expect(JSON.stringify(payload(result[0]!))).toContain('"arguments":"{}"'); + } finally { rewrite.dispose?.(); } + }); + + test("correlates early id-less completions and interleaved calls without mixing schemas", () => { + const budget = createTranslatorBudget(); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas, budget); + try { + expect(rewrite(frame("response.function_call_arguments.done", { output_index: 1, arguments: "" }))).toEqual([]); + rewrite(frame("response.output_item.added", { output_index: 0, item: item("", { status: "in_progress" }) })); + expect(payload(rewrite(frame("response.function_call_arguments.done", { item_id: "fc_one", arguments: raw }))[0]!)) + .toMatchObject({ arguments: canonical }); + const output = rewrite(frame("response.output_item.added", { output_index: 1, item: item("", { name: "get_state", id: "fc_two", status: "in_progress" }) })); + expect(output.map(block => payload(block).type)).toEqual(["response.output_item.added", "response.function_call_arguments.done"]); + expect(payload(output[1]!)).toMatchObject({ arguments: "{}", output_index: 1 }); + expect(budget.snapshot().currentBytes).toBe(0); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { rewrite.dispose?.(); budget.dispose(); } + }); + + test("terminal snapshots resolve early completions; authoritative arguments beat previews", () => { + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); + try { + const preview = frame("response.function_call_arguments.delta", { item_id: "fc_one", delta: '{"cell_id":999}' }); + expect(rewrite(preview)).toEqual([preview]); + expect(rewrite(frame("response.function_call_arguments.done", { item_id: "fc_one", arguments: raw }))).toEqual([]); + const output = rewrite(frame("response.completed", { response: { output: [item()] } })); + expect(output).toHaveLength(2); + expect(payload(output[0]!)).toMatchObject({ arguments: canonical }); + expect(payload(output[1]!).response).toEqual({ output: [item(canonical)] }); + } finally { rewrite.dispose?.(); } + }); + + test.each(["response.failed", "response.incomplete", "response.cancelled"])("%s flushes unknown completions unchanged and frees retention", type => { + const budget = createTranslatorBudget(); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas, budget); + try { + const early = frame("response.function_call_arguments.done", { item_id: "fc_one", arguments: raw }); + expect(rewrite(early)).toEqual([]); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + const terminal = frame(type, { response: { output: [item()] } }); + expect(rewrite(terminal)).toEqual([early, terminal]); + expect(budget.snapshot().currentBytes).toBe(0); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { rewrite.dispose?.(); budget.dispose(); } + }); + + test("charges identity metadata and early frames, releasing even on overflow or disposal", () => { + for (const early of [false, true]) { + const budget = createTranslatorBudget({ maxTurnBytes: 240 }); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas, budget); + try { + rewrite(frame("response.output_item.added", { output_index: 0, item: item("", { status: "in_progress" }) })); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + if (early) { + expect(() => rewrite(frame("response.function_call_arguments.done", { item_id: "unknown", arguments: "x".repeat(300) }))) + .toThrow(TranslatorBudgetExceededError); + } + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { rewrite.dispose?.(); budget.dispose(); } + } + }); + + test("empty forward map leaves every frame byte-identical", () => { + const rewrite = createResponsesFunctionToolRepairBlockRewrite(new Map()); + const block = frame("response.output_item.done", { output_index: 0, item: item() }); + expect(rewrite(block)).toEqual([block]); + }); +}); + +test("native Responses JSON/SSE and replay share the original function schema repair", async () => { + const originalFetch = globalThis.fetch; + const expected = '{"cell_id":"4","yield_time_ms":120000}'; + const output = { type: "function_call", id: "fc_wait", call_id: "call_wait", name: "wait", arguments: '{"cell_id":4,"yield_time_ms":120000.0}', status: "completed" }; + const tools = [{ type: "function", name: "wait", parameters: { type: "object", properties: { cell_id: { type: "string" }, yield_time_ms: { type: "integer" } } } }]; + const config = { + port: 0, defaultProvider: "fixture", + providers: { fixture: { adapter: "openai-responses", baseUrl: "https://function-parity.invalid/v1", authMode: "key", apiKey: "fixture-key" } }, + } as OcxConfig; + let activeId = ""; + let captured: { input?: Array> } | undefined; + const sse = (type: string, payload: object) => `event: ${type}\ndata: ${JSON.stringify({ type, ...payload })}\n\n`; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (!url.startsWith("https://function-parity.invalid/")) throw new Error("unexpected parity fixture destination"); + const body = JSON.parse(String(init?.body)); + captured = body; + const response = { id: activeId, status: "completed", output: [output] }; + return body.stream ? new Response([ + sse("response.output_item.added", { output_index: 0, item: { ...output, arguments: "", status: "in_progress" } }), + sse("response.function_call_arguments.delta", { output_index: 0, item_id: output.id, delta: output.arguments }), + sse("response.function_call_arguments.done", { output_index: 0, item_id: output.id, arguments: output.arguments }), + sse("response.output_item.done", { output_index: 0, item: output }), + sse("response.completed", { response }), "data: [DONE]\n\n", + ].join(""), { headers: { "content-type": "text/event-stream" } }) : Response.json(response); + }) as typeof fetch; + try { + for (const stream of [false, true]) { + activeId = `resp_fn_${crypto.randomUUID()}`; + const request = (extra: object = {}) => new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/grok-probe", stream, input: [{ role: "user", content: "synthetic" }], tools, ...extra }), + }); + const response = await handleResponses(request(), config); + expect(response.status).toBe(200); + const raw = await response.text(); + if (stream) { + const events = raw.split("\n").filter(line => line.startsWith("data:") && !line.includes("[DONE]")).map(line => JSON.parse(line.slice(5))); + expect(events.find(event => event.type === "response.function_call_arguments.done")?.arguments).toBe(expected); + expect(events.find(event => event.type === "response.output_item.done")?.item.arguments).toBe(expected); + expect(events.find(event => event.type === "response.completed")?.response.output[0].arguments).toBe(expected); + } else expect(JSON.parse(raw).output[0].arguments).toBe(expected); + const previous = activeId; + activeId = `resp_fn_followup_${crypto.randomUUID()}`; + const followup = await handleResponses(request({ previous_response_id: previous, input: [{ type: "function_call_output", call_id: "call_wait", output: "done" }] }), config); + await followup.text(); + expect(captured?.input?.find(item => item.type === "function_call" && item.call_id === "call_wait")?.arguments).toBe(expected); + } + } finally { globalThis.fetch = originalFetch; } +}); From f37227893e16690cb9390639d3045edbe058bd1b Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:11:10 +0900 Subject: [PATCH 062/236] fix(catalog): normalize picker order at every writer boundary --- .../031_ordering_build.md | 9 + src/codex/catalog/sync.ts | 14 +- .../catalog-full-picker-order.test.ts | 222 +++++++++++++++++- 3 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/031_ordering_build.md diff --git a/devlog/_plan/260906_lane_b_catalog_stack/031_ordering_build.md b/devlog/_plan/260906_lane_b_catalog_stack/031_ordering_build.md new file mode 100644 index 0000000000..7f68b1ca11 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/031_ordering_build.md @@ -0,0 +1,9 @@ +# Ordering carry build + +Replacement #3700 carries all four source #3571 commits through `0a935c5694229760c8c1cd5a62072107d8ae6696`, retaining voiys as author and coauthor. It preserves configured canonical OpenCode Go ladders in generation/retention and separates full-picker display order from natural spawn priority. + +Production-writer tests cover both convergence and retained sync, healthy/outage equivalence, refreshed featured ranks, idempotence and the same five eligible candidates. Source review found that the new merge paths lacked the builder's runtime normalization for the existing passthrough modelPickerOrder field. All three boundaries now share the same nonarray/nonstring/blank filtering while preserving significant ID spelling. Malformed-input production-writer cases and remote causal checks verify that repair. English/French source documentation is synchronized with the six other existing ordering guides and the catalog SoT. + +Parent #3695 was admin-merged on dev as `ab6762bdb35db24efbe1ceac77a1f9e5e6139616` after every actual CI producer succeeded. The aggregation-only ci job was still queued and explicitly recorded as an owner-authorized administrative exception; no actual test was bypassed. Independent reviews and remote backend/component/typecheck/docs/browser/red-green evidence passed. Source #3654 and issue #3651 were closed after dev ancestry proof, and #3700 was safely retargeted to dev. + +Final ordering review and exact-head remote/hosted execution are pending at this checkpoint. No local repository tests, typechecks or builds were run. diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 7b0be6e19b..469dcc2b1e 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -519,9 +519,7 @@ export function buildCatalogEntriesFromObservedState({ // modelPickerOrder is unset the helper is a no-op and every priority below is byte-identical to // before. The spawn_agent candidate window is derived separately from SPAWN_PRIORITY_FIELD, so // this display reorder cannot change which rows are spawn candidates. - const pickerOrder = Array.isArray(modelPickerOrder) - ? modelPickerOrder.filter((id): id is string => typeof id === "string" && id.trim().length > 0) - : []; + const pickerOrder = normalizeModelPickerOrder(modelPickerOrder); const pickerOrderRank = new Map(pickerOrder.map((slug, i) => [slug, i] as const)); const pickerOrderActive = pickerOrder.length > 0; // The display band reuses the existing high priority tier (>= PICKER_ORDER_PRIORITY_BASE, the @@ -781,6 +779,12 @@ export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly< unsupportedNativeEntries: "drop", }); +function normalizeModelPickerOrder(order: unknown): string[] { + return Array.isArray(order) + ? order.filter((id): id is string => typeof id === "string" && id.trim().length > 0) + : []; +} + /** Preserve exact-id precedence while accepting the existing raw/encoded slug spellings. */ function modelPickerRank(order: readonly string[]): (slug: string) => number | undefined { const exact = new Map(order.map((slug, index) => [slug, index])); @@ -790,7 +794,7 @@ function modelPickerRank(order: readonly string[]): (slug: string) => number | u /** A picker order containing native ids orders the whole list, without changing spawn ranks. */ export function applyFullModelPickerOrder(entries: RawEntry[], order: readonly string[]): void { - const pickerOrder = order.filter(slug => slug.trim().length > 0); + const pickerOrder = normalizeModelPickerOrder(order); if (!pickerOrder.some(slug => !slug.includes("/"))) return; const rankOf = modelPickerRank(pickerOrder); for (const entry of entries) { @@ -1089,7 +1093,7 @@ export function mergeCatalogEntriesFromObservedState({ }); // Retained rows bypass the builder. Recompute managed spawn ranks from current config // before either display-order mode; a saved display override is not current roster authority. - const pickerOrder = modelPickerOrder.filter(slug => slug.trim().length > 0); + const pickerOrder = normalizeModelPickerOrder(modelPickerOrder); const fullPickerOrder = pickerOrder.some(slug => !slug.includes("/")); const rankOf = modelPickerRank(pickerOrder); const featuredRankOf = modelPickerRank(featured); diff --git a/tests/codex-integration/catalog-full-picker-order.test.ts b/tests/codex-integration/catalog-full-picker-order.test.ts index 1dbda27090..a891b395f6 100644 --- a/tests/codex-integration/catalog-full-picker-order.test.ts +++ b/tests/codex-integration/catalog-full-picker-order.test.ts @@ -1,5 +1,22 @@ import { routedSlug } from "../../src/providers/slug-codec"; -import { expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import type { OcxConfig } from "../../src/types"; +import { captureCatalogAdmissionSnapshot } from "../../src/codex/catalog-admission"; +import { convergeCodexCatalog } from "../../src/codex/convergence"; +import { loadBundledCodexCatalog, resetCatalogRuntimeStateForTests, syncCatalogModels } from "../../src/codex/catalog"; +import { setBundledCatalogCacheForTests } from "../../src/codex/catalog/bundled"; +import type { RawCatalog, RawEntry } from "../../src/codex/catalog/parsing"; +import { clearModelCache, markModelsFetchFailure } from "../../src/codex/model-cache"; +import { persistCodexRuntime, resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import { resetCodexModelEntitlementCacheForTests } from "../../src/codex/model-entitlements"; +import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { effectiveSubagentRoster } from "../../src/codex/catalog/sync"; import { buildCatalogEntriesFromObservedState, mergeCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, applyFullModelPickerOrder, deriveEntry, mergeCatalogEntriesForSync, SPAWN_PRIORITY_FIELD } from "../../src/codex/catalog/sync"; test("native-first picker order preserves Go subagent ranks and is repeatable", () => { @@ -23,6 +40,14 @@ test("existing routed-only ordering retains its behavior", () => { expect(rows).toEqual([{ slug: "opencode-go/glm-5.3", priority: 1000 }]); }); +test("full picker helper treats a null passthrough order as absent", () => { + const rows = [{ slug: "gpt-5.5", priority: 9 }, { slug: "opencode-go/model", priority: 0 }]; + const before = structuredClone(rows); + // Production callers coalesce null; the exported boundary must also tolerate it directly. + applyFullModelPickerOrder(rows, null as unknown as readonly string[]); + expect(rows).toEqual(before); +}); + test("sync refreshes native spawn rank when featured models change", () => { const sol = deriveEntry(null, "gpt-5.6-sol", "Sol", 105); @@ -109,3 +134,198 @@ test("full ordering ignores empty entries and accepts raw upstream ids with slas applyFullModelPickerOrder(exact, ["gpt-5.6-sol", slug, "vendor/team/model"]); expect(exact[0]!.priority).toBe(1); }); + +describe("picker ordering through production catalog writers", () => { + const ids = ["ordering-a", "ordering-b", "ordering-c", "ordering-d", "ordering-e", "ordering-f"]; + const slugs = ids.map(id => routedSlug("opencode-go", id)); + const configuredEfforts = ["high", "xhigh"]; + const envKeys = ["CODEX_HOME", "OPENCODEX_HOME", "CODEX_CLI_PATH"] as const; + let previousEnv: Array; + let previousFetch: typeof fetch; + let root: string; + let codexHome: string; + let catalogPath: string; + let fetchCalls: number; + + beforeEach(() => { + previousEnv = envKeys.map(key => process.env[key]); + previousFetch = globalThis.fetch; + root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-picker-writers-"))); + codexHome = join(root, "codex"); + const opencodexHome = join(root, "ocx"); + mkdirSync(codexHome); + mkdirSync(opencodexHome); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + const runtimeCommand = join(root, "fixture-codex"); + process.env.CODEX_CLI_PATH = runtimeCommand; + catalogPath = join(codexHome, "custom-catalog.json"); + writeFileSync(join(codexHome, "config.toml"), + 'model_catalog_json = "custom-catalog.json"\n[features]\nmulti_agent_v2 = true\n'); + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); + const runtime = { command: runtimeCommand, version: "0.145.0", source: "fallback" as const }; + persistCodexRuntime(runtime); + setCodexRuntimeResolveCacheForTests({ runtime, failures: [] }, { discoverAlternatives: false }); + const native = deriveEntry(null, "gpt-5.5", "Native fixture", 9); + const catalog = { models: [native] }; + setBundledCatalogCacheForTests(runtime, catalog); + // Both runtime selection and bundled support are fixture-owned, before admission capture. + expect(loadBundledCodexCatalog()?.models?.[0]?.slug).toBe("gpt-5.5"); + writeFileSync(catalogPath, JSON.stringify(catalog)); + fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("catalog writer fixture must not make a network request"); + }) as typeof fetch; + }); + + afterEach(() => { + try { + const database = resolveCodexCatalogSerializationDatabasePath(resolveEffectiveUserIdentity(), codexHome); + for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(`${database}${suffix}`, { force: true }); + } finally { + globalThis.fetch = previousFetch; + envKeys.forEach((key, index) => { + const value = previousEnv[index]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }); + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); + removeTreeWithRetry(root); + } + }); + + function config(featured = slugs.slice(0, 5), order: string[] = []): OcxConfig { + return { + port: 10100, + defaultProvider: "opencode-go", + multiAgentMode: "v2", + subagentModels: featured, + modelPickerOrder: order, + providers: { + openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward" }, + "opencode-go": { + adapter: "openai-chat", baseUrl: "https://catalog-fixture.invalid/v1", authMode: "key", + apiKey: "ordering-fixture-key", liveModels: false, models: [...ids], + modelReasoningEfforts: Object.fromEntries(ids.map(id => [id, [...configuredEfforts]])), + modelDefaultReasoningEfforts: Object.fromEntries(ids.map(id => [id, "xhigh"])), + }, + }, + }; + } + + async function writeCatalog(writer: "convergence" | "retained", next: OcxConfig, degraded = false): Promise { + saveConfig(next); + if (degraded) { + // No cached/static rows: the caller must preserve the catalog already on disk. + clearModelCache("opencode-go"); + markModelsFetchFailure("opencode-go"); + } + if (writer === "convergence") { + const result = await convergeCodexCatalog(captureCatalogAdmissionSnapshot(next), { + action: "converge", scope: "catalog", reason: "management-mutation", mode: "explicit", deadlineMs: 5_000, + }); + expect(result.catalogRefresh).toMatchObject({ status: "committed", degraded }); + } else { + const result = await syncCatalogModels(next); + expect(result.path).toBe(catalogPath); + expect(result.skippedReason).toBeUndefined(); + } + expect(fetchCalls).toBe(0); + return (JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog).models ?? []; + } + + function roster(rows: RawEntry[], featured: string[]) { + const result = effectiveSubagentRoster(featured, "v2", rows); + expect(result.candidates.map(candidate => candidate.model)).toEqual(featured); + expect(result.candidates).toHaveLength(5); + expect(result.advertised).toEqual(result.candidates); + for (const candidate of result.candidates) expect(candidate.efforts).toEqual(configuredEfforts); + for (const slug of slugs) { + expect(rows.find(row => row.slug === slug)).toMatchObject({ + default_reasoning_level: "xhigh", + supported_reasoning_levels: configuredEfforts.map(effort => expect.objectContaining({ effort })), + }); + } + return result; + } + + for (const writer of ["convergence", "retained"] as const) { + const filteredOrder = ["gpt-5.5", slugs[5]!, ...slugs.slice(0, 5).reverse(), " gpt-5.5 "]; + const malformedOrders: Array<{ label: string; input: unknown; filtered: string[] }> = [ + { label: "string scalar", input: "gpt-5.5", filtered: [] }, + { label: "number scalar", input: 7, filtered: [] }, + { label: "boolean scalar", input: true, filtered: [] }, + { label: "object", input: { 0: "gpt-5.5", length: 1 }, filtered: [] }, + { + label: "mixed array", + input: [null, 7, "", " \t", filteredOrder[0], false, filteredOrder[1], {}, ...filteredOrder.slice(2)], + // Significant surrounding whitespace remains part of the original spelling. + filtered: filteredOrder, + }, + ]; + + test.each(malformedOrders)(`${writer} tolerates $label passthrough order in healthy and retained discovery`, async ({ input, filtered }) => { + const control = config(slugs.slice(0, 5), filtered); + const expected = await writeCatalog(writer, control); + if (filtered.length > 0) { + // Trimming the final nonblank string would incorrectly override the native rank. + expect(expected.find(row => row.slug === "gpt-5.5")?.priority).toBe(0); + } + const expectedRoster = roster(expected, control.subagentModels!); + // Model configuration is passthrough at runtime; exercise the writers, not the normalizer. + const malformed = Object.assign(config(control.subagentModels), { modelPickerOrder: input }); + const actual = await writeCatalog(writer, malformed); + expect(actual).toEqual(expected); + expect(roster(actual, control.subagentModels!)).toEqual(expectedRoster); + + malformed.providers["opencode-go"]!.liveModels = true; + malformed.providers["opencode-go"]!.models = []; + const retained = await writeCatalog(writer, malformed, true); + expect(retained).toEqual(expected); + expect(roster(retained, control.subagentModels!)).toEqual(expectedRoster); + }, 30_000); + + test(`${writer} applies full display order without changing five eligible Go candidates`, async () => { + const initial = config(); + const before = roster(await writeCatalog(writer, initial), initial.subagentModels!); + // Bring the sixth routed model above every featured model in the display. + const order = ["gpt-5.5", slugs[5]!, ...slugs.slice(0, 5).reverse()]; + const ordered = config(initial.subagentModels, order); + const rows = await writeCatalog(writer, ordered); + expect(rows.filter(row => order.includes(String(row.slug))) + .sort((a, b) => Number(a.priority) - Number(b.priority)).map(row => row.slug)).toEqual(order); + expect(roster(rows, ordered.subagentModels!)).toEqual(before); + expect(roster(await writeCatalog(writer, ordered), ordered.subagentModels!)).toEqual(before); + }, 30_000); + + test(`${writer} refreshes retained outage ranks after a full-picker and featured-roster change`, async () => { + const previous = await writeCatalog(writer, config(slugs.slice(0, 5), ["gpt-5.5", ...slugs])); + for (const row of previous) { + if (slugs.includes(String(row.slug))) row.ordering_retained_fixture = true; + } + // Promote the formerly excluded sixth model, demote the first, and clear full ordering. + const featured = slugs.slice(1).reverse(); + const next = config(featured, [slugs[0]!]); + const healthy = await writeCatalog(writer, next); + const expectedRoster = roster(healthy, featured); + writeFileSync(catalogPath, JSON.stringify({ models: previous })); + next.providers["opencode-go"]!.liveModels = true; + next.providers["opencode-go"]!.models = []; + const retained = await writeCatalog(writer, next, true); + expect(roster(retained, featured)).toEqual(expectedRoster); + for (const slug of slugs) { + const actual = retained.find(row => row.slug === slug)!; + const expected = healthy.find(row => row.slug === slug)!; + expect(actual.ordering_retained_fixture).toBe(true); + expect(actual.priority).toBe(expected.priority); + expect(actual[SPAWN_PRIORITY_FIELD]).toBe(expected[SPAWN_PRIORITY_FIELD]); + } + expect(await writeCatalog(writer, next, true)).toEqual(retained); + }, 30_000); + } +}); From 07357d34b90a7cfba8ea7ef41d232a78dcb9f8cb Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:11:10 +0900 Subject: [PATCH 063/236] docs(catalog): align complete picker ordering across locales --- .../content/docs/ja/guides/model-ordering.md | 36 +++++++++++++++-- .../content/docs/ko/guides/model-ordering.md | 35 ++++++++++++++-- .../content/docs/ru/guides/model-ordering.md | 40 +++++++++++++++++-- .../content/docs/tr/guides/model-ordering.md | 39 +++++++++++++++--- .../docs/zh-cn/guides/model-ordering.md | 33 +++++++++++++-- .../docs/zh-tw/guides/model-ordering.md | 36 ++++++++++++++--- 6 files changed, 196 insertions(+), 23 deletions(-) diff --git a/docs-site/src/content/docs/ja/guides/model-ordering.md b/docs-site/src/content/docs/ja/guides/model-ordering.md index 6108c08771..b2383a55ea 100644 --- a/docs-site/src/content/docs/ja/guides/model-ordering.md +++ b/docs-site/src/content/docs/ja/guides/model-ordering.md @@ -22,6 +22,8 @@ account-qualified native id にはその selector の `i * N + j` が使用さ selector がない場合の priority は次のとおりです。 +以下の優先順位表と例は、ピッカー全体の並び替えを有効にしていない場合のものです。 + | カタログ項目 | Priority | 根拠 | --- | ---: | --- | | `subagentModels[i]` | `i`(`0` から `4`) | `src/codex/catalog/sync.ts` の featured rank map | @@ -112,6 +114,34 @@ account selector がある場合、5 項目の制限は bare native の選択が 場合は 1 つの bare native が複数の selector-qualified 行に展開されるため、設定した選択肢と公開 される行は必ずしも一対一ではありません。 -現在 `OcxConfig` には一般 `modelOrder`、`providerOrder`、priority map 設定はありません。サポートされるソート -フィールドは `subagentModels` です。`disabledModels` と各プロバイダーの `selectedModels` は公開 -フィールドです。そのため残りのピッカー順序を変えるには設定変更ではなくコード動作の変更が必要です。 +`modelPickerOrder` はピッカーの表示順だけを指定します。ルーティング ID +`/` だけを指定した場合、一覧にある非 featured 行は指定順の表示帯 +(`1000 + i`)に並びます。一覧にないルーティング行は通常の優先順位を保ち、この表示帯より前に +残ります。`subagentModels` にも含まれる行は featured の優先順位を保ち、ネイティブ行の位置も変わりません。 +相対的な順序を指定したいルーティング行はすべて一覧に含めてください。 + +ピッカー全体を並び替えるには、`/` を含まない、空でも空白だけでもないカタログ ID +(例:`gpt-5.6-sol`)を含めます。 + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +指定した行が配列の順序で先頭に並び、未指定の行は本来の優先順位でその後に続きます。 +カタログ ID は完全一致で照合します。`gpt-5.6-sol` と `openai/gpt-5.6-sol` は別の行です。 +同じルーティング ID の未エンコード表記とエンコード済み表記も照合できますが、完全一致が優先されます。 +空の項目と空白だけの項目は無視します。アカウント別の行には selector を含む完全な ID を指定してください。 + +### 移行時の注意:既存の一覧に含まれるネイティブ ID + +以前は `modelPickerOrder` 内の bare native ID が無視されていました。既存の一覧にこのような ID が +あると、今後は featured 行を含むピッカー全体の並び替えが有効になります。従来のルーティング行だけの +動作を保つには、bare ID を取り除いてください。未設定、空、空白だけ、ルーティング ID だけの一覧は +従来どおり動作します。 + +`modelPickerOrder` は `spawn_agent` の候補 5 件とその選択用の優先順位を変えません。 +変わるのは Codex ピッカーの表示用 `priority` だけで、opencodex は移動した各行の本来の優先順位を +サブエージェント選択用に保持します。`disabledModels` と各プロバイダーの `selectedModels` は +表示の有無を制御するフィールドです。別の `modelOrder`、`providerOrder`、priority map 設定はありません。 diff --git a/docs-site/src/content/docs/ko/guides/model-ordering.md b/docs-site/src/content/docs/ko/guides/model-ordering.md index 3c960b1840..5a3863c4c2 100644 --- a/docs-site/src/content/docs/ko/guides/model-ordering.md +++ b/docs-site/src/content/docs/ko/guides/model-ordering.md @@ -22,6 +22,8 @@ native id는 해당 selector의 `i * N + j`를 사용합니다. Codex는 계속 selector가 없을 때의 priority는 다음과 같습니다. +아래 우선순위 표와 예시는 선택기 전체 정렬을 켜지 않은 경우를 설명합니다. + | 카탈로그 항목 | Priority | 근거 | | --- | ---: | --- | | `subagentModels[i]` | `i` (`0`부터 `4`) | `src/codex/catalog/sync.ts`의 featured rank map | @@ -111,6 +113,33 @@ account selector가 있으면 bare native 선택이 selector-qualified 그룹으 최대 5개만 사용하세요. account selector가 있으면 bare native 하나가 여러 selector-qualified 행으로 확장될 수 있으므로 설정 항목과 노출 행이 항상 일대일로 대응하지는 않습니다. -현재 `OcxConfig`에는 일반 `modelOrder`, `providerOrder`, priority map 설정이 없습니다. 지원되는 정렬 -필드는 `subagentModels`입니다. `disabledModels`와 각 프로바이더의 `selectedModels`는 노출 -필드입니다. 따라서 나머지 선택기 순서를 바꾸려면 설정 수정이 아니라 코드 동작 변경이 필요합니다. +`modelPickerOrder`는 선택기의 표시 순서만 지정합니다. 라우팅 ID인 `/`만 +넣으면 목록에 있는 비 featured 행이 지정 순서대로 별도 표시 구간(`1000 + i`)에 배치됩니다. +목록에 없는 라우팅 행은 원래 우선순위를 유지하므로 이 구간보다 앞에 남습니다. `subagentModels`에도 +들어 있는 행은 featured 우선순위를 유지하고, 네이티브 행도 원래 위치를 유지합니다. +상대적 순서를 정할 라우팅 행은 모두 목록에 넣어야 합니다. + +선택기 전체를 정렬하려면 `/`가 없는 카탈로그 ID를 하나 이상 넣으세요. `gpt-5.6-sol`처럼 실제 문자가 +있는 bare ID여야 하며, 빈 문자열이나 공백만 있는 항목은 해당하지 않습니다. + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +지정한 행이 배열 순서대로 먼저 나오고, 나머지 행은 원래 우선순위대로 뒤에 나옵니다. +카탈로그 ID는 정확히 일치하는 값으로 찾습니다. `gpt-5.6-sol`과 `openai/gpt-5.6-sol`은 서로 다른 행입니다. +같은 라우팅 ID의 원문 표기와 인코딩 표기도 허용하지만, 정확히 일치하는 항목이 우선합니다. +빈 항목과 공백뿐인 항목은 무시합니다. 계정별 행을 지정할 때는 selector가 포함된 전체 ID를 써야 합니다. + +### 마이그레이션 주의: 기존 목록에 들어 있는 네이티브 ID + +이전에는 `modelPickerOrder`의 bare native ID를 무시했습니다. 이제 기존 목록에 이런 ID가 있으면 +featured 행을 포함한 선택기 전체 정렬이 활성화됩니다. 기존 라우팅 전용 동작을 유지하려면 bare ID를 +제거하세요. 미설정 목록, 빈 목록, 공백만 있는 목록, 라우팅 ID만 있는 목록은 기존 동작을 유지합니다. + +`modelPickerOrder`는 `spawn_agent` 후보 5개와 후보 선택용 우선순위를 바꾸지 않습니다. +Codex 선택기의 표시용 `priority`만 바꾸며, opencodex는 이동한 각 행의 원래 우선순위를 서브에이전트 +선택용으로 보존합니다. `disabledModels`와 각 공급자의 `selectedModels`는 노출 여부를 정하는 필드입니다. +별도의 `modelOrder`, `providerOrder`, priority map 설정은 없습니다. diff --git a/docs-site/src/content/docs/ru/guides/model-ordering.md b/docs-site/src/content/docs/ru/guides/model-ordering.md index d5a3683834..557b0e4d6f 100644 --- a/docs-site/src/content/docs/ru/guides/model-ordering.md +++ b/docs-site/src/content/docs/ru/guides/model-ordering.md @@ -25,6 +25,8 @@ selector-групп. Приоритеты без селекторов: +Таблицы приоритетов и пример ниже описывают режим без сортировки всего селектора. + | Запись каталога | Priority | Источник | | --- | ---: | --- | | `subagentModels[i]` | `i` (от `0` до `4`) | Карта рангов избранных в `src/codex/catalog/sync.ts` | @@ -121,7 +123,37 @@ native-выбора в selector-qualified группы. развернуться в несколько selector-qualified строк, поэтому число настроенных вариантов и объявляемых строк не обязательно совпадает. -Общих настроек `modelOrder`, `providerOrder` или карты приоритетов в `OcxConfig` сейчас нет. -Поддерживаемое поле порядка — `subagentModels`; `disabledModels` и `selectedModels` каждого -провайдера — поля видимости. Изменение остальной части порядка селектора потребовало бы изменения -поведения на уровне кода, а не правки конфигурации. +`modelPickerOrder` управляет только порядком отображения в селекторе. Если список содержит лишь +маршрутизируемые ID `/`, указанные строки вне избранных попадают в отдельный +диапазон отображения (`1000 + i`) в порядке списка. Неуказанные маршрутизируемые строки сохраняют +обычный приоритет и остаются перед этим диапазоном. Строки из `subagentModels` сохраняют приоритет +избранных, а нативные строки — обычные позиции. Укажите все маршрутизируемые строки, относительный +порядок которых нужно задать. + +Чтобы сортировать весь селектор, включите хотя бы один непустой ID каталога без `/`, например +`gpt-5.6-sol`. Строка из одних пробелов не включает этот режим. + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +Указанные строки идут первыми в порядке массива, затем неуказанные — по исходному приоритету. +Сопоставление учитывает точный ID каталога: `gpt-5.6-sol` и `openai/gpt-5.6-sol` — разные строки. +Допускаются исходная и закодированная формы одного маршрутизируемого ID, но точное совпадение +имеет приоритет над эквивалентным. Пустые строки и строки из одних пробелов игнорируются. +Для строки конкретного аккаунта укажите полный ID с селектором. + +### Миграция: нативные ID в существующих списках + +Раньше нативные ID без префикса в `modelPickerOrder` игнорировались. Теперь такой ID в существующем +списке включает сортировку всего селектора, включая избранные строки. Удалите ID без префикса, +чтобы сохранить прежнее поведение только для маршрутизируемых строк. Отсутствующий или пустой +список, список из одних пробельных строк и список только с маршрутизируемыми ID работают как раньше. + +`modelPickerOrder` не меняет набор из пяти кандидатов `spawn_agent` и их приоритеты для выбора. +Меняется только отображаемый в Codex `priority`; opencodex сохраняет исходный приоритет каждой +перемещённой строки для выбора субагентов. `disabledModels` и `selectedModels` каждого провайдера +по-прежнему управляют видимостью. Отдельных настроек `modelOrder`, `providerOrder` или карты +приоритетов нет. diff --git a/docs-site/src/content/docs/tr/guides/model-ordering.md b/docs-site/src/content/docs/tr/guides/model-ordering.md index 54a19f22d4..2298d5f281 100644 --- a/docs-site/src/content/docs/tr/guides/model-ordering.md +++ b/docs-site/src/content/docs/tr/guides/model-ordering.md @@ -28,6 +28,8 @@ görünen ilk beş satırı tanıtır. İlgili seçicisiz öncelikler şunlardır: +Aşağıdaki öncelik tabloları ve örnek, seçicinin tamamını sıralama modu kapalıyken geçerlidir. + | Katalog girdisi | Öncelik | Kaynak | | --- | ---: | --- | | `subagentModels[i]` | `i` (`0` - `4`) | `src/codex/catalog/sync.ts` içindeki öne çıkan sıra haritası | @@ -134,10 +136,37 @@ kimlik kullanın. Hesap seçicileriyle tek bir yalın yerel seçenek birden çok seçici nitelikli katalog satırına genişleyebilir, bu nedenle yapılandırılmış seçimler ve tanıtılan satırlar birebir olmak zorunda değildir. -Şu anda `OcxConfig` içinde genel bir `modelOrder`, `providerOrder` veya öncelik -haritası ayarı yoktur. Desteklenen sıralama alanı `subagentModels`'dır; -`disabledModels` ve her sağlayıcının `selectedModels` alanı görünürlük -alanlarıdır. Kalan seçici sırasını değiştirmek bir yapılandırma düzenlemesinden -ziyade kod düzeyinde bir davranış değişikliği gerektirir. +`modelPickerOrder` yalnızca seçicideki görüntüleme sırasını belirler. Liste yalnızca yönlendirilmiş +`/` kimlikleri içeriyorsa, listelenen ve öne çıkarılmamış satırlar ayrı bir +görüntüleme aralığında (`1000 + i`) liste sırasıyla yer alır. Listelenmeyen yönlendirilmiş satırlar +normal önceliklerini korur ve bu aralıktan önce kalır. `subagentModels` içindeki satırlar öne çıkan +önceliklerini, yerel satırlar da normal konumlarını korur. Göreli sırasını belirlemek istediğiniz +tüm yönlendirilmiş satırları listeleyin. + +Seçicinin tamamını sıralamak için `gpt-5.6-sol` gibi `/` içermeyen en az bir yalın katalog kimliği +ekleyin. Boş veya yalnızca boşluk içeren girdiler bu modu etkinleştirmez. + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +Listelenen satırlar önce dizi sırasıyla, listelenmeyenler ise ardından doğal öncelik sırasıyla gelir. +Eşleştirme tam katalog kimliğini kullanır: `gpt-5.6-sol` ile `openai/gpt-5.6-sol` farklı satırlardır. +Aynı yönlendirilmiş kimliğin ham ve kodlanmış yazımları da kabul edilir; tam eşleşme, eşdeğer +eşleşmeden önceliklidir. Boş ve yalnızca boşluk içeren girdiler yok sayılır. Hesaba özel satırlar +için seçiciyi içeren tam kimliği yazın. + +### Geçiş uyarısı: mevcut listelerdeki yerel kimlikler +Önceden `modelPickerOrder` içindeki yalın yerel kimlikler yok sayılıyordu. Mevcut bir listede böyle +bir kimlik bulunması artık öne çıkan satırlar dahil tüm seçicinin sıralanmasını etkinleştirir. +Eski, yalnızca yönlendirilmiş satırlara uygulanan davranışı korumak için yalın kimlikleri kaldırın. +Tanımlanmamış, boş, yalnızca boşluk girdileri içeren veya yalnızca yönlendirilmiş kimliklerden oluşan +listeler önceki davranışlarını korur. +`modelPickerOrder`, `spawn_agent` için beş adaydan oluşan listeyi veya aday seçim önceliklerini +değiştirmez. Yalnızca Codex seçicisinde görünen `priority` değişir; opencodex taşınan her satırın doğal +önceliğini alt ajan seçimi için korur. `disabledModels` ve her sağlayıcının `selectedModels` alanı +görünürlüğü denetler. Ayrı bir `modelOrder`, `providerOrder` veya öncelik haritası ayarı yoktur. diff --git a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md index 2f229176cd..c7570abfa9 100644 --- a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md +++ b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md @@ -20,6 +20,8 @@ priority 为 `i * N + j` 的 selector 行,其中 `j` 是从 0 开始的 select 没有 selector 时的相关 priority 如下: +以下优先级表和示例适用于未开启完整选择器排序的情况。 + | 目录条目 | Priority | 来源 | | --- | ---: | --- | | `subagentModels[i]` | `i`(`0` 至 `4`) | `src/codex/catalog/sync.ts` 中的 featured rank map | @@ -106,6 +108,31 @@ subagentModels = [ id 请勿超过五个。存在账户 selector 时,一个裸原生选项可能展开为多个 selector-qualified 行,因此 已配置的选项与公布的行不一定一一对应。 -目前 `OcxConfig` 中没有通用的 `modelOrder`、`providerOrder` 或 priority map 设置。受支持的排序 -字段是 `subagentModels`;`disabledModels` 和各 provider 的 `selectedModels` 都是可见性字段。 -因此,要更改选择器其余部分的顺序,需要修改代码行为,而不是调整配置。 +`modelPickerOrder` 只控制选择器的显示顺序。如果列表只有路由 ID `/`, +其中未置顶的行会按列表顺序进入独立的显示区间(`1000 + i`)。未列出的路由行保留原有优先级, +因此仍排在该区间之前。同时列在 `subagentModels` 中的行保留置顶优先级,原生行也保持原有位置。 +需要控制相对顺序的路由行都应列入列表。 + +要对整个选择器排序,请加入至少一个不含 `/` 的裸目录 ID,例如 `gpt-5.6-sol`。 +空字符串或只有空白的条目不会启用此模式。 + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +列出的行按数组顺序排在最前面,未列出的行随后按原有优先级排列。匹配使用精确的目录 ID: +`gpt-5.6-sol` 和 `openai/gpt-5.6-sol` 是不同的行。同一路由 ID 的原始写法和编码写法也可匹配, +但精确匹配优先于等价匹配。空条目和只有空白的条目会被忽略。账户限定行必须使用包含 selector 的完整 ID。 + +### 迁移提醒:现有列表中的原生 ID + +以前 `modelPickerOrder` 中的裸原生 ID 会被忽略。现在,现有列表只要包含这样的 ID,就会启用 +整个选择器的排序,包括置顶行。要保持以前只调整路由行的行为,请移除裸 ID。 +未设置、空列表、只有空白条目的列表以及只有路由 ID 的列表都保留原有行为。 + +`modelPickerOrder` 不会改变 `spawn_agent` 的五个候选项及其选择优先级。它只改变 Codex 选择器中 +用于显示的 `priority`;opencodex 会保留每个移动行的原有优先级,供子代理选择使用。 +`disabledModels` 和各提供商的 `selectedModels` 仍是可见性字段。没有独立的 `modelOrder`、 +`providerOrder` 或优先级映射设置。 diff --git a/docs-site/src/content/docs/zh-tw/guides/model-ordering.md b/docs-site/src/content/docs/zh-tw/guides/model-ordering.md index efd505db85..6b11319969 100644 --- a/docs-site/src/content/docs/zh-tw/guides/model-ordering.md +++ b/docs-site/src/content/docs/zh-tw/guides/model-ordering.md @@ -14,6 +14,8 @@ Codex 的 models-manager 按 `priority` 升序排列選擇器中可見的目錄 因此,opencodex 透過分配更低的 priority 控制置頂位置,而不依賴陣列位置。相關 priority 如下: +以下優先級表與範例適用於未啟用完整選擇器排序的情況。 + | 目錄條目 | Priority | 來源 | | --- | ---: | --- | | `subagentModels[i]` | `i`(`0` 至 `4`) | `src/codex/catalog/sync.ts` 中的 featured rank map | @@ -92,10 +94,34 @@ subagentModels = [ ## 更改順序 -自訂開頭模型順序的唯一受支援方式是重新排列 `subagentModels`。你可以在儀表板的 +要調整 `spawn_agent` 候選模型的順序,請重新排列 `subagentModels`。你可以在儀表板的 **Sub-agents** 頁面或 opencodex 設定中修改它。該列表最多接受五個模型,其陣列順序有實際意義。 -目前 `OcxConfig` 中沒有通用的 `modelOrder`、`providerOrder` 或 priority map 設定。受支援的排序 -欄位是 `subagentModels`(`src/types.ts:238-246`);`disabledModels` 和各 provider 的 -`selectedModels` 都是可見性欄位(`src/types.ts:276-282`、`src/types.ts:439-446`)。因此,要更改 -選擇器其餘部分的順序,需要修改程式碼行為,而不是調整設定。 +`modelPickerOrder` 只控制選擇器的顯示順序。如果列表只有路由 ID `/`, +其中未置頂的列會按列表順序進入獨立的顯示區間(`1000 + i`)。未列出的路由列保留原有優先級, +因此仍排在該區間之前。同時列在 `subagentModels` 中的列保留置頂優先級,原生列也維持原有位置。 +需要控制相對順序的路由列都應列入列表。 + +要對整個選擇器排序,請加入至少一個不含 `/` 的裸目錄 ID,例如 `gpt-5.6-sol`。 +空字串或只有空白的項目不會啟用此模式。 + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +列出的項目按陣列順序排在最前面,未列出的項目隨後按原有優先級排列。比對使用精確的目錄 ID: +`gpt-5.6-sol` 和 `openai/gpt-5.6-sol` 是不同的列。同一路由 ID 的原始寫法和編碼寫法也可比對, +但精確比對優先於等價比對。空項目和只有空白的項目會被忽略。帳號限定列必須使用包含 selector 的完整 ID。 + +### 遷移提醒:現有列表中的原生 ID + +以前 `modelPickerOrder` 中的裸原生 ID 會被忽略。現在,現有列表只要包含這類 ID,就會啟用 +整個選擇器的排序,包括置頂列。要保留以前只調整路由列的行為,請移除裸 ID。 +未設定、空列表、只有空白項目的列表以及只有路由 ID 的列表都保留原有行為。 + +`modelPickerOrder` 不會改變 `spawn_agent` 的五個候選項及其選擇優先級。它只改變 Codex 選擇器中 +用於顯示的 `priority`;opencodex 會保留每個移動列的原有優先級,供子代理選擇使用。 +`disabledModels` 和各供應商的 `selectedModels` 仍是可見性欄位。沒有獨立的 `modelOrder`、 +`providerOrder` 或優先級對應表設定。 From e84d3d2a22321089d30e1adf16f70e036aa9c101 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:11:38 +0900 Subject: [PATCH 064/236] test(responses): supply route log context in parity regression --- tests/responses/responses-function-tool-repair.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/responses/responses-function-tool-repair.test.ts b/tests/responses/responses-function-tool-repair.test.ts index 2b8b129fb6..d848bafe1f 100644 --- a/tests/responses/responses-function-tool-repair.test.ts +++ b/tests/responses/responses-function-tool-repair.test.ts @@ -282,7 +282,7 @@ test("native Responses JSON/SSE and replay share the original function schema re method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "fixture/grok-probe", stream, input: [{ role: "user", content: "synthetic" }], tools, ...extra }), }); - const response = await handleResponses(request(), config); + const response = await handleResponses(request(), config, { model: "", provider: "" }); expect(response.status).toBe(200); const raw = await response.text(); if (stream) { @@ -293,7 +293,7 @@ test("native Responses JSON/SSE and replay share the original function schema re } else expect(JSON.parse(raw).output[0].arguments).toBe(expected); const previous = activeId; activeId = `resp_fn_followup_${crypto.randomUUID()}`; - const followup = await handleResponses(request({ previous_response_id: previous, input: [{ type: "function_call_output", call_id: "call_wait", output: "done" }] }), config); + const followup = await handleResponses(request({ previous_response_id: previous, input: [{ type: "function_call_output", call_id: "call_wait", output: "done" }] }), config, { model: "", provider: "" }); await followup.text(); expect(captured?.input?.find(item => item.type === "function_call" && item.call_id === "call_wait")?.arguments).toBe(expected); } From f121348a99603d3668e54a07a0e02cfc7297f486 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:16:53 +0900 Subject: [PATCH 065/236] fix(responses): preserve sparse and discovered tool completion parity --- .../040_native_tool_parity.md | 4 ++ src/responses/function-call-compat.ts | 12 +++- src/server/responses-function-tool-repair.ts | 9 ++- src/server/responses/core.ts | 26 ++++--- .../responses-function-tool-repair.test.ts | 68 ++++++++++++++++++- .../responses-snapshot-repair-server.test.ts | 31 +++++++++ 6 files changed, 136 insertions(+), 14 deletions(-) diff --git a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md index e1c0c13c91..8d85a71fa0 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md @@ -24,3 +24,7 @@ Main owns namespace-tool-compat.ts, extraction of existing ambiguity helpers to Namespace aliases are built after custom lowering (openai-responses.ts2410-2432), so an original custom tool can carry lowered kind=function. Preserve that existing namespace restoration kind behavior; only original-schema function repair enforces ordinary function kind. Dotted restoration adds spelling parity, not a new kind conversion. Explicit conflicting namespaces stay untouched. Reserved functions children participate in the shared collision inventory as bare names. The existing namespace tests are updated for additional alias entries rather than weakening their authorization assertions. Review-size exception: keep original-schema collection, native SSE/JSON/replay wiring and their end-to-end regressions in one layer because they jointly define the completion contract. Roughly half the added lines are focused regressions; the alias inventory is moved, not reimplemented. Prior catalog and patch concerns are already separate PRs. Additional Aside profile work remains separate future cycles. + +## Review synthesis, round1 + +Accept three medium findings: (1) sparse JSON receives inferred completion status after the new repair, so normalize snapshot/required fields before function repair and reuse that normalization for stored replay; (2) an index-only early completion can be correlated but still lacks item_id, so attach the known id even if arguments stay unchanged; (3) current-turn tool_search_output declarations are promoted by the adapter but absent from the original-schema collector, so include their original definitions in collector/selector resolution after the replay-prefix cut. Do not broaden collectResponsesToolGroups globally or include historical loaded declarations. Main owns normalization order/replay regression; existing worker owns early-frame id and loaded-declaration collector fixes/tests. Original authorization and preservation constraints remain. diff --git a/src/responses/function-call-compat.ts b/src/responses/function-call-compat.ts index bba6a8c8ea..01e4d1f873 100644 --- a/src/responses/function-call-compat.ts +++ b/src/responses/function-call-compat.ts @@ -38,8 +38,16 @@ function selectorAllows( export function collectFunctionCallRepairSchemas(body: unknown): Map { const schemas = new Map(); if (!isObject(body)) return schemas; + const groups = collectResponsesToolGroups(body); + if (Array.isArray(body.input)) { + for (const entry of body.input) { + if (isObject(entry) && entry.type === "tool_search_output" && Array.isArray(entry.tools)) groups.push(entry.tools); + } + } // Reuse namespace selector resolution, retaining schemas from the original objects below. - const lowered = rewriteRoutedNamespaceToolsForUpstream(body).body; + // This local catalog view includes loaded definitions without revisiting replay history or + // teaching the shared tool-group collector a new transport-wide interpretation. + const lowered = rewriteRoutedNamespaceToolsForUpstream({ ...body, tools: groups.flat(), input: [] }).body; const choice = body.tool_choice; const loweredChoice = isObject(lowered) ? lowered.tool_choice : undefined; const occupied = new Map(); @@ -62,7 +70,7 @@ export function collectFunctionCallRepairSchemas(body: unknown): Map, identity: Identity): string => { if (typeof event.arguments !== "string") return block; + const resolved = (typeof event.item_id !== "string" || event.item_id === "") && identity.itemId !== undefined + ? { ...event, item_id: identity.itemId } + : event; const repaired = repairFunctionCalls({ ...identity.item, status: identity.item.status ?? "completed", arguments: event.arguments }, schemas); - if (!repaired.changed || !isObject(repaired.value)) return block; - return replaceSseDataPayload(block, JSON.stringify({ ...event, arguments: repaired.value.arguments })); + if (repaired.changed && isObject(repaired.value)) { + return replaceSseDataPayload(block, JSON.stringify({ ...resolved, arguments: repaired.value.arguments })); + } + return resolved === event ? block : replaceSseDataPayload(block, JSON.stringify(resolved)); }; const flushPending = (identity: Identity): string[] => { const output: string[] = []; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index d992c07856..dd63864b57 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -380,7 +380,7 @@ import { } from "../sse-payload-rewrite"; import { restoreRoutedCustomCalls, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair"; -import { collectFunctionCallRepairSchemas, repairFunctionCalls, repairFunctionCallsInJson } from "../../responses/function-call-compat"; +import { collectFunctionCallRepairSchemas, repairFunctionCallsInJson } from "../../responses/function-call-compat"; import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-function-tool-repair"; import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair"; @@ -4046,6 +4046,14 @@ async function handleResponsesInner( ); const restoreAuthorizedBareNamespaceToolCalls = (value: unknown): unknown => restoreRoutedNamespaceCalls(value, authorizedBareNamespaceToolAliases).value; + const normalizeFunctionCompletionJson = (text: string): string => { + const snapshot = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair) + ? repairResponsesSnapshotJson(text, outboundRequestBody) + : text; + // Sparse gateways need completion status inferred before schema repair can + // distinguish completed arguments from in-progress placeholders. + return repairFunctionCallsInJson(backfillResponsesFieldsJson(snapshot), functionRepairSchemas); + }; let undeclaredToolGuardActive = false; const refreshUndeclaredToolGuard = (builtRequest: AdapterRequest): void => { outboundRequestBody = parseOutboundRequestBody(builtRequest.body); @@ -4158,12 +4166,15 @@ async function handleResponsesInner( const rememberPassthroughResponseChecked = rememberPassthroughResponse ? (response: { id?: unknown; output?: unknown; status?: unknown }) => { if (inspectionSawUndeclaredTool) return; - const restoredResponse = repairFunctionCalls(restoreRoutedCustomCalls( + const restored = restoreRoutedCustomCalls( restoreAuthorizedBareNamespaceToolCalls(restoreRoutedNamespaceCalls(response, routedNamespaceToolAliases).value), routedCustomToolNames, routedCustomToolRepairNames, declaredWireToolNames, - ).value, functionRepairSchemas).value as { id?: unknown; output?: unknown; status?: unknown }; + ).value; + const restoredResponse = (functionRepairSchemas.size > 0 + ? JSON.parse(normalizeFunctionCompletionJson(JSON.stringify(restored))) + : restored) as { id?: unknown; output?: unknown; status?: unknown }; if ( undeclaredToolGuardActive && undeclaredToolCallNameInResponse( @@ -5281,13 +5292,10 @@ async function handleResponsesInner( restored, routedToolSearchNames, ); - const restoredFunctionCalls = repairFunctionCallsInJson(restoredToolSearch, functionRepairSchemas); - const repaired = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair) - ? repairResponsesSnapshotJson(restoredFunctionCalls, outboundRequestBody) - : restoredFunctionCalls; + const repaired = normalizeFunctionCompletionJson(restoredToolSearch); const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId - ? rewriteResponsesModelJson(backfillResponsesFieldsJson(repaired), parsed._responseModelId) - : backfillResponsesFieldsJson(repaired); + ? rewriteResponsesModelJson(repaired, parsed._responseModelId) + : repaired; // The bounded-JSON answer bypasses the SSE payload rewrite, so content- // channel reasoning needs the same normalization here for the plain // JSON answer and every reframed-SSE variant built from clientJson. diff --git a/tests/responses/responses-function-tool-repair.test.ts b/tests/responses/responses-function-tool-repair.test.ts index d848bafe1f..f841135b7e 100644 --- a/tests/responses/responses-function-tool-repair.test.ts +++ b/tests/responses/responses-function-tool-repair.test.ts @@ -9,6 +9,7 @@ import { import { createResponsesFunctionToolRepairBlockRewrite } from "../../src/server/responses-function-tool-repair"; import { createTranslatorBudget, TranslatorBudgetExceededError } from "../../src/lib/translator-budget"; import { sseDataPayload } from "../../src/server/sse-payload-rewrite"; +import { currentTurnWireToolCatalogBody } from "../../src/server/responses-undeclared-tool-guard"; const parameters = { type: "object", properties: { cell_id: { type: "string" }, yield_time_ms: { type: "integer" }, @@ -95,6 +96,50 @@ describe("original function declaration authority", () => { } }); + test("loaded tool_search_output functions retain their original schemas", () => { + const body = { input: [{ type: "tool_search_output", tools: [wait, { type: "custom", name: "exec" }] }] }; + const before = JSON.stringify(body); + const map = collectFunctionCallRepairSchemas(body); + expect([...map.keys()]).toEqual(["wait"]); + expect(map.get("wait")?.parameters).toBe(parameters); + expect(repairFunctionCalls(item(), map).value).toEqual(item(canonical)); + expect(JSON.stringify(body)).toBe(before); + }); + + test.each([ + [{ type: "function", name: "left.wait" }, ["left__wait"]], + [{ type: "function", namespace: "right", name: "wait" }, ["right__wait"]], + [{ type: "function", name: "wait" }, []], + [{ type: "custom", name: "left.wait" }, []], + [{ type: "function", namespace: "right", name: "left__wait" }, []], + [{ type: "allowed_tools", tools: [{ type: "function", name: "right.wait" }] }, ["right__wait"]], + ["none", []], + ])("loaded namespace declarations honor selector %j", (tool_choice, keys) => { + const body = { tool_choice, input: [{ type: "tool_search_output", tools: groups }] }; + expect([...collectFunctionCallRepairSchemas(body).keys()]).toEqual(keys); + }); + + test("replay-trimmed loaded definitions cannot grant historical schema authority", () => { + const historical = { ...wait, parameters: { type: "object", properties: { cell_id: { type: "number" } } } }; + const body = { input: [ + { type: "tool_search_output", tools: [historical, { type: "function", name: "old_only" }] }, + { type: "message", role: "user", content: [] }, + { type: "tool_search_output", tools: [wait] }, + ] }; + const map = collectFunctionCallRepairSchemas(currentTurnWireToolCatalogBody(body, 2)); + expect([...map.keys()]).toEqual(["wait"]); + expect(map.get("wait")?.parameters).toBe(parameters); + expect(repairFunctionCalls(item(), map).value).toEqual(item(canonical)); + expect(collectFunctionCallRepairSchemas(currentTurnWireToolCatalogBody(body, 3)).size).toBe(0); + }); + + test("loaded and explicit conflicting declarations remain fail-closed in either order", () => { + const conflict = { ...wait, parameters: { type: "object", properties: { cell_id: { type: "number" } } } }; + for (const [explicit, loaded] of [[wait, conflict], [conflict, wait]]) { + expect(collectFunctionCallRepairSchemas({ tools: [explicit], input: [{ type: "tool_search_output", tools: [loaded] }] }).size).toBe(0); + } + }); + test("namespace wait does not inherit bare wait number-field exceptions", () => { const numberWait = { ...wait, parameters: { type: "object", properties: { yield_time_ms: { type: "number" } } } }; const map = collectFunctionCallRepairSchemas({ tools: [numberWait, { type: "namespace", name: "remote", tools: [numberWait] }] }); @@ -190,13 +235,34 @@ describe("native function completion SSE", () => { .toMatchObject({ arguments: canonical }); const output = rewrite(frame("response.output_item.added", { output_index: 1, item: item("", { name: "get_state", id: "fc_two", status: "in_progress" }) })); expect(output.map(block => payload(block).type)).toEqual(["response.output_item.added", "response.function_call_arguments.done"]); - expect(payload(output[1]!)).toMatchObject({ arguments: "{}", output_index: 1 }); + expect(payload(output[1]!)).toMatchObject({ arguments: "{}", output_index: 1, item_id: "fc_two" }); expect(budget.snapshot().currentBytes).toBe(0); rewrite.dispose?.(); expect(budget.snapshot().currentBytes).toBe(0); } finally { rewrite.dispose?.(); budget.dispose(); } }); + test.each([raw, canonical])("index-only completion gets downstream identity even when arguments stay unchanged: %s", argumentsText => { + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); + try { + expect(rewrite(frame("response.function_call_arguments.done", { output_index: 0, arguments: argumentsText }))).toEqual([]); + const output = rewrite(frame("response.output_item.added", { output_index: 0, item: item("", { status: "in_progress" }) })); + const calls = new Map(); + for (const block of output) { + const event = payload(block); + if (event.type === "response.output_item.added") { + const call = event.item as { id: string; arguments: string }; + calls.set(call.id, call.arguments); + } else if (event.type === "response.function_call_arguments.done") { + expect(typeof event.item_id).toBe("string"); + expect(calls.has(event.item_id as string)).toBe(true); + calls.set(event.item_id as string, event.arguments as string); + } + } + expect([...calls]).toEqual([["fc_one", canonical]]); + } finally { rewrite.dispose?.(); } + }); + test("terminal snapshots resolve early completions; authoritative arguments beat previews", () => { const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); try { diff --git a/tests/responses/responses-snapshot-repair-server.test.ts b/tests/responses/responses-snapshot-repair-server.test.ts index 57916e8f4e..3d7702b1af 100644 --- a/tests/responses/responses-snapshot-repair-server.test.ts +++ b/tests/responses/responses-snapshot-repair-server.test.ts @@ -190,3 +190,34 @@ describe("responsesSnapshotRepair through /v1/responses", () => { } }); }); + +test("sparse JSON completion inference precedes function repair in client output and replay", async () => { + const expected = '{"cell_id":"4","yield_time_ms":120000}'; + const item = { type: "function_call", id: "fc_sparse_wait", call_id: "call_sparse_wait", name: "wait", arguments: '{"cell_id":4,"yield_time_ms":120000.0}' }; + let responseId = `resp_sparse_${crypto.randomUUID()}`; + let capturedInput: Array> = []; + globalThis.fetch = (async (_input, init) => { + capturedInput = JSON.parse(String(init?.body)).input; + return Response.json({ id: responseId, output: [item] }); + }) as typeof fetch; + const config = { + port: 0, defaultProvider: "sparse", + providers: { sparse: { adapter: "openai-responses", baseUrl: "https://sparse-function.invalid/v1", authMode: "key", apiKey: "fixture", responsesSnapshotRepair: true } }, + } as OcxConfig; + const request = (extra: object = {}) => new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "sparse/probe", stream: false, input: "synthetic", + tools: [{ type: "function", name: "wait", parameters: { type: "object", properties: { cell_id: { type: "string" }, yield_time_ms: { type: "integer" } } } }], + ...extra, + }), + }); + const first = await handleResponses(request(), config, { model: "", provider: "" }); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ status: "completed", output: [{ status: "completed", arguments: expected }] }); + const previous = responseId; + responseId = `resp_sparse_followup_${crypto.randomUUID()}`; + const second = await handleResponses(request({ previous_response_id: previous, input: [{ type: "function_call_output", call_id: item.call_id, output: "done" }] }), config, { model: "", provider: "" }); + await second.text(); + expect(capturedInput.find(value => value.type === "function_call" && value.call_id === item.call_id)?.arguments).toBe(expected); +}); From d6bfb044a5dc6494cba57c1238ded7c23faf5586 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:25:21 +0900 Subject: [PATCH 066/236] fix(adapters): reject unsafe integer tool-call indexes Co-authored-by: Hako <25837994+devswha@users.noreply.github.com> --- .../020_tool_aliases.md | 8 ++- .../src/content/docs/reference/adapters.md | 2 +- src/adapters/openai-chat.ts | 3 +- structure/04_transports-and-sidecars.md | 11 ++-- .../openai-chat-parallel-stream.test.ts | 55 +++++++++++++++++++ 5 files changed, 71 insertions(+), 8 deletions(-) diff --git a/devlog/_plan/260906_d_integrations_delivery/020_tool_aliases.md b/devlog/_plan/260906_d_integrations_delivery/020_tool_aliases.md index 54ecb53073..845fd6fec7 100644 --- a/devlog/_plan/260906_d_integrations_delivery/020_tool_aliases.md +++ b/devlog/_plan/260906_d_integrations_delivery/020_tool_aliases.md @@ -44,7 +44,7 @@ Current anchors: `src/adapters/openai-chat.ts:1661` pending interface, `:1856` i ```ts if (typeof rawIndex === "number" - && (!Number.isInteger(rawIndex) || rawIndex < 0)) { + && (!Number.isSafeInteger(rawIndex) || rawIndex < 0)) { return yield* terminateWithError({ ...invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage), message: "upstream response contained invalid tool calls (invalid numeric index)", @@ -121,4 +121,8 @@ The implementation cycle certifies its published current-head candidate. Every d ## External review amendment: numeric index contract -Only non-negative integer indexes may become an alias. Immediately after reading rawIndex, if it is numeric but not an integer or is negative, terminate through the existing invalidToolCallsEvent/terminateWithError path; do not treat an invalid numeric index as absent and append its data to the last pending call. Other tolerated placeholder fields retain their existing rules. Add reachable negative/fractional numeric-index regressions with two distinct pending calls: one error, no done, no fragment reassignment, and all budget reservations released. Preserve all original positive and collision cases. This is an explicit source-patch amendment, not a claim the original commit already implements validation. +Only non-negative safe-integer indexes may become an alias. Immediately after reading rawIndex, if it is numeric but not an integer or is negative, terminate through the existing invalidToolCallsEvent/terminateWithError path; do not treat an invalid numeric index as absent and append its data to the last pending call. Other tolerated placeholder fields retain their existing rules. Add reachable negative/fractional numeric-index regressions with two distinct pending calls: one error, no done, no fragment reassignment, and all budget reservations released. Preserve all original positive and collision cases. This is an explicit source-patch amendment, not a claim the original commit already implements validation. + +## Safe-integer review repair + +The numeric guard uses Number.isSafeInteger: parsed indices beyond the safe range can already have lost identity precision. Add a raw-wire regression containing distinct large integer literals (not JS values rounded before serialization), and retain a positive MAX_SAFE_INTEGER boundary. Capture error/no tool success plus existing reservation-release coverage. The correction must be verified in this same unit; no original source tests are removed. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 62440679b2..64d5278ec6 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -53,7 +53,7 @@ Streaming tool calls retain their identity when a provider first sends an ID, then associates that ID with an index, and later sends index-only argument fragments. Those fragments assemble into one call with the original name and complete arguments; parallel calls retain separate identities. -Numeric streamed tool-call indexes must be non-negative integers; malformed numeric +Numeric streamed tool-call indexes must be non-negative safe integers; malformed numeric indexes terminate the stream with an upstream error before identity matching. ## `ollama-native` diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 76c9d7e931..66db05167b 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1850,9 +1850,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const idDelta = typeof rawId === "string" ? rawId : ""; const rawIndex = rawToolCall.index; // Invalid numeric indexes must not fall through to ID or last-call matching. + // Unsafe integers can collapse distinct wire indexes onto the same JS number. // Reject before an alias can bind or any pending call can consume the fragment. if (typeof rawIndex === "number" - && (!Number.isInteger(rawIndex) || rawIndex < 0)) { + && (!Number.isSafeInteger(rawIndex) || rawIndex < 0)) { return yield* terminateWithError({ ...invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage), message: "upstream response contained invalid tool calls (invalid numeric index)", diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index fa4258a800..be179a2682 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -1504,9 +1504,11 @@ Native Composer/MCP behavior and text-only historical replay remain unchanged. ## Chat streamed tool-call identity -`src/adapters/openai-chat.ts` retains a call's first observed non-negative integer -index as an alias when the call started by ID. Numeric indexes that are negative -or non-integer terminate the stream before any key, alias, ID or last-call matching. +`src/adapters/openai-chat.ts` retains a call's first observed non-negative safe integer +index as an alias when the call started by ID. Numeric indexes that are negative, +non-integer or outside JavaScript's safe-integer range terminate the stream before +any key, alias, ID or last-call matching. `Number.MAX_SAFE_INTEGER` is accepted; +larger integers are rejected because distinct wire literals can parse to the same number. The invalid-index error releases all pending call reservations without emitting those calls or a successful completion; invalid indexes are never treated as absent. Missing and non-numeric index placeholders retain their existing tolerance. @@ -1516,7 +1518,8 @@ ID fallback. The initial key continues to own all translator budget reservations and release; learning an alias creates no additional owner. Unassociated index-only fragments are not guessed onto pending ID-only calls. `tests/adapters/openai/openai-chat-parallel-stream.test.ts` covers late aliases, -parallel/colliding identities, invalid numeric indexes and UTF-8 byte-limit boundaries. +parallel/colliding identities, distinct unsafe raw JSON index literals, the maximum +safe-integer boundary, invalid numeric indexes and UTF-8 byte-limit boundaries. ## Sidecars diff --git a/tests/adapters/openai/openai-chat-parallel-stream.test.ts b/tests/adapters/openai/openai-chat-parallel-stream.test.ts index 3e65b8534e..85f49e8f21 100644 --- a/tests/adapters/openai/openai-chat-parallel-stream.test.ts +++ b/tests/adapters/openai/openai-chat-parallel-stream.test.ts @@ -313,6 +313,61 @@ describe("openai-chat parallel tool call stream assembly", () => { expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); }); + test("rejects distinct unsafe raw JSON indexes before they collapse into one call", async () => { + const budget = createTestTranslatorBudget(); + // Keep both index literals on the wire: constructing JS numbers before JSON.stringify + // would already round 9007199254740993 to 9007199254740992. Without rejection, + // both whitespace fragments would silently join call_a's valid JSON despite call_b's ID/name. + const response = new Response(String.raw`data: {"choices":[{"delta":{"tool_calls":[{"id":"call_a","function":{"name":"read","arguments":"{}"}}]}}]} + +data: {"choices":[{"delta":{"tool_calls":[{"id":"call_a","function":{"arguments":""}}]}}]} + +data: {"choices":[{"delta":{"tool_calls":[{"index":9007199254740992,"id":"call_a","function":{"name":"read","arguments":" "}}]}}]} + +data: {"choices":[{"delta":{"tool_calls":[{"index":9007199254740993,"id":"call_b","function":{"name":"write","arguments":" "}}]}}]} + +data: {"choices":[{"delta":{"tool_calls":[]},"finish_reason":"tool_calls"}]} + +data: [DONE] + +`); + const events: AdapterEvent[] = []; + let sawPendingReservation = false; + for await (const event of createOpenAIChatAdapter(provider).parseStream(response, budget)) { + events.push(event); + const snapshot = budget.snapshot(); + sawPendingReservation ||= snapshot.activeCalls === 1 && snapshot.currentBytes === 2; + if (event.type === "error") { + expect(snapshot).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + } + } + expect(sawPendingReservation).toBe(true); + // The first unsafe index terminates before either unsafe fragment emits a heartbeat, + // a tool call, or done; the buffered reservation is released at the error itself. + expect(events.map(event => event.type)).toEqual(["heartbeat", "heartbeat", "error"]); + expect(events.at(-1)).toMatchObject({ + type: "error", + status: 502, + errorType: "upstream_error", + message: "upstream response contained invalid tool calls (invalid numeric index)", + }); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + + test("retains a late MAX_SAFE_INTEGER alias for index-only continuation", async () => { + const budget = createTestTranslatorBudget(); + const events = await collect(sse([ + chunkOf([{ id: "call_boundary", function: { name: "read", arguments: '{"p":' } }]), + chunkOf([{ index: Number.MAX_SAFE_INTEGER, id: "call_boundary", function: { arguments: '"x"' } }]), + chunkOf([{ index: Number.MAX_SAFE_INTEGER, function: { arguments: "}" } }]), + chunkOf([], "tool_calls"), + ]), budget); + expect(assembled(events)).toEqual([{ id: "call_boundary", name: "read", args: '{"p":"x"}' }]); + expect(events.some(event => event.type === "error")).toBe(false); + expect(events.at(-1)?.type).toBe("done"); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + test("an observed index wins over a conflicting ID without rebinding either call", async () => { const events = await collect(sse([ chunkOf([{ id: "call_a", function: { name: "read", arguments: "{\"p\":" } }]), From 37b3a7f9b6f653bc54e5d57a3e9c81d03acaabd8 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:41:39 +0900 Subject: [PATCH 067/236] docs: plan Aside profile controls and ownership boundaries --- .../260906_aside_profiles/000_research.md | 21 +++++++++++++++ .../010_profiles_backend_cli.md | 27 +++++++++++++++++++ .../260906_aside_profiles/020_profiles_gui.md | 15 +++++++++++ 3 files changed, 63 insertions(+) create mode 100644 devlog/_plan/260906_aside_profiles/000_research.md create mode 100644 devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md create mode 100644 devlog/_plan/260906_aside_profiles/020_profiles_gui.md diff --git a/devlog/_plan/260906_aside_profiles/000_research.md b/devlog/_plan/260906_aside_profiles/000_research.md new file mode 100644 index 0000000000..b1e32b995f --- /dev/null +++ b/devlog/_plan/260906_aside_profiles/000_research.md @@ -0,0 +1,21 @@ +# Aside profile synchronization roadmap + +User scope extension: synchronize all Aside profiles and expose independent profile switches in GUI and CLI. Continue the original Grok catalog/Responses stabilization stack; no local test suites or local typecheck, no release/service deployment. Existing push --no-verify and admin-merge authorization applies to these scoped layers. + +Observed installed contract: accounts.json has currentAccountId plus accounts[] with numeric id and name; profileAccountBindings maps browser profiles to accountId. This machine has three account-backed profiles (one cloud, two local), with a models.json only in the current account. Every model catalog lives under the configured Aside root/u//models.json. Browser profilePath is metadata, never a write destination. Multiple bindings sharing one account share one model catalog and therefore one control row. Keep only id/name/current metadata; never serialize sessions, tokens, user IDs, email or subscription metadata from the manifest. + +Current owners: config-export.ts asideCurrentAccountId resolves only currentAccountId. registry.ts aside.resolvePaths freezes that current path pair. writer.ts synchronous input supports resolvedPaths but its async freeze recomputes current paths; state.ts has no frozen-pair input. The ownership store is keyed by clientId, so a single root can retain only one Aside account. FileIntegrationPage already owns safe toggle/overwrite/history/restore, and all its resource keys currently use only client ID. CLI is a thin management caller with no profile flag. + +Decision: enumerate account-backed profiles; derive paths strictly from numeric IDs under Aside root, not profilePath. Partition each new profile's ownership and journal into /aside-profiles/; retain exactly one stable writable legacy root owner; all sibling writes use independent child stores. Older mixed legacy history remains readable by exact profile path and can be imported into the correct child store only for explicit restore. Freeze the chosen profile's paths for status/write/restore. Do not move user files, change currentAccountId, or copy credentials. + +Desired state: add asideProfileSync:{allProfiles?:boolean,profiles?:Record,legacyProfileId?:number|null} to OcxConfig. Absent defaults to whether a legacy Aside ownership record establishes prior connection. That legacy connection enables all discovered profiles by default, satisfying the user's all-profile request. A per-profile override persists independently. Before modifying a per-profile override, materialize the prior global default so disabling the legacy profile does not flip siblings. Explicit actions persist desired policy before any file writes; a save failure aborts with no file mutation. Bulk intent sets allProfiles and clears overrides, while actual per-profile applied states and refusals remain separate. A failed file mutation leaves visible pending intent, never an all-applied claim. Restore reconciles only its target profile policy with validated prior ownership so Undo cannot be silently reversed by the next sync. Per-profile-only enable when previously disconnected leaves other profiles off. Implicit sync refreshes owned enabled profiles and may safely apply an absent block in an explicitly/legacy-enabled unowned profile; it never overwrites foreign blocks or recreates a manually removed previously-owned block. + +Cycle map: docs-only roadmap; 010 backend/profile ownership/API/CLI (foundation and API can be separate dependent PRs within this single implementation unit); 020 GUI controls/QA and final full-stack landing. Every original exact-head CI and merge-ancestry criterion remains open until terminal delivery. + +Design Read: a repeated-use integration settings page using the existing monochrome dashboard: --bg white/#212121, --surface white/#262626, --accent #0d0d0d/#ececec, existing --font-ui and ClientMark. Compact profile rows show name/current marker, state, and switch; a global switch and enabled/total count summarize all profiles. Details reuse the existing FileIntegrationPage scoped to a selected profile so history/restore stays available. No new visual framework, assets or motion. DESIGN_VARIANCE2, MOTION_INTENSITY1, densityD5. Loading/error/empty/partial/busy states are explicit; the current browser account never changes when an integration switch changes. + +Resource bounds inherited: six-hour window from original goal, no requested token budget, original at-most24 live synthetic provider requests. Profile probes use temporary roots with three profiles; bulk production discovery is bounded to128 account entries. Existing local/GitHub credentials only for authorized repo work. Actual user profile files remain read-only during development. Runtime file writes are tested only in isolated fixtures. C4 ownership/path review is required before production merge; security working notes stay ignored scratch. + +## Baseline + +`bun .tmp/aside-profiles/baseline.ts` runs only synthetic temp files: manifest has0/1/2, legacy owned0, current model-selection route runs refresh, and configuredAsideProfiles remains1. This reproduces the user report without editing any real profile. Browser profile bindings resolve to three distinct account IDs in the current install. diff --git a/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md new file mode 100644 index 0000000000..7a434dc7f9 --- /dev/null +++ b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md @@ -0,0 +1,27 @@ +# 010 Profile data, ownership, API and CLI + +Class C4 for controlled multi-file writes; spec-satisfaction repair. Goal: all account-backed Aside profiles receive the selected catalog and can be independently enabled/disabled. Non-goals: login/account switching, browser profile data, credential changes, unowned overwrite without the existing explicit flag, other clients redesign. + +NEW src/clients/aside-profiles.ts: typed AsideProfile {id:number,name?:string,current:boolean,configPath:string,detectDir:string}; read configured asideHomeDir accounts.json, validate bounded account array, dedupe safe nonnegative integer IDs, fall back to current-only legacy manifest when accounts is absent, fail on malformed identities. Map only safe metadata; derive root/u/id paths. A numeric query selector must refer to this enumeration. No path from browser profile bindings reaches writes. +MODIFY src/types/config.ts + src/config.ts: asideProfileSync optional object with allProfiles boolean, numeric-key boolean overrides, and optional nullable safe-integer legacyProfileId provenance. Per-field validity must not erase unrelated configuration; preserve unknown future policy fields where existing conventions require. Full field chain: creation in Aside mutation service; persistence via saveConfigPreservingClaudeCode; deserialization in config schema; consumers profile status, explicit toggles and implicit sync; serialization GUI/CLI receives effective enabled per row, not raw credentials. +MODIFY src/integrations/state.ts: optional resolvedPaths in IntegrationStateInput, use it instead of resolving current profile. MODIFY writer.ts freezeIntegrationInput to clone a supplied internal resolved pair, preserving existing resolution otherwise. This is an internal seam only; routes never accept caller-provided paths. +NEW src/integrations/aside-profiles.ts: resolve profile-specific store/path input. Exactly one profile may use the writable legacy root: a matching current legacy ownership record wins; only if no record exists may the newest legacy Aside operation choose it. An unrecognized existing record makes the root unassigned. Persist the resolved legacyProfileId (number or null) before the first explicit mutation, so disabling/reloading cannot reassign it. All other profiles use isolated child stores. Read statuses with same classifier. Model load memoized across profiles. Compute effective default and per-profile override. Explicit enable/disable/overwrite uses existing coordinated writer and mutation-flight exclusivity, serializes profiles, returns per-profile results; persist desired preferences through the caller save seam before file mutation, under the same exclusive operation. On save failure restore the in-memory prior policy and abort before filesystem changes. Report desired enabled separately from actual state and per-profile refusals; do not fabricate all-applied success. Missing/foreign/unsafe/drifted profile remains untouched with explicit refusal. A manual deletion with a surviving ownership record stays absent on implicit refresh. First safe creation in enabled unowned profile uses apply without overwrite. Never switch the active account. +MODIFY src/integrations/owned-refresh.ts optional internal resolvedPaths; MODIFY catalog-refresh.ts Aside fan-out to profile service and preserve per-profile outcome IDs; update CLI explicit sync logs/type projection to identify profiles. + +NEW src/server/management/aside-profile-routes.ts: GET /api/client-integrations/aside/profiles returns {profiles:[{profileId,name?,current,enabled,...IntegrationStatus}],allEnabled,enabledCount,total}; GET /aside without profile returns aggregate IntegrationStatus+profiles, PUT /aside without profile acts on all discovered profiles. Existing /aside?profile= handles one explicit profile with same mutation/refusal semantics. Numeric profile parsing is strict, membership checked, non-Aside use rejected. Reuse existing jsonResponse/body parsing/CSRF outer boundary. Return partial failures visibly; do not turn mixed outcomes into a successful all-applied status. +MODIFY integration-routes.ts: route Aside list/status/toggle to profile service; collection projects Aside aggregate while other clients remain unchanged. Bind optional profile scope for journal/delete/restore query paths to the same selected store and frozen paths; no snapshot can restore into another profile. Existing no-profile legacy history remains accessible. Existing test hooks (root store/env/home/io/lock seams) must propagate. New prefs writes use deps.saveConfigPreservingClaudeCode, never bypass fixture isolation. +MODIFY src/cli/integrations.ts: --profile for Aside status/show/list, enable/disable, history/journal and restore/delete equivalents that exist; reject on other clients and malformed IDs. No --profile on Aside enable/disable means all. Route flag through query profile; status prints all per-profile rows, JSON preserves metadata; mixed failure exits nonzero with structured result retained. Update usage/capability source if help registry owns it, and operating docs. + +Tests: new profile enumeration/store/writer domain tests registered in both layout manifests; management and CLI tests cover current0+local1+local2, all-enable, individual-off persists through sync, legacy-default all, explicit one-only enable, active-account changes do not retarget a pinned write, unowned/drifted/removed/symlink/missing profile refusals, malformed selectors, unknown ID, partial outcome, per-profile journal/restore isolation and old legacy history. Actual temporary fixtures and original writer/management calls; no live user config mutation. + +Verification: standalone temp-root production probe establishes three distinct file outputs and one-off persistence across refresh; remote Bun focused regressions/typecheck/privacy gates; independent ownership/API review. Final exact-head hosted CI and all PR ancestry are terminal obligations, not satisfied by queueing. Candidate new paths source-checked before B. Escalation only for a concrete unresolvable external constraint, not routine design choices. + +## Audit-locked operational contracts + +- One outer Aside mutation flight owns the complete action, including policy persistence and every coordinated writer call. Its key includes root fingerprint, sorted selected profile IDs, operation/overwrite/restore semantics and a unique operation nonce. Overlap returns busy; no profile ever joins another result. Do not nest refreshOwnedIntegration inside that flight; call coordinated refresh/apply directly after the service's ownership checks. Different profile roots cannot coalesce either. +- Profile status and every writer use the concrete filesystem validation/guard contract recorded in ignored .tmp/aside-profiles/security-scope.md. Frozen path pairs alone are not the boundary. The guard is rechecked immediately before file mutation and is shared with status. +- Restore resolves the operation's exact profile independently of currentAccountId. Before policy persistence validate operation/snapshot availability, target identity and ordinary drift preflight. Desired state after Undo is true only when priorRecord describes the exact snapshot bytes as owned; absent/foreign/conflicted snapshots set a target false override. Global defaults and sibling overrides remain unchanged. Persist that target intent first; writer refuses or restores under the same flight. Cover enable->undo->sync and disable->undo->sync after reload. A later filesystem refusal remains visible as desired/actual mismatch, not success. +- NEW src/integrations/aside-profile-journal.ts (if separation needed): path-filtered profile history combines its writable store and matching legacy operations, deduping operation IDs. Snapshot reads use each operation's source store. A restore of an older sibling legacy operation imports only that immutable operation and its available snapshot into the target child store (same opId, exact priorRecord/configPath, no original deletion), then uses the existing coordinated restore there; it never changes the legacy owner's record. Expired snapshots stay expired. Profile history deletion checks the newest operation within that profile and retires duplicate imported/source copies together so a deleted row cannot reappear. Generic history/restore paths resolve Aside operation scope by exact configPath when no profile query is supplied, and reject an operation whose profile is no longer registered instead of retargeting it. +- Add profileId to journal/API rows, and treat (clientId,profileId/configPath) as history ownership for latest/undo/delete checks. Existing non-Aside behavior stays unchanged. + +C4 audit findings and concrete filesystem guard details are kept in ignored scratch; the public roadmap records feature contracts only. diff --git a/devlog/_plan/260906_aside_profiles/020_profiles_gui.md b/devlog/_plan/260906_aside_profiles/020_profiles_gui.md new file mode 100644 index 0000000000..7dda350656 --- /dev/null +++ b/devlog/_plan/260906_aside_profiles/020_profiles_gui.md @@ -0,0 +1,15 @@ +# 020 Aside GUI profile controls and terminal delivery + +Depends on 010 verified API and CLI. Class C3 UI with C4 backend unchanged. Goal: all discovered profiles are visible, bulk and individual switches operate on exact profiles, and existing history/restore is still usable. + +NEW gui/src/pages/integrations/AsideProfilesPage.tsx: useDataSurface GET profiles endpoint, existing Notice/Switch/ClientMark/IntegrationStateBadge. Global switch sets desired sync for all; rows show profile name or translated numeric fallback, current marker, actual state, independent switch, and details action. Single pending target serializes interactions consistently with backend. Switches read desired enabled; badges and applied/total count read actual file state. Show pending mismatch and per-profile refusal after partial failure, never optimistic applied success for siblings. A retry repeats the same desired action. Empty profile list prompts opening Aside; errors offer existing refresh action; inactive tabs do not fetch. A selected profile opens the existing FileIntegrationPage with profileId plus name and a back action; do not duplicate its rollback machinery. +MODIFY gui/src/pages/Integrations.tsx: Aside renders new page; remaining file clients stay on existing page. +NEW or MODIFY integration-api.ts profile contract/types and load function; optional profileId appended to state/toggle/history/restore/delete query URLs. Preserve old call signatures for other clients. Runtime response validation must accept only safe profile IDs and recognized IntegrationStatus states, and retain partial outcomes for UI display. +MODIFY FileIntegrationPage.tsx: optional profileId/profileLabel, read optional desired enabled on scoped status, include profile in every resource/cache/dependency key and every state/history/mutation call. MODIFY RestoreDialog.tsx if needed to pass profile scope through; rollback/delete remain on selected profile. +MODIFY styles-integrations.css: compact row layout using existing tokens; responsive wrapping for long labels/paths. No new color system or decorative assets. +MODIFY every gui/src/i18n locale module: profile list/title, sync-all, enabled count, current profile, details/back, empty, per-profile switch labels and partial failure copy. All visible text uses t/useT; names and numeric IDs are API metadata. +UPDATE guides/integrations.md and operating CLI docs with all-profile default, --profile examples, active-profile independence, per-profile exclusions and restart behavior. Translations must not contradict new all-profile behavior. + +Verification: remote focused GUI/API tests, GUI lint/i18n/build, root typecheck and required CI. Browser QA on local dev UI against three synthetic profiles, never real user profile mutation: initial mixed state, global enable, one profile disable, return to list after details, correct request selector, reload retains off state, failed profile does not imply sibling success, keyboard switches and narrow viewport. Capture actual screenshot for PR body using existing browser plugin, view it, and fix layout if needed. Screenshot contains synthetic labels only. A PR mentioning GUI includes screenshot. No local test suite or local typecheck; local dev server/browser probes are permitted. + +Terminal: verify every PR current head and all applicable hosted checks; native stack registration, owner-authorized admin merge, async merge completion, fetch dev and prove every merge SHA ancestry. Resolve CI or reviews rather than bypass evidence. No release or live service deployment. All original Grok/Pi/Codex and added Aside-profile criteria must be met before host goal completion. From 4f0c278420998778e1341f7c7ed88e818c7ae048 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:13:04 -0600 Subject: [PATCH 068/236] fix(cursor): preserve executable tool schemas --- src/adapters/cursor/tool-schemas.ts | 31 ++++++++++ .../cursor/cursor-tool-definitions.test.ts | 60 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/adapters/cursor/tool-schemas.ts b/src/adapters/cursor/tool-schemas.ts index 96ad3dfa63..d96fb6e41a 100644 --- a/src/adapters/cursor/tool-schemas.ts +++ b/src/adapters/cursor/tool-schemas.ts @@ -10,11 +10,36 @@ export const CURSOR_EXEC_COMMAND_INPUT_SCHEMA = { tty: { type: "boolean", description: "True allocates a PTY for the command; false or omitted uses plain pipes." }, yield_time_ms: { type: "number", description: "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms." }, max_output_tokens: { type: "number", description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy." }, + sandbox_permissions: { + type: "string", + enum: ["use_default", "require_escalated"], + description: "Per-command sandbox override. Defaults to use_default; use require_escalated for unsandboxed execution.", + }, + justification: { + type: "string", + description: "User-facing approval question for require_escalated; omit otherwise.", + }, + prefix_rule: { + type: "array", + items: { type: "string" }, + description: "Reusable approval prefix for cmd, only with sandbox_permissions: require_escalated.", + }, + login: { + type: "boolean", + description: "True runs the shell with login semantics; false disables them. Defaults to true.", + }, }, required: ["cmd"], additionalProperties: false, } as const; +/** Cursor represents a Responses freeform tool body as one string-valued input field. */ +export const CURSOR_FREEFORM_INPUT_SCHEMA = { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], +} as const; + /** * Structured single-replacement schema advertised to Cursor models in addition to the freeform * `apply_patch` tool. Cursor-trained models reliably emit exact-match replacements (the native @@ -71,6 +96,10 @@ export const CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA = { yield_time_ms: { type: "number", description: "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms." }, max_output_tokens: { type: "number", description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy." }, max_output_chars: { type: "number", description: "Output character budget when the Responses tool uses chars instead of tokens." }, + sandbox_permissions: { type: "string", enum: ["use_default", "require_escalated"] }, + justification: { type: "string" }, + prefix_rule: { type: "array", items: { type: "string" } }, + login: { type: "boolean" }, }, required: ["command"], } as const; @@ -78,6 +107,7 @@ export const CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA = { /** Schema advertised to Cursor for this tool (may use Cursor-preferred field names like `cmd`). */ export function cursorToolInputSchema(tool: OcxTool): unknown { + if (tool.freeform) return CURSOR_FREEFORM_INPUT_SCHEMA; return isBareCodexExecCommandTool(tool) ? CURSOR_EXEC_COMMAND_INPUT_SCHEMA : (tool.parameters ?? {}); } @@ -87,6 +117,7 @@ export function cursorToolInputSchema(tool: OcxTool): unknown { * treating `cmd` as canonical prevents the `cmd` → `command` rewrite Codex requires (#399). */ export function cursorToolArgNormalizeSchema(tool: OcxTool): unknown { + if (tool.freeform) return CURSOR_FREEFORM_INPUT_SCHEMA; if (isBareCodexShellBridgeTool(tool)) { return shellBridgeArgNormalizeSchema(tool); } diff --git a/tests/providers/cursor/cursor-tool-definitions.test.ts b/tests/providers/cursor/cursor-tool-definitions.test.ts index a2bd4150fe..0d97b7c0ed 100644 --- a/tests/providers/cursor/cursor-tool-definitions.test.ts +++ b/tests/providers/cursor/cursor-tool-definitions.test.ts @@ -7,7 +7,9 @@ import { buildCursorToolDefinitions, cursorToolsForActivePrompt, buildCursorToolGuidanceSystemNote, + CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA, CURSOR_EXEC_COMMAND_INPUT_SCHEMA, + CURSOR_FREEFORM_INPUT_SCHEMA, cursorRequestAdvertisesApplyPatch, cursorRequestUsesCodeMode, isCursorCodeModeExecTool, @@ -128,6 +130,38 @@ describe("Cursor tool definitions", () => { expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(CURSOR_EXEC_COMMAND_INPUT_SCHEMA); }); + test("preserves sandbox escalation controls in shell advertisement and normalization", () => { + const advertised = CURSOR_EXEC_COMMAND_INPUT_SCHEMA.properties; + const normalized = CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA.properties; + + expect(advertised.sandbox_permissions.enum).toEqual(["use_default", "require_escalated"]); + expect(advertised.justification.type).toBe("string"); + expect(advertised.prefix_rule.items).toEqual({ type: "string" }); + expect(advertised.login.type).toBe("boolean"); + expect(normalized.sandbox_permissions.enum).toEqual(["use_default", "require_escalated"]); + expect(normalized.justification.type).toBe("string"); + expect(normalized.prefix_rule.items).toEqual({ type: "string" }); + expect(normalized.login.type).toBe("boolean"); + }); + + test("advertises and normalizes freeform tools as one required string input", () => { + const tool: OcxTool = { + name: "apply_patch", + description: "Apply a patch", + parameters: {}, + freeform: true, + }; + + expect(cursorToolInputSchema(tool)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + expect(cursorToolArgNormalizeSchema(tool)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + const defs = buildCursorToolDefinitions([tool]); + expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + + const codeModeExec: OcxTool = { name: "exec", description: "Run JavaScript", freeform: true }; + expect(cursorToolInputSchema(codeModeExec)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + expect(cursorToolArgNormalizeSchema(codeModeExec)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + }); + test("normalizes advertised shell_command cmd args to Responses command before Codex sees them", () => { // Live #399 failure: Cursor advertisement requires `cmd`, models send `cmd`, but Codex // shell_command validates `command` → "missing field `command`". Normalization must use the @@ -154,6 +188,19 @@ describe("Cursor tool definitions", () => { expect(normalizeArgKeys({ command: "git status" }, cursorToolArgNormalizeSchema(tool))).toEqual({ command: "git status", }); + expect(normalizeArgKeys({ + cmd: "git status", + sandbox_permissions: "require_escalated", + justification: "Fetch the requested upstream ref", + prefix_rule: ["git", "fetch"], + login: false, + }, cursorToolArgNormalizeSchema(tool))).toEqual({ + command: "git status", + sandbox_permissions: "require_escalated", + justification: "Fetch the requested upstream ref", + prefix_rule: ["git", "fetch"], + login: false, + }); }); test("preserves cmd-only exec_command schemas during Responses normalization", () => { @@ -182,6 +229,19 @@ describe("Cursor tool definitions", () => { cmd: "git status", workdir: "C:/repo", }); + expect(normalizeArgKeys({ + cmd: "git fetch", + sandbox_permissions: "require_escalated", + justification: "Fetch the requested upstream ref", + prefix_rule: ["git", "fetch"], + login: false, + }, cursorToolArgNormalizeSchema(tool))).toEqual({ + cmd: "git fetch", + sandbox_permissions: "require_escalated", + justification: "Fetch the requested upstream ref", + prefix_rule: ["git", "fetch"], + login: false, + }); }); test("shell bridge command validation honors the schema-required command key", () => { From 3a7e4996435e68fd8caf8374dc75b3c759133582 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:44:57 -0600 Subject: [PATCH 069/236] fix(cursor): reject reserved freeform shell names --- src/adapters/cursor/tool-schemas.ts | 14 ++++++-- .../cursor/cursor-tool-definitions.test.ts | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/adapters/cursor/tool-schemas.ts b/src/adapters/cursor/tool-schemas.ts index d96fb6e41a..5fc394cb49 100644 --- a/src/adapters/cursor/tool-schemas.ts +++ b/src/adapters/cursor/tool-schemas.ts @@ -107,7 +107,12 @@ export const CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA = { /** Schema advertised to Cursor for this tool (may use Cursor-preferred field names like `cmd`). */ export function cursorToolInputSchema(tool: OcxTool): unknown { - if (tool.freeform) return CURSOR_FREEFORM_INPUT_SCHEMA; + if (tool.freeform) { + if (isBareCodexShellBridgeTool(tool)) { + throw new Error(`freeform Cursor tools cannot use reserved shell bridge name ${tool.name}; use a namespace`); + } + return CURSOR_FREEFORM_INPUT_SCHEMA; + } return isBareCodexExecCommandTool(tool) ? CURSOR_EXEC_COMMAND_INPUT_SCHEMA : (tool.parameters ?? {}); } @@ -117,7 +122,12 @@ export function cursorToolInputSchema(tool: OcxTool): unknown { * treating `cmd` as canonical prevents the `cmd` → `command` rewrite Codex requires (#399). */ export function cursorToolArgNormalizeSchema(tool: OcxTool): unknown { - if (tool.freeform) return CURSOR_FREEFORM_INPUT_SCHEMA; + if (tool.freeform) { + if (isBareCodexShellBridgeTool(tool)) { + throw new Error(`freeform Cursor tools cannot use reserved shell bridge name ${tool.name}; use a namespace`); + } + return CURSOR_FREEFORM_INPUT_SCHEMA; + } if (isBareCodexShellBridgeTool(tool)) { return shellBridgeArgNormalizeSchema(tool); } diff --git a/tests/providers/cursor/cursor-tool-definitions.test.ts b/tests/providers/cursor/cursor-tool-definitions.test.ts index 0d97b7c0ed..c5cc698755 100644 --- a/tests/providers/cursor/cursor-tool-definitions.test.ts +++ b/tests/providers/cursor/cursor-tool-definitions.test.ts @@ -162,6 +162,40 @@ describe("Cursor tool definitions", () => { expect(cursorToolArgNormalizeSchema(codeModeExec)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); }); + test("rejects freeform tools that reuse bare shell bridge names", () => { + for (const name of ["exec_command", "shell_command"]) { + const tool: OcxTool = { name, description: "Custom", parameters: {}, freeform: true }; + + expect(() => cursorToolInputSchema(tool)).toThrow(`freeform Cursor tools cannot use reserved shell bridge name ${name}`); + expect(() => cursorToolArgNormalizeSchema(tool)).toThrow(`freeform Cursor tools cannot use reserved shell bridge name ${name}`); + expect(() => buildCursorToolDefinitions([tool])).toThrow(`freeform Cursor tools cannot use reserved shell bridge name ${name}`); + } + }); + + test("preserves namespaced shell names and ordinary freeform/non-freeform contracts", () => { + const namespacedFreeform: OcxTool = { + name: "exec_command", + namespace: "mcp__custom", + description: "Custom", + parameters: {}, + freeform: true, + }; + expect(cursorToolInputSchema(namespacedFreeform)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + expect(cursorToolArgNormalizeSchema(namespacedFreeform)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + + const ordinaryFreeform: OcxTool = { name: "apply_patch", description: "Patch", parameters: {}, freeform: true }; + expect(cursorToolInputSchema(ordinaryFreeform)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + expect(cursorToolArgNormalizeSchema(ordinaryFreeform)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + + const ordinaryFunction: OcxTool = { + name: "exec_command", + description: "Run", + parameters: { type: "object", properties: { cmd: { type: "string" } }, required: ["cmd"] }, + }; + expect(cursorToolInputSchema(ordinaryFunction)).toEqual(CURSOR_EXEC_COMMAND_INPUT_SCHEMA); + expect(cursorToolArgNormalizeSchema(ordinaryFunction)).toEqual(ordinaryFunction.parameters); + }); + test("normalizes advertised shell_command cmd args to Responses command before Codex sees them", () => { // Live #399 failure: Cursor advertisement requires `cmd`, models send `cmd`, but Codex // shell_command validates `command` → "missing field `command`". Normalization must use the From b2a0a22aa9582b554a296ddfcd2ee1ae0f2516c5 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:44:26 +0900 Subject: [PATCH 070/236] test(clients): bound client-state probe subprocesses --- tests/clients/client-connect.test.ts | 95 ++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 18 deletions(-) diff --git a/tests/clients/client-connect.test.ts b/tests/clients/client-connect.test.ts index 05dff4a708..b1f8fc4d19 100644 --- a/tests/clients/client-connect.test.ts +++ b/tests/clients/client-connect.test.ts @@ -3,8 +3,7 @@ import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join } from "node:path"; import { downloadClientCatalog, exchangeConnectPairingGrant, @@ -14,8 +13,40 @@ import { } from "../../src/client/hub-client"; import { handleConnectCommand } from "../../src/cli/connect"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoRoot as findRepoRoot } from "../helpers/repo-root"; +import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; -const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); +const repoRoot = findRepoRoot(); + +class ClientStateProbeError extends Error { + constructor( + readonly pid: number, + readonly status: number | null, + readonly signal: NodeJS.Signals | null, + readonly timedOut: boolean, + ) { + // Do not include the child script, environment, stdout or stderr in failure output. + super(`Client state probe ${timedOut ? "timed out" : "failed"} (status=${status}, signal=${signal})`); + this.name = "ClientStateProbeError"; + } +} + +function readStateProbe(script: string, home: string, timeoutMs = INTERNAL_DEADLINE_MS) { + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: home }, + encoding: "utf8", + timeout: timeoutMs, + killSignal: "SIGKILL", + }); + if (child.error || child.status !== 0 || child.signal !== null) { + throw new ClientStateProbeError( + child.pid, child.status, child.signal, + (child.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT", + ); + } + return JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}"); +} function readyBody(protocol = 1, minimumClientProtocol = 1) { return { @@ -41,21 +72,49 @@ describe("remote hub client boundary", () => { console.log(JSON.stringify(readClientConnectionState())); `; const home = mkdtempSync(join(tmpdir(), "ocx-hub-role-")); - const readState = () => { - const child = spawnSync(process.execPath, ["--eval", readScript], { - cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: home }, - encoding: "utf8", - }); - return JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}"); - }; - writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub" })); - expect(readState().kind).toBe("disconnected"); - // Hub role WITH a client block stays mismatched (the honest conflict). - writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub", client: { serverUrl: "https://hub.example.test" } })); - expect(readState().kind).toBe("mismatched"); - removeTreeWithRetry(home); - }); + try { + writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub" })); + expect(readStateProbe(readScript, home).kind).toBe("disconnected"); + // Hub role WITH a client block stays mismatched (the honest conflict). + writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub", client: { serverUrl: "https://hub.example.test" } })); + expect(readStateProbe(readScript, home).kind).toBe("mismatched"); + } finally { + removeTreeWithRetry(home); + } + }, 35_000); // Two 15s child deadlines plus setup and cleanup, below the CI 60s cap. + + test("state probe kills a stalled child before parsing its output", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-state-probe-stall-")); + const startedPath = join(home, "probe-started"); + const script = ` + const fs = require("node:fs"); + fs.writeFileSync(require("node:path").join(process.env.OPENCODEX_HOME, "probe-started"), String(process.pid)); + fs.writeSync(1, "not-json"); + setInterval(() => {}, 1000); + `; + try { + const startedAt = performance.now(); + let failure: unknown; + try { readStateProbe(script, home, 2_000); } + catch (error) { failure = error; } + expect(performance.now() - startedAt).toBeLessThan(10_000); + expect(failure).toBeInstanceOf(ClientStateProbeError); + if (!(failure instanceof ClientStateProbeError)) throw new Error("Expected bounded child failure"); + expect(failure.timedOut).toBe(true); + expect(failure.status).toBeNull(); + expect(failure.signal).toBe("SIGKILL"); + expect(failure.message).not.toContain("not-json"); + expect(Number(readFileSync(startedPath, "utf8"))).toBe(failure.pid); + // spawnSync must reap this exact child, not merely return while it remains alive. + let exitCode: string | undefined; + try { process.kill(failure.pid, 0); } + catch (error) { exitCode = (error as NodeJS.ErrnoException).code; } + expect(exitCode).toBe("ESRCH"); + } finally { + removeTreeWithRetry(home); + } + }, 10_000); + test("canonicalizes origin and terminal /v1 only", () => { expect(normalizeHubOrigin("https://hub.example.test/v1")).toBe("https://hub.example.test"); expect(normalizeHubOrigin("https://hub.example.test/v1/")).toBe("https://hub.example.test"); From cfe48cc8e569638b2850970c94f8946c6d4f650f Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:47:50 +0900 Subject: [PATCH 071/236] fix(cursor): integrate closed freeform schemas with current module layout Carries source commits 1b29236c5bee9dd166b9d23983a2f1f1c2f0b793 and 37e6115c8a2ad3ffe20fee1e5a1e79a054625a56 through the current schema owner. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- .../031_cursor_refresh.md | 9 ++++ .../src/content/docs/reference/adapters.md | 6 +++ src/adapters/cursor/tool-definitions.ts | 2 +- src/adapters/cursor/tool-schemas.ts | 1 + structure/04_transports-and-sidecars.md | 11 +++++ .../cursor/cursor-tool-definitions.test.ts | 41 +++++++++++++++---- 6 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 devlog/_plan/260906_d_integrations_delivery/031_cursor_refresh.md diff --git a/devlog/_plan/260906_d_integrations_delivery/031_cursor_refresh.md b/devlog/_plan/260906_d_integrations_delivery/031_cursor_refresh.md new file mode 100644 index 0000000000..599084c6e0 --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/031_cursor_refresh.md @@ -0,0 +1,9 @@ +# Cursor schema cycle P refresh + +Parent: d6bfb044a5dc6494cba57c1238ded7c23faf5586, open PR #3702. Original #3628 remains at 37e6115c8a2ad3ffe20fee1e5a1e79a054625a56, author SB Yoon (yansigit). + +The source commits 1b29236c5bee9dd166b9d23983a2f1f1c2f0b793 and 37e6115c8a2ad3ffe20fee1e5a1e79a054625a56 are prepared as mailbox patches with only production diff paths mapped from tool-definitions.ts to current tool-schemas.ts. `git apply --check` accepted the first mapped patch. Apply both in order during B, retaining their original author/date/message. Main then adds the new constant to the existing public re-export, closes the freeform object with additionalProperties:false, strengthens literal/protobuf assertions and updates the planned docs/structure. + +The current naming path preserves namespaces through namespacedToolName; the existing bare-shell helper remains the authority for the original rejection. No tool execution or approval policy changes are introduced. Read current 030 for all activation cases and complete scope. + +Main owns authored patch application, public facade and documentation edits, commits and PR publication. An inherited worker may amend only tool-schemas.ts and cursor-tool-definitions.test.ts after A; no Git or local tests/typecheck. Independent review plus current-head remote full/typecheck/docs and hosted CI supply proof. The candidate can remain open in the stack while shipping/closure criteria remain separately pending. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 64d5278ec6..8dbb85be36 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -351,6 +351,12 @@ compatibility pair: `agent.v1.AgentService/RunSSE` for server output and broader built-in executor and bypasses Codex approval/sandbox semantics, and legacy `unsafeAllowNativeLocalExec: true` remains equivalent only when `nativeLocalExec` is unset. +Codex-compatible shell schemas retain sandbox permissions, justification, reusable +prefix rules and login mode. Freeform tools expose one required string `input`; +bare `exec_command` and `shell_command` names are reserved for non-freeform shell +bridges. Namespace a custom freeform tool that uses either name. These schema +declarations do not grant approval or change execution policy. + ## `azure-openai` (alias: `azure`) **Targets:** **Azure OpenAI**. Wraps `openai-responses` (so also `passthrough: true`). diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 164b05433d..12be95cdf5 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -5,7 +5,7 @@ import { McpToolDefinitionSchema, McpToolsSchema, type McpToolDefinition } from import { CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL, cursorRequestAdvertisesApplyPatch, cursorToolAllowedByChoice, cursorToolWireName, OCX_RESPONSES_TOOL_PROVIDER } from "./tool-naming"; import { CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA, cursorToolInputSchema } from "./tool-schemas"; export { OCX_RESPONSES_TOOL_PROVIDER, CODEX_EXEC_COMMAND_TOOL, CODEX_SHELL_COMMAND_TOOL, CODEX_UNIFIED_EXEC_TOOL, CODEX_WAIT_TOOL, CODEX_APPLY_PATCH_TOOL, CODEX_TOOL_SEARCH_TOOL, CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL, CURSOR_STRUCTURED_EDIT_TOOLS, CURSOR_EXEC_COMMAND_TOOL, CODEX_SHELL_BRIDGE_TOOL_NAMES, isCodexShellBridgeToolName, resolveShellBridgeAliasKey, cursorToolChoiceAliases, isBareCodexShellBridgeTool, isCursorExecutionPathTool, isCursorWaitTool, isCursorCodeModeExecTool, cursorRequestUsesCodeMode, cursorRequestHasShellAlias, cursorRequestAdvertisesApplyPatch, isCursorStructuredEditToolName, isCursorSyntheticStructuredEditTool, cursorToolWireName, normalizeCursorWireName, normalizeCursorTextToolMarkers, responsesToolNameFromCursorWire, cursorToolAllowedByChoice } from "./tool-naming"; -export { CURSOR_EXEC_COMMAND_INPUT_SCHEMA, CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA, CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA, cursorToolInputSchema, cursorToolArgNormalizeSchema, shellBridgeRequiredCommandKeys, defaultShellBridgeArgNormalizeSchema, cursorShellBridgeDropError, nonEmptyShellBridgeCommandFromArgs, cursorShellBridgeArgsValid } from "./tool-schemas"; +export { CURSOR_EXEC_COMMAND_INPUT_SCHEMA, CURSOR_FREEFORM_INPUT_SCHEMA, CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA, CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA, cursorToolInputSchema, cursorToolArgNormalizeSchema, shellBridgeRequiredCommandKeys, defaultShellBridgeArgNormalizeSchema, cursorShellBridgeDropError, nonEmptyShellBridgeCommandFromArgs, cursorShellBridgeArgsValid } from "./tool-schemas"; export { CURSOR_SHELL_ALIAS_SYSTEM_NOTE, CURSOR_GENERIC_TOOL_USE_USER_HINT, isGenericToolUseCountDemoPrompt, requestedCursorToolUseCount, shouldAppendCursorGenericToolUseHint, appendCursorGenericToolUseHint, shouldUseNativeExecOnlyForGenericToolUse, cursorToolsForActivePrompt, buildCursorToolGuidanceSystemNote } from "./tool-guidance"; /** diff --git a/src/adapters/cursor/tool-schemas.ts b/src/adapters/cursor/tool-schemas.ts index 5fc394cb49..ff2b2aa4ea 100644 --- a/src/adapters/cursor/tool-schemas.ts +++ b/src/adapters/cursor/tool-schemas.ts @@ -38,6 +38,7 @@ export const CURSOR_FREEFORM_INPUT_SCHEMA = { type: "object", properties: { input: { type: "string" } }, required: ["input"], + additionalProperties: false, } as const; /** diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index be179a2682..d092348cd2 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -1521,6 +1521,17 @@ fragments are not guessed onto pending ID-only calls. parallel/colliding identities, distinct unsafe raw JSON index literals, the maximum safe-integer boundary, invalid numeric indexes and UTF-8 byte-limit boundaries. +## Cursor executable tool schema ownership + +`src/adapters/cursor/tool-schemas.ts` owns advertised and argument-normalization +schemas; `tool-definitions.ts` remains the public facade and protobuf encoder. +Advertisement and normalization intentionally differ for shell bridges: Cursor may +emit `cmd`, while the declared Responses contract decides whether it becomes +`command`. Both paths preserve execution-control fields. Freeform tools use one +required string `input`; bare shell bridge names are rejected on the freeform path. +Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in +`tests/providers/cursor/cursor-tool-definitions.test.ts`. + ## Sidecars Web search and vision sidecars run only when the main request needs that capability and a usable diff --git a/tests/providers/cursor/cursor-tool-definitions.test.ts b/tests/providers/cursor/cursor-tool-definitions.test.ts index c5cc698755..94b4e58022 100644 --- a/tests/providers/cursor/cursor-tool-definitions.test.ts +++ b/tests/providers/cursor/cursor-tool-definitions.test.ts @@ -145,6 +145,14 @@ describe("Cursor tool definitions", () => { }); test("advertises and normalizes freeform tools as one required string input", () => { + // Independent wire contract: using the production constant as the expected value + // would let an incorrect constant validate both schema selection and protobuf output. + const expectedSchema = { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, + }; const tool: OcxTool = { name: "apply_patch", description: "Apply a patch", @@ -152,14 +160,19 @@ describe("Cursor tool definitions", () => { freeform: true, }; - expect(cursorToolInputSchema(tool)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); - expect(cursorToolArgNormalizeSchema(tool)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + expect(CURSOR_FREEFORM_INPUT_SCHEMA).toEqual(expectedSchema); + expect(cursorToolInputSchema(tool)).toEqual(expectedSchema); + expect(cursorToolArgNormalizeSchema(tool)).toEqual(expectedSchema); const defs = buildCursorToolDefinitions([tool]); - expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + expect(defs).toHaveLength(1); + expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(expectedSchema); const codeModeExec: OcxTool = { name: "exec", description: "Run JavaScript", freeform: true }; - expect(cursorToolInputSchema(codeModeExec)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); - expect(cursorToolArgNormalizeSchema(codeModeExec)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + expect(cursorToolInputSchema(codeModeExec)).toEqual(expectedSchema); + expect(cursorToolArgNormalizeSchema(codeModeExec)).toEqual(expectedSchema); + const execDefs = buildCursorToolDefinitions([codeModeExec]); + expect(execDefs).toHaveLength(1); + expect(toJson(ValueSchema, fromBinary(ValueSchema, execDefs[0]!.inputSchema))).toEqual(expectedSchema); }); test("rejects freeform tools that reuse bare shell bridge names", () => { @@ -173,6 +186,12 @@ describe("Cursor tool definitions", () => { }); test("preserves namespaced shell names and ordinary freeform/non-freeform contracts", () => { + const expectedFreeformSchema = { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, + }; const namespacedFreeform: OcxTool = { name: "exec_command", namespace: "mcp__custom", @@ -180,12 +199,16 @@ describe("Cursor tool definitions", () => { parameters: {}, freeform: true, }; - expect(cursorToolInputSchema(namespacedFreeform)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); - expect(cursorToolArgNormalizeSchema(namespacedFreeform)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + expect(cursorToolInputSchema(namespacedFreeform)).toEqual(expectedFreeformSchema); + expect(cursorToolArgNormalizeSchema(namespacedFreeform)).toEqual(expectedFreeformSchema); + const defs = buildCursorToolDefinitions([namespacedFreeform]); + expect(defs).toHaveLength(1); + expect(defs[0]?.toolName).toBe("mcp__custom__exec_command"); + expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(expectedFreeformSchema); const ordinaryFreeform: OcxTool = { name: "apply_patch", description: "Patch", parameters: {}, freeform: true }; - expect(cursorToolInputSchema(ordinaryFreeform)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); - expect(cursorToolArgNormalizeSchema(ordinaryFreeform)).toEqual(CURSOR_FREEFORM_INPUT_SCHEMA); + expect(cursorToolInputSchema(ordinaryFreeform)).toEqual(expectedFreeformSchema); + expect(cursorToolArgNormalizeSchema(ordinaryFreeform)).toEqual(expectedFreeformSchema); const ordinaryFunction: OcxTool = { name: "exec_command", From f8b964fa47a2ac31d084647972cf891be7158a31 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:53:34 +0900 Subject: [PATCH 072/236] fix(catalog): isolate fresh guidance ranks from cached templates --- .../032_ordering_repair.md | 11 ++ src/codex/catalog/sync.ts | 40 ++++--- src/types/config.ts | 5 +- .../catalog-full-picker-order.test.ts | 107 +++++++++++++++--- 4 files changed, 128 insertions(+), 35 deletions(-) create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/032_ordering_repair.md diff --git a/devlog/_plan/260906_lane_b_catalog_stack/032_ordering_repair.md b/devlog/_plan/260906_lane_b_catalog_stack/032_ordering_repair.md new file mode 100644 index 0000000000..1d1538cf4e --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/032_ordering_repair.md @@ -0,0 +1,11 @@ +# Ordering check repair + +The first remote check of 1c2616bfd failed 14 new production-writer cases; no failing result was treated as a pass. Investigation separated fixture isolation from a production defect. + +The fixture now provides a runnable deterministic Codex command through forced refresh, asserts runtime identity, uses the current featured-roster migration marker, and checks effort arrays without mutating metadata. Full catalog equality and the same five OpenCodex guidance candidates remain required. + +Fresh row derivation could copy opencodex_spawn_priority from a previously ordered native template. Assigning a new featured priority did not replace that inherited private rank, so repeated healthy writes could change the guidance window. Fresh clones now clear that previous row's private marker; retained-row markers and reader behavior are unchanged. Direct dirty-template and repeated real-writer regressions cover the cause. Remote causal confirmation and reruns are required before closing this repair. + +The native-consumer audit also corrected an overbroad explanation: OpenCodex natural-priority guidance and native Codex's advertised five are separate. Native advertisement may follow display priority on V1 and exposed V2; exact-name override eligibility is not limited to that advertisement. This clarification preserves the existing #1649 design and does not waive the failing natural-guidance assertions. Current code comments, configuration reference and eight ordering guides now make the distinction explicit; the original source-diff appendix remains historical evidence. + +No local tests, builds or typechecks were run. Verification must use the repaired committed head and retain red/green, runtime identity and teardown evidence. diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 469dcc2b1e..3ed5597d31 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -88,15 +88,15 @@ import { export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5; // Base for config.modelPickerOrder display priorities (#1649). modelPickerOrder is a DISPLAY-ONLY -// reordering of the Codex model picker: it rewrites a row's Codex-visible `priority` but never the -// spawn_agent candidate window. The window is derived from SPAWN_PRIORITY_FIELD (the natural -// priority captured before the override), so display order and spawn candidates are decoupled. +// reordering of the Codex model picker: it rewrites a row's Codex-visible `priority` but not +// OpenCodex's natural-priority guidance window. Native Codex advertisements still follow the +// visible priority and can differ from that guidance window. export const PICKER_ORDER_PRIORITY_BASE = 1_000; -// OpenCodex-private catalog field: the spawn_agent candidate priority a row would have WITHOUT +// OpenCodex-private catalog field: the guidance candidate priority a row would have WITHOUT // modelPickerOrder. Codex ignores unknown catalog fields (same as opencodex_catalog_kind), so this -// is invisible to Codex; effectiveSubagentRoster reads it so a display reorder cannot change which -// rows are spawn_agent candidates. Absent on rows modelPickerOrder did not move. +// is invisible to Codex; effectiveSubagentRoster reads it to keep OpenCodex guidance candidates +// independent of display order. It does not freeze native advertisements. Absent on unmoved rows. export const SPAWN_PRIORITY_FIELD = "opencodex_spawn_priority"; export type SpawnAgentSurface = "v1" | "v2"; @@ -154,7 +154,9 @@ export interface SubagentRosterExclusion { } export interface EffectiveSubagentRoster { + /** OpenCodex's natural-priority guidance projection, not captured native tool text. */ candidates: EffectiveSubagentModel[]; + /** Configured models within that projection; exact-name eligibility is a separate check. */ advertised: EffectiveSubagentModel[]; excluded: SubagentRosterExclusion[]; } @@ -191,8 +193,8 @@ export function effectiveSubagentRoster( .filter(({ entry }) => entry.visibility === "list") .filter(({ entry }) => surface !== "v2" || isEligibleV2SubagentEntry(entry)) .sort((left, right) => { - // Spawn candidates rank by the natural priority (SPAWN_PRIORITY_FIELD when present), so a - // modelPickerOrder display reorder (#1649) can never change candidate membership. Rows the + // OpenCodex guidance candidates rank by natural priority (SPAWN_PRIORITY_FIELD when present), + // so modelPickerOrder does not change this projection. Native tool advertisements differ. Rows the // override did not move fall back to their Codex-visible `priority`. const spawnPriorityOf = (entry: RawEntry): number => { const spawn = entry[SPAWN_PRIORITY_FIELD]; @@ -330,6 +332,8 @@ export function deriveEntry( } if (template || codexForwardNativeCapabilityAlias) { const e = JSON.parse(JSON.stringify(codexForwardNativeCapabilityAlias ?? template)) as RawEntry; + // A cached template may carry display-order history; each new row owns its natural rank. + delete e[SPAWN_PRIORITY_FIELD]; e.slug = slug; e.display_name = routedDisplayName(slug, model); e.description = desc; @@ -518,25 +522,25 @@ export function buildCatalogEntriesFromObservedState({ // catalog stays put across rebuilds. Featured rows keep their existing 0..N-1 band; when // modelPickerOrder is unset the helper is a no-op and every priority below is byte-identical to // before. The spawn_agent candidate window is derived separately from SPAWN_PRIORITY_FIELD, so - // this display reorder cannot change which rows are spawn candidates. + // this display reorder does not change OpenCodex's guidance candidate calculation. const pickerOrder = normalizeModelPickerOrder(modelPickerOrder); const pickerOrderRank = new Map(pickerOrder.map((slug, i) => [slug, i] as const)); const pickerOrderActive = pickerOrder.length > 0; // The display band reuses the existing high priority tier (>= PICKER_ORDER_PRIORITY_BASE, the // same 1_000+ neighborhood account rows occupy), keeping listed rows visually after the featured - // band. Candidate membership does not depend on this — see SPAWN_PRIORITY_FIELD. + // band. OpenCodex guidance membership does not depend on this — see SPAWN_PRIORITY_FIELD. /** * Priority for a non-featured routed row that is explicitly LISTED in modelPickerOrder. Listed * slugs sort in declared order within the high picker-order display tier * (>= PICKER_ORDER_PRIORITY_BASE). This sets the Codex-visible `priority` only; the caller records - * the row's natural priority in SPAWN_PRIORITY_FIELD so the spawn_agent candidate window is - * unchanged. Returns undefined when the feature is off or the row is not listed, so those rows + * the row's natural priority in SPAWN_PRIORITY_FIELD for OpenCodex's unchanged guidance window. + * Returns undefined when the feature is off or the row is not listed, so those rows * keep their original assignment (default 5 / account 1_000+) untouched. * * Scope: only the generic routed `/` rows call this (see the goModels loop * below). Native passthrough rows and account-qualified native rows keep their own priority - * logic and are intentionally not reordered here — this matches the documented contract on - * OcxConfig.modelPickerOrder (route native ordering through subagentModels instead). + * logic and are intentionally not reordered in this legacy builder pass. The final merge can + * apply complete ordering when the configured list includes a bare id. */ const pickerOrderPriority = (slug: string, altSlug?: string): number | undefined => { if (!pickerOrderActive) return undefined; @@ -658,9 +662,9 @@ export function buildCatalogEntriesFromObservedState({ // Keep the generated account rows together in Codex's priority-sorted flat picker. e.priority = 1_000 + (typeof e.priority === "number" ? e.priority : 5); } - // #1649: modelPickerOrder is a DISPLAY-ONLY override. Record the natural priority spawn_agent - // must keep using, then let modelPickerOrder move only the Codex-visible `priority`. Featured - // rows are never overridden (their rank is authoritative for both display and spawn). + // The legacy routed-only builder pass keeps featured ranks and records natural priority + // before changing non-featured display priority. The final complete-order pass may move + // featured display rows too; OpenCodex guidance continues to use their natural ranks. if (rankHit === undefined) { const pickerPriority = pickerOrderPriority(slug, `${m.provider}/${m.id}`); if (pickerPriority !== undefined) { @@ -792,7 +796,7 @@ function modelPickerRank(order: readonly string[]): (slug: string) => number | u return slug => exact.get(slug) ?? equivalent.get(slugEquivalenceKey(slug)); } -/** A picker order containing native ids orders the whole list, without changing spawn ranks. */ +/** Complete display ordering retains natural ranks for OpenCodex's separate guidance projection. */ export function applyFullModelPickerOrder(entries: RawEntry[], order: readonly string[]): void { const pickerOrder = normalizeModelPickerOrder(order); if (!pickerOrder.some(slug => !slug.includes("/"))) return; diff --git a/src/types/config.ts b/src/types/config.ts index 2593dba84f..a0e095d403 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -424,8 +424,9 @@ export interface OcxConfig { * their normal positions. Including a bare native id opts into ordering the complete * picker: listed ids appear first in array order, followed by unlisted rows in their * natural priority order. Exact catalog ids take precedence over equivalent raw/encoded - * routed ids; empty entries are ignored. The separate natural spawn - * priority is preserved, so display order does not change subagent candidates. + * routed ids; empty entries are ignored. The separate natural priority used by + * OpenCodex guidance is preserved. Native Codex's advertised five follow display + * priority and may change; exact-name override eligibility is not restricted by that list. * Unset or empty leaves catalog priorities unchanged. */ modelPickerOrder?: string[]; diff --git a/tests/codex-integration/catalog-full-picker-order.test.ts b/tests/codex-integration/catalog-full-picker-order.test.ts index a891b395f6..592034ee0f 100644 --- a/tests/codex-integration/catalog-full-picker-order.test.ts +++ b/tests/codex-integration/catalog-full-picker-order.test.ts @@ -1,17 +1,17 @@ import { routedSlug } from "../../src/providers/slug-codec"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { saveConfig } from "../../src/config"; +import { loadConfig, saveConfig } from "../../src/config"; +import { SUBAGENT_MODELS_VERSION } from "../../src/config/subagent-models"; import type { OcxConfig } from "../../src/types"; import { captureCatalogAdmissionSnapshot } from "../../src/codex/catalog-admission"; import { convergeCodexCatalog } from "../../src/codex/convergence"; import { loadBundledCodexCatalog, resetCatalogRuntimeStateForTests, syncCatalogModels } from "../../src/codex/catalog"; -import { setBundledCatalogCacheForTests } from "../../src/codex/catalog/bundled"; import type { RawCatalog, RawEntry } from "../../src/codex/catalog/parsing"; import { clearModelCache, markModelsFetchFailure } from "../../src/codex/model-cache"; -import { persistCodexRuntime, resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import { loadPersistedCodexRuntime, resetCodexRuntimeResolveCacheForTests, resolveCodexRuntime } from "../../src/codex/runtime"; import { resetCodexModelEntitlementCacheForTests } from "../../src/codex/model-entitlements"; import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; @@ -65,6 +65,40 @@ test("sync refreshes native spawn rank when featured models change", () => { expect(demoted.find(entry => entry.slug === sol.slug)?.[SPAWN_PRIORITY_FIELD]).toBe(101); }); +test("fresh routed rows do not inherit a previously ordered native template's guidance rank", () => { + const ids = ["fresh-a", "fresh-b", "fresh-c", "fresh-d", "fresh-e", "fresh-f"]; + const slugs = ids.map(id => routedSlug("opencode-go", id)); + const featured = slugs.slice(0, 5); + const order = ["gpt-5.5", slugs[5]!, ...featured.toReversed()]; + const template = deriveEntry(null, "gpt-5.5", "Previously ordered native", 0); + template[SPAWN_PRIORITY_FIELD] = 9; + const previousTemplate = structuredClone(template); + const rows = buildCatalogEntriesFromObservedState({ + template, + gptSlugs: ["gpt-5.5"], + goModels: ids.map(id => ({ + provider: "opencode-go", id, + reasoningEfforts: ["high", "xhigh"], defaultReasoningEffort: "xhigh", + })), + featured, modelPickerOrder: order, + wsEnabled: false, multiAgentMode: "v2", multiAgentV2Enabled: true, + exactComboSlugs: new Set(), accountSelectors: [], + suppressedBareNativeSlugs: new Set(), disabledNativeAccountSlugs: new Set(), + }); + + const featuredRows = featured.map(slug => rows.find(row => row.slug === slug)!); + expect(featuredRows.map(row => row[SPAWN_PRIORITY_FIELD] ?? row.priority)).toEqual([0, 1, 2, 3, 4]); + const expectedCandidates = featured.map(model => ({ model, efforts: ["high", "xhigh"] })); + const before = effectiveSubagentRoster(featured, "v2", rows); + expect(before.candidates).toEqual(expectedCandidates); + expect(before.advertised).toEqual(expectedCandidates); + + applyFullModelPickerOrder(rows, order); + expect(effectiveSubagentRoster(featured, "v2", rows)).toEqual(before); + expect(rows.toSorted((a, b) => Number(a.priority) - Number(b.priority)).map(row => row.slug)).toEqual(order); + expect(template).toEqual(previousTemplate); +}); + test("bare native ids and routed slugs match exactly, without suffix aliases", () => { const rows: any[] = [ @@ -146,6 +180,39 @@ describe("picker ordering through production catalog writers", () => { let codexHome: string; let catalogPath: string; let fetchCalls: number; + let runtimeCommand: string; + + // Same executable-fixture protocol as codex-convergence-account-selectors.test.ts: + // a forced resolver refresh must receive the same version and catalog as a warm read. + function createRuntimeFixture(catalog: RawCatalog): string { + const script = join(root, "fixture-codex.js"); + writeFileSync(script, [ + 'if (process.argv.includes("--version")) {', + ' console.log("codex-cli 0.145.0");', + '} else {', + ` process.stdout.write(${JSON.stringify(JSON.stringify(catalog))});`, + '}', + ].join("\n")); + if (process.platform === "win32") { + const command = join(root, "fixture-codex.cmd"); + writeFileSync(command, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); + return command; + } + const command = join(root, "fixture-codex"); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + writeFileSync(command, `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(script)} "$@"\n`); + chmodSync(command, 0o755); + return command; + } + + function assertRuntimeIdentity(): void { + const resolved = resolveCodexRuntime({ discoverAlternatives: false }); + expect(resolved.runtime.command).toBe(runtimeCommand); + expect(resolved.runtime.version).toBe("0.145.0"); + const persisted = loadPersistedCodexRuntime(); + expect(persisted?.command).toBe(runtimeCommand); + expect(persisted?.selectedVersion).toBe("0.145.0"); + } beforeEach(() => { previousEnv = envKeys.map(key => process.env[key]); @@ -157,22 +224,19 @@ describe("picker ordering through production catalog writers", () => { mkdirSync(opencodexHome); process.env.CODEX_HOME = codexHome; process.env.OPENCODEX_HOME = opencodexHome; - const runtimeCommand = join(root, "fixture-codex"); - process.env.CODEX_CLI_PATH = runtimeCommand; catalogPath = join(codexHome, "custom-catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "custom-catalog.json"\n[features]\nmulti_agent_v2 = true\n'); resetCatalogRuntimeStateForTests(); resetCodexRuntimeResolveCacheForTests(); resetCodexModelEntitlementCacheForTests(); - const runtime = { command: runtimeCommand, version: "0.145.0", source: "fallback" as const }; - persistCodexRuntime(runtime); - setCodexRuntimeResolveCacheForTests({ runtime, failures: [] }, { discoverAlternatives: false }); const native = deriveEntry(null, "gpt-5.5", "Native fixture", 9); const catalog = { models: [native] }; - setBundledCatalogCacheForTests(runtime, catalog); - // Both runtime selection and bundled support are fixture-owned, before admission capture. + runtimeCommand = createRuntimeFixture(catalog); + process.env.CODEX_CLI_PATH = runtimeCommand; + // Resolve the real fixture executable before admission captures runtime provenance. expect(loadBundledCodexCatalog()?.models?.[0]?.slug).toBe("gpt-5.5"); + assertRuntimeIdentity(); writeFileSync(catalogPath, JSON.stringify(catalog)); fetchCalls = 0; globalThis.fetch = (async () => { @@ -205,6 +269,7 @@ describe("picker ordering through production catalog writers", () => { defaultProvider: "opencode-go", multiAgentMode: "v2", subagentModels: featured, + subagentModelsVersion: SUBAGENT_MODELS_VERSION, modelPickerOrder: order, providers: { openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward" }, @@ -219,7 +284,13 @@ describe("picker ordering through production catalog writers", () => { } async function writeCatalog(writer: "convergence" | "retained", next: OcxConfig, degraded = false): Promise { + assertRuntimeIdentity(); + const requestedRoster = [...next.subagentModels!]; saveConfig(next); + const saved = loadConfig(); + expect(saved.subagentModelsVersion).toBe(SUBAGENT_MODELS_VERSION); + expect(saved.subagentModels).toEqual(requestedRoster); + expect(next.subagentModels).toEqual(requestedRoster); if (degraded) { // No cached/static rows: the caller must preserve the catalog already on disk. clearModelCache("opencode-go"); @@ -235,22 +306,28 @@ describe("picker ordering through production catalog writers", () => { expect(result.path).toBe(catalogPath); expect(result.skippedReason).toBeUndefined(); } + assertRuntimeIdentity(); expect(fetchCalls).toBe(0); return (JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog).models ?? []; } function roster(rows: RawEntry[], featured: string[]) { + const beforeAssertions = JSON.stringify(rows); const result = effectiveSubagentRoster(featured, "v2", rows); expect(result.candidates.map(candidate => candidate.model)).toEqual(featured); expect(result.candidates).toHaveLength(5); expect(result.advertised).toEqual(result.candidates); for (const candidate of result.candidates) expect(candidate.efforts).toEqual(configuredEfforts); for (const slug of slugs) { - expect(rows.find(row => row.slug === slug)).toMatchObject({ - default_reasoning_level: "xhigh", - supported_reasoning_levels: configuredEfforts.map(effort => expect.objectContaining({ effort })), - }); + const row = rows.find(row => row.slug === slug); + expect(row).toBeDefined(); + expect(row!.default_reasoning_level).toBe("xhigh"); + const levels = row!.supported_reasoning_levels; + expect(Array.isArray(levels)).toBe(true); + expect((levels as Array<{ effort: string }>).map(level => level.effort)).toEqual(configuredEfforts); } + // Full catalog comparisons below must still compare untouched metadata, not matcher nodes. + expect(JSON.stringify(rows)).toBe(beforeAssertions); return result; } From 6b144f66e0ed87d4c7da949d41fb4e7ac43c7cf5 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:53:34 +0900 Subject: [PATCH 073/236] docs(catalog): distinguish guidance from native model advertisement --- .../030_ordering.md | 10 ++++++-- .../content/docs/fr/guides/model-ordering.md | 23 +++++++++++++++---- .../src/content/docs/guides/model-ordering.md | 21 +++++++++++++---- .../content/docs/ja/guides/model-ordering.md | 16 ++++++++++--- .../content/docs/ko/guides/model-ordering.md | 16 ++++++++++--- .../docs/reference/configuration/providers.md | 6 +++-- .../content/docs/ru/guides/model-ordering.md | 19 ++++++++++++--- .../content/docs/tr/guides/model-ordering.md | 17 +++++++++++--- .../docs/zh-cn/guides/model-ordering.md | 13 +++++++++-- .../docs/zh-tw/guides/model-ordering.md | 13 +++++++++-- structure/03_catalog-and-subagents.md | 13 +++++++---- 11 files changed, 132 insertions(+), 35 deletions(-) diff --git a/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md b/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md index 0a323a2997..10d33d01fb 100644 --- a/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md +++ b/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md @@ -4,7 +4,7 @@ Class: C3 cross-module catalog contract. One future PABCD cycle consumes this do ## Outcome and necessity -Configured canonical `opencode-go` efforts survive generation and retained sync without injected max/ultra. A nonblank bare catalog id in `modelPickerOrder` opts into complete-picker display ordering; exact ids outrank raw/encoded equivalents. Routed-only and empty configurations retain legacy behavior. Display sorting must leave the natural five-candidate subagent roster unchanged. Existing `applyReasoningLevels`, `slugEquivalenceKey`, `SPAWN_PRIORITY_FIELD`, and observed-state merge own these behaviors; reuse them, with no new catalog engine or provider roster. +Configured canonical `opencode-go` efforts survive generation and retained sync without injected max/ultra. A nonblank bare catalog id in `modelPickerOrder` opts into complete-picker display ordering; exact ids outrank raw/encoded equivalents. Routed-only and empty configurations retain legacy behavior. Display sorting must leave OpenCodex's natural-priority guidance candidates unchanged. Native Codex advertisements are a separate consumer and may follow the changed display order. Existing `applyReasoningLevels`, `slugEquivalenceKey`, `SPAWN_PRIORITY_FIELD`, and observed-state merge own these behaviors; reuse them, with no new catalog engine or provider roster. ## Current owners and amendment anchors @@ -47,7 +47,7 @@ Repeat at retained sync. Complete ordering preserves `entry[SPAWN_PRIORITY_FIELD | NEW `tests/codex-integration/catalog-full-picker-order.test.ts` | Bare native id + Go routed ids, then apply twice | Specified complete display order; unchanged stored natural ranks and byte-equivalent repeated result | | Same | Empty, whitespace-only, routed-only, raw slash upstream id plus encoded id | Legacy behavior; no whitespace activation; exact rank wins equivalence and no suffix aliasing | | Same | Start full order, switch to empty/routed-only during provider outage; change featured order, promote/demote; zero/two selectors and nonzero picker index | Healthy and degraded rows agree on both display and spawn rank; second merge is stable; input snapshot unmutated | -| Same plus existing `codex-v2-gate.test.ts` | Change picker only while retaining configured subagent roster; use v2 eligibility | Same five candidates and valid exact Go effort membership | +| Same plus existing `codex-v2-gate.test.ts` | Change picker only while retaining configured subagent roster; use v2 eligibility | Same five OpenCodex guidance candidates and valid exact Go effort membership | | Existing `tests/codex-integration/codex-catalog.test.ts` | Existing normalization/recovery fixtures | Existing native Reserve/exact ladders and account rows retain their contracts; align assertions only for intentional Go tier change | | Existing `tests/test-layout.test.ts`, `tests/test-layout-tooling.test.ts` | NEW file registration | Both explicit layout map and expected fixture contain both file names in codex-integration | @@ -671,3 +671,9 @@ Parent preparation head is 29f98462c4a63cf217347c26668733169fd65736. Source #357 The initial roadmap listed source English/French edits, but six other existing model-ordering guides also contain the legacy native-order contract. MODIFY docs-site/src/content/docs/{ja,ko,ru,tr,zh-cn,zh-tw}/guides/model-ordering.md with the same complete-order opt-in, exact/equivalent matching, unchanged spawn roster and existing-list migration warning. Do not create new locales or alter unrelated routing semantics. The runtime/template output remains separately verified from any native client capture; a synthetic rendering must never be described as an actual client capture. Delegation: main carries the final source diff and owns SoT/commits; catalog worker supplies caller-level coverage and a captured generated-list comparison; docs worker owns the six translated guides; independent code reviewer checks priorities/retained paths; remote verifier uses isolated exact-head tests/docs plus a native client capture if the installed client can be run safely with synthetic state. No local test/build/typecheck and no real personal proxy/account calls. Final merge gates remain unchanged. + +## C evidence-driven contract clarification + +The independent native-consumer audit distinguishes three concepts: OpenCodex natural-priority guidance (must remain unchanged), native advertised five (can follow changed display priority), and exact-name override eligibility (not restricted to the advertised five). This preserves the already-recorded #1649 design while correcting the earlier unqualified wording. No wire rewriting or native-client patch is added. The source appendix above remains an immutable record of the original PR and is not a current universal native-advertisement guarantee. + +Native source d2d5b702 (local upstream checkout, not claimed to match binary0.153.4) shows both V1/exposedV2 using native priority; current valid generated before/after data demonstrates the expected displacement. The actual0.153.4 capture proves picker/data consumption only until a separate toolspec capture is obtained. V1 has no OCX preferred-roster injection; V2 guidance is conditional on catalog state. New production-writer fixture failures remain blockers for the natural-guidance criterion and cannot be waived by this wording correction. diff --git a/docs-site/src/content/docs/fr/guides/model-ordering.md b/docs-site/src/content/docs/fr/guides/model-ordering.md index 64408efa53..ada196c8fc 100644 --- a/docs-site/src/content/docs/fr/guides/model-ordering.md +++ b/docs-site/src/content/docs/fr/guides/model-ordering.md @@ -158,10 +158,23 @@ Auparavant, les identifiants natifs dans `modelPickerOrder` étaient ignorés. U existante contenant un identifiant natif non qualifié ordonne désormais tout le sélecteur, y compris les lignes mises en avant. Supprimez ces identifiants pour conserver l’ancien comportement limité aux lignes routées. Les listes absentes, vides ou uniquement routées -conservent leur comportement ; les priorités des candidats sous-agents ne changent pas. - -`modelPickerOrder` ne modifie jamais l’ensemble des candidats de `spawn_agent`. Il change uniquement la -priorité visible par Codex dans le sélecteur, tandis qu’OpenCodex conserve la priorité naturelle de chaque -ligne déplacée pour la sélection des sous-agents. `disabledModels` et `selectedModels` de chaque fournisseur +conservent leur comportement ; le calcul des candidats pour les consignes d’OpenCodex selon les priorités naturelles reste inchangé. + +`modelPickerOrder` préserve le calcul d’OpenCodex qui retient jusqu’à cinq candidats préférés +pour les consignes aux sous-agents, selon leur priorité naturelle. Chaque ligne déplacée conserve +cette priorité séparément de son `priority` natif ; changer uniquement l’ordre du sélecteur ne doit +pas modifier ce calcul. Cela ne restreint pas l’admissibilité d’un modèle désigné par son nom exact : +la liste annoncée n’est pas une liste d’autorisation. Les contraintes d’authentification, de modèle, +d’effort et de backend restent applicables. + +Codex natif utilise le `priority` natif pour annoncer les cinq premiers modèles admissibles et +visibles dans le sélecteur via `spawn_agent`, en V1 et en V2 lorsque les substitutions de modèle +sont exposées. Ces cinq modèles peuvent donc changer avec l’ordre du sélecteur, même si les +candidats préférés d’OpenCodex restent identiques. La V1 ne reçoit aucune injection de liste +préférée d’OpenCodex. La V2 peut recevoir en plus des consignes fondées sur les priorités naturelles +si l’état du catalogue client le permet ; ces consignes ne réordonnent pas la liste annoncée par +l’outil natif. + +`disabledModels` et `selectedModels` de chaque fournisseur restent des champs de visibilité, pas des contrôles d’ordre. Il n’existe aucun paramètre distinct `modelOrder`, `providerOrder` ou de carte de priorité. diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md index 352c8ddb12..79e74e8e2d 100644 --- a/docs-site/src/content/docs/guides/model-ordering.md +++ b/docs-site/src/content/docs/guides/model-ordering.md @@ -154,9 +154,20 @@ their selector-qualified id in the list. Previously, native ids in `modelPickerOrder` were ignored. An existing list containing a bare native id now activates complete-picker ordering, including featured rows. Remove bare native ids to keep the previous routed-only behavior. Unset, empty and -routed-only lists retain their behavior; subagent candidate priorities are unchanged. - -`modelPickerOrder` never changes the `spawn_agent` candidate set. It changes only the -Codex-visible picker priority while opencodex retains each moved row's natural priority for -sub-agent selection. `disabledModels` and each provider's `selectedModels` remain visibility fields, +routed-only lists retain their behavior; OpenCodex's natural-priority guidance candidate calculation is unchanged. + +`modelPickerOrder` preserves OpenCodex's natural-priority calculation of up to five preferred +candidates for subagent guidance. Each moved row retains its natural priority separately from +its native `priority`; changing picker order alone must not change that OpenCodex calculation. +It does not restrict eligibility for an exact-name model override: the native advertised list +is not an allowlist, and existing authentication, model/effort and backend constraints still apply. + +Native Codex uses native `priority` to select the first five eligible picker-visible models +advertised by `spawn_agent` on V1 and on V2 when model overrides are exposed. Those advertised +five may therefore change with picker order, even when OpenCodex's preferred candidates remain +unchanged. V1 receives no OpenCodex preferred-roster injection. V2 may additionally receive +OpenCodex's natural-priority guidance when the client catalog state permits; that guidance does +not reorder the native tool's advertised list. + +`disabledModels` and each provider's `selectedModels` remain visibility fields, not ordering controls. There is no separate `modelOrder`, `providerOrder`, or priority-map setting. diff --git a/docs-site/src/content/docs/ja/guides/model-ordering.md b/docs-site/src/content/docs/ja/guides/model-ordering.md index b2383a55ea..d9c782b3fb 100644 --- a/docs-site/src/content/docs/ja/guides/model-ordering.md +++ b/docs-site/src/content/docs/ja/guides/model-ordering.md @@ -141,7 +141,17 @@ account selector がある場合、5 項目の制限は bare native の選択が 動作を保つには、bare ID を取り除いてください。未設定、空、空白だけ、ルーティング ID だけの一覧は 従来どおり動作します。 -`modelPickerOrder` は `spawn_agent` の候補 5 件とその選択用の優先順位を変えません。 -変わるのは Codex ピッカーの表示用 `priority` だけで、opencodex は移動した各行の本来の優先順位を -サブエージェント選択用に保持します。`disabledModels` と各プロバイダーの `selectedModels` は +`modelPickerOrder` は、自然な優先順位から最大 5 件の推奨候補を選ぶ OpenCodex の +サブエージェント向けガイダンス計算を保持します。移動した各行の自然な優先順位はネイティブの +`priority` とは別に残り、ピッカー順だけを変えてもこの計算結果は変わりません。 +正確なモデル名を指定する override の利用資格を制限するものでもありません。広告リストは許可リストではなく、 +認証、モデル、effort、バックエンドに関する既存の制約は引き続き適用されます。 + +ネイティブ Codex はネイティブの `priority` に従い、利用可能でピッカーに表示されるモデルの先頭 5 件を +`spawn_agent` に広告します。これは V1 と、モデル override を公開している V2 に当てはまります。 +そのため、OpenCodex の推奨候補が同じでも、ピッカー順を変えると広告される 5 件は変わる場合があります。 +V1 には OpenCodex の推奨候補リストを注入しません。V2 にはクライアントのカタログ状態が許す場合に +自然な優先順位に基づくガイダンスを追加できますが、ネイティブツールの広告リストは並び替えません。 + +`disabledModels` と各プロバイダーの `selectedModels` は 表示の有無を制御するフィールドです。別の `modelOrder`、`providerOrder`、priority map 設定はありません。 diff --git a/docs-site/src/content/docs/ko/guides/model-ordering.md b/docs-site/src/content/docs/ko/guides/model-ordering.md index 5a3863c4c2..365ea476fc 100644 --- a/docs-site/src/content/docs/ko/guides/model-ordering.md +++ b/docs-site/src/content/docs/ko/guides/model-ordering.md @@ -139,7 +139,17 @@ account selector가 있으면 bare native 선택이 selector-qualified 그룹으 featured 행을 포함한 선택기 전체 정렬이 활성화됩니다. 기존 라우팅 전용 동작을 유지하려면 bare ID를 제거하세요. 미설정 목록, 빈 목록, 공백만 있는 목록, 라우팅 ID만 있는 목록은 기존 동작을 유지합니다. -`modelPickerOrder`는 `spawn_agent` 후보 5개와 후보 선택용 우선순위를 바꾸지 않습니다. -Codex 선택기의 표시용 `priority`만 바꾸며, opencodex는 이동한 각 행의 원래 우선순위를 서브에이전트 -선택용으로 보존합니다. `disabledModels`와 각 공급자의 `selectedModels`는 노출 여부를 정하는 필드입니다. +`modelPickerOrder`는 자연 우선순위로 최대 5개의 선호 후보를 고르는 OpenCodex의 +서브에이전트 안내용 계산을 보존합니다. 이동한 각 행의 자연 우선순위는 네이티브 `priority`와 별도로 +남으며, 선택기 순서만 바꿔서는 이 계산 결과가 달라지지 않습니다. 정확한 모델 이름으로 override를 +지정할 자격도 제한하지 않습니다. 광고 목록은 허용 목록이 아니며, 기존 인증·모델·effort·백엔드 제약은 +그대로 적용됩니다. + +네이티브 Codex는 네이티브 `priority` 순서에서 사용 가능하고 선택기에 표시되는 모델 중 앞의 5개를 +`spawn_agent`에 광고합니다. V1과 모델 override를 공개하는 V2가 여기에 해당합니다. +따라서 OpenCodex의 선호 후보가 그대로여도 선택기 순서에 따라 광고되는 5개는 달라질 수 있습니다. +V1에는 OpenCodex의 선호 후보 목록을 주입하지 않습니다. V2는 클라이언트 카탈로그 상태가 허용할 때 +자연 우선순위 기반 안내를 추가로 받을 수 있지만, 이 안내가 네이티브 도구의 광고 목록을 재정렬하지는 않습니다. + +`disabledModels`와 각 공급자의 `selectedModels`는 노출 여부를 정하는 필드입니다. 별도의 `modelOrder`, `providerOrder`, priority map 설정은 없습니다. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index ebb35d28d1..eaf6790c2c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -824,7 +824,9 @@ See the [OpenCode Go model list](https://opencode.ai/docs/go/#models) for the cu A configured subset can exclude the lower tiers. Other providers retain their existing behavior. For a native-first picker, include native ids in `modelPickerOrder` followed by the -routed ids. This orders the complete picker while preserving the separate subagent -candidate priorities. Routed-only orders keep their previous behavior. See the +routed ids. This orders the complete picker while preserving OpenCodex's separate natural-priority +guidance calculation. Native Codex's advertised five follow picker priority and may change; +exact-name override eligibility is not limited to that advertisement. Routed-only orders keep +their previous behavior. See the [ordering migration note](/guides/model-ordering/#migration-note-native-ids-in-existing-orders). `modelDisplayNames` on a provider controls readable labels without changing wire ids. diff --git a/docs-site/src/content/docs/ru/guides/model-ordering.md b/docs-site/src/content/docs/ru/guides/model-ordering.md index 557b0e4d6f..290194fcd2 100644 --- a/docs-site/src/content/docs/ru/guides/model-ordering.md +++ b/docs-site/src/content/docs/ru/guides/model-ordering.md @@ -152,8 +152,21 @@ native-выбора в selector-qualified группы. чтобы сохранить прежнее поведение только для маршрутизируемых строк. Отсутствующий или пустой список, список из одних пробельных строк и список только с маршрутизируемыми ID работают как раньше. -`modelPickerOrder` не меняет набор из пяти кандидатов `spawn_agent` и их приоритеты для выбора. -Меняется только отображаемый в Codex `priority`; opencodex сохраняет исходный приоритет каждой -перемещённой строки для выбора субагентов. `disabledModels` и `selectedModels` каждого провайдера +`modelPickerOrder` сохраняет расчёт OpenCodex, который выбирает до пяти предпочтительных +кандидатов для рекомендаций субагентам по исходному приоритету. У каждой перемещённой строки +этот приоритет хранится отдельно от нативного `priority`; изменение только порядка селектора +не должно менять результат этого расчёта. Оно также не ограничивает допустимость переопределения +модели по точному имени: объявленный список не является списком разрешений. Существующие +ограничения аутентификации, модели, effort и бэкенда продолжают действовать. + +Нативный Codex использует нативный `priority`, чтобы объявить через `spawn_agent` первые пять +допустимых моделей, видимых в селекторе. Это относится к V1 и к V2 с открытыми переопределениями +моделей. Поэтому объявленные пять моделей могут меняться вместе с порядком селектора, даже если +предпочтительные кандидаты OpenCodex не изменились. В V1 OpenCodex не внедряет список +предпочтительных моделей. V2 может дополнительно получать рекомендации по исходным приоритетам, +если состояние каталога клиента это допускает; эти рекомендации не меняют порядок списка, +объявленного нативным инструментом. + +`disabledModels` и `selectedModels` каждого провайдера по-прежнему управляют видимостью. Отдельных настроек `modelOrder`, `providerOrder` или карты приоритетов нет. diff --git a/docs-site/src/content/docs/tr/guides/model-ordering.md b/docs-site/src/content/docs/tr/guides/model-ordering.md index 2298d5f281..336513225a 100644 --- a/docs-site/src/content/docs/tr/guides/model-ordering.md +++ b/docs-site/src/content/docs/tr/guides/model-ordering.md @@ -166,7 +166,18 @@ Eski, yalnızca yönlendirilmiş satırlara uygulanan davranışı korumak için Tanımlanmamış, boş, yalnızca boşluk girdileri içeren veya yalnızca yönlendirilmiş kimliklerden oluşan listeler önceki davranışlarını korur. -`modelPickerOrder`, `spawn_agent` için beş adaydan oluşan listeyi veya aday seçim önceliklerini -değiştirmez. Yalnızca Codex seçicisinde görünen `priority` değişir; opencodex taşınan her satırın doğal -önceliğini alt ajan seçimi için korur. `disabledModels` ve her sağlayıcının `selectedModels` alanı +`modelPickerOrder`, OpenCodex'in alt ajan rehberliği için doğal önceliğe göre en fazla beş tercih +edilen adayı seçen hesaplamasını korur. Taşınan her satırın doğal önceliği, yerel `priority` değerinden +ayrı saklanır; yalnızca seçici sırasını değiştirmek bu hesaplamanın sonucunu değiştirmemelidir. +Tam model adıyla geçersiz kılma uygunluğunu da kısıtlamaz: tanıtılan liste bir izin listesi değildir. +Mevcut kimlik doğrulama, model, effort ve arka uç kısıtlamaları geçerliliğini korur. + +Yerel Codex, `spawn_agent` içinde tanıtılacak beş modeli yerel `priority` sırasındaki uygun ve +seçicide görünür modellerden seçer. Bu, V1 ve model geçersiz kılmalarının sunulduğu V2 için geçerlidir. +Dolayısıyla OpenCodex'in tercih edilen adayları değişmese bile, tanıtılan beş model seçici sırasıyla +birlikte değişebilir. V1'e OpenCodex tercih listesi enjekte edilmez. V2, istemci katalog durumu izin +verdiğinde ek olarak doğal önceliğe dayalı OpenCodex rehberliği alabilir; bu rehberlik yerel aracın +tanıttığı listeyi yeniden sıralamaz. + +`disabledModels` ve her sağlayıcının `selectedModels` alanı görünürlüğü denetler. Ayrı bir `modelOrder`, `providerOrder` veya öncelik haritası ayarı yoktur. diff --git a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md index c7570abfa9..a07d4cf01f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md +++ b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md @@ -132,7 +132,16 @@ id 请勿超过五个。存在账户 selector 时,一个裸原生选项可能 整个选择器的排序,包括置顶行。要保持以前只调整路由行的行为,请移除裸 ID。 未设置、空列表、只有空白条目的列表以及只有路由 ID 的列表都保留原有行为。 -`modelPickerOrder` 不会改变 `spawn_agent` 的五个候选项及其选择优先级。它只改变 Codex 选择器中 -用于显示的 `priority`;opencodex 会保留每个移动行的原有优先级,供子代理选择使用。 +`modelPickerOrder` 保留 OpenCodex 按原有优先级计算最多五个首选候选项的规则,供子代理指导使用。 +每个移动行的原有优先级与原生 `priority` 分开保存;仅改变选择器顺序不得改变这一计算结果。 +它也不会限制通过精确模型名称指定 override 的资格:公布的列表不是允许列表,现有的认证、模型、 +effort 和后端限制仍然适用。 + +原生 Codex 按原生 `priority` 排序,从符合条件且在选择器中可见的模型中取前五个,公布在 +`spawn_agent` 中。这适用于 V1,以及公开模型 override 的 V2。因此,即使 OpenCodex 的首选候选项 +不变,原生公布的五个模型仍可能随选择器顺序改变。V1 不接收 OpenCodex 注入的首选模型列表。 +V2 在客户端目录状态允许时,可以额外接收基于原有优先级的 OpenCodex 指导;这些指导不会重排 +原生工具公布的列表。 + `disabledModels` 和各提供商的 `selectedModels` 仍是可见性字段。没有独立的 `modelOrder`、 `providerOrder` 或优先级映射设置。 diff --git a/docs-site/src/content/docs/zh-tw/guides/model-ordering.md b/docs-site/src/content/docs/zh-tw/guides/model-ordering.md index 6b11319969..e0946db620 100644 --- a/docs-site/src/content/docs/zh-tw/guides/model-ordering.md +++ b/docs-site/src/content/docs/zh-tw/guides/model-ordering.md @@ -121,7 +121,16 @@ subagentModels = [ 整個選擇器的排序,包括置頂列。要保留以前只調整路由列的行為,請移除裸 ID。 未設定、空列表、只有空白項目的列表以及只有路由 ID 的列表都保留原有行為。 -`modelPickerOrder` 不會改變 `spawn_agent` 的五個候選項及其選擇優先級。它只改變 Codex 選擇器中 -用於顯示的 `priority`;opencodex 會保留每個移動列的原有優先級,供子代理選擇使用。 +`modelPickerOrder` 保留 OpenCodex 按原有優先級計算最多五個偏好候選項的規則,供子代理指引使用。 +每個移動列的原有優先級與原生 `priority` 分開儲存;僅改變選擇器順序不得改變這項計算結果。 +它也不會限制以精確模型名稱指定 override 的資格:公佈的列表不是允許清單,既有的驗證、模型、 +effort 與後端限制仍然適用。 + +原生 Codex 按原生 `priority` 排序,從符合條件且在選擇器中可見的模型中取前五個,公佈在 +`spawn_agent` 中。這適用於 V1,以及開放模型 override 的 V2。因此,即使 OpenCodex 的偏好候選項 +不變,原生公佈的五個模型仍可能隨選擇器順序改變。V1 不接收 OpenCodex 注入的偏好模型列表。 +V2 在用戶端目錄狀態允許時,可以額外接收基於原有優先級的 OpenCodex 指引;這些指引不會重排 +原生工具公佈的列表。 + `disabledModels` 和各供應商的 `selectedModels` 仍是可見性欄位。沒有獨立的 `modelOrder`、 `providerOrder` 或優先級對應表設定。 diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index b1401e0a22..b06c00a47e 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -243,11 +243,14 @@ advertises) but `expose_spawn_agent_model_overrides` on V2 (default `true`; when is omitted *and* the `model`/`reasoning_effort` schema fields are removed). And V2's `hide_spawn_agent_metadata` defaults true, which removes `service_tier`. -`modelPickerOrder` (#1649) deliberately does **not** feed this window: it rewrites only the -Codex-visible `priority` while `SPAWN_PRIORITY_FIELD` preserves the natural priority the roster -sorts by, so a display reorder can never change candidate membership. That divergence from -upstream's own ordering is the feature's purpose, not a defect — -`tests/codex-integration/codex-catalog-model-picker-order.test.ts` pins it. +`modelPickerOrder` (#1649) separates **OpenCodex guidance** from native advertisement. +`SPAWN_PRIORITY_FIELD` preserves the natural priority used by `effectiveSubagentRoster`, so +OpenCodex's preferred/guidance candidate calculation stays independent of display order. +Native Codex ignores that private field: its advertised five on V1 and exposed V2 follow the +native `priority` and may change when the picker is reordered. Exact-name override lookup is +not restricted to those five advertised rows. V1 receives no OpenCodex preferred-roster +injection; V2 can additionally receive natural-priority guidance when its catalog state permits. +The helper tests pin guidance behavior, not native tool-description equivalence. A nonblank bare id in `modelPickerOrder` opts into complete-picker display ordering. Exact ids take precedence over raw/encoded equivalents; routed-only and empty lists keep the legacy From 52d8d5ca844d9be09d4c6c4fbb2d92ff0b903555 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:05:50 +0900 Subject: [PATCH 074/236] feat(aside): synchronize profiles with independent CLI controls --- .../010_profiles_backend_cli.md | 16 + .../src/content/docs/guides/integrations.md | 31 +- scripts/test-layout/layout.json | 3 + skills/ocx/references/03_recipes.md | 18 +- src/cli/dispatch.ts | 5 +- src/cli/integrations.ts | 48 ++- src/clients/aside-profiles.ts | 224 +++++++++++ src/config.ts | 11 + src/integrations/aside-profile-context.ts | 270 +++++++++++++ src/integrations/aside-profile-journal.ts | 215 ++++++++++ src/integrations/aside-profiles.ts | 166 ++++++++ src/integrations/catalog-refresh.ts | 5 + src/integrations/owned-refresh.ts | 3 + src/integrations/state.ts | 4 +- src/integrations/writer.ts | 4 +- src/server/management/aside-profile-routes.ts | 166 ++++++++ src/server/management/config-routes.ts | 1 + src/server/management/integration-routes.ts | 58 ++- src/types/config.ts | 7 + structure/09_client-integrations.md | 13 + tests/cli/cli-headless-parity.test.ts | 39 ++ tests/clients/aside-profile-paths.test.ts | 267 +++++++++++++ tests/clients/aside-profiles.test.ts | 373 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 3 + tests/server/aside-profiles-routes.test.ts | 129 ++++++ 25 files changed, 2054 insertions(+), 25 deletions(-) create mode 100644 src/clients/aside-profiles.ts create mode 100644 src/integrations/aside-profile-context.ts create mode 100644 src/integrations/aside-profile-journal.ts create mode 100644 src/integrations/aside-profiles.ts create mode 100644 src/server/management/aside-profile-routes.ts create mode 100644 tests/clients/aside-profile-paths.test.ts create mode 100644 tests/clients/aside-profiles.test.ts create mode 100644 tests/server/aside-profiles-routes.test.ts diff --git a/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md index 7a434dc7f9..36be9363ae 100644 --- a/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md +++ b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md @@ -25,3 +25,19 @@ Verification: standalone temp-root production probe establishes three distinct f - Add profileId to journal/API rows, and treat (clientId,profileId/configPath) as history ownership for latest/undo/delete checks. Existing non-Aside behavior stays unchanged. C4 audit findings and concrete filesystem guard details are kept in ignored scratch; the public roadmap records feature contracts only. + +## P implementation interfaces at37b3a7f9b + +Delegation is within this one010 cycle with disjoint write sets. Path worker owns clients/aside-profiles.ts and tests/clients/aside-profile-paths.test.ts. Engine worker owns integrations/aside-profile-context.ts, aside-profiles.ts, aside-profile-journal.ts and tests/clients/aside-profiles.test.ts. Main owns type/config schemas, resolved-path seams in state/writer, management routes, CLI, implicit fan-out wiring and route/CLI tests. No worker commits, orchestration, local suites or real profile mutation. + +Path module exports AsideProfile {id,name?,current,root,configPath,detectDir}; listAsideProfiles(env?,home?) and guardAsideProfileIO(profile,io,profiles?) plus assertAsideProfileBoundary(profile,profiles?,mutation?). Invalid manifest/selector/path raises ClientPathError with safe text. Engine module exports AsideProfilesInput (config, models array/lazy, port, env/home/store/io, persistConfig?, lockSeams?), AsideProfileState (IntegrationStatus plus profileId/name/current/enabled and optional safe error), AsideProfileList (clientId,profiles,allEnabled,enabledCount,appliedCount,total plus aggregate state fields), listAsideProfileStates, getAsideProfileState(input,id), mutateAsideProfiles(input,{enabled,profileId?,overwriteConflict?}), refreshAsideProfiles. Mutations return {ok,clientId,changed,state,message,results:[WriteOutcome+profileId]}; singleton result stays accessible for the existing refusal serializer. + +Journal module exports listAsideOperations(input,profileId?) -> [{profileId,entry,store}], findAsideOperation(input,opId,profileId?) -> row|null, restoreAsideProfile(input,{opId,profileId?,confirmDrift?}) -> WriteOutcome+profileId and deleteAsideOperation(input,{opId,profileId?,principal?}). Main serializes journal metadata using each source store; profile-scoped newest protection and duplicate retirement live in the journal service. Journal discovery can return null for unrecognized non-Aside operations so the existing route handles them. + +The context owner centralizes exact scope/store resolution, desired policy, guarded IO and outer flight; engine/journal import it without circular imports. Scope includes a safe ownership-store root as well as the client file target. No writable legacy root may be shared across profiles. Domain errors carry safe code/status for route mapping; no manifest/session payload reaches diagnostics. + +## Implementation evidence and review scope + +`bun .tmp/aside-profiles/api-cli-probe.ts` passed against an isolated live HTTP management handler and actual CLI: three-profile bulk enable, individual-off after persisted reload/model selection, Undo followed by sync, unrelated settings and metadata privacy. Default unconfigured/disabled Aside now skips implicit fan-out before manifest/catalog discovery. + +This C4 backend layer is larger than the default review-size guideline because the new filesystem scope, one-owner store model, reversible desired state, and API/CLI consumers must be assessed as one complete contract; these are new cohesive modules with focused fixtures, not unrelated cleanup. UI implementation remains a separate dependent PR/cycle, and the original Grok work is already four separate reviewed PRs. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index a6977f4066..d12c038862 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -52,12 +52,10 @@ disagree about which file is meant. Its managed block owns only stay untouched. Prime Agent reads `models.json` when a session starts, so start a new session after connecting it. -Aside is per-account: its state lives under `~/.aside/u//` and opencodex -writes the catalog of whichever account Aside's own `accounts.json` names as -current. If that manifest is missing or unreadable the integration refuses rather -than guessing an account, because a guess on a multi-account machine would write -into a different account's catalog. Its managed block owns only -`providers.opencodex`, so your other Aside providers stay untouched. +Aside keeps a separate model catalog for each account-backed browser profile. OpenCodex lists +all registered profiles, including local profiles, and can synchronize them together or control +one profile at a time. Switching an integration never changes Aside's active account. A prior +Aside connection enables all profiles by default; individual exclusions survive later syncs. One caveat specific to Aside: the running app rewrites `models.json` itself, so fully quit and reopen Aside after applying, the same way Claude Desktop needs a @@ -248,3 +246,24 @@ decision to make. Client details were verified against each project's own configuration format; see the research notes in `devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` for what was checked and when. + +## Aside profile controls + +```bash +ocx integration client status --client aside --json +ocx integration client enable --client aside +ocx integration client disable --client aside --profile 1 +ocx integration client history --client aside --profile 1 +ocx integration client restore --client aside --profile 1 --op +``` + +The profile number is the account ID shown by the status command. Omitting `--profile` on an +Aside toggle applies the desired state to every registered profile. A per-profile change leaves +siblings unchanged. Desired sync settings are saved before file changes; actual state and any +refusal are reported for each profile. A partial bulk result is not an all-applied success and +the CLI exits nonzero. Undo restores the selected profile's synchronization intent as well as +its file, so a later sync does not silently reverse Undo. + +Each profile has separate ownership and history. Existing user edits, unsafe paths and linked +catalogs are refused; the existing explicit overwrite and drift-confirmation controls remain +available. Fully quit and reopen Aside to load changed model files. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 999863fbc0..4e57ed3311 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -232,6 +232,9 @@ "artifacts-prune.test.ts": "images", "artifacts-ssrf.test.ts": "images", "aside-client.test.ts": "providers", + "aside-profiles-routes.test.ts": "server", + "aside-profiles.test.ts": "clients", + "aside-profile-paths.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", "autostart-health.test.ts": "service", diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 4f48d2bfd7..d482261932 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -1,6 +1,7 @@ # Recipes -Each sequence below was run against a live proxy. Every command named here exists; where the +The original sequences below were run against a live proxy; the Aside profile sequence was +verified through an isolated live management handler and the production CLI. Every command named here exists; where the obvious-sounding command does *not* exist, that is called out rather than left as a trap. Preflight for all of them: @@ -205,3 +206,18 @@ Two absences are also expected and are not defects: provider sets `liveModels: false` deliberately — its authenticated roster includes image and voice models this Responses-agent provider cannot drive — so the absence of a live probe is a design decision, not a broken connection. + +## Aside profiles + +```bash +ocx integration client status --client aside --json +ocx integration client enable --client aside +ocx integration client disable --client aside --profile 1 +ocx integration client history --client aside --profile 1 +ocx integration client restore --client aside --profile 1 --op +``` + +Read `profiles[]` to find numeric profile IDs. No profile selector means a bulk toggle; an +explicit selector affects only that account-backed profile. Sync intent and actual file state +are distinct, so inspect each result after a partial bulk operation. The CLI returns nonzero +for a partial refusal. Never use the overwrite or drift flags merely to suppress a refusal. diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6fef425ad5..131ab7b1f6 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -402,8 +402,9 @@ const commandRunners: Record = { port: live.port, }, ["mcode", "pi", "aside"]); for (const result of results) { - if (result.changed) console.log(`${result.client} integration refreshed from the current catalog.`); - else if (result.reason) console.warn(`${result.client} integration was not refreshed: ${result.reason}`); + const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`; + if (result.changed) console.log(`${label} integration refreshed from the current catalog.`); + else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}`); } } catch (error) { console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index 3b417632ff..e151a3c836 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -1,5 +1,6 @@ import { CliUsageError, + RuntimeApiError, csv, printData, rejectArgs, @@ -28,10 +29,20 @@ const GROK_USAGE = `Usage: ocx grok apply [--json]`; const CLIENT_USAGE = `Usage: - ocx integration client [status] [--client ] [--json] - ocx integration client --client [--overwrite-conflict] [--json] - ocx integration client history [--client ] [--json] - ocx integration client restore --op [--confirm-drift] [--json]`; + ocx integration client [status] [--client ] [--profile ] [--json] + ocx integration client --client [--profile ] [--overwrite-conflict] [--json] + ocx integration client history [--client ] [--profile ] [--json] + ocx integration client restore --op [--client aside --profile ] [--confirm-drift] [--json] + --profile selects one Aside account-backed profile; omitted Aside toggles affect all profiles.`; + +function asideProfileQuery(profile: string | undefined, client: string | undefined): string { + if (profile === undefined) return ""; + if (client !== "aside") throw new CliUsageError("--profile requires --client aside", CLIENT_USAGE); + if (!/^(0|[1-9][0-9]*)$/.test(profile) || !Number.isSafeInteger(Number(profile))) { + throw new CliUsageError("--profile must be a nonnegative integer account ID", CLIENT_USAGE); + } + return `profile=${encodeURIComponent(profile)}`; +} function parseMap(raw: string): Record { if (raw === "-") return {}; @@ -163,16 +174,21 @@ export async function handleClientIntegrationCommand( const args = [...argv]; const action = (args.shift() ?? "status").toLowerCase(); const wantsJson = takeFlag(args, "--json"); + const profile = takeOption(args, "--profile"); if (action === "status" || action === "show" || action === "list") { const client = takeOption(args, "--client"); + const profileQuery = asideProfileQuery(profile, client); rejectArgs(args, CLIENT_USAGE); const path = client - ? `/api/client-integrations/${encodeURIComponent(client)}` + ? `/api/client-integrations/${encodeURIComponent(client)}${profileQuery ? `?${profileQuery}` : ""}` : "/api/client-integrations"; const result = await runtimeRequest(path, {}, deps); const rows = (result as { clients?: Array> }).clients; - printData(result, wantsJson, rows + const profiles = (result as { profiles?: Array> }).profiles; + printData(result, wantsJson, profiles + ? profiles.map(row => `${String(row.profileId)} ${String(row.name ?? "Aside")}: ${row.enabled ? "on" : "off"} (${String(row.state)})${row.current ? " [current]" : ""}`) + : rows ? rows.map(row => `${String(row.clientId)}: ${String(row.state)}${row.installed ? "" : " (not installed)"}`) : summaryLines(result)); return; @@ -180,8 +196,9 @@ export async function handleClientIntegrationCommand( if (action === "history" || action === "journal") { const client = takeOption(args, "--client"); + const profileQuery = asideProfileQuery(profile, client); rejectArgs(args, CLIENT_USAGE); - const query = client ? `?client=${encodeURIComponent(client)}` : ""; + const query = client ? `?client=${encodeURIComponent(client)}${profileQuery ? `&${profileQuery}` : ""}` : ""; const result = await runtimeRequest(`/api/client-integrations/journal${query}`, {}, deps); const operations = (result as { operations?: Array> }).operations ?? []; printData(result, wantsJson, operations.length === 0 @@ -190,7 +207,8 @@ export async function handleClientIntegrationCommand( // `snapshot` is resolved against the disk by the route, so "expired" // here means the bytes are genuinely gone, not merely old. const backup = row.snapshot === "expired" ? "backup expired" : `op ${String(row.opId)}`; - return `${String(row.at)} ${String(row.clientId)} ${String(row.kind)} (${backup})`; + const owner = row.profileId === undefined ? String(row.clientId) : `${String(row.clientId)}:${String(row.profileId)}`; + return `${String(row.at)} ${owner} ${String(row.kind)} (${backup})`; })); return; } @@ -198,9 +216,12 @@ export async function handleClientIntegrationCommand( if (action === "restore") { const opId = takeOption(args, "--op") ?? takeOption(args, "--op-id"); const confirmDrift = takeFlag(args, "--confirm-drift"); + const client = takeOption(args, "--client"); + const profileQuery = asideProfileQuery(profile, client); + if (client !== undefined && !profileQuery) throw new CliUsageError("restore --client requires --profile", CLIENT_USAGE); rejectArgs(args, CLIENT_USAGE); if (!opId) throw new CliUsageError("--op is required", CLIENT_USAGE); - const result = await runtimeRequest("/api/client-integrations/restore", { + const result = await runtimeRequest(`/api/client-integrations/restore${profileQuery ? `?client=aside&${profileQuery}` : ""}`, { method: "POST", body: JSON.stringify({ opId, confirmDrift }), }, deps); @@ -212,6 +233,7 @@ export async function handleClientIntegrationCommand( throw new CliUsageError(`unknown client integration command ${action}`, CLIENT_USAGE); } const client = takeOption(args, "--client"); + const profileQuery = asideProfileQuery(profile, client); /* * The conflict escape hatch, spelled the way `restore --confirm-drift` is: the * refusal is the default and the waiver has to be typed. @@ -232,7 +254,7 @@ export async function handleClientIntegrationCommand( if (overwriteConflict && action === "disable") { throw new CliUsageError("--overwrite-conflict applies only to enable", CLIENT_USAGE); } - const result = await runtimeRequest(`/api/client-integrations/${encodeURIComponent(client)}`, { + const result = await runtimeRequest(`/api/client-integrations/${encodeURIComponent(client)}${profileQuery ? `?${profileQuery}` : ""}`, { method: "PUT", // Sent only when asked for, so a proxy on an older build sees the request it // has always seen rather than an unknown field. @@ -240,7 +262,11 @@ export async function handleClientIntegrationCommand( ? { enabled: true, overwriteConflict: true } : { enabled: action === "enable" }), }, deps); - printData(result, wantsJson, [String((result as Record).message ?? `${client} ${action}d.`)]); + const batch = result as { ok?: boolean; message?: string; results?: Array> }; + printData(result, wantsJson, batch.results + ? batch.results.map(row => `aside:${String(row.profileId)} ${String(row.message ?? (row.ok ? "updated" : "refused"))}`) + : [String(batch.message ?? `${client} ${action}d.`)]); + if (batch.ok === false) throw new RuntimeApiError(batch.message ?? "Some Aside profiles could not be updated", 207, result); }); } diff --git a/src/clients/aside-profiles.ts b/src/clients/aside-profiles.ts new file mode 100644 index 0000000000..31f13d9b76 --- /dev/null +++ b/src/clients/aside-profiles.ts @@ -0,0 +1,224 @@ +import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync, type Stats } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import type { IntegrationIO } from "../integrations/config-io"; +import { asideHomeDir, ClientPathError } from "./config-export"; + +export interface AsideProfile { + id: number; + name?: string; + current: boolean; + root: string; + configPath: string; + detectDir: string; +} + +const MAX_PROFILES = 128; +const MAX_MANIFEST_BYTES = 4 * 1024 * 1024; +const MAX_LEAF_LINKS = 40; + +function refuse(message: string): never { + // Never include manifest contents or underlying filesystem error messages. + throw new ClientPathError(`Aside profile: ${message}`); +} + +function isId(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0); +} + +function object(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function inspect(path: string, follow = false): Stats | null { + try { + return follow ? statSync(path) : lstatSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + return refuse("a filesystem boundary could not be inspected."); + } +} + +function canonical(path: string): string { + try { return realpathSync.native(path); } catch { + return refuse("a filesystem boundary could not be resolved."); + } +} + +/** Resolve a peer's leaf link even when its final model file does not exist yet. */ +function leafDestination(path: string): string | null { + const visited = new Set(); + while (inspect(path)?.isSymbolicLink()) { + if (visited.has(path) || visited.size >= MAX_LEAF_LINKS) refuse("an account catalog has a cyclic or excessive link chain."); + visited.add(path); + try { path = resolve(dirname(path), readlinkSync(path)); } catch { + return refuse("an account catalog link could not be inspected."); + } + } + if (!inspect(dirname(path), true)?.isDirectory()) return null; + return join(canonical(dirname(path)), basename(path)); +} + +function readProfiles(root: string): AsideProfile[] { + const rootStat = inspect(root); + if (!rootStat || rootStat.isSymbolicLink() || !rootStat.isDirectory()) { + refuse("the configured root is missing or is not a safe directory."); + } + const manifest = join(root, "accounts.json"); + const manifestStat = inspect(manifest); + if (!manifestStat || manifestStat.isSymbolicLink() || !manifestStat.isFile() + || manifestStat.size > MAX_MANIFEST_BYTES) { + refuse("the account manifest is missing, unreadable or unsafe. Launch Aside to create it."); + } + let parsed: unknown; + try { parsed = JSON.parse(readFileSync(manifest, "utf8")); } catch { + return refuse("the account manifest is not readable JSON."); + } + if (!object(parsed) || !isId(parsed.currentAccountId)) { + refuse("the account manifest has no valid current account ID."); + } + const currentId = parsed.currentAccountId; + const accounts: unknown = Object.hasOwn(parsed, "accounts") ? parsed.accounts : [{ id: currentId }]; + if (!Array.isArray(accounts) || accounts.length === 0 || accounts.length > MAX_PROFILES) { + refuse("the account manifest must contain between 1 and 128 accounts."); + } + const ids = new Set(); + const profiles = accounts.map((account: unknown): AsideProfile => { + if (!object(account) || !isId(account.id) || ids.has(account.id)) { + return refuse("the account manifest contains an invalid or duplicate account ID."); + } + const current = account.id === currentId; + if (Object.hasOwn(account, "current") && account.current !== current) { + refuse("the account manifest has inconsistent current account metadata."); + } + ids.add(account.id); + const detectDir = join(root, "u", String(account.id)); + return { + id: account.id, + ...(typeof account.name === "string" ? { name: account.name } : {}), + current, root, detectDir, configPath: join(detectDir, "models.json"), + }; + }); + if (!ids.has(currentId)) refuse("the current account is not registered in the account manifest."); + return profiles; +} + +/** Enumerate account catalogs; browser bindings and session data are never projected. */ +export function listAsideProfiles(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): AsideProfile[] { + const root = asideHomeDir(env, home); + if (!isAbsolute(root)) refuse("the configured root must be absolute."); + return readProfiles(root); +} + +type DirectoryIdentity = { path: string; dev: number; ino: number }; +type Boundary = Array; + +function sameIdentity(a: Pick, b: Pick): boolean { + return a.dev === b.dev && a.ino === b.ino; +} + +function validatePaths(profile: AsideProfile): void { + if (!isId(profile.id) || !isAbsolute(profile.root) || resolve(profile.root) !== profile.root + || profile.detectDir !== join(profile.root, "u", String(profile.id)) + || profile.configPath !== join(profile.detectDir, "models.json")) { + refuse("the selected account paths are invalid."); + } +} + +function registeredProfiles(profile: AsideProfile, profiles?: AsideProfile[]): AsideProfile[] { + validatePaths(profile); + const registered = profiles ?? readProfiles(profile.root); + if (registered.length === 0 || registered.length > MAX_PROFILES) refuse("the account list is invalid."); + const ids = new Set(); + for (const peer of registered) { + validatePaths(peer); + if (peer.root !== profile.root || ids.has(peer.id)) refuse("the account list has conflicting paths."); + ids.add(peer.id); + } + if (!ids.has(profile.id)) refuse("the selected account is not registered."); + return registered; +} + +function boundary(profile: AsideProfile, profiles: AsideProfile[], mutation: boolean): Boundary { + const directories = [profile.root, join(profile.root, "u"), profile.detectDir]; + const identities: Boundary = []; + let parent: string | undefined; + let absent = false; + for (const [index, directory] of directories.entries()) { + const stats = absent ? null : inspect(directory); + if (!stats) { + if (index === 0 || mutation) refuse("the account directory is not installed; it will not be created."); + absent = true; + identities.push(null); + continue; + } + if (stats.isSymbolicLink() || !stats.isDirectory()) refuse("an account directory is a link or is unsafe."); + const path = canonical(directory); + // Aliases ABOVE the chosen root (notably macOS /var) are valid. + const child = index === 1 ? "u" : String(profile.id); + if (parent && path !== join(parent, child)) refuse("an account directory resolves outside its boundary."); + identities.push({ path, dev: stats.dev, ino: stats.ino }); + parent = path; + } + if (absent) return identities; + const leaf = inspect(profile.configPath); + if (leaf && (leaf.isSymbolicLink() || !leaf.isFile() || leaf.nlink > 1)) { + refuse("the model catalog is a link, shared file or non-regular file."); + } + if (leaf && canonical(profile.configPath) !== join(parent!, "models.json")) { + refuse("the model catalog resolves outside its account directory."); + } + const account = identities[2]!; + for (const peer of profiles) { + if (peer.id === profile.id) continue; + // Follow peers only for identity comparison, never for content or writes. + // This also detects a sibling symlink pointing BACK at this safe target. + const peerDirectory = inspect(peer.detectDir, true); + if (peerDirectory && sameIdentity(account, peerDirectory)) refuse("account directories share a target."); + if (!peerDirectory?.isDirectory()) continue; + if (inspect(peer.configPath)?.isSymbolicLink() + && leafDestination(peer.configPath) === join(parent!, "models.json")) { + refuse("account catalogs share a target."); + } + const peerLeaf = inspect(peer.configPath, true); + if (leaf && peerLeaf && sameIdentity(leaf, peerLeaf)) refuse("account catalogs share a target."); + } + return identities; +} + +/** Missing account directories are readable as not installed, but never writable. */ +export function assertAsideProfileBoundary(profile: AsideProfile, profiles?: AsideProfile[], mutation = false): void { + boundary(profile, registeredProfiles(profile, profiles), mutation); +} + +/** + * Pin directories for one status/write operation, retaining the caller's IO and store. + * Rechecks complement atomic writes; they do not defeat a hostile same-user process + * racing every filesystem syscall. Leaf inodes may change during our atomic writes. + */ +export function guardAsideProfileIO(profile: AsideProfile, io: IntegrationIO, profiles?: AsideProfile[]): IntegrationIO { + const selected = { ...profile }; + const registered = registeredProfiles(selected, profiles).map(peer => ({ ...peer })); + const captured = boundary(selected, registered, false); + function check(path: string, directory: boolean, mutation: boolean): void { + if (path !== (directory ? selected.detectDir : selected.configPath)) { + refuse("IO attempted to access a different account path."); + } + const current = boundary(selected, registered, mutation); + if (current.some((item, index) => { + const prior = captured[index]; + return item === null || prior == null ? item !== prior : item.path !== prior.path || !sameIdentity(item, prior); + })) refuse("the account directory changed after the operation began."); + } + return { + readText: path => { check(path, false, false); return io.readText(path); }, + statKind: path => { check(path, path === selected.detectDir, false); return io.statKind(path); }, + writeText: (path, text) => { check(path, false, true); io.writeText(path, text); }, + removeFile: path => { check(path, false, true); io.removeFile(path); }, + mkdirp: path => { check(path, true, true); io.mkdirp(path); }, + now: () => io.now(), + appendJournal: entry => io.appendJournal(entry), + putRecord: record => io.putRecord(record), + dropRecord: clientId => io.dropRecord(clientId), + }; +} diff --git a/src/config.ts b/src/config.ts index 5d67275dce..a5eb9a0a70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -932,6 +932,15 @@ const clientIntegrationsSchema = z.object({ "claude-desktop": z.boolean().optional().catch(undefined), }).passthrough(); +const asideProfileSyncSchema = z.object({ + allProfiles: z.boolean().optional(), + profiles: z.record( + z.string().regex(/^(0|[1-9][0-9]*)$/).refine(value => Number.isSafeInteger(Number(value))), + z.boolean(), + ).optional(), + legacyProfileId: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable().optional(), +}).passthrough(); + const agentTaskRecoverySchema = z.object({ enabled: z.boolean().optional(), model: z.string().trim().min(1).optional(), @@ -1134,6 +1143,8 @@ const configSchema = z.object({ subagentModelsVersion: z.number().int().positive().optional().catch(undefined), subagentModels: z.array(z.string().min(1)).optional().catch(undefined), clientIntegrations: clientIntegrationsSchema.optional().catch(undefined), + // A malformed profile policy must not fall back to legacy all-profile activation. + asideProfileSync: asideProfileSyncSchema.optional().catch({ allProfiles: false }), providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), diff --git a/src/integrations/aside-profile-context.ts b/src/integrations/aside-profile-context.ts new file mode 100644 index 0000000000..d0bd9536cb --- /dev/null +++ b/src/integrations/aside-profile-context.ts @@ -0,0 +1,270 @@ +import { lstatSync, readdirSync, realpathSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; +import { ClientPathError, type ExportModel } from "../clients/config-export"; +import { assertAsideProfileBoundary, guardAsideProfileIO, listAsideProfiles, type AsideProfile } from "../clients/aside-profiles"; +import type { OcxConfig } from "../types"; +import { type IntegrationIO } from "./config-io"; +import type { JournalEntry } from "./journal"; +import { IntegrationMutationBusyError, runIntegrationMutationFlight } from "./mutation-flight"; +import { fingerprint } from "./ownership"; +import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; +import type { IntegrationWriteInput, WriteOutcome } from "./writer"; +import type { IntegrationWriterLockSeams } from "./writer-lock"; + +export interface AsideProfilesInput { + config: OcxConfig; + models: readonly ExportModel[] | (() => Promise); + port: number; + env?: NodeJS.ProcessEnv; + home?: string; + store?: IntegrationStateStore; + io?: IntegrationIO; + persistConfig?: (config: OcxConfig) => void | Promise; + lockSeams?: IntegrationWriterLockSeams; +} + +export class AsideProfileError extends Error { + constructor(readonly code: string, readonly status: number, message: string) { + super(message); + this.name = "AsideProfileError"; + } +} + +export type AsideProfilePolicy = NonNullable; +export type AsideProfileWriteOutcome = WriteOutcome & { profileId: number }; +export interface AsideProfileScope { + profile: AsideProfile; + store: IntegrationStateStore; + io: IntegrationIO; + assertBoundary: () => void; +} +export interface AsideProfileContext { + input: AsideProfilesInput; + profiles: AsideProfile[]; + rootStore: IntegrationStateStore; + legacyProfileId: number | null; + defaultEnabled: boolean; + models: () => Promise; + scopes: Map; +} + +function storeUnsafe(): never { + throw new AsideProfileError("aside_profile_store_unsafe", 409, "Aside profile ownership storage cannot be accessed safely"); +} + +/** Allow aliases above the trusted anchor, never at or below it. */ +function storeGuard(anchor: string, target: string): () => void { + const base = resolve(anchor); + const root = resolve(target); + const rel = relative(base, root); + if (rel.startsWith(`..${sep}`) || rel === ".." || resolve(base, rel) !== root) storeUnsafe(); + const identities = new Map(); + const inspect = (path: string, directory: boolean): boolean => { + try { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (!directory && stat.nlink > 1)) storeUnsafe(); + if (directory) { + const identity = `${realpathSync(path)}:${stat.dev}:${stat.ino}`; + if (identities.has(path) && identities.get(path) !== identity) storeUnsafe(); + identities.set(path, identity); + } + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" && !identities.has(path)) return false; + storeUnsafe(); + } + }; + return () => { + let path = base; + if (!inspect(path, true)) return; + for (const part of rel ? rel.split(sep) : []) { + path = join(path, part); + if (!inspect(path, true)) return; + } + for (const name of ["records.json", "journal.jsonl", "maintenance.json"]) inspect(join(root, name), false); + const snapshots = join(root, "snapshots"); + if (!inspect(snapshots, true)) return; + const aside = join(snapshots, "aside"); + if (inspect(aside, true)) for (const name of readdirSync(aside)) inspect(join(aside, name), false); + }; +} + +/** Store methods close over their original root; IO bookkeeping must bind to the guarded facade. */ +function guardedStore(store: IntegrationStateStore, anchor: string): IntegrationStateStore { + const guard = storeGuard(anchor, store.root); + guard(); + return new Proxy(store, { + get(target, property, receiver) { + const value: unknown = Reflect.get(target, property, receiver); + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + guard(); + if (property === "readSnapshot") assertAsideSnapshotEntry(args[0] as JournalEntry); + // Maintenance in this service is Aside-scoped, including a legacy shared store. + if (property === "retryPendingPrunes") { + if (store.readMaintenance().pruneFailures.aside) { + if (store.pruneSnapshots("aside").ok) store.clearPruneFailure("aside"); + } + return; + } + return Reflect.apply(value, target, args); + }; + }, + }); +} + +export function asideRootStore(input: AsideProfilesInput): IntegrationStateStore { + const raw = input.store ?? createIntegrationStateStore(); + return guardedStore(raw, raw.root); +} + +export function assertAsideSnapshotEntry(entry: JournalEntry): void { + if (!entry || entry.clientId !== "aside" || typeof entry.opId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(entry.opId) || !entry.snapshot + || !["none", "stored", "expired"].includes(entry.snapshot.kind) + || (entry.snapshot.kind === "stored" && entry.snapshot.relPath !== join("snapshots", "aside", entry.opId))) { + throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation snapshot metadata is invalid"); + } +} + +export function createAsideProfileContext(input: AsideProfilesInput): AsideProfileContext { + let profiles: AsideProfile[]; + try { profiles = listAsideProfiles(input.env, input.home); } + catch (error) { + if (error instanceof ClientPathError) throw new AsideProfileError("aside_profiles_unavailable", 409, error.message); + throw new AsideProfileError("aside_profiles_unavailable", 409, "Aside profiles cannot be read"); + } + const rootStore = asideRootStore(input); + const record = rootStore.readRecords().aside; + const matched = record?.clientId === "aside" ? profiles.find(profile => profile.configPath === record.configPath) : undefined; + const pinned = input.config.asideProfileSync?.legacyProfileId; + const newest = !record && pinned === undefined ? rootStore.listOperations("aside", 1)[0] : undefined; + const legacyProfileId = pinned !== undefined ? pinned : record + ? matched?.id ?? null + : profiles.find(profile => profile.configPath === newest?.configPath)?.id ?? null; + let loaded: Promise | undefined; + return { + input: { ...input, env: { ...(input.env ?? process.env) } }, profiles, rootStore, legacyProfileId, scopes: new Map(), + defaultEnabled: input.config.asideProfileSync?.allProfiles ?? Boolean(matched), + models: () => loaded ??= Promise.resolve().then(() => typeof input.models === "function" ? input.models() : input.models), + }; +} + +export function selectAsideProfiles(ctx: AsideProfileContext, profileId?: number): AsideProfile[] { + if (profileId === undefined) return ctx.profiles; + if (!Number.isSafeInteger(profileId) || profileId < 0 || Object.is(profileId, -0)) { + throw new AsideProfileError("invalid_aside_profile", 400, "Aside profile must be a nonnegative safe integer"); + } + const profile = ctx.profiles.find(candidate => candidate.id === profileId); + if (!profile) throw new AsideProfileError("aside_profile_not_found", 404, "Aside profile is not registered"); + return [profile]; +} + +export function asideProfileEnabled(ctx: AsideProfileContext, id: number): boolean { + return ctx.input.config.asideProfileSync?.profiles?.[String(id)] ?? ctx.defaultEnabled; +} + +export function asideProfileScope(ctx: AsideProfileContext, profile: AsideProfile): AsideProfileScope { + try { return resolveScope(ctx, profile); } + catch (error) { + if (error instanceof ClientPathError) throw new AsideProfileError("aside_profile_unsafe", 409, error.message); + throw error; + } +} + +function resolveScope(ctx: AsideProfileContext, profile: AsideProfile): AsideProfileScope { + const cached = ctx.scopes.get(profile.id); + if (cached) { cached.assertBoundary(); return cached; } + assertAsideProfileBoundary(profile, ctx.profiles); + const store = profile.id === ctx.legacyProfileId ? ctx.rootStore + : guardedStore(createIntegrationStateStore(join(ctx.rootStore.root, "aside-profiles", String(profile.id))), ctx.rootStore.root); + const assertBoundary = () => { + assertAsideProfileBoundary(profile, ctx.profiles); + const record = store.readRecords().aside; + if (record && (record.clientId !== "aside" || record.configPath !== profile.configPath)) { + throw new AsideProfileError("aside_profile_owner_mismatch", 409, "Aside ownership belongs to a different profile"); + } + }; + assertBoundary(); + const baseIO = ctx.input.io ?? store.io(); + const guarded = guardAsideProfileIO(profile, { + ...baseIO, + appendJournal: entry => store.appendJournal(entry), + putRecord: record => store.putRecord(record), + dropRecord: clientId => store.dropRecord(clientId), + }, ctx.profiles); + const io: IntegrationIO = { + ...guarded, + writeText: (path, text) => { assertBoundary(); guarded.writeText(path, text); }, + removeFile: path => { assertBoundary(); guarded.removeFile(path); }, + mkdirp: path => { assertBoundary(); guarded.mkdirp(path); }, + }; + const scope = { profile, store, io, assertBoundary }; + ctx.scopes.set(profile.id, scope); + return scope; +} + +export async function asideWriteInput(ctx: AsideProfileContext, scope: AsideProfileScope): Promise { + const models = await ctx.models(); + scope.assertBoundary(); + return { + clientId: "aside", config: ctx.input.config, models, port: ctx.input.port, + env: ctx.input.env, home: ctx.input.home, store: scope.store, io: scope.io, + resolvedPaths: { configPath: scope.profile.configPath, detectDir: scope.profile.detectDir }, + }; +} + +/** Save intent first; an unsuccessful save must not leave even in-memory intent changed. */ +export async function persistAsidePolicy(ctx: AsideProfileContext, change?: { enabled: boolean; profileId?: number }): Promise { + const { config, persistConfig } = ctx.input; + if (!persistConfig) throw new AsideProfileError("aside_profile_persistence_required", 500, "Aside profile changes require configuration persistence"); + const previous = config.asideProfileSync; + const policy: AsideProfilePolicy = { + ...previous, allProfiles: ctx.defaultEnabled, legacyProfileId: ctx.legacyProfileId, + profiles: { ...previous?.profiles }, + }; + if (change && change.profileId === undefined) { + policy.allProfiles = change.enabled; + policy.profiles = {}; + } else if (change) policy.profiles![String(change.profileId)] = change.enabled; + config.asideProfileSync = policy; + try { await persistConfig(config); } + catch { + if (previous === undefined) delete config.asideProfileSync; + else config.asideProfileSync = previous; + throw new AsideProfileError("aside_profile_persist_failed", 500, "Aside profile preferences could not be saved; no profile files were changed"); + } + ctx.defaultEnabled = policy.allProfiles!; +} + +export async function runAsideProfileAction( + input: AsideProfilesInput, + profileId: number | undefined, + semantics: string, + action: (ctx: AsideProfileContext, profiles: AsideProfile[]) => Promise, +): Promise { + const ctx = createAsideProfileContext(input); + const profiles = selectAsideProfiles(ctx, profileId); + const key = `aside:${fingerprint(`${ctx.rootStore.root}:${profiles.map(p => p.root).join(",")}`)}:${profiles.map(p => p.id).sort((a, b) => a - b).join(",")}:${semantics}:${crypto.randomUUID()}`; + try { + return await runIntegrationMutationFlight("aside", key, input.io?.now ?? Date.now, async () => { + // Publish the flight before invoking user-supplied persistence callbacks. + await Promise.resolve(); + return action(ctx, profiles); + }); + } catch (error) { + if (error instanceof IntegrationMutationBusyError) { + throw new AsideProfileError("integration_mutation_busy", 409, "An Aside profile operation is already running"); + } + if (error instanceof AsideProfileError) throw error; + if (error instanceof ClientPathError) throw new AsideProfileError("aside_profile_unsafe", 409, error.message); + throw new AsideProfileError("aside_profile_operation_failed", 500, "Aside profile operation could not be completed"); + } +} + +export function asideProfileFailure(profileId: number, error: unknown): AsideProfileWriteOutcome { + return { + clientId: "aside", profileId, ok: false, reason: "unsafe", state: "unsafe", + message: error instanceof AsideProfileError || error instanceof ClientPathError + ? error.message : "Aside profile could not be updated safely", + }; +} diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts new file mode 100644 index 0000000000..5c31024332 --- /dev/null +++ b/src/integrations/aside-profile-journal.ts @@ -0,0 +1,215 @@ +import { EXPORT_CLIENTS } from "../clients/config-export"; +import { loadTarget, parseConfig } from "./config-io"; +import { matchesOperationResult, type JournalEntry } from "./journal"; +import { fingerprint, type OwnershipRecord } from "./ownership"; +import { classifyIntegration, exportContextOf } from "./state"; +import type { IntegrationStateStore } from "./store"; +import { restoreIntegrationCoordinated, type IntegrationWriteInput } from "./writer"; +import { + AsideProfileError, asideProfileFailure, asideProfileScope, asideRootStore, asideWriteInput, assertAsideSnapshotEntry, + createAsideProfileContext, persistAsidePolicy, runAsideProfileAction, selectAsideProfiles, + type AsideProfileContext, type AsideProfilesInput, type AsideProfileScope, type AsideProfileWriteOutcome, +} from "./aside-profile-context"; + +export interface AsideOperation { + profileId: number; + entry: JournalEntry; + store: IntegrationStateStore; +} + +function operationRows(ctx: AsideProfileContext, profileId?: number): AsideOperation[] { + const rows: AsideOperation[] = []; + for (const profile of selectAsideProfiles(ctx, profileId)) { + const scope = asideProfileScope(ctx, profile); + const stores = scope.store.root === ctx.rootStore.root ? [scope.store] : [scope.store, ctx.rootStore]; + for (const store of stores) { + for (const entry of store.listOperations("aside", Number.MAX_SAFE_INTEGER)) { + if (entry.clientId === "aside" && entry.configPath === profile.configPath) { + assertAsideSnapshotEntry(entry); + if (typeof entry.at !== "string") throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation timestamp is invalid"); + rows.push({ profileId: profile.id, entry, store }); + } + } + } + } + // A copied entry retains its original timestamp; import time cannot make it newest. + return rows.sort((a, b) => b.entry.at.localeCompare(a.entry.at)); +} + +function uniqueOperations(rows: AsideOperation[]): AsideOperation[] { + const seen = new Map(); + for (const row of rows) { + const previous = seen.get(row.entry.opId); + if (previous && (previous.profileId !== row.profileId || JSON.stringify(previous.entry) !== JSON.stringify(row.entry))) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation identifies multiple profiles"); + } + if (!previous) seen.set(row.entry.opId, row); + } + return [...seen.values()]; +} + +export function listAsideOperations(input: AsideProfilesInput, profileId?: number): AsideOperation[] { + try { return uniqueOperations(operationRows(createAsideProfileContext(input), profileId)); } + catch (error) { + if (profileId === undefined && error instanceof AsideProfileError && error.code === "aside_profiles_unavailable") return []; + throw error; + } +} + +function findOperation(ctx: AsideProfileContext, opId: string, profileId?: number): AsideOperation | null { + if (typeof opId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(opId)) { + throw new AsideProfileError("invalid_op_id", 400, "Aside operation ID is invalid"); + } + const rows = uniqueOperations(operationRows(ctx, profileId)); + const found = rows.find(row => row.entry.opId === opId); + if (found) return found; + const legacy = ctx.rootStore.findOperation(opId); + if (legacy?.clientId === "aside") { + if (!ctx.profiles.some(profile => profile.configPath === legacy.configPath)) { + throw new AsideProfileError("aside_profile_not_found", 404, "The operation's Aside profile is no longer registered"); + } + if (profileId !== undefined) throw new AsideProfileError("aside_operation_profile_mismatch", 409, "Aside operation belongs to a different profile"); + } + return null; +} + +export function findAsideOperation(input: AsideProfilesInput, opId: string, profileId?: number): AsideOperation | null { + const legacy = asideRootStore(input).findOperation(opId); + if (profileId === undefined && legacy && legacy.clientId !== "aside") return null; + try { return findOperation(createAsideProfileContext(input), opId, profileId); } + catch (error) { + if (profileId === undefined && !legacy && error instanceof AsideProfileError && error.code === "aside_profiles_unavailable") return null; + throw error; + } +} + +/** Guarded history projection: never expose profile bytes to API serializers. */ +export function asideOperationMatchesCurrent(input: AsideProfilesInput, row: AsideOperation): boolean { + try { + const ctx = createAsideProfileContext(input); + const verified = findOperation(ctx, row.entry.opId, row.profileId); + if (!verified || verified.entry.configPath !== row.entry.configPath) return false; + const profile = selectAsideProfiles(ctx, row.profileId)[0]!; + const scope = asideProfileScope(ctx, profile); + const target = loadTarget(scope.io, profile.configPath); + return target.ok && scope.io.statKind(profile.detectDir) === "dir" && matchesOperationResult(verified.entry, target.before); + } catch { return false; } +} + +function requiredOperation(ctx: AsideProfileContext, opId: string, profileId?: number): AsideOperation { + const row = findOperation(ctx, opId, profileId); + if (!row) throw new AsideProfileError("integration_operation_not_found", 404, "Aside operation not found"); + return row; +} + +function validatePriorRecord(record: OwnershipRecord | null, configPath: string): void { + if (record === null) return; + if (!record || record.clientId !== "aside" || record.configPath !== configPath + || typeof record.fileFingerprint !== "string" || typeof record.blockFingerprint !== "string" + || typeof record.opId !== "string" || typeof record.appliedAt !== "string" + || !Array.isArray(record.fragmentPaths) || record.fragmentPaths.length !== 1 + || record.fragmentPaths[0]?.length !== 2 || record.fragmentPaths[0][0] !== "providers" + || record.fragmentPaths[0][1] !== "opencodex" + || (record.createdContainers !== undefined && (!Array.isArray(record.createdContainers) + || !record.createdContainers.every(path => path === "providers")))) { + throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation ownership metadata is invalid"); + } +} + +function snapshotWasOwned(entry: JournalEntry, text: string | null, bound: IntegrationWriteInput): boolean { + const record = entry.priorRecord; + if (!record || text === null || record.fileFingerprint !== fingerprint(text)) return false; + const state = classifyIntegration({ + fileText: text, fileIsRegular: true, parsed: parseConfig(text, "json"), record, + contribution: EXPORT_CLIENTS.aside.buildContribution(exportContextOf(bound)), + configPath: entry.configPath, clientId: "aside", + }).state; + return state === "current" || state === "stale"; +} + +/** Import only an immutable historical row and its bytes, never the legacy ownership record. */ +function importOperation(row: AsideOperation, scope: AsideProfileScope): void { + if (row.store.root === scope.store.root) return; + const existing = scope.store.findOperation(row.entry.opId); + if (existing) { + if (JSON.stringify(existing) !== JSON.stringify(row.entry)) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation conflicts with existing profile history"); + } + return; + } + const snapshot = row.store.readSnapshot(row.entry); + if (snapshot.kind === "expired") throw new AsideProfileError("integration_snapshot_expired", 410, "That backup has expired"); + scope.io.statKind(scope.profile.detectDir); + scope.assertBoundary(); + if (snapshot.kind === "stored") { + const present = scope.store.readSnapshot(row.entry); + if (present.kind === "stored" && present.text !== snapshot.text) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside snapshot conflicts with existing profile history"); + } + if (present.kind !== "stored") scope.store.captureSnapshot("aside", row.entry.opId, snapshot.text); + } + scope.store.appendJournal(structuredClone(row.entry)); +} + +export function restoreAsideProfile( + input: AsideProfilesInput, + request: { opId: string; profileId?: number; confirmDrift?: boolean }, +): Promise { + return runAsideProfileAction(input, request.profileId, `restore:${request.opId}:${Boolean(request.confirmDrift)}`, async ctx => { + const row = requiredOperation(ctx, request.opId, request.profileId); + const profile = selectAsideProfiles(ctx, row.profileId)[0]!; + const scope = asideProfileScope(ctx, profile); + assertAsideSnapshotEntry(row.entry); + validatePriorRecord(row.entry.priorRecord, profile.configPath); + const snapshot = row.store.readSnapshot(row.entry); + if (snapshot.kind === "expired") { + return { clientId: "aside", profileId: profile.id, ok: false, reason: "snapshot_expired", state: "absent", message: "That backup has expired" }; + } + const bound = await asideWriteInput(ctx, scope); + const target = loadTarget(scope.io, profile.configPath); + if (!target.ok || scope.io.statKind(profile.detectDir) !== "dir") { + return asideProfileFailure(profile.id, new AsideProfileError("aside_profile_unsafe", 409, "Aside profile cannot be restored safely")); + } + if (!request.confirmDrift && !matchesOperationResult(row.entry, target.before)) { + return { clientId: "aside", profileId: profile.id, ok: false, reason: "drift_requires_confirm", state: "conflict", message: "This profile changed after that operation; confirm to replace it" }; + } + const restoredText = snapshot.kind === "stored" ? snapshot.text : null; + await persistAsidePolicy(ctx, { profileId: profile.id, enabled: snapshotWasOwned(row.entry, restoredText, bound) }); + try { + scope.assertBoundary(); + const currentSnapshot = row.store.readSnapshot(row.entry); + if (JSON.stringify(row.store.findOperation(row.entry.opId)) !== JSON.stringify(row.entry) + || currentSnapshot.kind !== snapshot.kind + || (currentSnapshot.kind === "stored" && currentSnapshot.text !== restoredText)) { + throw new AsideProfileError("aside_operation_changed", 409, "Aside operation or snapshot changed while saving preferences"); + } + importOperation(row, scope); + return { ...await restoreIntegrationCoordinated({ ...bound, opId: request.opId, confirmDrift: request.confirmDrift }, { lockSeams: input.lockSeams }), profileId: profile.id }; + } catch (error) { return asideProfileFailure(profile.id, error); } + }); +} + +export function deleteAsideOperation( + input: AsideProfilesInput, + request: { opId: string; profileId?: number; principal?: string }, +): Promise<{ ok: true; clientId: "aside"; profileId: number; opId: string; snapshotRemoved: boolean }> { + return runAsideProfileAction(input, request.profileId, `delete:${request.opId}`, async ctx => { + const row = requiredOperation(ctx, request.opId, request.profileId); + const rows = operationRows(ctx, row.profileId); + if (rows[0]?.entry.opId === request.opId) { + throw new AsideProfileError("integration_journal_newest_protected", 409, "The newest operation for an Aside profile cannot be deleted"); + } + await persistAsidePolicy(ctx); + const stores = new Map(rows.filter(candidate => candidate.entry.opId === request.opId).map(candidate => [candidate.store.root, candidate.store])); + const tombstone = { tombstone: request.opId, at: new Date().toISOString(), by: request.principal ?? "management" }; + // Retire every copy before pruning any snapshot; deduped history must not resurrect a source row. + for (const store of stores.values()) store.retireOperation(tombstone); + let snapshotRemoved = true; + for (const store of stores.values()) { + const pruned = store.pruneSnapshots("aside"); + if (pruned.ok) store.clearPruneFailure("aside"); + else { snapshotRemoved = false; store.markPruneFailure("aside", pruned.error); } + } + return { ok: true, clientId: "aside", profileId: row.profileId, opId: request.opId, snapshotRemoved }; + }); +} diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts new file mode 100644 index 0000000000..bdea5a5cde --- /dev/null +++ b/src/integrations/aside-profiles.ts @@ -0,0 +1,166 @@ +import type { AsideProfile } from "../clients/aside-profiles"; +import { asideHomeDir } from "../clients/config-export"; +import { join } from "node:path"; +import type { OwnedIntegrationRefreshOutcome } from "./owned-refresh"; +import { readIntegrationState, type IntegrationState, type IntegrationStatus } from "./state"; +import { + applyIntegrationCoordinated, disableIntegrationCoordinated, + overwriteIntegrationCoordinated, refreshIntegrationCoordinated, +} from "./writer"; +import { + asideProfileEnabled, asideProfileFailure, asideProfileScope, asideWriteInput, + asideRootStore, createAsideProfileContext, persistAsidePolicy, runAsideProfileAction, selectAsideProfiles, + type AsideProfileContext, type AsideProfilesInput, type AsideProfileWriteOutcome, +} from "./aside-profile-context"; + +export { AsideProfileError } from "./aside-profile-context"; +export type { AsideProfilesInput, AsideProfileWriteOutcome } from "./aside-profile-context"; + +export interface AsideProfileState extends IntegrationStatus { + profileId: number; + name?: string; + current: boolean; + enabled: boolean; + error?: string; +} + +export interface AsideProfileList extends IntegrationStatus { + profiles: AsideProfileState[]; + allEnabled: boolean; + enabledCount: number; + appliedCount: number; + total: number; + error?: string; +} + +export interface AsideProfileMutationResult { + ok: boolean; + clientId: "aside"; + changed: boolean; + state: IntegrationState; + message: string; + results: AsideProfileWriteOutcome[]; + /** Preserve the ordinary refusal serializer for a single selected profile. */ + result?: AsideProfileWriteOutcome; +} + +function aggregateState(states: readonly IntegrationState[]): IntegrationState { + if (states.includes("unsafe")) return "unsafe"; + if (states.includes("conflict")) return "conflict"; + if (states.every(state => state === "absent")) return "absent"; + return states.every(state => state === "current") ? "current" : "stale"; +} + +async function profileState(ctx: AsideProfileContext, profile: AsideProfile): Promise { + const metadata = { + profileId: profile.id, ...(profile.name !== undefined ? { name: profile.name } : {}), + current: profile.current, enabled: asideProfileEnabled(ctx, profile.id), + }; + try { + const scope = asideProfileScope(ctx, profile); + const input = await asideWriteInput(ctx, scope); + return { ...readIntegrationState(input), ...metadata }; + } catch (error) { + return { + clientId: "aside", ...metadata, state: "unsafe", installed: false, + configPath: profile.configPath, reason: "unresolvable-path", snapshotCount: -1, + retentionDegraded: true, error: asideProfileFailure(profile.id, error).message, + }; + } +} + +export async function listAsideProfileStates(input: AsideProfilesInput): Promise { + let ctx: AsideProfileContext; + try { ctx = createAsideProfileContext(input); } + catch (error) { + return { + clientId: "aside", profiles: [], total: 0, enabledCount: 0, appliedCount: 0, allEnabled: false, + state: "unsafe", installed: false, configPath: join(asideHomeDir(input.env, input.home), "u"), + snapshotCount: -1, retentionDegraded: true, reason: "unresolvable-path", + error: asideProfileFailure(0, error).message, + }; + } + const profiles: AsideProfileState[] = []; + for (const profile of ctx.profiles) profiles.push(await profileState(ctx, profile)); + const enabledCount = profiles.filter(profile => profile.enabled).length; + const snapshotCount = profiles.some(profile => profile.snapshotCount < 0) ? -1 + : profiles.reduce((sum, profile) => sum + profile.snapshotCount, 0); + return { + clientId: "aside", profiles, total: profiles.length, enabledCount, + allEnabled: profiles.length > 0 && enabledCount === profiles.length, + appliedCount: profiles.filter(profile => profile.state === "current" || profile.state === "stale").length, + state: aggregateState(profiles.map(profile => profile.state)), + installed: profiles.some(profile => profile.installed), + configPath: profiles.find(profile => profile.current)?.configPath ?? profiles[0]?.configPath ?? "", + snapshotCount, retentionDegraded: profiles.some(profile => profile.retentionDegraded), + }; +} + +export async function getAsideProfileState(input: AsideProfilesInput, id: number): Promise { + const ctx = createAsideProfileContext(input); + return profileState(ctx, selectAsideProfiles(ctx, id)[0]!); +} + +export function mutateAsideProfiles( + input: AsideProfilesInput, + change: { enabled: boolean; profileId?: number; overwriteConflict?: boolean }, +): Promise { + return runAsideProfileAction(input, change.profileId, `${change.enabled ? "enable" : "disable"}:${Boolean(change.overwriteConflict)}`, async (ctx, profiles) => { + const refused = new Map(); + for (const profile of profiles) { + try { asideProfileScope(ctx, profile); } + catch (error) { refused.set(profile.id, asideProfileFailure(profile.id, error)); } + } + // This await precedes model loading, writer preflight, snapshots and all client writes. + await persistAsidePolicy(ctx, change); + const results: AsideProfileWriteOutcome[] = []; + for (const profile of profiles) { + const refusal = refused.get(profile.id); + if (refusal) { results.push(refusal); continue; } + try { + const scope = asideProfileScope(ctx, profile); + const bound = await asideWriteInput(ctx, scope); + const operation = !change.enabled ? disableIntegrationCoordinated + : change.overwriteConflict ? overwriteIntegrationCoordinated : applyIntegrationCoordinated; + results.push({ ...await operation(bound, { lockSeams: input.lockSeams }), profileId: profile.id }); + } catch (error) { results.push(asideProfileFailure(profile.id, error)); } + } + const ok = results.every(result => result.ok); + return { + ok, clientId: "aside", changed: results.some(result => result.ok && result.changed), + state: aggregateState(results.map(result => result.state)), + message: ok ? "Aside profile preferences applied" : "Aside preferences saved; some profiles could not be updated", + results, ...(results.length === 1 ? { result: results[0] } : {}), + }; + }); +} + +export function refreshAsideProfiles(input: AsideProfilesInput): Promise> { + const policy = input.config.asideProfileSync; + const selected = Object.values(policy?.profiles ?? {}).some(enabled => enabled === true); + if (!selected && (policy?.allProfiles === false + || (policy?.allProfiles !== true && !asideRootStore(input).readRecords().aside))) return Promise.resolve([]); + return runAsideProfileAction(input, undefined, "refresh", async (ctx, profiles) => { + const outcomes: Array = []; + for (const profile of profiles) { + if (!asideProfileEnabled(ctx, profile.id)) continue; + try { + const scope = asideProfileScope(ctx, profile); + const owned = scope.store.readRecords().aside !== undefined; + const bound = await asideWriteInput(ctx, scope); + // A surviving ownership record means a removed block stays removed. + // A newly discovered, enabled profile may receive its first safe apply. + const operation = owned ? refreshIntegrationCoordinated : applyIntegrationCoordinated; + const result = await operation(bound, { lockSeams: input.lockSeams }); + outcomes.push({ + client: "aside", profileId: profile.id, ok: result.ok, + ...(result.ok ? { changed: result.changed } : {}), + ...(!result.ok || result.state === "absent" ? { reason: result.message } : {}), + }); + } catch (error) { + outcomes.push({ client: "aside", profileId: profile.id, ok: false, reason: asideProfileFailure(profile.id, error).message }); + } + } + return outcomes; + }); +} diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 6ace895e00..0b83af9654 100644 --- a/src/integrations/catalog-refresh.ts +++ b/src/integrations/catalog-refresh.ts @@ -18,6 +18,11 @@ export async function refreshOwnedCatalogIntegrations( const outcomes: OwnedIntegrationRefreshOutcome[] = []; for (const clientId of clientIds) { try { + if (clientId === "aside") { + const { refreshAsideProfiles } = await import("./aside-profiles"); + outcomes.push(...await refreshAsideProfiles({ ...input, models: loadModels })); + continue; + } const result = await refreshOwnedIntegration({ ...input, clientId, models: loadModels }); if (result) outcomes.push(result); } catch (error) { diff --git a/src/integrations/owned-refresh.ts b/src/integrations/owned-refresh.ts index 6167c1aa35..8d8f04581a 100644 --- a/src/integrations/owned-refresh.ts +++ b/src/integrations/owned-refresh.ts @@ -27,6 +27,8 @@ export interface OwnedIntegrationRefreshInput { home?: string; store?: IntegrationStateStore; io?: IntegrationIO; + /** Internal profile target selected before entering the coordinated writer. */ + resolvedPaths?: { configPath: string; detectDir: string }; } export interface OwnedIntegrationRefreshOutcome { @@ -34,6 +36,7 @@ export interface OwnedIntegrationRefreshOutcome { readonly ok: boolean; readonly changed?: boolean; readonly reason?: string; + readonly profileId?: number; } /** diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 71dd93a71d..008f46fbf1 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -352,6 +352,8 @@ export interface IntegrationStateInput { /** The whole integration state store, bound to one root. */ store?: IntegrationStateStore; io?: IntegrationIO; + /** Internal explicit profile target; never accepted as a caller-provided path. */ + resolvedPaths?: { configPath: string; detectDir: string }; } export function exportContextOf(input: { @@ -431,7 +433,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS try { // One resolution for both, so a client whose paths come from mutable state // cannot report one account's install beside another account's config path. - const paths = resolveIntegrationPaths(input.clientId, input.env, input.home); + const paths = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home); configPath = paths.configPath; installed = io.statKind(paths.detectDir) === "dir"; } catch (error) { diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 4aa0944c80..23b3eaaad4 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -711,7 +711,9 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration * its manifest, so two independent calls could verify one account's install * and then write another account's catalog if a switch landed between them. */ - const resolvedPaths = resolveIntegrationPaths(input.clientId, env, home); + const resolvedPaths = input.resolvedPaths + ? { ...input.resolvedPaths } + : resolveIntegrationPaths(input.clientId, env, home); return { ...input, env, home, store, io, resolvedPaths }; } diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts new file mode 100644 index 0000000000..6e36bbe52b --- /dev/null +++ b/src/server/management/aside-profile-routes.ts @@ -0,0 +1,166 @@ +import { redactSecretString } from "../../lib/redact"; +import { ClientPathError } from "../../clients/config-export"; +import { IntegrationMutationBusyError } from "../../integrations/mutation-flight"; +import { IntegrationWriterLockBusyError } from "../../integrations/writer-lock"; +import { + getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, + type AsideProfilesInput, +} from "../../integrations/aside-profiles"; +import { + listAsideOperations, findAsideOperation, restoreAsideProfile, deleteAsideOperation, + asideOperationMatchesCurrent, +} from "../../integrations/aside-profile-journal"; +import type { WriteRefused } from "../../integrations/writer"; +import type { ManagementContext } from "./context"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import { jsonResponse } from "../auth-cors"; + +export interface AsideProfileRouteOptions { + input: () => AsideProfilesInput; + failure: (result: WriteRefused) => Response; +} + +class ProfileQueryError extends Error { readonly status = 400; readonly code = "invalid_aside_profile"; } + +function profileId(ctx: ManagementContext): number | undefined { + const raw = ctx.url.searchParams.get("profile"); + if (raw === null) return undefined; + if (!/^(0|[1-9][0-9]*)$/.test(raw) || !Number.isSafeInteger(Number(raw))) { + throw new ProfileQueryError("profile must be a nonnegative integer account ID"); + } + return Number(raw); +} + +function errorResponse(error: unknown, ctx: ManagementContext): Response { + rethrowManagementBodyTooLarge(error); + const detail = error as { status?: unknown; code?: unknown } | null; + const busy = error instanceof IntegrationMutationBusyError || error instanceof IntegrationWriterLockBusyError; + const status = busy ? 409 : typeof detail?.status === "number" && [400,404,409,410,500].includes(detail.status) + ? detail.status : error instanceof ClientPathError ? 409 : 500; + const code = busy ? "integration_mutation_busy" + : typeof detail?.code === "string" ? detail.code : "aside_profile_error"; + return jsonResponse({ + error: redactSecretString(error instanceof Error ? error.message : "Aside profile operation failed"), + code, clientId: "aside", + }, status, ctx.req, ctx.config); +} + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Own only Aside status/toggle paths; other clients keep the existing adapter. */ +export async function handleAsideProfileRoutes( + ctx: ManagementContext, options: AsideProfileRouteOptions, +): Promise { + const { req, url } = ctx; + if (url.pathname !== "/api/client-integrations/aside" && url.pathname !== "/api/client-integrations/aside/profiles") return null; + if (req.method !== "GET" && req.method !== "PUT") return null; + try { + const id = profileId(ctx); + if (url.pathname.endsWith("/profiles")) { + if (req.method !== "GET" || id !== undefined) throw new ProfileQueryError("The profile collection supports GET without a profile selector"); + return jsonResponse(await listAsideProfileStates(options.input()), 200, req, ctx.config); + } + if (req.method === "GET") { + const state = id === undefined ? await listAsideProfileStates(options.input()) : await getAsideProfileState(options.input(), id); + return jsonResponse(state, 200, req, ctx.config); + } + let body: unknown; + try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); throw new ProfileQueryError("invalid JSON body"); } + if (!isObject(body) || typeof body.enabled !== "boolean") throw new ProfileQueryError("enabled must be a boolean"); + if (body.overwriteConflict !== undefined && typeof body.overwriteConflict !== "boolean") throw new ProfileQueryError("overwriteConflict must be a boolean"); + if (body.overwriteConflict === true && !body.enabled) throw new ProfileQueryError("overwriteConflict applies only to enabling an integration"); + const batch = await mutateAsideProfiles(options.input(), { enabled: body.enabled, profileId: id, overwriteConflict: body.overwriteConflict === true }); + if (id !== undefined) { + const result = batch.results[0]; + if (!result) throw new Error("Aside profile mutation returned no result"); + return result.ok ? jsonResponse(result, 200, req, ctx.config) : options.failure(result); + } + return jsonResponse(batch, batch.ok ? 200 : 207, req, ctx.config); + } catch (error) { return errorResponse(error, ctx); } +} + +/** Profile-qualified history, including source-store provenance for imported legacy entries. */ +export async function asideJournalResponse( + ctx: ManagementContext, requestedClient: string | null, options: AsideProfileRouteOptions, +): Promise { + if (requestedClient === null && !ctx.url.searchParams.has("profile")) return null; + if (requestedClient !== null && requestedClient !== "aside") { + return ctx.url.searchParams.has("profile") ? errorResponse(new ProfileQueryError("profile applies only to Aside"), ctx) : null; + } + try { + const id = profileId(ctx); + if (id !== undefined && requestedClient !== "aside") throw new ProfileQueryError("profile requires client=aside"); + const input = options.input(); + const aside = await listAsideOperations(input, id); + const rows = [...aside].sort((a, b) => b.entry.at.localeCompare(a.entry.at)); + const newest = new Map(); + const ownerKey = (row: typeof rows[number]) => `${row.entry.clientId}:${row.profileId ?? row.entry.configPath}`; + for (const row of rows) if (!newest.has(ownerKey(row))) newest.set(ownerKey(row), row.entry.opId); + const operations = rows.map(row => { + const { entry, store } = row; + const snapshot = store.readSnapshot(entry).kind; + const latest = newest.get(ownerKey(row)) === entry.opId; + return { + opId: entry.opId, clientId: entry.clientId, kind: entry.kind, at: entry.at, + configPath: entry.configPath, snapshot, + ...(row.profileId !== undefined ? { profileId: row.profileId } : {}), + undoable: snapshot !== "expired" && latest && row.profileId !== undefined && asideOperationMatchesCurrent(input, row), + deletable: !latest, + }; + }); + return jsonResponse({ operations }, 200, ctx.req, ctx.config); + } catch (error) { + return requestedClient === null && !ctx.url.searchParams.has("profile") ? null : errorResponse(error, ctx); + } +} + +export async function asideRestoreResponse( + ctx: ManagementContext, body: { opId: string; confirmDrift?: boolean }, options: AsideProfileRouteOptions, +): Promise { + try { + const id = profileId(ctx); + const input = options.input(); + const rootEntry = input.store?.findOperation(body.opId); + if (rootEntry && rootEntry.clientId !== "aside") { + if (id !== undefined) throw new ProfileQueryError("profile applies only to Aside"); + return null; + } + const operation = await findAsideOperation(input, body.opId, id); + if (!operation) { + if (id === undefined) return null; + return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId: body.opId }, 404, ctx.req, ctx.config); + } + const result = await restoreAsideProfile(input, { ...body, profileId: operation.profileId }); + return result.ok ? jsonResponse(result, 200, ctx.req, ctx.config) : options.failure(result); + } catch (error) { + if (error instanceof ClientPathError && !ctx.url.searchParams.has("profile") + && options.input().store?.findOperation(body.opId)?.clientId !== "aside") return null; + return errorResponse(error, ctx); + } +} + +export async function asideJournalDeleteResponse( + ctx: ManagementContext, opId: string, options: AsideProfileRouteOptions, +): Promise { + try { + const id = profileId(ctx); + const input = options.input(); + const rootEntry = input.store?.findOperation(opId); + if (rootEntry && rootEntry.clientId !== "aside") { + if (id !== undefined) throw new ProfileQueryError("profile applies only to Aside"); + return null; + } + const operation = await findAsideOperation(input, opId, id); + if (!operation) { + if (id === undefined) return null; + return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId }, 404, ctx.req, ctx.config); + } + return jsonResponse(await deleteAsideOperation(input, { opId, profileId: operation.profileId, principal: ctx.principal ?? "admin-token" }), 200, ctx.req, ctx.config); + } catch (error) { + if (error instanceof ClientPathError && !ctx.url.searchParams.has("profile") + && options.input().store?.findOperation(opId)?.clientId !== "aside") return null; + return errorResponse(error, ctx); + } +} diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index cb226314ea..8103db0188 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -156,6 +156,7 @@ interface ClientIntegrationSyncOutcome { readonly ok: boolean; readonly changed?: boolean; readonly reason?: string; + readonly profileId?: number; } /** diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 0d8a5c1e55..28b4c4cadd 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -9,6 +9,12 @@ * Design of record: devlog/_fin/260802_client_toggle_api/040_wp4_management_api.md. */ import { readFileSync } from "node:fs"; +import { saveConfigPreservingClaudeCode } from "../../config"; +import { listAsideProfileStates, type AsideProfilesInput } from "../../integrations/aside-profiles"; +import { + handleAsideProfileRoutes, asideJournalResponse, asideRestoreResponse, + asideJournalDeleteResponse, type AsideProfileRouteOptions, +} from "./aside-profile-routes"; import type { IntegrationIO } from "../../integrations/config-io"; import { matchesOperationResult } from "../../integrations/journal"; import { @@ -85,6 +91,7 @@ export interface IntegrationJournalRow { * is what a user reaches for right after the mistake. */ deletable: boolean; + profileId?: number; } export interface IntegrationToggleBody { @@ -181,6 +188,23 @@ function integrationStore(): IntegrationStateStore { return integrationMutationTestHooks?.store ?? createIntegrationStateStore(); } +function asideOptions(ctx: ManagementContext): AsideProfileRouteOptions { + let input: AsideProfilesInput | undefined; + return { + input: () => input ??= { + config: ctx.config, + port: Number(ctx.url.port) || ctx.config.port, + models: () => loadExportModels(ctx.config), + store: integrationStore(), + ...pathOverrides(), + io: integrationMutationTestHooks?.io, + lockSeams: integrationMutationTestHooks?.lockSeams, + persistConfig: ctx.deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode, + }, + failure: result => writerFailureResponse("aside", result, ctx), + }; +} + async function buildIntegrationWriteInput( clientId: IntegrationClientId, ctx: ManagementContext, @@ -331,6 +355,8 @@ async function handleJournalDelete(ctx: ManagementContext): Promise { code: "invalid_op_id", }, 400, req, ctx.config); } + const aside = await asideJournalDeleteResponse(ctx, opId, asideOptions(ctx)); + if (aside) return aside; try { const store = integrationStore(); const operation = store.findOperation(opId); @@ -392,6 +418,15 @@ async function handleJournalDelete(ctx: ManagementContext): Promise { export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { const { req, url } = ctx; + const profileOptions = asideOptions(ctx); + const aside = await handleAsideProfileRoutes(ctx, profileOptions); + if (aside) return aside; + if (url.searchParams.has("profile") + && (url.pathname === "/api/client-integrations" || url.pathname.startsWith(INTEGRATION_ROUTE_PREFIX)) + && url.pathname !== "/api/client-integrations/journal" + && url.pathname !== "/api/client-integrations/restore") { + return jsonResponse({ error: "profile applies only to Aside", code: "invalid_aside_profile" }, 400, req, ctx.config); + } if (url.pathname === "/api/client-integrations" && req.method === "GET") { try { @@ -408,8 +443,13 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise - readIntegrationState({ clientId, models, config: ctx.config, port, store, ...pathOverrides() })); + const clients = await Promise.all(INTEGRATION_CLIENT_IDS.map(async clientId => { + if (clientId === "aside") { + try { return await listAsideProfileStates({ ...profileOptions.input(), models }); } + catch { /* Existing status projection retains a safe unresolved-path diagnostic. */ } + } + return readIntegrationState({ clientId, models, config: ctx.config, port, store, ...pathOverrides() }); + })); return jsonResponse({ clients } satisfies IntegrationStateListEnvelope, 200, req, ctx.config); } catch (error) { return internalErrorResponse(error, ctx); @@ -426,6 +466,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { + let operations: IntegrationJournalRow[] = storedOperations.map(operation => { /* * Resolved against the DISK, not read off the row. * @@ -481,6 +523,14 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise row.clientId !== "aside"), ...body.operations] + .sort((a, b) => b.at.localeCompare(a.at)); + } + } return jsonResponse({ operations } satisfies IntegrationJournalEnvelope, 200, req, ctx.config); } catch (error) { return internalErrorResponse(error, ctx); @@ -506,6 +556,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise; + /** Stable provenance for the one legacy root ownership record, or no root owner. */ + legacyProfileId?: number | null; + }; /** * Up to 5 Codex-facing catalog ids to feature first. Values may be bare catalog ids, * exact account-qualified "/" ids, or routed diff --git a/structure/09_client-integrations.md b/structure/09_client-integrations.md index 8b5086b32f..00dcdd82d8 100644 --- a/structure/09_client-integrations.md +++ b/structure/09_client-integrations.md @@ -148,3 +148,16 @@ fingerprint-only tests are supplementary; they cannot prove the status and write ## Remote connection lifecycle Remote clients journal and restore native integrations locally while model traffic travels directly to the hub. Catalog writes occur only after protocol negotiation and full remote schema validation. The management relay is launcher-scoped and fixed to the connection's management origin. Claude/Codex launch behavior remains integration-scoped. Key rotation uses `pendingOperation` plus `.prev`; disconnect restores locally without hub-side revocation or usage mirroring. + +## Aside profile ownership + +Aside discovery projects only registered numeric account IDs, labels and current status. Catalog +paths derive from the configured root/u/id, never from browser profilePath. Guarded filesystem +identity and IO apply to status and writes; internal resolved path pairs survive async freezing. + +`asideProfileSync` owns desired all-profile defaults and per-profile overrides. The legacy +connection defaults all profiles on; explicit per-profile changes materialize that default and +pin one legacy root owner before changing it. Sibling stores remain independent. Policy saves +precede coordinated writes under one scoped flight, and actual file state/refusals remain +separate. Restore reconciles target intent from validated snapshot ownership without changing +sibling policy. Profile journal views retain source-store provenance for older legacy entries. diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index a494c8b2e1..d0eff35077 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -920,3 +920,42 @@ describe("#2566 per-account quota in ocx account list", () => { expect(formatAccountTable([row({ quotaUnavailable: true })] as never, true)).toContain("unavailable"); }); }); + +describe("Aside profile integration CLI", () => { + test("scopes status, toggle, history and restore without changing other client routes", async () => { + const runtime = fakeRuntime(); + expect(await handleClientIntegrationCommand(["status", "--client", "aside", "--profile", "2", "--json"], runtime.deps)).toBe(0); + expect(await handleClientIntegrationCommand(["disable", "--client", "aside", "--profile", "2", "--json"], runtime.deps)).toBe(0); + expect(await handleClientIntegrationCommand(["history", "--client", "aside", "--profile", "2", "--json"], runtime.deps)).toBe(0); + expect(await handleClientIntegrationCommand(["restore", "--client", "aside", "--profile", "2", "--op", "op-profile", "--json"], runtime.deps)).toBe(0); + expect(runtime.requests.map(row => row.path)).toEqual([ + "/api/client-integrations/aside?profile=2", + "/api/client-integrations/aside?profile=2", + "/api/client-integrations/journal?client=aside&profile=2", + "/api/client-integrations/restore?client=aside&profile=2", + ]); + expect(runtime.requests[1]!.body).toEqual({ enabled: false }); + expect(runtime.requests[3]!.body).toEqual({ opId: "op-profile", confirmDrift: false }); + }); + + test.each([ + ["enable", "--client", "pi", "--profile", "0"], + ["status", "--profile", "0"], + ["enable", "--client", "aside", "--profile", "../0"], + ["disable", "--client", "aside", "--profile", "01"], + ["restore", "--client", "aside", "--op", "op-profile"], + ].map(args => ({ args })))("rejects unsupported or ambiguous profile selectors before a request: $args", async ({ args }) => { + const runtime = fakeRuntime(); + expect(await handleClientIntegrationCommand(args, runtime.deps)).toBe(2); + expect(runtime.requests).toHaveLength(0); + }); + + test("unqualified Aside enable remains bulk and reports partial failure as nonzero", async () => { + const runtime = fakeRuntime(() => ({ + ok: false, clientId: "aside", message: "one profile refused", + results: [{ profileId: 0, ok: true, message: "updated" }, { profileId: 1, ok: false, message: "conflict" }], + })); + expect(await handleClientIntegrationCommand(["enable", "--client", "aside", "--json"], runtime.deps)).toBe(1); + expect(runtime.requests[0]).toEqual({ path: "/api/client-integrations/aside", method: "PUT", body: { enabled: true } }); + }); +}); diff --git a/tests/clients/aside-profile-paths.test.ts b/tests/clients/aside-profile-paths.test.ts new file mode 100644 index 0000000000..2856189f34 --- /dev/null +++ b/tests/clients/aside-profile-paths.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, test } from "bun:test"; +import { + existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, renameSync, + rmSync, symlinkSync, writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + assertAsideProfileBoundary, guardAsideProfileIO, listAsideProfiles, +} from "../../src/clients/aside-profiles"; +import { ClientPathError } from "../../src/clients/config-export"; +import { defaultIntegrationIO } from "../../src/integrations/config-io"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const accounts = [{ id: 0, name: "Cloud" }, { id: 1, name: "Local one" }, { id: 2, name: "Local two" }]; + +function fixture(run: (home: string, root: string) => void): void { + const home = mkdtempSync(join(tmpdir(), "ocx-aside-paths-")); + const root = join(home, ".aside"); + try { + for (const { id } of accounts) mkdirSync(join(root, "u", String(id)), { recursive: true }); + manifest(root, { currentAccountId: 0, accounts }); + run(home, root); + } finally { removeTreeWithRetry(home); } +} + +function manifest(root: string, value: unknown): void { + writeFileSync(join(root, "accounts.json"), JSON.stringify(value)); +} + +function ioFor(home: string) { + const store = createIntegrationStateStore(join(home, "integration-store")); + return { store, io: defaultIntegrationIO(store) }; +} + +function directoryLink(target: string, path: string): void { + symlinkSync(target, path, process.platform === "win32" ? "junction" : "dir"); +} + +describe("Aside profile manifest", () => { + test("projects only safe metadata for cloud and local accounts", () => fixture((home, root) => { + manifest(root, { + currentAccountId: 1, + accounts: accounts.map(account => ({ + ...account, session: { token: "fixture-private-value" }, email: "fixture@example.invalid", userId: "private-user", + })), + profileAccountBindings: [{ accountId: 0, profilePath: join(home, "not-a-target") }], + }); + const profiles = listAsideProfiles({}, home); + expect(profiles).toEqual(accounts.map(account => ({ + ...account, current: account.id === 1, root, + detectDir: join(root, "u", String(account.id)), + configPath: join(root, "u", String(account.id), "models.json"), + }))); + expect(JSON.stringify(profiles)).not.toMatch(/session|token|email|userId|private-value|profilePath/); + })); + + test("supports currentAccountId-only legacy manifests without guessing zero", () => fixture((home, root) => { + manifest(root, { currentAccountId: 2 }); + expect(listAsideProfiles({}, home)).toEqual([{ + id: 2, current: true, root, detectDir: join(root, "u", "2"), configPath: join(root, "u", "2", "models.json"), + }]); + })); + + test("refuses a missing or malformed manifest with safe errors", () => fixture((home, root) => { + rmSync(join(root, "accounts.json")); + expect(() => listAsideProfiles({}, home)).toThrow(ClientPathError); + writeFileSync(join(root, "accounts.json"), '{"session":"fixture-private-value",broken'); + try { listAsideProfiles({}, home); throw new Error("expected refusal"); } catch (error) { + expect(error).toBeInstanceOf(ClientPathError); + expect((error as Error).message).not.toContain("fixture-private-value"); + } + })); + + test("rejects malformed identities, duplicates and inconsistent current metadata", () => fixture((home, root) => { + const invalid = [ + null, [], {}, { currentAccountId: "0" }, { currentAccountId: -1 }, + { currentAccountId: 0, accounts: null }, { currentAccountId: 0, accounts: [] }, + { currentAccountId: 0, accounts: [{ id: 0 }, { id: 0 }] }, + { currentAccountId: 3, accounts }, + { currentAccountId: 0, accounts: [{ id: 0, current: false }] }, + { currentAccountId: 0, accounts: [{ id: 0 }, { id: 1, current: true }] }, + ...["1", "../2", -1, 0.5, Number.MAX_SAFE_INTEGER + 1, null].map(id => ({ + currentAccountId: 0, accounts: [{ id: 0 }, { id }], + })), + ]; + for (const value of invalid) { + manifest(root, value); + expect(() => listAsideProfiles({}, home)).toThrow(ClientPathError); + } + writeFileSync(join(root, "accounts.json"), '{"currentAccountId":-0}'); + expect(() => listAsideProfiles({}, home)).toThrow(ClientPathError); + })); + + test("accepts safe integer IDs and exactly 128 accounts, but never truncates overflow", () => fixture((home, root) => { + manifest(root, { currentAccountId: Number.MAX_SAFE_INTEGER }); + expect(listAsideProfiles({}, home)[0]!.id).toBe(Number.MAX_SAFE_INTEGER); + const bounded = Array.from({ length: 128 }, (_, id) => ({ id })); + manifest(root, { currentAccountId: 0, accounts: bounded }); + expect(listAsideProfiles({}, home)).toHaveLength(128); + manifest(root, { currentAccountId: 0, accounts: [...bounded, { id: 128 }] }); + expect(() => listAsideProfiles({}, home)).toThrow(ClientPathError); + })); +}); + +describe("Aside profile filesystem boundary", () => { + test("missing account directories report not installed and cannot be recreated", () => fixture((home, root) => { + rmSync(join(root, "u", "1"), { recursive: true }); + const profiles = listAsideProfiles({}, home); + const profile = profiles[1]!; + assertAsideProfileBoundary(profile, profiles); + const guarded = guardAsideProfileIO(profile, ioFor(home).io, profiles); + expect(guarded.statKind(profile.detectDir)).toBe("missing"); + expect(guarded.readText(profile.configPath)).toEqual({ kind: "missing" }); + expect(() => assertAsideProfileBoundary(profile, profiles, true)).toThrow(ClientPathError); + expect(() => guarded.mkdirp(profile.detectDir)).toThrow(ClientPathError); + expect(() => guarded.writeText(profile.configPath, "{}")).toThrow(ClientPathError); + expect(() => guarded.removeFile(profile.configPath)).toThrow(ClientPathError); + expect(existsSync(profile.detectDir)).toBe(false); + })); + + test("allows an OS alias before the configured root", () => fixture((home, root) => { + const alias = join(home, "home-alias"); + const actual = join(home, "actual-home"); + mkdirSync(actual); + renameSync(root, join(actual, ".aside")); + directoryLink(actual, alias); + const profiles = listAsideProfiles({}, alias); + const selected = profiles[0]!; + assertAsideProfileBoundary(selected, profiles, true); + guardAsideProfileIO(selected, ioFor(home).io, profiles).writeText(selected.configPath, "{}"); + expect(readFileSync(join(actual, ".aside", "u", "0", "models.json"), "utf8")).toBe("{}"); + })); + + for (const component of ["root", "u", "account"] as const) { + test(`rejects a linked ${component} directory`, () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const path = component === "root" ? root : component === "u" ? join(root, "u") : selected.detectDir; + const moved = join(home, `moved-${component}`); + renameSync(path, moved); + directoryLink(moved, path); + expect(() => assertAsideProfileBoundary(selected, profiles)).toThrow(ClientPathError); + })); + } + + test("rejects leaf links, including dangling links", () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const target = join(root, "u", "1", "models.json"); + symlinkSync(target, selected.configPath, "file"); + expect(() => assertAsideProfileBoundary(selected, profiles)).toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(profiles[1]!, profiles)).toThrow(ClientPathError); + writeFileSync(target, "{}"); + expect(() => assertAsideProfileBoundary(selected, profiles)).toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(profiles[1]!, profiles)).toThrow(ClientPathError); + })); + + test("detects sibling directory aliases even when the selected path is safe", () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + rmSync(profiles[1]!.detectDir, { recursive: true }); + directoryLink(profiles[0]!.detectDir, profiles[1]!.detectDir); + expect(() => assertAsideProfileBoundary(profiles[0]!, profiles)).toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(profiles[0]!)).toThrow(ClientPathError); + expect(existsSync(join(root, "u", "0", "models.json"))).toBe(false); + })); + + test("rejects shared leaf inodes independently of ownership stores", () => fixture(home => { + const profiles = listAsideProfiles({}, home); + const a = profiles[0]!; + const b = profiles[1]!; + writeFileSync(a.configPath, "{}"); + linkSync(a.configPath, b.configPath); + expect(() => assertAsideProfileBoundary(a, profiles)).toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(b, [b])).toThrow(ClientPathError); + })); + + test("does not permit caller-supplied or sibling IO paths", () => fixture(home => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + expect(() => assertAsideProfileBoundary({ ...selected, configPath: profiles[1]!.configPath }, profiles)) + .toThrow(ClientPathError); + expect(() => assertAsideProfileBoundary(selected, profiles.slice(1))).toThrow(ClientPathError); + const guarded = guardAsideProfileIO(selected, ioFor(home).io, profiles); + expect(() => guarded.writeText(profiles[1]!.configPath, "{}")).toThrow(ClientPathError); + expect(() => guarded.readText(join(selected.detectDir, "settings.json"))).toThrow(ClientPathError); + expect(() => guarded.mkdirp(selected.root)).toThrow(ClientPathError); + })); + + for (const component of ["root", "u", "account"] as const) { + test(`rejects a ${component} inode replacement after guard capture`, () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const guarded = guardAsideProfileIO(selected, ioFor(home).io, profiles); + const path = component === "root" ? root : component === "u" ? join(root, "u") : selected.detectDir; + renameSync(path, join(home, `old-${component}`)); + mkdirSync(selected.detectDir, { recursive: true }); + expect(() => guarded.statKind(selected.detectDir)).toThrow(ClientPathError); + expect(() => guarded.readText(selected.configPath)).toThrow(ClientPathError); + expect(() => guarded.mkdirp(selected.detectDir)).toThrow(ClientPathError); + expect(() => guarded.writeText(selected.configPath, "{}")).toThrow(ClientPathError); + expect(() => guarded.removeFile(selected.configPath)).toThrow(ClientPathError); + expect(existsSync(selected.configPath)).toBe(false); + })); + } + + test("rechecks leaf collisions immediately before all config mutations", () => fixture(home => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const sibling = profiles[1]!; + const guarded = guardAsideProfileIO(selected, ioFor(home).io, profiles); + writeFileSync(selected.configPath, "original"); + symlinkSync(selected.configPath, sibling.configPath, "file"); + expect(() => guarded.writeText(selected.configPath, "changed")).toThrow(ClientPathError); + expect(() => guarded.removeFile(selected.configPath)).toThrow(ClientPathError); + expect(() => guarded.mkdirp(selected.detectDir)).toThrow(ClientPathError); + expect(readFileSync(selected.configPath, "utf8")).toBe("original"); + })); + + test("rejects a selected leaf replaced by a link after guard capture", () => fixture(home => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + const guarded = guardAsideProfileIO(selected, ioFor(home).io, profiles); + writeFileSync(profiles[1]!.configPath, "sibling"); + symlinkSync(profiles[1]!.configPath, selected.configPath, "file"); + expect(() => guarded.readText(selected.configPath)).toThrow(ClientPathError); + expect(() => guarded.writeText(selected.configPath, "changed")).toThrow(ClientPathError); + expect(() => guarded.removeFile(selected.configPath)).toThrow(ClientPathError); + expect(readFileSync(profiles[1]!.configPath, "utf8")).toBe("sibling"); + })); + + test("pins the chosen account across current-account switches and preserves bound IO", () => fixture((home, root) => { + const profiles = listAsideProfiles({}, home); + const selected = profiles[1]!; + const { store, io } = ioFor(home); + const receiverIO = { + ...io, + now() { expect(this).toBe(receiverIO); return 123; }, + writeText(path: string, text: string) { expect(this).toBe(receiverIO); io.writeText(path, text); }, + }; + const guarded = guardAsideProfileIO(selected, receiverIO, profiles); + manifest(root, { currentAccountId: 2, accounts }); + expect(listAsideProfiles({}, home)[2]!.current).toBe(true); + guarded.mkdirp(selected.detectDir); + guarded.writeText(selected.configPath, "first"); + guarded.writeText(selected.configPath, "second"); + expect(guarded.now()).toBe(123); + expect(readFileSync(selected.configPath, "utf8")).toBe("second"); + expect(existsSync(profiles[2]!.configPath)).toBe(false); + guarded.appendJournal({ + opId: "fixture-op", clientId: "aside", kind: "apply", at: new Date(123).toISOString(), + configPath: selected.configPath, snapshot: { kind: "none" }, resultFingerprint: "fixture-hash", + resultAbsent: false, priorRecord: null, + }); + expect(store.listOperations()).toHaveLength(1); + guarded.putRecord({ + clientId: "aside", configPath: selected.configPath, fileFingerprint: "fixture-file", + blockFingerprint: "fixture-block", fragmentPaths: [], appliedAt: new Date(123).toISOString(), opId: "fixture-op", + }); + expect(store.readRecords().aside?.configPath).toBe(selected.configPath); + guarded.dropRecord("aside"); + expect(store.readRecords().aside).toBeUndefined(); + guarded.removeFile(selected.configPath); + expect(existsSync(selected.configPath)).toBe(false); + })); +}); diff --git a/tests/clients/aside-profiles.test.ts b/tests/clients/aside-profiles.test.ts new file mode 100644 index 0000000000..11f6303a02 --- /dev/null +++ b/tests/clients/aside-profiles.test.ts @@ -0,0 +1,373 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ExportModel } from "../../src/clients/config-export"; +import { AsideProfileError, type AsideProfilesInput } from "../../src/integrations/aside-profile-context"; +import { getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, refreshAsideProfiles } from "../../src/integrations/aside-profiles"; +import { asideOperationMatchesCurrent, deleteAsideOperation, findAsideOperation, listAsideOperations, restoreAsideProfile } from "../../src/integrations/aside-profile-journal"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration } from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +describe("Aside profile desired state, ownership and history", () => { + const models: ExportModel[] = [ + { namespaced: "mock/alpha", provider: "mock", id: "alpha", contextWindow: 128_000 }, + { namespaced: "mock/beta", provider: "mock", id: "beta", contextWindow: 64_000 }, + ]; + const original = JSON.stringify({ theme: "dark", providers: { personal: { models: [{ id: "mine" }] } } }); + let root: string; + let home: string; + let store: IntegrationStateStore; + let config: OcxConfig; + let saved: OcxConfig | undefined; + let saves: number; + + function manifest(currentAccountId = 0, ids = [0, 1, 2]): void { + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId, accounts: ids.map(id => ({ id, name: `Profile ${id}` })), + })); + } + function path(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } + function input(extra: Partial = {}): AsideProfilesInput { + return { config, models, port: 10100, env: {}, home, store, + persistConfig: next => { saved = structuredClone(next); saves += 1; }, ...extra }; + } + function reload(): void { expect(saved).toBeDefined(); config = structuredClone(saved!); } + function seedLegacy(id = 0): string { + manifest(id); + const result = applyIntegration({ ...input(), models, clientId: "aside" }); + expect(result.ok).toBe(true); + manifest(); + return store.readRecords().aside!.opId; + } + function modelIds(id: number): string[] { + const doc = JSON.parse(readFileSync(path(id), "utf8")) as { providers?: { opencodex?: { models: Array<{ id: string }> } } }; + return doc.providers?.opencodex?.models.map(model => model.id) ?? []; + } + function bytes(): string[] { return [0, 1, 2].map(id => readFileSync(path(id), "utf8")); } + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-aside-profiles-")); + home = join(root, "home"); + for (const id of [0, 1, 2]) { + mkdirSync(join(home, ".aside", "u", String(id)), { recursive: true }); + writeFileSync(path(id), original); + } + manifest(); + store = createIntegrationStateStore(join(root, "state", "integrations")); + config = { port: 10100, hostname: "127.0.0.1", defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } } } as OcxConfig; + saved = undefined; + saves = 0; + }); + afterEach(() => removeTreeWithRetry(root)); + + test.each([false, true])("implicit sync stays quiet for unconfigured or disabled Aside (legacy=%s)", async legacy => { + if (legacy) { + seedLegacy(); + config.asideProfileSync = { allProfiles: false }; + } + removeTreeWithRetry(join(home, ".aside")); + let loads = 0; + expect(await refreshAsideProfiles(input({ models: async () => { loads += 1; return models; } }))).toEqual([]); + expect(loads).toBe(0); + expect(saves).toBe(0); + }); + + test("legacy connection defaults all profiles on and refresh shares one catalog load", async () => { + seedLegacy(); + expect((await listAsideProfileStates(input())).enabledCount).toBe(3); + let loads = 0; + const results = await refreshAsideProfiles(input({ models: async () => { loads += 1; return models.slice(0, 1); } })); + expect(results.map(result => [result.profileId, result.ok])).toEqual([[0, true], [1, true], [2, true]]); + expect(loads).toBe(1); + for (const id of [0, 1, 2]) expect(modelIds(id)).toEqual(["mock/alpha"]); + expect(store.readRecords().aside?.configPath).toBe(path(0)); + for (const id of [1, 2]) { + expect(createIntegrationStateStore(join(store.root, "aside-profiles", String(id))).readRecords().aside?.configPath).toBe(path(id)); + expect(JSON.parse(readFileSync(path(id), "utf8"))).toMatchObject(JSON.parse(original)); + } + expect(saves).toBe(0); + }); + + test("disabling legacy profile 0 pins its root and preserves sibling intent after reload", async () => { + seedLegacy(); + expect((await mutateAsideProfiles(input(), { profileId: 0, enabled: false })).ok).toBe(true); + expect(saved?.asideProfileSync).toEqual({ allProfiles: true, profiles: { "0": false }, legacyProfileId: 0 }); + reload(); + const results = await refreshAsideProfiles(input()); + expect(results.map(row => row.profileId)).toEqual([1, 2]); + expect(modelIds(0)).toEqual([]); + expect(modelIds(1)).toEqual(["mock/alpha", "mock/beta"]); + expect(modelIds(2)).toEqual(["mock/alpha", "mock/beta"]); + expect(store.readRecords().aside).toBeUndefined(); + expect((await getAsideProfileState(input(), 0)).enabled).toBe(false); + }); + + test("a disconnected single-profile enable leaves other profiles off", async () => { + expect((await mutateAsideProfiles(input(), { profileId: 1, enabled: true })).ok).toBe(true); + reload(); + expect(config.asideProfileSync).toEqual({ allProfiles: false, profiles: { "1": true }, legacyProfileId: null }); + expect((await refreshAsideProfiles(input())).map(row => row.profileId)).toEqual([1]); + expect(bytes()[0]).toBe(original); + expect(bytes()[2]).toBe(original); + expect((await listAsideProfileStates(input())).enabledCount).toBe(1); + }); + + test("a disconnected implicit refresh loads no catalog and creates no ownership store", async () => { + let loads = 0; + expect(await refreshAsideProfiles(input({ models: async () => { loads += 1; return models; } }))).toEqual([]); + expect(loads).toBe(0); + expect(bytes()).toEqual([original, original, original]); + expect(existsSync(store.root)).toBe(false); + }); + + test("bulk intent clears overrides without conflating desired and actual outcomes", async () => { + await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + await mutateAsideProfiles(input(), { enabled: false }); + expect(saved?.asideProfileSync).toEqual({ allProfiles: false, profiles: {}, legacyProfileId: null }); + expect((await listAsideProfileStates(input())).enabledCount).toBe(0); + expect((await mutateAsideProfiles(input(), { enabled: true })).results).toHaveLength(3); + expect((await listAsideProfileStates(input())).appliedCount).toBe(3); + }); + + test("save failure restores the original in-memory policy before any model or file work", async () => { + seedLegacy(); + const before = bytes(); + const records = store.readRecords(); + const operations = store.listOperations(); + const previous = config.asideProfileSync; + let loads = 0; + await expect(mutateAsideProfiles(input({ + persistConfig: () => { throw new Error("synthetic save failure"); }, + models: async () => { loads += 1; return models; }, + }), { enabled: false })).rejects.toMatchObject({ code: "aside_profile_persist_failed", status: 500 }); + expect(config.asideProfileSync).toBe(previous); + expect(loads).toBe(0); + expect(bytes()).toEqual(before); + expect(store.readRecords()).toEqual(records); + expect(store.listOperations()).toEqual(operations); + expect(existsSync(join(store.root, "aside-profiles"))).toBe(false); + }); + + test("foreign and malformed profiles refuse independently after desired policy is saved", async () => { + const foreign = JSON.stringify({ providers: { opencodex: { models: [{ id: "manual" }] } } }); + writeFileSync(path(1), foreign); + writeFileSync(path(2), "{broken"); + const result = await mutateAsideProfiles(input(), { enabled: true }); + expect(result.ok).toBe(false); + expect(result.results.map(row => [row.profileId, row.ok])).toEqual([[0, true], [1, false], [2, false]]); + expect(saved?.asideProfileSync?.allProfiles).toBe(true); + expect(readFileSync(path(1), "utf8")).toBe(foreign); + expect(readFileSync(path(2), "utf8")).toBe("{broken"); + expect(modelIds(0)).toEqual(["mock/alpha", "mock/beta"]); + }); + + test("implicit refresh preserves removed owned blocks and foreign edits", async () => { + await mutateAsideProfiles(input(), { enabled: true }); + writeFileSync(path(1), original); + const drifted = readFileSync(path(2), "utf8").replace("http://127.0.0.1:10100/v1", "http://user.invalid/v1"); + writeFileSync(path(2), drifted); + const result = await refreshAsideProfiles(input({ models: models.slice(0, 1) })); + expect(result[0]).toMatchObject({ profileId: 0, ok: true, changed: true }); + expect(result[1]).toMatchObject({ profileId: 1, ok: true, changed: false }); + expect(result[2]).toMatchObject({ profileId: 2, ok: false }); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect(readFileSync(path(2), "utf8")).toBe(drifted); + }); + + test("one profile IO failure does not suppress later writes", async () => { + const io = store.io(); + const result = await mutateAsideProfiles(input({ io: { ...io, writeText: (target, text) => { + if (target === path(1)) throw new Error("synthetic profile write failure"); + io.writeText(target, text); + } } }), { enabled: true }); + expect(result.results.map(row => [row.profileId, row.ok])).toEqual([[0, true], [1, false], [2, true]]); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect(modelIds(2)).toEqual(["mock/alpha", "mock/beta"]); + expect(saved?.asideProfileSync?.allProfiles).toBe(true); + }); + + test("missing account directories and aliased child stores are not created or adopted", async () => { + removeTreeWithRetry(join(home, ".aside", "u", "2")); + const children = join(store.root, "aside-profiles"); + mkdirSync(children, { recursive: true }); + const external = join(root, "external-store"); + mkdirSync(external); + symlinkSync(external, join(children, "1"), process.platform === "win32" ? "junction" : "dir"); + const result = await mutateAsideProfiles(input(), { enabled: true }); + expect(result.results.map(row => [row.profileId, row.ok])).toEqual([[0, true], [1, false], [2, false]]); + expect(existsSync(join(external, "records.json"))).toBe(false); + expect(existsSync(join(home, ".aside", "u", "2"))).toBe(false); + }); + + test("an account switch during persistence does not retarget a selected write", async () => { + const result = await mutateAsideProfiles(input({ persistConfig: next => { + saved = structuredClone(next); manifest(2); + } }), { profileId: 1, enabled: true }); + expect(result.ok).toBe(true); + expect(modelIds(1)).toEqual(["mock/alpha", "mock/beta"]); + expect(readFileSync(path(0), "utf8")).toBe(original); + expect(readFileSync(path(2), "utf8")).toBe(original); + }); + + test("enable then Undo stays off through reload and sync", async () => { + const enabled = await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + const enabledResult = enabled.results[0]!; + if (!enabledResult.ok) throw new Error("fixture enable failed"); + const opId = enabledResult.opId!; + const row = findAsideOperation(input(), opId, 1)!; + expect(asideOperationMatchesCurrent(input(), row)).toBe(true); + expect((await restoreAsideProfile(input(), { opId, profileId: 1 })).ok).toBe(true); + reload(); + expect(await refreshAsideProfiles(input())).toEqual([]); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect((await getAsideProfileState(input(), 1)).enabled).toBe(false); + }); + + test("disable then Undo restores target intent without changing sibling overrides", async () => { + seedLegacy(); + await mutateAsideProfiles(input(), { profileId: 2, enabled: false }); + const disabled = await mutateAsideProfiles(input(), { profileId: 0, enabled: false }); + const result = disabled.results[0]!; + expect(result.ok).toBe(true); + if (!result.ok) return; + expect((await restoreAsideProfile(input(), { opId: result.opId!, profileId: 0 })).ok).toBe(true); + reload(); + expect(config.asideProfileSync).toEqual({ allProfiles: true, legacyProfileId: 0, profiles: { "0": true, "2": false } }); + expect((await refreshAsideProfiles(input())).map(row => row.profileId)).toEqual([0, 1]); + expect(modelIds(0)).toEqual(["mock/alpha", "mock/beta"]); + expect(readFileSync(path(2), "utf8")).toBe(original); + }); + + test("Undo of explicit overwrite restores a foreign block and leaves its profile off", async () => { + const foreign = JSON.stringify({ providers: { opencodex: { models: [{ id: "user-owned" }] } } }); + writeFileSync(path(1), foreign); + const overwritten = await mutateAsideProfiles(input(), { profileId: 1, enabled: true, overwriteConflict: true }); + const result = overwritten.results[0]!; + if (!result.ok) throw new Error("fixture overwrite failed"); + expect((await restoreAsideProfile(input(), { opId: result.opId!, profileId: 1 })).ok).toBe(true); + reload(); + expect(await refreshAsideProfiles(input())).toEqual([]); + expect(readFileSync(path(1), "utf8")).toBe(foreign); + expect((await getAsideProfileState(input(), 1)).enabled).toBe(false); + }); + + test("restore preflight refuses drift and expired snapshots before saving intent", async () => { + const enabled = await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + const result = enabled.results[0]!; + if (!result.ok) throw new Error("fixture enable failed"); + const row = findAsideOperation(input(), result.opId!, 1)!; + writeFileSync(path(1), original); + const beforeSaves = saves; + expect(asideOperationMatchesCurrent(input(), row)).toBe(false); + expect(await restoreAsideProfile(input(), { opId: result.opId!, profileId: 1 })) + .toMatchObject({ ok: false, reason: "drift_requires_confirm" }); + expect(saves).toBe(beforeSaves); + const snapshot = row.store.readSnapshot(row.entry); + if (snapshot.kind !== "stored") throw new Error("fixture snapshot missing"); + removeTreeWithRetry(snapshot.path); + expect(await restoreAsideProfile(input(), { opId: result.opId!, profileId: 1, confirmDrift: true })) + .toMatchObject({ ok: false, reason: "snapshot_expired" }); + expect(saves).toBe(beforeSaves); + }); + + test("mixed legacy history imports a sibling snapshot without clobbering the root owner", async () => { + const siblingOp = seedLegacy(1); + const ownerOp = seedLegacy(0); + const owner = store.readRecords().aside; + expect(listAsideOperations(input(), 1).map(row => row.entry.opId)).toEqual([siblingOp]); + expect(listAsideOperations(input(), 0).map(row => row.entry.opId)).toEqual([ownerOp]); + expect((await restoreAsideProfile(input(), { opId: siblingOp })).ok).toBe(true); + expect(store.readRecords().aside).toEqual(owner); + expect(store.findOperation(siblingOp)).not.toBeNull(); + const child = createIntegrationStateStore(join(store.root, "aside-profiles", "1")); + expect(child.findOperation(siblingOp)).toEqual(store.findOperation(siblingOp)); + expect(listAsideOperations(input(), 1).filter(row => row.entry.opId === siblingOp)).toHaveLength(1); + const latest = listAsideOperations(input(), 1)[0]!; + await expect(deleteAsideOperation(input(), { opId: latest.entry.opId, profileId: 1 })) + .rejects.toMatchObject({ code: "integration_journal_newest_protected", status: 409 }); + expect((await deleteAsideOperation(input(), { opId: siblingOp, profileId: 1 })).ok).toBe(true); + expect(child.findOperation(siblingOp)).toBeNull(); + expect(store.findOperation(siblingOp)).toBeNull(); + expect(store.readRecords().aside).toEqual(owner); + }); + + test("unknown profile selectors and unregistered historical targets are not retargeted", async () => { + await expect(getAsideProfileState(input(), 9)).rejects.toMatchObject({ code: "aside_profile_not_found", status: 404 }); + await expect(mutateAsideProfiles(input(), { profileId: -1, enabled: true })).rejects.toBeInstanceOf(AsideProfileError); + const opId = seedLegacy(1); + manifest(0, [0, 2]); + expect(() => findAsideOperation(input(), opId)).toThrow("no longer registered"); + expect(saves).toBe(0); + }); + + test("default status is safe when discovery fails and non-Aside history stays available", async () => { + const opId = seedLegacy(); + const aside = store.findOperation(opId)!; + store.appendJournal({ ...aside, opId: "mcode-history", clientId: "mcode" }); + writeFileSync(join(home, ".aside", "accounts.json"), "{invalid"); + const state = await listAsideProfileStates(input()); + expect(state).toMatchObject({ clientId: "aside", profiles: [], installed: false, state: "unsafe", total: 0 }); + expect(state.error).toBeDefined(); + expect(findAsideOperation(input(), "mcode-history")).toBeNull(); + expect(listAsideOperations(input())).toEqual([]); + await expect(mutateAsideProfiles(input(), { profileId: 0, enabled: true })) + .rejects.toMatchObject({ code: "aside_profiles_unavailable", status: 409 }); + expect(saves).toBe(0); + }); + + test("restore save failure leaves history, bytes and existing policy unchanged", async () => { + const enabled = await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + const result = enabled.results[0]!; + if (!result.ok) throw new Error("fixture enable failed"); + const previous = config.asideProfileSync; + const before = bytes(); + const rows = listAsideOperations(input(), 1).map(row => row.entry); + await expect(restoreAsideProfile(input({ persistConfig: async () => { throw new Error("save unavailable"); } }), { opId: result.opId! })) + .rejects.toMatchObject({ code: "aside_profile_persist_failed" }); + expect(config.asideProfileSync).toBe(previous); + expect(bytes()).toEqual(before); + expect(listAsideOperations(input(), 1).map(row => row.entry)).toEqual(rows); + }); + + test("Undo never enables policy from a snapshot whose prior owner names another profile", async () => { + seedLegacy(); + const disabled = await mutateAsideProfiles(input(), { profileId: 0, enabled: false }); + const result = disabled.results[0]!; + if (!result.ok) throw new Error("fixture disable failed"); + const entry = store.findOperation(result.opId!)!; + store.appendJournal({ ...entry, opId: "wrong-owner", snapshot: { kind: "none" }, + priorRecord: { ...entry.priorRecord!, configPath: path(1) } }); + const before = bytes(); + const beforeSaves = saves; + await expect(restoreAsideProfile(input(), { opId: "wrong-owner", profileId: 0 })) + .rejects.toMatchObject({ code: "aside_operation_invalid", status: 409 }); + expect(saves).toBe(beforeSaves); + expect(bytes()).toEqual(before); + }); + + test("one flight covers save and writes across different profile scopes", async () => { + let release!: () => void; + let observe!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { observe = resolve; }); + const first = mutateAsideProfiles(input({ persistConfig: async next => { + observe(); await gate; saved = structuredClone(next); + } }), { profileId: 0, enabled: true }); + try { + await started; + await expect(mutateAsideProfiles(input(), { profileId: 1, enabled: true })) + .rejects.toMatchObject({ code: "integration_mutation_busy", status: 409 }); + await expect(refreshAsideProfiles(input())).rejects.toMatchObject({ code: "integration_mutation_busy" }); + await expect(refreshAsideProfiles(input({ store: createIntegrationStateStore(join(root, "other-state")) }))) + .rejects.toMatchObject({ code: "integration_mutation_busy" }); + expect(bytes()).toEqual([original, original, original]); + } finally { release(); await first; } + expect(modelIds(0)).toEqual(["mock/alpha", "mock/beta"]); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect((await mutateAsideProfiles(input(), { profileId: 1, enabled: true })).ok).toBe(true); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index cfab75fefb..58a72a350b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -69,6 +69,9 @@ "artifacts-prune.test.ts": "images", "artifacts-ssrf.test.ts": "images", "aside-client.test.ts": "providers", + "aside-profiles-routes.test.ts": "server", + "aside-profiles.test.ts": "clients", + "aside-profile-paths.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", "autostart-health.test.ts": "service", diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts new file mode 100644 index 0000000000..33a63fdaea --- /dev/null +++ b/tests/server/aside-profiles-routes.test.ts @@ -0,0 +1,129 @@ +import { loadConfig } from "../../src/config"; +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks } from "../../src/server/management/integration-routes"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration } from "../../src/integrations/writer"; +import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh"; +import type { OcxConfig } from "../../src/types"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root: string; +let home: string; +let store: IntegrationStateStore; +let config: OcxConfig; +let isolation: IsolatedCodexHome; +let priorOcxHome: string | undefined; +let saved: OcxConfig | undefined; +const env: NodeJS.ProcessEnv = {}; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-aside-profile-routes-")); + home = join(root, "home"); + priorOcxHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(root, "config"); + isolation = installIsolatedCodexHome("ocx-aside-profile-codex-"); + store = createIntegrationStateStore(join(root, "store")); + mkdirSync(join(home, ".aside"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0, name: "Primary" }, { id: 1, name: "Local one" }, { id: 2, name: "Local two" }], + sessions: { private: { accessToken: "do-not-project" } }, + })); + for (const id of [0,1,2]) { + mkdirSync(join(home, ".aside", "u", String(id)), { recursive: true }); + writeFileSync(path(id), JSON.stringify({ theme: "keep", providers: { personal: { models: [] } } })); + } + config = { port: 10100, hostname: "127.0.0.1", defaultProvider: "fixture", fastRows: false, providers: { + fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["one","two"] }, + } } as OcxConfig; + saved = undefined; + setIntegrationPathTestHooks({ home, env }); + setIntegrationMutationFlightTestHooks({ store }); +}); + +afterEach(() => { + setIntegrationPathTestHooks(null); + setIntegrationMutationFlightTestHooks(null); + isolation.restore(); + if (priorOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorOcxHome; + removeTreeWithRetry(root); +}); + +function path(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } +function document(id: number) { return JSON.parse(readFileSync(path(id), "utf8")); } +async function api(pathname: string, method = "GET", body?: unknown) { + const url = new URL(`http://127.0.0.1:10100${pathname}`); + const response = await handleManagementAPI(new Request(url, { + method, headers: { Host: url.host, "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), url, config, { + saveConfigPreservingClaudeCode: value => { saved = structuredClone(value); }, + createManagementConvergeCodex: catalogConvergenceFactory(), + refreshOwnedCatalogIntegrations: input => refreshOwnedCatalogIntegrations({ ...input, store, env, home }), + }); + if (!response) throw new Error("route missing"); + return response; +} + +test("legacy connection refreshes all profiles, and an individual off survives selection refresh and reload", async () => { + expect(applyIntegration({ clientId: "aside", config, port: 10100, store, env, home, + models: [{ provider: "fixture", id: "one", namespaced: "fixture/one" }] }).ok).toBe(true); + const initial = await (await api("/api/client-integrations/aside/profiles")).json(); + expect(initial.profiles).toHaveLength(3); + expect(JSON.stringify(initial)).not.toContain("do-not-project"); + expect((await api("/api/selected-models", "PUT", { provider: "fixture", models: ["one"] })).status).toBe(200); + for (const id of [0,1,2]) { + expect(document(id).providers.opencodex.models.filter((m: { id: string }) => m.id.startsWith("fixture/")).map((m: { id: string }) => m.id)).toEqual(["fixture/one"]); + expect(document(id).theme).toBe("keep"); + expect(document(id).providers.personal).toEqual({ models: [] }); + } + expect((await api("/api/client-integrations/aside?profile=1", "PUT", { enabled: false })).status).toBe(200); + config = structuredClone(saved!); + expect((await api("/api/selected-models", "PUT", { provider: "fixture", models: ["two"] })).status).toBe(200); + expect(document(1).providers.opencodex).toBeUndefined(); + for (const id of [0,2]) expect(document(id).providers.opencodex.models.some((m: { id: string }) => m.id === "fixture/two")).toBe(true); + const state = await (await api("/api/client-integrations/aside?profile=1")).json(); + expect(state).toMatchObject({ profileId: 1, enabled: false, state: "absent" }); +}); + +test("profile history and Undo cannot recreate an undone enable on the next sync", async () => { + const enabled = await (await api("/api/client-integrations/aside?profile=2", "PUT", { enabled: true })).json(); + expect(enabled.ok).toBe(true); + const journal = await (await api("/api/client-integrations/journal?client=aside&profile=2")).json(); + expect(journal.operations[0]).toMatchObject({ profileId: 2, opId: enabled.opId, undoable: true }); + expect((await api("/api/client-integrations/restore?client=aside&profile=2", "POST", { opId: enabled.opId })).status).toBe(200); + config = structuredClone(saved!); + await api("/api/selected-models", "PUT", { provider: "fixture", models: ["one"] }); + expect(document(2).providers.opencodex).toBeUndefined(); + expect(document(0).providers.opencodex).toBeUndefined(); +}); + +test.each(["../0", "01", "-1", "9007199254740992"])("rejects invalid profile %s before file mutation", async id => { + const before = [0,1,2].map(i => readFileSync(path(i), "utf8")); + const response = await api(`/api/client-integrations/aside?profile=${encodeURIComponent(id)}`, "PUT", { enabled: true }); + expect(response.status).toBe(400); + expect([0,1,2].map(i => readFileSync(path(i), "utf8"))).toEqual(before); + expect(saved).toBeUndefined(); +}); + +test("a non-Aside client cannot silently consume a profile selector", async () => { + expect((await api("/api/client-integrations/pi?profile=0", "PUT", { enabled: true })).status).toBe(400); + expect(saved).toBeUndefined(); +}); + + +test("invalid persisted profile policy fails closed without resetting the surrounding config", () => { + const configRoot = process.env.OPENCODEX_HOME!; + mkdirSync(configRoot, { recursive: true }); + writeFileSync(join(configRoot, "config.json"), JSON.stringify({ ...config, asideProfileSync: { allProfiles: true, profiles: { "1": "off" } } })); + const loaded = loadConfig(); + expect(loaded.asideProfileSync).toEqual({ allProfiles: false }); + expect(loaded.port).toBe(10100); + expect(loaded.providers.fixture).toBeDefined(); +}); From aa46afd1e878070c21b359bc4e28e98692cd91cc Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:07:29 +0900 Subject: [PATCH 075/236] test(aside): align profile refresh outcomes and layout seeds --- scripts/test-layout/layout.json | 4 +++- src/integrations/catalog-refresh.ts | 5 ++++- tests/clients/sync-client-integrations.test.ts | 9 +++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4e57ed3311..d2a8dde5bf 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -11,7 +11,7 @@ "domains": { "providers": { "match": [ - "^(?:aside|auto|azure|baseten|chutes|cline|command|commandcode|context|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-" + "^(?:aside(?!-profile)|auto|azure|baseten|chutes|cline|command|commandcode|context|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-" ], "children": { "cursor": [ @@ -38,6 +38,7 @@ }, "server": { "match": [ + "^aside-profiles-routes", "^(?:account|alias|bounded|cancel|config\\.test\\.ts|consume|data|debug|error|errors|fetch|health|input|loopback|management|memory|outbound|owned|passive|port|ports\\.test\\.ts|proxy|relay|response|retry|server|session|sidebar|stream|v2)-" ] }, @@ -107,6 +108,7 @@ }, "clients": { "match": [ + "^aside-profile(?!s-routes)", "^(?:desktop|omp|pi|prime|remote|sync)-" ] }, diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 0b83af9654..8efb990002 100644 --- a/src/integrations/catalog-refresh.ts +++ b/src/integrations/catalog-refresh.ts @@ -26,10 +26,13 @@ export async function refreshOwnedCatalogIntegrations( const result = await refreshOwnedIntegration({ ...input, clientId, models: loadModels }); if (result) outcomes.push(result); } catch (error) { + const busy = error !== null && typeof error === "object" + && "code" in error && error.code === "integration_mutation_busy"; outcomes.push({ client: clientId, ok: false, - reason: redactSecretString(error instanceof Error ? error.message : String(error)), + reason: busy ? "integration_mutation_busy" + : redactSecretString(error instanceof Error ? error.message : String(error)), }); } } diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index e870f53b1b..b56ef490af 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -365,7 +365,7 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { loads += 1; return filteredModels; })); - expect(outcomes).toEqual(clients.map(client => ({ client, ok: true, changed: true }))); + expect(outcomes).toEqual(clients.map(client => ({ client, ok: true, changed: true, ...(client === "aside" ? { profileId: 0 } : {}) }))); expect(loads).toBe(1); for (const client of clients) { expect(document(client)).toMatchObject({ theme: "dark", providers: { personal: sibling } }); @@ -403,6 +403,7 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { writeFileSync(path, before); expect(await refreshOwnedCatalogIntegrations(input(filteredModels))).toEqual([{ client: clientId, ok: true, changed: false, + ...(clientId === "aside" ? { profileId: 0 } : {}), reason: "managed block is absent; refresh did not reconnect it", }]); expect(readFileSync(path, "utf8")).toBe(before); @@ -456,7 +457,7 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { }); expect(outcomes).toEqual([ { client: "pi", ok: false, reason: "synthetic Pi stat failure" }, - { client: "aside", ok: true, changed: true }, + { client: "aside", profileId: 0, ok: true, changed: true }, ]); expect(readFileSync(path, "utf8")).toBe(before); expect(store.readRecords().pi).toEqual(recordBefore); @@ -489,7 +490,7 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { }, [clientId]); await contended; release(); - expect(await first).toEqual([{ client: clientId, ok: true, changed: true }]); + expect(await first).toEqual([{ client: clientId, ok: true, changed: true, ...(clientId === "aside" ? { profileId: 0 } : {}) }]); expect(await second).toEqual([{ client: clientId, ok: false, reason: "integration_mutation_busy" }]); expect(document(clientId).providers.opencodex?.models.map(model => model.id)).toEqual(["mock/visible"]); expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["refresh", "apply"]); @@ -499,7 +500,7 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { setIntegrationMutationFlightTestHook(null); } expect(await refreshOwnedCatalogIntegrations(input(nextModels), [clientId])) - .toEqual([{ client: clientId, ok: true, changed: true }]); + .toEqual([{ client: clientId, ok: true, changed: true, ...(clientId === "aside" ? { profileId: 0 } : {}) }]); expect(document(clientId).providers.opencodex?.models.map(model => model.id)).toEqual(["mock/hidden"]); expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["refresh", "refresh", "apply"]); }); From 7ff811ced56ead2cf308a97948859617a784aec1 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:08:22 +0900 Subject: [PATCH 076/236] test(responses): keep replay credentials stable across clock boundaries --- .../260906_a_replay_credentials/000_plan.md | 3 + .../010_replay_plan.md | 9 +++ .../server-agent-task-recovery-replay.test.ts | 81 +++++++++++++++---- 3 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 devlog/_plan/260906_a_replay_credentials/000_plan.md create mode 100644 devlog/_plan/260906_a_replay_credentials/010_replay_plan.md diff --git a/devlog/_plan/260906_a_replay_credentials/000_plan.md b/devlog/_plan/260906_a_replay_credentials/000_plan.md new file mode 100644 index 0000000000..01b5f57117 --- /dev/null +++ b/devlog/_plan/260906_a_replay_credentials/000_plan.md @@ -0,0 +1,3 @@ +# Stable replay-fixture caller identity + +C2 spec-satisfaction repair of a concrete macOS control failure. Two logical replay conversations generated a new synthetic credential for each request; a second-boundary change made them different callers. Preserve production credential scope and every existing response/cache assertion. Only tests/server/server-agent-task-recovery-replay.test.ts and this numbered unit change. No local tests/typecheck/build; pinned remote Bun1.4 isolated regressions, deterministic old/new control, typecheck and current-head CI before final landing. Owner-authorized no-verify pushes/admin merge remain scoped to A. No credential or service changes. Same session goal/ledger owns this extra mandatory cycle; no completion criteria removed. diff --git a/devlog/_plan/260906_a_replay_credentials/010_replay_plan.md b/devlog/_plan/260906_a_replay_credentials/010_replay_plan.md new file mode 100644 index 0000000000..abe95054b7 --- /dev/null +++ b/devlog/_plan/260906_a_replay_credentials/010_replay_plan.md @@ -0,0 +1,9 @@ +# Replay fixture diff plan + +MODIFY tests/server/server-agent-task-recovery-replay.test.ts only: + +1. In the two original real-handler tests (cached NEW_TASK continuation and MESSAGE replay), capture one headers object before the first post and reuse it for the second. Keep status200, one recovery, two provider bodies, plaintext-present and ciphertext-absent assertions. +2. Scope a Date.now spy to each test at a real current second plus995ms. Advance controlled time by10ms between posts. Assert a newly constructed unused credential differs across that boundary, while the actual conversation continues with its original headers. Restore the clock in finally. No sleep or timeout increase. +3. Add a changed-token isolation control using the existing fakeChatGptJwt claim override: same account/envelope and two valid tokens differing in exp must not share cached plaintext. Reusing the original request still restores. Assert no extra network recovery and unchanged encrypted input on the miss. +4. Main performs exact-head remote isolated replay/cache/security tests and typecheck. A scratch red control restores per-post codexHeaders() calls while keeping the forced boundary; both conversations must lose the expected plaintext. The changed-token negative remains a pass. Restore candidate bytes after the probe. +5. Independent review checks fixture identity, clock cleanup and unchanged production boundary. Publish the own affinity branch, cascade the capability child and obtain fresh CI after all recorded verification repairs. Original source author commits remain intact. No new production file or test-layout entry. diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts index 0e96c21aaf..28050c5369 100644 --- a/tests/server/server-agent-task-recovery-replay.test.ts +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -6,7 +6,7 @@ import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../sr import { conversationIdFromResponsesRequest } from "../../src/server/request-log-conversation"; import type { OcxParsedRequest } from "../../src/types"; import { recoverEncryptedAgentTask, resetAgentTaskRecoveryState, restoreCachedEncryptedAgentTasks } from "../../src/server/responses/agent-task-recovery"; -import { codexHeaders, encryptedInput, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; +import { codexHeaders, encryptedInput, fakeChatGptJwt, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); test("replay reuses admitted recovery after a tool result without another network call", async () => { @@ -51,12 +51,22 @@ test("Responses handler restores a cached task in a continued child turn", async return providerResponse(); }) as typeof fetch; const config = routedConfig({ enabled: true }); - expect((await post(config, "xai/grok-4.5", encryptedInput(), codexHeaders())).status).toBe(200); - expect((await post(config, "xai/grok-4.5", [...encryptedInput(), { type: "message", role: "user", content: "Continue the original task." }], codexHeaders())).status).toBe(200); - expect(recoveries).toBe(1); - expect(bodies).toHaveLength(2); - expect(bodies[1]).toContain("Read nonce.txt exactly."); - expect(bodies[1]).not.toContain(FERNET_TASK); + let now = Math.floor(Date.now() / 1_000) * 1_000 + 995; + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + const headers = codexHeaders(); + expect((await post(config, "xai/grok-4.5", encryptedInput(), headers)).status).toBe(200); + now += 10; + // A freshly generated fixture JWT would be a different caller across this boundary. + expect(codexHeaders().get("authorization")).not.toBe(headers.get("authorization")); + expect((await post(config, "xai/grok-4.5", [...encryptedInput(), { type: "message", role: "user", content: "Continue the original task." }], headers)).status).toBe(200); + expect(recoveries).toBe(1); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain("Read nonce.txt exactly."); + expect(bodies[1]).not.toContain(FERNET_TASK); + } finally { + clock.mockRestore(); + } }); function encryptedMessage(): unknown[] { @@ -156,18 +166,57 @@ test("MESSAGE recovery reaches the provider and survives tool-result replay", as return providerResponse(); }) as typeof fetch; const config = routedConfig({ enabled: true }); - expect((await post(config, "xai/grok-4.5", encryptedMessage(), codexHeaders())).status).toBe(200); - expect((await post(config, "xai/grok-4.5", [...encryptedMessage(), { - type: "message", role: "user", content: "Continue after the tool result.", - }], codexHeaders())).status).toBe(200); - expect(recoveries).toBe(1); - expect(bodies).toHaveLength(2); - for (const body of bodies) { - expect(body).toContain("Stop waiting and report your result."); - expect(body).not.toContain(FERNET_TASK); + let now = Math.floor(Date.now() / 1_000) * 1_000 + 995; + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + const headers = codexHeaders(); + expect((await post(config, "xai/grok-4.5", encryptedMessage(), headers)).status).toBe(200); + now += 10; + expect(codexHeaders().get("authorization")).not.toBe(headers.get("authorization")); + expect((await post(config, "xai/grok-4.5", [...encryptedMessage(), { + type: "message", role: "user", content: "Continue after the tool result.", + }], headers)).status).toBe(200); + expect(recoveries).toBe(1); + expect(bodies).toHaveLength(2); + for (const body of bodies) { + expect(body).toContain("Stop waiting and report your result."); + expect(body).not.toContain(FERNET_TASK); + } + } finally { + clock.mockRestore(); } }); +test("a changed valid token cannot read another credential snapshot's recovery", async () => { + let recoveries = 0; + globalThis.fetch = (async () => { + recoveries++; + return new Response(recoverySse("Original caller assignment.")); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const exp = Math.floor(Date.now() / 1_000) + 3_600; + const headers = codexHeaders("acct-caller"); + headers.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp })}`); + const req = new Request("http://localhost/v1/responses", { headers }); + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config)).toBe(true); + + const changedHeaders = new Headers(headers); + changedHeaders.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp: exp + 1 })}`); + expect(changedHeaders.get("authorization")).not.toBe(headers.get("authorization")); + const changedCallerInput = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(new Request("http://localhost/v1/responses", { + headers: changedHeaders, + }), changedCallerInput, config)).toBe(0); + expect(JSON.stringify(changedCallerInput)).toContain(FERNET_TASK); + expect(JSON.stringify(changedCallerInput)).not.toContain("Original caller assignment."); + + const sameCallerInput = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(req, sameCallerInput, config)).toBe(1); + expect(JSON.stringify(sameCallerInput)).toContain("Original caller assignment."); + expect(JSON.stringify(sameCallerInput)).not.toContain(FERNET_TASK); + expect(recoveries).toBe(1); +}); + test("MESSAGE cache remains isolated by message type, account, parent and sender", async () => { let calls = 0; globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Private message.")); }) as typeof fetch; From efd20b15451aee7650ab93f25f70fc0a7902b55f Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:16:17 +0900 Subject: [PATCH 077/236] docs: plan bounded Unix probe cleanup verification --- devlog/_plan/260906_a_macos_verification/000_plan.md | 5 +++++ .../260906_a_macos_verification/010_cleanup_plan.md | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 devlog/_plan/260906_a_macos_verification/000_plan.md create mode 100644 devlog/_plan/260906_a_macos_verification/010_cleanup_plan.md diff --git a/devlog/_plan/260906_a_macos_verification/000_plan.md b/devlog/_plan/260906_a_macos_verification/000_plan.md new file mode 100644 index 0000000000..460d566fec --- /dev/null +++ b/devlog/_plan/260906_a_macos_verification/000_plan.md @@ -0,0 +1,5 @@ +# Final macOS verification repairs + +C4 spec-satisfaction repair of Unix probe cleanup classification. Consume the already reviewed replay-fixture commit7ff811ced to keep caller identity stable in the shared verification baseline. Main owns this new foundation PR below the two remaining A layers. No local suite/typecheck/build; all execution uses isolated remote Bun1.4.0 and CI. Existing GitHub/SSH identities and own branches only; no account/service/release changes. The only live processes exercised are temporary launchers created by the regression fixture. No additional termination signals or widened permissions are authorized. A2h checkpoint reassesses progress; no token/cost cap was specified. Detailed OS traces stay in ignored scratch. + +Goal: initial EPERM during an already-owned probe-group teardown does not prevent bounded observation of that group's disappearance. Success still requires an observed ESRCH. Persistent permission uncertainty or live groups continue to refuse installation and restore the launcher. Keep the existing one-second cleanup bound, one SIGKILL attempt, diagnostic sanitation and rollback guarantees. diff --git a/devlog/_plan/260906_a_macos_verification/010_cleanup_plan.md b/devlog/_plan/260906_a_macos_verification/010_cleanup_plan.md new file mode 100644 index 0000000000..403b1770e4 --- /dev/null +++ b/devlog/_plan/260906_a_macos_verification/010_cleanup_plan.md @@ -0,0 +1,11 @@ +# Diff-level cleanup plan + +1. Carry reviewed commit7ff811ced (test-only replay caller snapshots, forced second boundary and changed-token isolation) onto this dev foundation. Resolve only contextual offsets; do not introduce affinity production code or its cohort matrix. +2. MODIFY src/codex/shim.ts terminateUnixProcessGroup: retain the single initial SIGKILL. Save EPERM rather than immediately throwing it; other non-ESRCH errors still throw. Use the unchanged one-second passive signal-0 observation loop. An observed disappearance succeeds; if the group remains or cannot be observed, rethrow saved EPERM, otherwise retain the existing generic nontermination error. No new signal retry, timeout increase, cache change or test-only production export. +3. MODIFY tests/codex-integration/codex-shim.test.ts timeout rollback fixture. Keep its real native case, exact timeout message, restored launcher/no backup/no marker, native group-missing and child-dead/zombie assertions. Add scoped parent-only process.kill observation for its recorded negative PGID; unrelated calls delegate unchanged and spawned probes have independent native bindings. +4. Deterministic cases: SIGKILL throws sentinel EPERM then signal-0 EPERM→ESRCH must produce ordinary timeout refusal; persistent EPERM and continually-live signal-0 must retain fail-closed EPERM diagnostics. Assert one SIGKILL, actual passive probes, and the existing bounded runtime. Restore spies before native process cleanup proof; never count synthetic ESRCH as real cleanup. Passive bounded joining of the known fixture group is allowed for injected cases; native case retains its original immediate cleanup assertions. Finally restore environment/mocks and clean only fixture-owned paths/processes. +5. Emit bounded pid/state/error-code diagnostics on failure, with no commands, credentials or environment dumps. Actual CI EPERM is observed; the zombie-only-group explanation is a hypothesis, not claimed captured fact. +6. Remote proof: focused shim and replay/cache/security tests plus typecheck. Revert only the EPERM observation correction in remote scratch; the disappearing-group control must fail its exact diagnostic assertion. Candidate must pass transient, persistent and live controls, the native timeout integration and all original rollback checks. Restore source bytes. Independent implementation/security audit then exact-head full CI before admin landing. +7. Cascade verified foundation into affinity then capability, retain source-author commits, update PR bases before auto-deletion and reverify their current heads. No original remaining PR is closed before its change is on dev. Full current-head CI and final dev proof remain mandatory. + +Cleanup completion is not installation approval: existing timeout/recursive/descendant markers and the pre-cleanup group-survival result still refuse the launcher. The change only permits bounded absence proof before choosing the existing refusal diagnostic. No previously unsafe launcher is accepted. From af33336590647e89bbe436074161952fb5d08906 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:08:22 +0900 Subject: [PATCH 078/236] test(responses): keep replay credentials stable across clock boundaries (cherry picked from commit 7ff811ced56ead2cf308a97948859617a784aec1) --- .../260906_a_replay_credentials/000_plan.md | 3 + .../010_replay_plan.md | 9 +++ .../server-agent-task-recovery-replay.test.ts | 81 +++++++++++++++---- 3 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 devlog/_plan/260906_a_replay_credentials/000_plan.md create mode 100644 devlog/_plan/260906_a_replay_credentials/010_replay_plan.md diff --git a/devlog/_plan/260906_a_replay_credentials/000_plan.md b/devlog/_plan/260906_a_replay_credentials/000_plan.md new file mode 100644 index 0000000000..01b5f57117 --- /dev/null +++ b/devlog/_plan/260906_a_replay_credentials/000_plan.md @@ -0,0 +1,3 @@ +# Stable replay-fixture caller identity + +C2 spec-satisfaction repair of a concrete macOS control failure. Two logical replay conversations generated a new synthetic credential for each request; a second-boundary change made them different callers. Preserve production credential scope and every existing response/cache assertion. Only tests/server/server-agent-task-recovery-replay.test.ts and this numbered unit change. No local tests/typecheck/build; pinned remote Bun1.4 isolated regressions, deterministic old/new control, typecheck and current-head CI before final landing. Owner-authorized no-verify pushes/admin merge remain scoped to A. No credential or service changes. Same session goal/ledger owns this extra mandatory cycle; no completion criteria removed. diff --git a/devlog/_plan/260906_a_replay_credentials/010_replay_plan.md b/devlog/_plan/260906_a_replay_credentials/010_replay_plan.md new file mode 100644 index 0000000000..abe95054b7 --- /dev/null +++ b/devlog/_plan/260906_a_replay_credentials/010_replay_plan.md @@ -0,0 +1,9 @@ +# Replay fixture diff plan + +MODIFY tests/server/server-agent-task-recovery-replay.test.ts only: + +1. In the two original real-handler tests (cached NEW_TASK continuation and MESSAGE replay), capture one headers object before the first post and reuse it for the second. Keep status200, one recovery, two provider bodies, plaintext-present and ciphertext-absent assertions. +2. Scope a Date.now spy to each test at a real current second plus995ms. Advance controlled time by10ms between posts. Assert a newly constructed unused credential differs across that boundary, while the actual conversation continues with its original headers. Restore the clock in finally. No sleep or timeout increase. +3. Add a changed-token isolation control using the existing fakeChatGptJwt claim override: same account/envelope and two valid tokens differing in exp must not share cached plaintext. Reusing the original request still restores. Assert no extra network recovery and unchanged encrypted input on the miss. +4. Main performs exact-head remote isolated replay/cache/security tests and typecheck. A scratch red control restores per-post codexHeaders() calls while keeping the forced boundary; both conversations must lose the expected plaintext. The changed-token negative remains a pass. Restore candidate bytes after the probe. +5. Independent review checks fixture identity, clock cleanup and unchanged production boundary. Publish the own affinity branch, cascade the capability child and obtain fresh CI after all recorded verification repairs. Original source author commits remain intact. No new production file or test-layout entry. diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts index fd572e9fd8..42821ac30f 100644 --- a/tests/server/server-agent-task-recovery-replay.test.ts +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -6,7 +6,7 @@ import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../sr import { conversationIdFromResponsesRequest } from "../../src/server/request-log-conversation"; import type { OcxParsedRequest } from "../../src/types"; import { recoverEncryptedAgentTask, resetAgentTaskRecoveryState, restoreCachedEncryptedAgentTasks } from "../../src/server/responses/agent-task-recovery"; -import { codexHeaders, encryptedInput, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; +import { codexHeaders, encryptedInput, fakeChatGptJwt, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); test("replay reuses admitted recovery after a tool result without another network call", async () => { @@ -51,12 +51,22 @@ test("Responses handler restores a cached task in a continued child turn", async return providerResponse(); }) as typeof fetch; const config = routedConfig({ enabled: true }); - expect((await post(config, "xai/grok-4.5", encryptedInput(), codexHeaders())).status).toBe(200); - expect((await post(config, "xai/grok-4.5", [...encryptedInput(), { type: "message", role: "user", content: "Continue the original task." }], codexHeaders())).status).toBe(200); - expect(recoveries).toBe(1); - expect(bodies).toHaveLength(2); - expect(bodies[1]).toContain("Read nonce.txt exactly."); - expect(bodies[1]).not.toContain(FERNET_TASK); + let now = Math.floor(Date.now() / 1_000) * 1_000 + 995; + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + const headers = codexHeaders(); + expect((await post(config, "xai/grok-4.5", encryptedInput(), headers)).status).toBe(200); + now += 10; + // A freshly generated fixture JWT would be a different caller across this boundary. + expect(codexHeaders().get("authorization")).not.toBe(headers.get("authorization")); + expect((await post(config, "xai/grok-4.5", [...encryptedInput(), { type: "message", role: "user", content: "Continue the original task." }], headers)).status).toBe(200); + expect(recoveries).toBe(1); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain("Read nonce.txt exactly."); + expect(bodies[1]).not.toContain(FERNET_TASK); + } finally { + clock.mockRestore(); + } }); function encryptedMessage(): unknown[] { @@ -77,18 +87,57 @@ test("MESSAGE recovery reaches the provider and survives tool-result replay", as return providerResponse(); }) as typeof fetch; const config = routedConfig({ enabled: true }); - expect((await post(config, "xai/grok-4.5", encryptedMessage(), codexHeaders())).status).toBe(200); - expect((await post(config, "xai/grok-4.5", [...encryptedMessage(), { - type: "message", role: "user", content: "Continue after the tool result.", - }], codexHeaders())).status).toBe(200); - expect(recoveries).toBe(1); - expect(bodies).toHaveLength(2); - for (const body of bodies) { - expect(body).toContain("Stop waiting and report your result."); - expect(body).not.toContain(FERNET_TASK); + let now = Math.floor(Date.now() / 1_000) * 1_000 + 995; + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + const headers = codexHeaders(); + expect((await post(config, "xai/grok-4.5", encryptedMessage(), headers)).status).toBe(200); + now += 10; + expect(codexHeaders().get("authorization")).not.toBe(headers.get("authorization")); + expect((await post(config, "xai/grok-4.5", [...encryptedMessage(), { + type: "message", role: "user", content: "Continue after the tool result.", + }], headers)).status).toBe(200); + expect(recoveries).toBe(1); + expect(bodies).toHaveLength(2); + for (const body of bodies) { + expect(body).toContain("Stop waiting and report your result."); + expect(body).not.toContain(FERNET_TASK); + } + } finally { + clock.mockRestore(); } }); +test("a changed valid token cannot read another credential snapshot's recovery", async () => { + let recoveries = 0; + globalThis.fetch = (async () => { + recoveries++; + return new Response(recoverySse("Original caller assignment.")); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const exp = Math.floor(Date.now() / 1_000) + 3_600; + const headers = codexHeaders("acct-caller"); + headers.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp })}`); + const req = new Request("http://localhost/v1/responses", { headers }); + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config)).toBe(true); + + const changedHeaders = new Headers(headers); + changedHeaders.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp: exp + 1 })}`); + expect(changedHeaders.get("authorization")).not.toBe(headers.get("authorization")); + const changedCallerInput = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(new Request("http://localhost/v1/responses", { + headers: changedHeaders, + }), changedCallerInput, config)).toBe(0); + expect(JSON.stringify(changedCallerInput)).toContain(FERNET_TASK); + expect(JSON.stringify(changedCallerInput)).not.toContain("Original caller assignment."); + + const sameCallerInput = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(req, sameCallerInput, config)).toBe(1); + expect(JSON.stringify(sameCallerInput)).toContain("Original caller assignment."); + expect(JSON.stringify(sameCallerInput)).not.toContain(FERNET_TASK); + expect(recoveries).toBe(1); +}); + test("MESSAGE cache remains isolated by message type, account, parent and sender", async () => { let calls = 0; globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Private message.")); }) as typeof fetch; From 4954eaac07ec62da91fa59b450b036798b17718b Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:16:55 +0900 Subject: [PATCH 079/236] test(catalog): compare retained orders under identical discovery policy --- .../033_ordering_control.md | 7 ++++ .../catalog-full-picker-order.test.ts | 33 +++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/033_ordering_control.md diff --git a/devlog/_plan/260906_lane_b_catalog_stack/033_ordering_control.md b/devlog/_plan/260906_lane_b_catalog_stack/033_ordering_control.md new file mode 100644 index 0000000000..ea2d9e7110 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/033_ordering_control.md @@ -0,0 +1,7 @@ +# Matched retained-discovery control + +After the template-rank correction, the direct regression and repeated production-writer guidance cases passed remotely. Ten malformed-order cases still compared a static healthy catalog (14 rows in that snapshot) against a live/degraded catalog (36 rows). Maintained Go metadata augmentation is skipped for liveModels:false and enabled for liveModels:true, so changing that setting admitted additional rows independently of picker-order validity. + +The test now restores identical catalog/cache bytes before a valid-filtered retained control and a malformed retained run. Both use the same live/empty-model/failure settings; only picker order differs. It still compares complete model arrays, the full guidance roster and all original fixture models' exact effort ladders. Healthy valid-versus-malformed equality is retained. No registry model count is hardcoded and no production fallback behavior is changed. + +Previous failed outputs remain evidence. The revised counterfactual requires an exact-head remote rerun before a success claim. No local tests were run. diff --git a/tests/codex-integration/catalog-full-picker-order.test.ts b/tests/codex-integration/catalog-full-picker-order.test.ts index 592034ee0f..985b7760f9 100644 --- a/tests/codex-integration/catalog-full-picker-order.test.ts +++ b/tests/codex-integration/catalog-full-picker-order.test.ts @@ -1,6 +1,6 @@ import { routedSlug } from "../../src/providers/slug-codec"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig, saveConfig } from "../../src/config"; @@ -292,7 +292,7 @@ describe("picker ordering through production catalog writers", () => { expect(saved.subagentModels).toEqual(requestedRoster); expect(next.subagentModels).toEqual(requestedRoster); if (degraded) { - // No cached/static rows: the caller must preserve the catalog already on disk. + // Preserve disk rows without a discovery cache; built-in metadata augmentation still runs. clearModelCache("opencode-go"); markModelsFetchFailure("opencode-go"); } @@ -360,11 +360,30 @@ describe("picker ordering through production catalog writers", () => { expect(actual).toEqual(expected); expect(roster(actual, control.subagentModels!)).toEqual(expectedRoster); - malformed.providers["opencode-go"]!.liveModels = true; - malformed.providers["opencode-go"]!.models = []; - const retained = await writeCatalog(writer, malformed, true); - expect(retained).toEqual(expected); - expect(roster(retained, control.subagentModels!)).toEqual(expectedRoster); + const priorCatalog = readFileSync(catalogPath); + const cachePath = join(codexHome, "models_cache.json"); + const priorCache = existsSync(cachePath) ? readFileSync(cachePath) : null; + const restoreSeed = () => { + writeFileSync(catalogPath, priorCatalog); + if (priorCache === null) rmSync(cachePath, { force: true }); + else writeFileSync(cachePath, priorCache); + }; + const retainedControl = config(control.subagentModels, filtered); + const retainedMalformed = Object.assign(config(control.subagentModels), { modelPickerOrder: input }); + for (const candidate of [retainedControl, retainedMalformed]) { + candidate.providers["opencode-go"]!.liveModels = true; + candidate.providers["opencode-go"]!.models = []; + } + // Both sides activate identical Go metadata augmentation and failure/cooldown state. + // Only the malformed order differs; a static healthy catalog is not this counterfactual. + restoreSeed(); + const expectedRetained = await writeCatalog(writer, retainedControl, true); + const expectedRetainedRoster = roster(expectedRetained, control.subagentModels!); + expect(expectedRetainedRoster).toEqual(expectedRoster); + restoreSeed(); + const retained = await writeCatalog(writer, retainedMalformed, true); + expect(retained).toEqual(expectedRetained); + expect(roster(retained, control.subagentModels!)).toEqual(expectedRetainedRoster); }, 30_000); test(`${writer} applies full display order without changing five eligible Go candidates`, async () => { From 51057b61180f259e59942f77a3b081dc9658f1aa Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:17:41 +0900 Subject: [PATCH 080/236] fix(codex): observe probe group disappearance after EPERM --- src/codex/shim.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/codex/shim.ts b/src/codex/shim.ts index f53f778b12..a63c48fb7a 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -961,14 +961,20 @@ function unixProcessGroupAlive(groupId: number): boolean { } function terminateUnixProcessGroup(groupId: number): void { + let permissionError: unknown; try { process.kill(-groupId, "SIGKILL"); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + const code = (error as NodeJS.ErrnoException).code; + if (code === "EPERM") permissionError = error; + else if (code !== "ESRCH") throw error; } + // A concurrently exiting group can briefly reject a second signal. Only + // observed disappearance clears that uncertainty; never send another signal. const deadline = Date.now() + CODEX_SHIM_INSTALL_PROBE_EXIT_TIMEOUT_MS; while (Date.now() < deadline && unixProcessGroupAlive(groupId)) Bun.sleepSync(10); if (unixProcessGroupAlive(groupId)) { + if (permissionError) throw permissionError; throw new Error(`Codex shim install probe process group ${groupId} did not terminate`); } } From 95fbcbcb5fa623c1c12d8d5d21f6eb894ec7c799 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:19:08 +0900 Subject: [PATCH 081/236] test(codex): cover transient and persistent probe group EPERM --- tests/codex-integration/codex-shim.test.ts | 176 ++++++++++++++++----- 1 file changed, 136 insertions(+), 40 deletions(-) diff --git a/tests/codex-integration/codex-shim.test.ts b/tests/codex-integration/codex-shim.test.ts index f413fbdffb..406ed945bd 100644 --- a/tests/codex-integration/codex-shim.test.ts +++ b/tests/codex-integration/codex-shim.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { delimiter, dirname, join } from "node:path"; @@ -712,53 +712,149 @@ os._exit(0) }, ); - test("Unix install rolls back when launcher validation times out", () => { - if (process.platform === "win32") return; - - const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-bin-")); - const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-home-")); - const oldPath = process.env.PATH; - const oldHome = process.env.OPENCODEX_HOME; - const codexPath = join(binDir, "codex"); - const childPidPath = join(home, "probe-child.pid"); - const groupIdPath = join(home, "probe-group.pid"); - const original = `#!/bin/sh + for (const [name, mode] of [ + ["Unix install rolls back when launcher validation times out", "native"], + ["Unix timeout cleanup observes disappearance after EPERM without another signal", "disappears"], + ["Unix timeout cleanup preserves EPERM when passive probes keep failing", "permission"], + ["Unix timeout cleanup preserves EPERM when passive probes report a live group", "live"], + ] as const) { + test(name, () => { + if (process.platform === "win32") return; + + const binDir = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-bin-")); + const home = mkdtempSync(join(tmpdir(), "ocx-shim-install-timeout-home-")); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + const codexPath = join(binDir, "codex"); + const childPidPath = join(home, "probe-child.pid"); + const groupIdPath = join(home, "probe-group.pid"); + const original = `#!/bin/sh /bin/sleep 30 & child=$! printf '%s\\n' "$child" > "${childPidPath}" printf '%s\\n' "$$" > "${groupIdPath}" wait "$child" `; - try { - process.env.PATH = prependPath(binDir, oldPath); - process.env.OPENCODEX_HOME = home; - writeFileSync(codexPath, original, "utf8"); - chmodSync(codexPath, 0o755); + const nativeKill = process.kill.bind(process); + const permissionError = Object.assign(new Error("fixture termination denied"), { code: "EPERM" }); + let restoreKill: (() => void) | undefined; + let killCalls = 0; + let passiveProbes = 0; + let terminationStartedAt = 0; + let terminationElapsedMs = 0; + let childPid = 0; + let groupId = 0; + try { + process.env.PATH = prependPath(binDir, oldPath); + process.env.OPENCODEX_HOME = home; + writeFileSync(codexPath, original, "utf8"); + chmodSync(codexPath, 0o755); - const installed = installCodexShim(); + if (mode !== "native") { + const killSpy = spyOn(process, "kill").mockImplementation((pid, signal) => { + // The child writes its own group identity before the parent resumes from spawnSync. + if (groupId === 0 && existsSync(groupIdPath)) { + const recorded = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); + if (Number.isInteger(recorded) && recorded > 1) groupId = recorded; + } + if (groupId <= 1 || pid !== -groupId) return nativeKill(pid, signal); + if (signal === "SIGKILL") { + killCalls += 1; + if (killCalls === 1) terminationStartedAt = Date.now(); + throw permissionError; + } + if (signal === 0 && killCalls > 0) { + passiveProbes += 1; + if (mode === "permission" || (mode === "disappears" && passiveProbes === 1)) { + throw permissionError; + } + if (mode === "disappears") { + throw Object.assign(new Error("fixture group disappeared"), { code: "ESRCH" }); + } + return true; + } + return nativeKill(pid, signal); + }); + restoreKill = () => { killSpy.mockRestore(); }; + } + let installed: ReturnType; + try { + installed = installCodexShim(); + terminationElapsedMs = Date.now() - terminationStartedAt; + } finally { + restoreKill?.(); + restoreKill = undefined; + if (mode !== "native" && existsSync(groupIdPath)) { + groupId = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); + if (Number.isInteger(groupId) && groupId > 1) { + // Join only this fixture's real group, even when a later assertion fails. + // Synthetic ESRCH never proves cleanup; these observations use the native binding. + const deadline = Date.now() + 1_000; + while (Date.now() < deadline) { + try { nativeKill(-groupId, 0); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") break; + } + Bun.sleepSync(10); + } + } + } + } + childPid = Number.parseInt(readFileSync(childPidPath, "utf8").trim(), 10); + groupId = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); - expect(installed.installed).toBe(false); - expect(installed.message).toContain("did not finish --version within 5000ms"); - expect(installed.message).toContain("original launcher was restored"); - expect(readFileSync(codexPath, "utf8")).toBe(original); - expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); - expect(existsSync(join(home, "codex-shim.json"))).toBe(false); - const childPid = Number.parseInt(readFileSync(childPidPath, "utf8").trim(), 10); - const groupId = Number.parseInt(readFileSync(groupIdPath, "utf8").trim(), 10); - expect(Number.isInteger(childPid)).toBe(true); - expect(Number.isInteger(groupId)).toBe(true); - expectProcessGroupMissing(groupId); - const childState = processState(childPid); - expect(childState === "" || childState.startsWith("Z")).toBe(true); - } finally { - if (oldPath === undefined) delete process.env.PATH; - else process.env.PATH = oldPath; - if (oldHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = oldHome; - removeTreeWithRetry(binDir); - removeTreeWithRetry(home); - } - }, 10_000); + expect(installed.installed).toBe(false); + if (mode === "native" || mode === "disappears") { + expect(installed.message).toContain("did not finish --version within 5000ms"); + } else { + expect(installed.message).toContain("[phase=termination; code=EPERM; status=124; signal=none]"); + expect(installed.message).not.toContain("did not finish --version within 5000ms"); + expect(terminationElapsedMs).toBeGreaterThanOrEqual(1_000); + } + if (mode !== "native") { + expect(killCalls).toBe(1); + expect(passiveProbes).toBeGreaterThanOrEqual(2); + } + expect(installed.message).toContain("original launcher was restored"); + expect(readFileSync(codexPath, "utf8")).toBe(original); + expect(existsSync(`${codexPath}.opencodex-real`)).toBe(false); + expect(existsSync(join(home, "codex-shim.json"))).toBe(false); + expect(Number.isInteger(childPid)).toBe(true); + expect(Number.isInteger(groupId)).toBe(true); + expect(childPid).toBeGreaterThan(1); + expect(groupId).toBeGreaterThan(1); + expectProcessGroupMissing(groupId); + const childState = processState(childPid); + expect(childState === "" || childState.startsWith("Z")).toBe(true); + } catch (error) { + restoreKill?.(); + restoreKill = undefined; + let groupState = "unrecorded"; + if (groupId > 1) { + try { nativeKill(-groupId, 0); groupState = "present"; } + catch (probeError) { + const code = (probeError as NodeJS.ErrnoException).code; + groupState = code === "ESRCH" || code === "EPERM" ? code : "other-error"; + } + } + let childState = "unrecorded"; + if (childPid > 1) { + try { childState = processState(childPid).replace(/[^A-Za-z+<>N]/g, "").slice(0, 16) || "absent"; } + catch { childState = "unavailable"; } + } + console.error("[shim-timeout-fixture]", { mode, groupId, childPid, groupState, childState, killCalls, passiveProbes }); + throw error; + } finally { + restoreKill?.(); + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(binDir); + removeTreeWithRetry(home); + } + }, 10_000); + } test("Unix install preserves an existing backup without probing or mutation", () => { if (process.platform === "win32") return; From 3c2eb3fe3e1e8db03abd5bb8434145c8aca1dbf5 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:25:37 +0900 Subject: [PATCH 082/236] fix(aside): centralize profile mutations and preserve backup sources --- .../010_profiles_backend_cli.md | 4 + .../260906_aside_profiles/020_profiles_gui.md | 2 +- scripts/test-layout/layout.json | 1 + src/cli/aside-profiles.ts | 17 ++ src/cli/dispatch.ts | 8 +- src/cli/integrations.ts | 32 ++- src/integrations/aside-profile-journal.ts | 22 +- src/server/management/aside-profile-routes.ts | 84 +++++- src/server/management/integration-routes.ts | 5 + tests/cli/cli-headless-parity.test.ts | 10 +- .../clients/aside-profile-sync-owner.test.ts | 266 ++++++++++++++++++ tests/clients/aside-profiles.test.ts | 54 ++++ .../clients/sync-client-integrations.test.ts | 3 +- tests/fixtures/test-layout-expected.json | 1 + tests/server/aside-profiles-routes.test.ts | 31 ++ 15 files changed, 501 insertions(+), 39 deletions(-) create mode 100644 src/cli/aside-profiles.ts create mode 100644 tests/clients/aside-profile-sync-owner.test.ts diff --git a/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md index 36be9363ae..570ec2a3ea 100644 --- a/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md +++ b/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md @@ -41,3 +41,7 @@ The context owner centralizes exact scope/store resolution, desired policy, guar `bun .tmp/aside-profiles/api-cli-probe.ts` passed against an isolated live HTTP management handler and actual CLI: three-profile bulk enable, individual-off after persisted reload/model selection, Undo followed by sync, unrelated settings and metadata privacy. Default unconfigured/disabled Aside now skips implicit fan-out before manifest/catalog discovery. This C4 backend layer is larger than the default review-size guideline because the new filesystem scope, one-owner store model, reversible desired state, and API/CLI consumers must be assessed as one complete contract; these are new cohesive modules with focused fixtures, not unrelated cleanup. UI implementation remains a separate dependent PR/cycle, and the original Grok work is already four separate reviewed PRs. + +## Coordinated client interface amendment + +CLI Aside refresh runs through POST /api/client-integrations/aside/sync on the live server, never through the local file writer; MCode/Pi keep their existing paths. Add a deterministic two-process CLI/server coordination regression. Dedicated primary profile paths are /aside/profiles (GET list, PUT bulk), /aside/profiles/ (GET/PUT one), /aside/profiles//journal (GET/DELETE) and /aside/profiles//restore (POST). CLI and new UI use these paths so unsupported old servers refuse rather than ignore a profile query. The new server may retain validated query compatibility, but Aside can never fall through to a legacy generic writer. Journal source availability and request selector consistency are part of the final regression matrix; detailed review synthesis stays ignored scratch. diff --git a/devlog/_plan/260906_aside_profiles/020_profiles_gui.md b/devlog/_plan/260906_aside_profiles/020_profiles_gui.md index 7dda350656..16e5798e04 100644 --- a/devlog/_plan/260906_aside_profiles/020_profiles_gui.md +++ b/devlog/_plan/260906_aside_profiles/020_profiles_gui.md @@ -4,7 +4,7 @@ Depends on 010 verified API and CLI. Class C3 UI with C4 backend unchanged. Goal NEW gui/src/pages/integrations/AsideProfilesPage.tsx: useDataSurface GET profiles endpoint, existing Notice/Switch/ClientMark/IntegrationStateBadge. Global switch sets desired sync for all; rows show profile name or translated numeric fallback, current marker, actual state, independent switch, and details action. Single pending target serializes interactions consistently with backend. Switches read desired enabled; badges and applied/total count read actual file state. Show pending mismatch and per-profile refusal after partial failure, never optimistic applied success for siblings. A retry repeats the same desired action. Empty profile list prompts opening Aside; errors offer existing refresh action; inactive tabs do not fetch. A selected profile opens the existing FileIntegrationPage with profileId plus name and a back action; do not duplicate its rollback machinery. MODIFY gui/src/pages/Integrations.tsx: Aside renders new page; remaining file clients stay on existing page. -NEW or MODIFY integration-api.ts profile contract/types and load function; optional profileId appended to state/toggle/history/restore/delete query URLs. Preserve old call signatures for other clients. Runtime response validation must accept only safe profile IDs and recognized IntegrationStatus states, and retain partial outcomes for UI display. +NEW or MODIFY integration-api.ts profile contract/types and load function; optional profileId selects dedicated nested profile paths for state/toggle/history/restore/delete; old servers must refuse unsupported scoped mutations. Preserve old call signatures for other clients. Runtime response validation must accept only safe profile IDs and recognized IntegrationStatus states, and retain partial outcomes for UI display. MODIFY FileIntegrationPage.tsx: optional profileId/profileLabel, read optional desired enabled on scoped status, include profile in every resource/cache/dependency key and every state/history/mutation call. MODIFY RestoreDialog.tsx if needed to pass profile scope through; rollback/delete remain on selected profile. MODIFY styles-integrations.css: compact row layout using existing tokens; responsive wrapping for long labels/paths. No new color system or decorative assets. MODIFY every gui/src/i18n locale module: profile list/title, sync-all, enabled count, current profile, details/back, empty, per-profile switch labels and partial failure copy. All visible text uses t/useT; names and numeric IDs are API metadata. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d2a8dde5bf..1fcf0b33dd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -237,6 +237,7 @@ "aside-profiles-routes.test.ts": "server", "aside-profiles.test.ts": "clients", "aside-profile-paths.test.ts": "clients", + "aside-profile-sync-owner.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", "autostart-health.test.ts": "service", diff --git a/src/cli/aside-profiles.ts b/src/cli/aside-profiles.ts new file mode 100644 index 0000000000..5c16061c00 --- /dev/null +++ b/src/cli/aside-profiles.ts @@ -0,0 +1,17 @@ +import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh"; +import { runtimeRequest, RuntimeApiError, type RuntimeApiDeps } from "./runtime-api"; + +/** Aside policy and file writes share the running server's mutation owner. Never fall back locally. */ +export async function refreshAsideProfilesThroughServer( + deps: RuntimeApiDeps = {}, +): Promise { + const result = await runtimeRequest<{ results?: OwnedIntegrationRefreshOutcome[] }>( + "/api/client-integrations/aside/sync", + { method: "POST", body: "{}" }, + deps, + ); + if (!Array.isArray(result.results)) { + throw new RuntimeApiError("The running proxy does not support Aside profile synchronization", 502, result); + } + return result.results; +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 131ab7b1f6..3b690f420c 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -400,7 +400,13 @@ const commandRunners: Record = { }, config, port: live.port, - }, ["mcode", "pi", "aside"]); + }, ["mcode", "pi"]); + try { + const { refreshAsideProfilesThroughServer } = await import("./aside-profiles"); + results.push(...await refreshAsideProfilesThroughServer({ findLiveProxy: async () => live })); + } catch (error) { + console.warn(`Aside profiles were not refreshed: ${error instanceof Error ? error.message : String(error)}`); + } for (const result of results) { const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`; if (result.changed) console.log(`${label} integration refreshed from the current catalog.`); diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index e151a3c836..921326d1a3 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -35,13 +35,18 @@ const CLIENT_USAGE = `Usage: ocx integration client restore --op [--client aside --profile ] [--confirm-drift] [--json] --profile selects one Aside account-backed profile; omitted Aside toggles affect all profiles.`; -function asideProfileQuery(profile: string | undefined, client: string | undefined): string { - if (profile === undefined) return ""; +function validateAsideProfile(profile: string | undefined, client: string | undefined): void { + if (profile === undefined) return; if (client !== "aside") throw new CliUsageError("--profile requires --client aside", CLIENT_USAGE); if (!/^(0|[1-9][0-9]*)$/.test(profile) || !Number.isSafeInteger(Number(profile))) { throw new CliUsageError("--profile must be a nonnegative integer account ID", CLIENT_USAGE); } - return `profile=${encodeURIComponent(profile)}`; + +} + +function clientIntegrationPath(client: string, profile?: string): string { + const base = `/api/client-integrations/${encodeURIComponent(client)}`; + return client === "aside" ? `${base}/profiles${profile === undefined ? "" : `/${encodeURIComponent(profile)}`}` : base; } function parseMap(raw: string): Record { @@ -178,10 +183,10 @@ export async function handleClientIntegrationCommand( if (action === "status" || action === "show" || action === "list") { const client = takeOption(args, "--client"); - const profileQuery = asideProfileQuery(profile, client); + validateAsideProfile(profile, client); rejectArgs(args, CLIENT_USAGE); const path = client - ? `/api/client-integrations/${encodeURIComponent(client)}${profileQuery ? `?${profileQuery}` : ""}` + ? clientIntegrationPath(client, profile) : "/api/client-integrations"; const result = await runtimeRequest(path, {}, deps); const rows = (result as { clients?: Array> }).clients; @@ -196,10 +201,11 @@ export async function handleClientIntegrationCommand( if (action === "history" || action === "journal") { const client = takeOption(args, "--client"); - const profileQuery = asideProfileQuery(profile, client); + validateAsideProfile(profile, client); rejectArgs(args, CLIENT_USAGE); - const query = client ? `?client=${encodeURIComponent(client)}${profileQuery ? `&${profileQuery}` : ""}` : ""; - const result = await runtimeRequest(`/api/client-integrations/journal${query}`, {}, deps); + const path = client === "aside" ? `${clientIntegrationPath(client, profile)}/journal` + : `/api/client-integrations/journal${client ? `?client=${encodeURIComponent(client)}` : ""}`; + const result = await runtimeRequest(path, {}, deps); const operations = (result as { operations?: Array> }).operations ?? []; printData(result, wantsJson, operations.length === 0 ? ["No integration operations recorded yet."] @@ -217,11 +223,11 @@ export async function handleClientIntegrationCommand( const opId = takeOption(args, "--op") ?? takeOption(args, "--op-id"); const confirmDrift = takeFlag(args, "--confirm-drift"); const client = takeOption(args, "--client"); - const profileQuery = asideProfileQuery(profile, client); - if (client !== undefined && !profileQuery) throw new CliUsageError("restore --client requires --profile", CLIENT_USAGE); + validateAsideProfile(profile, client); + if (client !== undefined && profile === undefined) throw new CliUsageError("restore --client requires --profile", CLIENT_USAGE); rejectArgs(args, CLIENT_USAGE); if (!opId) throw new CliUsageError("--op is required", CLIENT_USAGE); - const result = await runtimeRequest(`/api/client-integrations/restore${profileQuery ? `?client=aside&${profileQuery}` : ""}`, { + const result = await runtimeRequest(profile === undefined ? "/api/client-integrations/restore" : `${clientIntegrationPath("aside", profile)}/restore`, { method: "POST", body: JSON.stringify({ opId, confirmDrift }), }, deps); @@ -233,7 +239,7 @@ export async function handleClientIntegrationCommand( throw new CliUsageError(`unknown client integration command ${action}`, CLIENT_USAGE); } const client = takeOption(args, "--client"); - const profileQuery = asideProfileQuery(profile, client); + validateAsideProfile(profile, client); /* * The conflict escape hatch, spelled the way `restore --confirm-drift` is: the * refusal is the default and the waiver has to be typed. @@ -254,7 +260,7 @@ export async function handleClientIntegrationCommand( if (overwriteConflict && action === "disable") { throw new CliUsageError("--overwrite-conflict applies only to enable", CLIENT_USAGE); } - const result = await runtimeRequest(`/api/client-integrations/${encodeURIComponent(client)}${profileQuery ? `?${profileQuery}` : ""}`, { + const result = await runtimeRequest(clientIntegrationPath(client, profile), { method: "PUT", // Sent only when asked for, so a proxy on an older build sees the request it // has always seen rather than an unknown field. diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts index 5c31024332..f5cd228c90 100644 --- a/src/integrations/aside-profile-journal.ts +++ b/src/integrations/aside-profile-journal.ts @@ -43,7 +43,18 @@ function uniqueOperations(rows: AsideOperation[]): AsideOperation[] { if (previous && (previous.profileId !== row.profileId || JSON.stringify(previous.entry) !== JSON.stringify(row.entry))) { throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation identifies multiple profiles"); } - if (!previous) seen.set(row.entry.opId, row); + if (!previous) { + seen.set(row.entry.opId, row); + continue; + } + // Identical journal rows can outlive different snapshot-retention windows. + const previousSnapshot = previous.store.readSnapshot(previous.entry); + const candidateSnapshot = row.store.readSnapshot(row.entry); + if (previousSnapshot.kind === "stored" && candidateSnapshot.kind === "stored" + && previousSnapshot.text !== candidateSnapshot.text) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation has conflicting snapshot copies"); + } + if (previousSnapshot.kind === "expired" && candidateSnapshot.kind === "stored") seen.set(row.entry.opId, row); } return [...seen.values()]; } @@ -131,11 +142,8 @@ function snapshotWasOwned(entry: JournalEntry, text: string | null, bound: Integ function importOperation(row: AsideOperation, scope: AsideProfileScope): void { if (row.store.root === scope.store.root) return; const existing = scope.store.findOperation(row.entry.opId); - if (existing) { - if (JSON.stringify(existing) !== JSON.stringify(row.entry)) { - throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation conflicts with existing profile history"); - } - return; + if (existing && JSON.stringify(existing) !== JSON.stringify(row.entry)) { + throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation conflicts with existing profile history"); } const snapshot = row.store.readSnapshot(row.entry); if (snapshot.kind === "expired") throw new AsideProfileError("integration_snapshot_expired", 410, "That backup has expired"); @@ -148,7 +156,7 @@ function importOperation(row: AsideOperation, scope: AsideProfileScope): void { } if (present.kind !== "stored") scope.store.captureSnapshot("aside", row.entry.opId, snapshot.text); } - scope.store.appendJournal(structuredClone(row.entry)); + if (!existing) scope.store.appendJournal(structuredClone(row.entry)); } export function restoreAsideProfile( diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index 6e36bbe52b..a33b613130 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -3,7 +3,7 @@ import { ClientPathError } from "../../clients/config-export"; import { IntegrationMutationBusyError } from "../../integrations/mutation-flight"; import { IntegrationWriterLockBusyError } from "../../integrations/writer-lock"; import { - getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, + getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, refreshAsideProfiles, type AsideProfilesInput, } from "../../integrations/aside-profiles"; import { @@ -49,25 +49,85 @@ function isObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +async function readProfileBody(req: Request): Promise { + try { return await readManagementJsonBody(req); } + catch (error) { rethrowManagementBodyTooLarge(error); throw new ProfileQueryError("invalid JSON body"); } +} + +function validateClientSelector(ctx: ManagementContext): void { + const client = ctx.url.searchParams.get("client"); + if (client !== null && client !== "aside") throw new ProfileQueryError("client/profile selectors must identify Aside"); +} + +/** Dedicated scoped paths fail closed even when a newer client reaches an older server. */ +function nestedProfileContext(ctx: ManagementContext): { ctx: ManagementContext; action?: string } { + const prefix = "/api/client-integrations/aside/profiles/"; + if (!ctx.url.pathname.startsWith(prefix)) return { ctx }; + validateClientSelector(ctx); + const parts = ctx.url.pathname.slice(prefix.length).split("/"); + const url = new URL(ctx.url); + if (parts.length === 1 && parts[0] === "journal") { + if (url.searchParams.has("profile")) throw new ProfileQueryError("Use a profile-specific journal path"); + url.pathname = "/api/client-integrations/journal"; + url.searchParams.set("client", "aside"); + return { ctx: { ...ctx, url }, action: "journal" }; + } + if (parts.length > 2 || !parts[0] || (parts[1] !== undefined && !["journal", "restore"].includes(parts[1]))) { + throw new ProfileQueryError("Invalid Aside profile path"); + } + const prior = url.searchParams.get("profile"); + if (prior !== null && prior !== parts[0]) throw new ProfileQueryError("Conflicting Aside profile selectors"); + url.searchParams.set("profile", parts[0]); + url.searchParams.set("client", "aside"); + url.pathname = parts[1] ? `/api/client-integrations/${parts[1]}` : "/api/client-integrations/aside"; + return { ctx: { ...ctx, url }, action: parts[1] }; +} + /** Own only Aside status/toggle paths; other clients keep the existing adapter. */ export async function handleAsideProfileRoutes( ctx: ManagementContext, options: AsideProfileRouteOptions, ): Promise { - const { req, url } = ctx; - if (url.pathname !== "/api/client-integrations/aside" && url.pathname !== "/api/client-integrations/aside/profiles") return null; - if (req.method !== "GET" && req.method !== "PUT") return null; + if (ctx.url.pathname !== "/api/client-integrations/aside" + && !ctx.url.pathname.startsWith("/api/client-integrations/aside/")) return null; try { + const normalized = nestedProfileContext(ctx); + ctx = normalized.ctx; + const { req, url } = ctx; + validateClientSelector(ctx); const id = profileId(ctx); - if (url.pathname.endsWith("/profiles")) { - if (req.method !== "GET" || id !== undefined) throw new ProfileQueryError("The profile collection supports GET without a profile selector"); - return jsonResponse(await listAsideProfileStates(options.input()), 200, req, ctx.config); + if (normalized.action === "journal") { + if (req.method === "GET") return asideJournalResponse(ctx, "aside", options); + if (req.method === "DELETE") { + const opId = url.searchParams.get("opId")?.trim(); + if (!opId) throw new ProfileQueryError("opId is required"); + return asideJournalDeleteResponse(ctx, opId, options); + } + return null; + } + if (normalized.action === "restore") { + if (req.method !== "POST") return null; + const body = await readProfileBody(req); + if (!isObject(body) || typeof body.opId !== "string" || !body.opId.trim() + || (body.confirmDrift !== undefined && typeof body.confirmDrift !== "boolean")) throw new ProfileQueryError("Invalid Aside restore request"); + return asideRestoreResponse(ctx, { opId: body.opId.trim(), confirmDrift: body.confirmDrift === true }, options); + } + if (url.pathname === "/api/client-integrations/aside/sync") { + if (req.method !== "POST") return null; + if (id !== undefined) throw new ProfileQueryError("Aside sync uses the server's selected profiles"); + const body = await readProfileBody(req); + if (!isObject(body) || Object.keys(body).length !== 0) throw new ProfileQueryError("Aside sync expects an empty object"); + const results = await refreshAsideProfiles(options.input()); + const ok = results.every(result => result.ok); + return jsonResponse({ ok, clientId: "aside", results }, ok ? 200 : 207, req, ctx.config); } + if (url.pathname !== "/api/client-integrations/aside" && url.pathname !== "/api/client-integrations/aside/profiles") return null; + if (req.method !== "GET" && req.method !== "PUT") return null; + if (url.pathname.endsWith("/profiles") && id !== undefined) throw new ProfileQueryError("Use a profile-specific path"); if (req.method === "GET") { const state = id === undefined ? await listAsideProfileStates(options.input()) : await getAsideProfileState(options.input(), id); return jsonResponse(state, 200, req, ctx.config); } - let body: unknown; - try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); throw new ProfileQueryError("invalid JSON body"); } + const body = await readProfileBody(req); if (!isObject(body) || typeof body.enabled !== "boolean") throw new ProfileQueryError("enabled must be a boolean"); if (body.overwriteConflict !== undefined && typeof body.overwriteConflict !== "boolean") throw new ProfileQueryError("overwriteConflict must be a boolean"); if (body.overwriteConflict === true && !body.enabled) throw new ProfileQueryError("overwriteConflict applies only to enabling an integration"); @@ -120,11 +180,12 @@ export async function asideRestoreResponse( ctx: ManagementContext, body: { opId: string; confirmDrift?: boolean }, options: AsideProfileRouteOptions, ): Promise { try { + validateClientSelector(ctx); const id = profileId(ctx); const input = options.input(); const rootEntry = input.store?.findOperation(body.opId); if (rootEntry && rootEntry.clientId !== "aside") { - if (id !== undefined) throw new ProfileQueryError("profile applies only to Aside"); + if (id !== undefined || ctx.url.searchParams.has("client")) throw new ProfileQueryError("client/profile selectors do not match the operation"); return null; } const operation = await findAsideOperation(input, body.opId, id); @@ -145,11 +206,12 @@ export async function asideJournalDeleteResponse( ctx: ManagementContext, opId: string, options: AsideProfileRouteOptions, ): Promise { try { + validateClientSelector(ctx); const id = profileId(ctx); const input = options.input(); const rootEntry = input.store?.findOperation(opId); if (rootEntry && rootEntry.clientId !== "aside") { - if (id !== undefined) throw new ProfileQueryError("profile applies only to Aside"); + if (id !== undefined || ctx.url.searchParams.has("client")) throw new ProfileQueryError("client/profile selectors do not match the operation"); return null; } const operation = await findAsideOperation(input, opId, id); diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 28b4c4cadd..83a3c3d464 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -622,6 +622,11 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { expect(await handleClientIntegrationCommand(["history", "--client", "aside", "--profile", "2", "--json"], runtime.deps)).toBe(0); expect(await handleClientIntegrationCommand(["restore", "--client", "aside", "--profile", "2", "--op", "op-profile", "--json"], runtime.deps)).toBe(0); expect(runtime.requests.map(row => row.path)).toEqual([ - "/api/client-integrations/aside?profile=2", - "/api/client-integrations/aside?profile=2", - "/api/client-integrations/journal?client=aside&profile=2", - "/api/client-integrations/restore?client=aside&profile=2", + "/api/client-integrations/aside/profiles/2", + "/api/client-integrations/aside/profiles/2", + "/api/client-integrations/aside/profiles/2/journal", + "/api/client-integrations/aside/profiles/2/restore", ]); expect(runtime.requests[1]!.body).toEqual({ enabled: false }); expect(runtime.requests[3]!.body).toEqual({ opId: "op-profile", confirmDrift: false }); @@ -956,6 +956,6 @@ describe("Aside profile integration CLI", () => { results: [{ profileId: 0, ok: true, message: "updated" }, { profileId: 1, ok: false, message: "conflict" }], })); expect(await handleClientIntegrationCommand(["enable", "--client", "aside", "--json"], runtime.deps)).toBe(1); - expect(runtime.requests[0]).toEqual({ path: "/api/client-integrations/aside", method: "PUT", body: { enabled: true } }); + expect(runtime.requests[0]).toEqual({ path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true } }); }); }); diff --git a/tests/clients/aside-profile-sync-owner.test.ts b/tests/clients/aside-profile-sync-owner.test.ts new file mode 100644 index 0000000000..093f25dbdf --- /dev/null +++ b/tests/clients/aside-profile-sync-owner.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { spawn } from "node:child_process"; +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { pathToFileURL } from "node:url"; +import type { OwnedIntegrationRefreshOutcome } from "../../src/integrations/owned-refresh"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks } from "../../src/server/management/integration-routes"; +import type { OcxConfig } from "../../src/types"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath, repoRoot } from "../helpers/repo-root"; + +const SYNC_PATH = "/api/client-integrations/aside/sync"; +const CHILD_BUDGET_MS = 20_000; +let root: string; +let home: string; +let configHome: string; +let config: OcxConfig; +let isolation: IsolatedCodexHome; +let priorConfigHome: string | undefined; +let server: ReturnType | undefined; +let baseUrl: string; +let mode: "live" | "missing-route" | "old-response"; +let writes: number[]; +let syncRequests: Array<{ method: string; body: string }>; +const children: Array> = []; + +function bounded(promise: Promise, label: string, ms = CHILD_BUDGET_MS): Promise { + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} exceeded ${ms}ms`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +interface ChildMessage { + phase: "ready" | "refreshed" | "refused"; + pid: number; + staleEnabled: boolean; + results?: OwnedIntegrationRefreshOutcome[]; + name?: string; + status?: number; +} + +/** An actual CLI module in a second process, with its own already-loaded config. */ +function startCli() { + const helperUrl = pathToFileURL(repoPath("src", "cli", "aside-profiles.ts")).href; + const configUrl = pathToFileURL(repoPath("src", "config.ts")).href; + const source = ` + import { once } from "node:events"; + import { createInterface } from "node:readline"; + import { loadConfig } from ${JSON.stringify(configUrl)}; + import { refreshAsideProfilesThroughServer } from ${JSON.stringify(helperUrl)}; + const deadline = setTimeout(() => process.exit(124), ${CHILD_BUDGET_MS}); + const stale = loadConfig(); + const enabled = () => stale.asideProfileSync?.profiles?.["1"] ?? stale.asideProfileSync?.allProfiles ?? false; + const emit = value => console.log("ASIDE_SYNC_MESSAGE " + JSON.stringify({ pid: process.pid, staleEnabled: enabled(), ...value })); + const lines = createInterface({ input: process.stdin }); + const gate = once(lines, "line"); + emit({ phase: "ready" }); + const [release] = await gate; + lines.close(); + process.stdin.pause(); + if (release !== "refresh") throw new Error("unexpected parent gate message"); + try { + const results = await refreshAsideProfilesThroughServer({ baseUrl: process.env.ASIDE_SYNC_FIXTURE_URL }); + emit({ phase: "refreshed", results }); + } catch (error) { + emit({ phase: "refused", name: error.name, status: error.status }); + } finally { clearTimeout(deadline); } + `; + const child = spawn(process.execPath, ["--eval", source], { + cwd: repoRoot(), stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, HOME: home, USERPROFILE: home, OPENCODEX_HOME: configHome, + CODEX_HOME: isolation.path, XDG_CONFIG_HOME: join(home, ".config"), + OPENCODEX_ADMIN_AUTH_TOKEN: "", ASIDE_SYNC_FIXTURE_URL: baseUrl, + }, + }); + let stderr = ""; + child.stderr.on("data", chunk => { stderr = (stderr + String(chunk)).slice(-16_384); }); + child.on("error", error => { stderr += error.message; }); + const exited = new Promise(resolve => child.once("close", resolve)); + const lines = createInterface({ input: child.stdout }); + const iterator = lines[Symbol.asyncIterator](); + const cli = { + async next(): Promise { + return bounded((async () => { + for (;;) { + const line = await iterator.next(); + if (line.done) throw new Error(`CLI exited before its next gate message: ${stderr}`); + if (line.value.startsWith("ASIDE_SYNC_MESSAGE ")) { + return JSON.parse(line.value.slice("ASIDE_SYNC_MESSAGE ".length)) as ChildMessage; + } + } + })(), "CLI gate"); + }, + release() { child.stdin.end("refresh\n"); }, + async finish() { + const code = await bounded(exited, "CLI exit"); + if (code !== 0) throw new Error(`CLI exited with ${code}: ${stderr}`); + }, + async dispose() { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + try { await bounded(exited, "CLI cleanup", 5_000); } finally { lines.close(); } + }, + }; + children.push(cli); + return cli; +} + +function profilePath(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } +function profileFiles() { + return [0, 1, 2].map(id => { + const path = profilePath(id); + const stat = lstatSync(path, { bigint: true }); + return { text: readFileSync(path, "utf8"), ino: stat.ino.toString(), mtime: stat.mtimeNs.toString() }; + }); +} +function catalog(id: number): string[] { + const doc = JSON.parse(readFileSync(profilePath(id), "utf8")); + return (doc.providers?.opencodex?.models ?? []).map((model: { id: string }) => model.id) + .filter((id: string) => id.startsWith("fixture/")); +} +function persist(value: OcxConfig = config): void { + writeFileSync(join(configHome, "config.json"), JSON.stringify(value)); +} +async function api(path: string, method = "GET", body?: unknown): Promise { + return fetch(`${baseUrl}${path}`, { + method, headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(5_000), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-aside-sync-owner-")); + home = join(root, "home"); + configHome = join(root, "opencodex"); + mkdirSync(configHome, { recursive: true }); + priorConfigHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = configHome; + isolation = installIsolatedCodexHome("ocx-aside-sync-owner-codex-"); + for (const id of [0, 1, 2]) { + mkdirSync(join(home, ".aside", "u", String(id)), { recursive: true }); + writeFileSync(profilePath(id), JSON.stringify({ theme: "keep", providers: {} })); + } + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0, name: "Cloud" }, { id: 1, name: "Local one" }, { id: 2, name: "Local two" }], + })); + config = { + port: 10100, hostname: "127.0.0.1", defaultProvider: "fixture", fastRows: false, + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["one"] } }, + } as OcxConfig; + // Match the child's default ownership-store location: a local fallback must + // encounter real owned targets, rather than vacuously skip an empty store. + const store = createIntegrationStateStore(join(configHome, "integrations")); + const io = store.io(); + writes = []; + syncRequests = []; + mode = "live"; + setIntegrationPathTestHooks({ home, env: {} }); + setIntegrationMutationFlightTestHooks({ store, io: { + ...io, writeText(path, text) { + const id = [0, 1, 2].find(candidate => profilePath(candidate) === path); + if (id !== undefined) writes.push(id); + io.writeText(path, text); + }, + } }); + server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { + const url = new URL(req.url); + if (url.pathname === SYNC_PATH) { + syncRequests.push({ method: req.method, body: await req.clone().text() }); + if (mode === "missing-route") return Response.json({ error: "endpoint not found" }, { status: 404 }); + if (mode === "old-response") return Response.json({ ok: true }); + } + return await handleManagementAPI(req, url, config, { + saveConfigPreservingClaudeCode: persist, createManagementConvergeCodex: catalogConvergenceFactory(), + }) ?? new Response("Not found", { status: 404 }); + } }); + config.port = server.port!; + baseUrl = `http://127.0.0.1:${server.port}`; + persist(); +}); + +afterEach(async () => { + try { await Promise.all(children.splice(0).map(child => child.dispose())); } + finally { + await server?.stop(true); + server = undefined; + setIntegrationMutationFlightTestHooks(null); + setIntegrationPathTestHooks(null); + isolation.restore(); + if (priorConfigHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorConfigHome; + removeTreeWithRetry(root); + } +}); + +async function enableAll(): Promise { + const response = await api("/api/client-integrations/aside/profiles", "PUT", { enabled: true }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true }); + for (const id of [0, 1, 2]) expect(catalog(id)).toEqual(["fixture/one"]); +} + +test("a stale CLI process refreshes only the profiles still enabled by the live server", async () => { + await enableAll(); + const cli = startCli(); + const ready = await cli.next(); + expect(ready).toMatchObject({ phase: "ready", staleEnabled: true }); + expect(ready.pid).not.toBe(process.pid); + const disabled = await api("/api/client-integrations/aside/profiles/1", "PUT", { enabled: false }); + expect(disabled.status).toBe(200); + expect(await disabled.json()).toMatchObject({ ok: true }); + expect(JSON.parse(readFileSync(join(configHome, "config.json"), "utf8")).asideProfileSync.profiles["1"]).toBe(false); + expect(catalog(1)).toEqual([]); + const disabledFile = profileFiles()[1]; + // Change only the server's catalog fixture: no selection endpoint may refresh + // it before the released child reaches the production sync owner. + config.providers.fixture!.models = ["two"]; + persist(); + writes.length = 0; + cli.release(); + const result = await cli.next(); + await cli.finish(); + expect(result).toMatchObject({ phase: "refreshed", pid: ready.pid, staleEnabled: true }); + expect(result.results).toEqual([ + { client: "aside", profileId: 0, ok: true, changed: true }, + { client: "aside", profileId: 2, ok: true, changed: true }, + ]); + expect(syncRequests).toEqual([{ method: "POST", body: "{}" }]); + expect(writes).toEqual([0, 2]); + for (const id of [0, 2]) expect(catalog(id)).toEqual(["fixture/two"]); + expect(profileFiles()[1]).toEqual(disabledFile); + expect(await (await api("/api/client-integrations/aside/profiles/1")).json()) + .toMatchObject({ profileId: 1, enabled: false, state: "absent" }); +}, 45_000); + +test.each(["missing-route", "old-response", "offline"] as const)( + "CLI refuses %s without falling back to local profile writes", async failure => { + await enableAll(); + const before = profileFiles(); + config.providers.fixture!.models = ["two"]; + persist(); + const configBefore = readFileSync(join(configHome, "config.json"), "utf8"); + if (failure === "offline") { await server!.stop(true); server = undefined; } + else mode = failure; + writes.length = 0; + const cli = startCli(); + expect(await cli.next()).toMatchObject({ phase: "ready", staleEnabled: true }); + cli.release(); + expect(await cli.next()).toMatchObject({ + phase: "refused", name: "RuntimeApiError", status: failure === "offline" ? 503 : failure === "missing-route" ? 404 : 502, + }); + await cli.finish(); + expect(profileFiles()).toEqual(before); + expect(readFileSync(join(configHome, "config.json"), "utf8")).toBe(configBefore); + expect(writes).toEqual([]); + expect(syncRequests).toEqual(failure === "offline" ? [] : [{ method: "POST", body: "{}" }]); + }, 45_000, +); diff --git a/tests/clients/aside-profiles.test.ts b/tests/clients/aside-profiles.test.ts index 11f6303a02..35a60426d8 100644 --- a/tests/clients/aside-profiles.test.ts +++ b/tests/clients/aside-profiles.test.ts @@ -295,6 +295,60 @@ describe("Aside profile desired state, ownership and history", () => { expect(store.readRecords().aside).toEqual(owner); }); + test.each(["child", "legacy"] as const)("history restores from the remaining copy when %s retention expires", async expired => { + const opId = seedLegacy(1); + seedLegacy(0); + const rootOwner = store.readRecords().aside; + const applied = readFileSync(path(1), "utf8"); + expect((await restoreAsideProfile(input(), { opId })).ok).toBe(true); + const child = createIntegrationStateStore(join(store.root, "aside-profiles", "1")); + const entry = store.findOperation(opId)!; + expect(child.findOperation(opId)).toEqual(entry); + const expiredStore = expired === "child" ? child : store; + const remainingStore = expired === "child" ? store : child; + const snapshot = expiredStore.readSnapshot(entry); + if (snapshot.kind !== "stored") throw new Error("fixture snapshot missing"); + removeTreeWithRetry(snapshot.path); + expect(expiredStore.readSnapshot(entry).kind).toBe("expired"); + expect(remainingStore.readSnapshot(entry)).toMatchObject({ kind: "stored", text: original }); + const selected = findAsideOperation(input(), opId, 1)!; + expect(selected.store.root).toBe(remainingStore.root); + expect(listAsideOperations(input(), 1).filter(row => row.entry.opId === opId)).toHaveLength(1); + // Recreate the operation's result so ordinary Undo needs no drift override. + writeFileSync(path(1), applied); + expect(asideOperationMatchesCurrent(input(), selected)).toBe(true); + expect((await restoreAsideProfile(input(), { opId, profileId: 1 })).ok).toBe(true); + expect(readFileSync(path(1), "utf8")).toBe(original); + expect(child.readSnapshot(entry)).toMatchObject({ kind: "stored", text: original }); + expect(child.listOperations("aside").filter(row => row.opId === opId)).toHaveLength(1); + expect(store.listOperations("aside").filter(row => row.opId === opId)).toHaveLength(1); + expect(store.readRecords().aside).toEqual(rootOwner); + if (expired === "legacy") expect(store.readSnapshot(entry).kind).toBe("expired"); + }); + + test("conflicting available snapshot copies refuse lookup and restore before saving or writing", async () => { + const opId = seedLegacy(1); + seedLegacy(0); + expect((await restoreAsideProfile(input(), { opId })).ok).toBe(true); + const child = createIntegrationStateStore(join(store.root, "aside-profiles", "1")); + const entry = store.findOperation(opId)!; + const snapshot = child.readSnapshot(entry); + if (snapshot.kind !== "stored") throw new Error("fixture snapshot missing"); + writeFileSync(snapshot.path, JSON.stringify({ theme: "conflicting-copy" })); + expect(child.findOperation(opId)).toEqual(entry); + const before = bytes(); + const beforeSaves = saves; + const beforeHistory = child.listOperations("aside"); + expect(() => findAsideOperation(input(), opId, 1)).toThrow("conflicting snapshot copies"); + expect(() => listAsideOperations(input(), 1)).toThrow("conflicting snapshot copies"); + await expect(restoreAsideProfile(input(), { opId, profileId: 1, confirmDrift: true })) + .rejects.toMatchObject({ code: "aside_operation_ambiguous", status: 409 }); + expect(saves).toBe(beforeSaves); + expect(bytes()).toEqual(before); + expect(child.listOperations("aside")).toEqual(beforeHistory); + expect(store.readSnapshot(entry)).toMatchObject({ kind: "stored", text: original }); + }); + test("unknown profile selectors and unregistered historical targets are not retargeted", async () => { await expect(getAsideProfileState(input(), 9)).rejects.toMatchObject({ code: "aside_profile_not_found", status: 404 }); await expect(mutateAsideProfiles(input(), { profileId: -1, enabled: true })).rejects.toBeInstanceOf(AsideProfileError); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index b56ef490af..bf78bd65c6 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -511,7 +511,8 @@ test("the direct ocx sync command refreshes MCode, Pi and Aside instead of relyi const start = src.indexOf("sync: async deps =>"); const command = src.slice(start, src.indexOf("v2: async deps =>", start)); expect(command).toContain("refreshOwnedCatalogIntegrations"); - expect(command).toContain('["mcode", "pi", "aside"]'); + expect(command).toContain('["mcode", "pi"]'); + expect(command).toContain("refreshAsideProfilesThroughServer"); expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations")); expect(command).toContain('synced.status !== "refused"'); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 58a72a350b..1b8d917abc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -72,6 +72,7 @@ "aside-profiles-routes.test.ts": "server", "aside-profiles.test.ts": "clients", "aside-profile-paths.test.ts": "clients", + "aside-profile-sync-owner.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", "autostart-health.test.ts": "service", diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index 33a63fdaea..b129e8f701 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -127,3 +127,34 @@ test("invalid persisted profile policy fails closed without resetting the surrou expect(loaded.port).toBe(10100); expect(loaded.providers.fixture).toBeDefined(); }); + +test.each(["%61side", "as%69de"])("alternate Aside spelling %s cannot reach the legacy writer", async spelling => { + const before = [0,1,2].map(id => readFileSync(path(id), "utf8")); + expect((await api(`/api/client-integrations/${spelling}`, "PUT", { enabled: true })).status).toBe(400); + expect(saved).toBeUndefined(); + expect([0,1,2].map(id => readFileSync(path(id), "utf8"))).toEqual(before); + expect(store.listOperations("aside")).toEqual([]); +}); + +test("conflicting client selectors cannot restore Aside or delete its history", async () => { + const on = await (await api("/api/client-integrations/aside/profiles/0", "PUT", { enabled: true })).json(); + await api("/api/client-integrations/aside/profiles/0", "PUT", { enabled: false }); + const before = readFileSync(path(0), "utf8"); + const policy = structuredClone(config.asideProfileSync); + expect((await api("/api/client-integrations/restore?client=pi&profile=0", "POST", { opId: on.opId })).status).toBe(400); + expect((await api(`/api/client-integrations/journal?client=pi&profile=0&opId=${on.opId}`, "DELETE")).status).toBe(400); + expect(readFileSync(path(0), "utf8")).toBe(before); + expect(config.asideProfileSync).toEqual(policy); + const history = await (await api("/api/client-integrations/aside/profiles/0/journal")).json(); + expect(history.operations.some((row: { opId: string }) => row.opId === on.opId)).toBe(true); +}); + +test("dedicated nested paths retain profile scope for status, history and restore", async () => { + const on = await (await api("/api/client-integrations/aside/profiles/2", "PUT", { enabled: true })).json(); + expect(on).toMatchObject({ ok: true, profileId: 2 }); + expect(await (await api("/api/client-integrations/aside/profiles/2")).json()).toMatchObject({ profileId: 2, enabled: true }); + expect((await api("/api/client-integrations/aside/profiles/2?profile=1", "PUT", { enabled: false })).status).toBe(400); + expect((await api("/api/client-integrations/aside/profiles/2/restore", "POST", { opId: on.opId })).status).toBe(200); + expect(document(2).providers.opencodex).toBeUndefined(); + expect(document(0).providers.opencodex).toBeUndefined(); +}); From 2a30d48e1f4667bcf8ee0d8d11856bb4517a8f7b Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:27:31 +0900 Subject: [PATCH 083/236] docs: close verified C-lane integration record --- .../{_plan => _fin}/260906_c_lane/000_plan.md | 2 ++ devlog/_fin/260906_c_lane/010_result.md | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) rename devlog/{_plan => _fin}/260906_c_lane/000_plan.md (92%) create mode 100644 devlog/_fin/260906_c_lane/010_result.md diff --git a/devlog/_plan/260906_c_lane/000_plan.md b/devlog/_fin/260906_c_lane/000_plan.md similarity index 92% rename from devlog/_plan/260906_c_lane/000_plan.md rename to devlog/_fin/260906_c_lane/000_plan.md index 8f7385965e..5abf70bf69 100644 --- a/devlog/_plan/260906_c_lane/000_plan.md +++ b/devlog/_fin/260906_c_lane/000_plan.md @@ -7,3 +7,5 @@ The user explicitly requires security working plans and reviews to stay in gitig Order: roadmap → service scheduler → account persistence → OAuth configuration → Antigravity refresh/replay → quota diagnostics → final stack integration. OAuth refresh consumes the configuration layer; other layers retain the user-requested stack order. Each layer is independently reviewed and tested on a remote host before cycle close. Hosted full CI runs at each PR head and gates final bottom-up merges. Original PR and fully solved linked issues close immediately after the matching change is proven on dev. Partial diagnostic work does not close a broader unresolved report. Release branches and live account settings are out of scope. + +Completed: see [010_result.md](010_result.md) for published landings, verification and residual scope. diff --git a/devlog/_fin/260906_c_lane/010_result.md b/devlog/_fin/260906_c_lane/010_result.md new file mode 100644 index 0000000000..2dcb6a338b --- /dev/null +++ b/devlog/_fin/260906_c_lane/010_result.md @@ -0,0 +1,20 @@ +# C-lane delivery result + +All five assigned code changes are merged into `dev`. Original-author credit survives in the landed history. This record contains published outcomes only; working security notes remain in scratch. + +| Source | Landed PR | Scope | Merge commit | CI | +|---|---|---|---|---| +| [#3638](https://github.com/lidge-jun/opencodex/pull/3638) | [#3682](https://github.com/lidge-jun/opencodex/pull/3682) | Windows scheduler priority | `9b3955a3345561b3310508793b40ac0813e26b78` | [run 33978490397](https://github.com/lidge-jun/opencodex/actions/runs/33978490397) | +| [#3536](https://github.com/lidge-jun/opencodex/pull/3536) | [#3687](https://github.com/lidge-jun/opencodex/pull/3687) | Account deletion persistence | `ed7ecc5780ea0bd936468aff3828e60c7d9d0d34` | [run 33978685977](https://github.com/lidge-jun/opencodex/actions/runs/33978685977) | +| [#3631](https://github.com/lidge-jun/opencodex/pull/3631) | [#3688](https://github.com/lidge-jun/opencodex/pull/3688) | OAuth provider configuration | `789f69ab1bf57d74dcdd0d658f1bc13d9d486e7b` | [run 33979181943](https://github.com/lidge-jun/opencodex/actions/runs/33979181943) | +| [#3576](https://github.com/lidge-jun/opencodex/pull/3576) | [#3691](https://github.com/lidge-jun/opencodex/pull/3691) | Antigravity OAuth 401 recovery | `7e7ab281cca35600b41f1f80222f3462a87dd4e1` | [run 33979752516](https://github.com/lidge-jun/opencodex/actions/runs/33979752516) | +| [#3658](https://github.com/lidge-jun/opencodex/pull/3658) | [#3693](https://github.com/lidge-jun/opencodex/pull/3693) | Bounded main quota diagnostics | `71edeec8807d99e8e56a8c093f74da27d163d47a` | [run 33985146886](https://github.com/lidge-jun/opencodex/actions/runs/33985146886) | + +Verification: + +- Each recorded code head passed the hosted Cross-platform CI with executed Linux/macOS suites and typecheck. Independent source/security reviews passed on those heads. +- The service change also passed native Linux, macOS and Windows lifecycle run [33978490408](https://github.com/lidge-jun/opencodex/actions/runs/33978490408). +- Focused remote checks passed for each layer; the top diagnostic head passed 554 tests across eleven files, including the bounded subprocess regression. Documentation built 425 pages at that head. +- No local product test suite, typecheck or build was run. Pushes used the maintainer-authorized `--no-verify` path; admin merges followed verified code gates and review evidence. +- Original PRs #3638, #3536, #3631, #3576 and #3658 were closed after dev ancestry was proven. Resolved issues #3634 and #3575 were closed at their respective landings. +- Issue [#3644](https://github.com/lidge-jun/opencodex/issues/3644) remains open: diagnostics were delivered, while its underlying Windows/WHAM failure is still a separate investigation. From baf8303bbda32335424afc8fe2065950f04f6d9b Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:29:08 +0900 Subject: [PATCH 084/236] fix(adapters): reject every invalid claimed tool-call index type Co-authored-by: Hako <25837994+devswha@users.noreply.github.com> --- .../020_tool_aliases.md | 12 ++++-- .../021_tool_alias_refresh.md | 2 + .../022_index_type_repair.md | 41 +++++++++++++++++++ .../src/content/docs/reference/adapters.md | 6 ++- src/adapters/openai-chat.ts | 10 +++-- structure/04_transports-and-sidecars.md | 13 +++--- .../openai-chat-parallel-stream.test.ts | 40 ++++++++++++++---- 7 files changed, 103 insertions(+), 21 deletions(-) create mode 100644 devlog/_plan/260906_d_integrations_delivery/022_index_type_repair.md diff --git a/devlog/_plan/260906_d_integrations_delivery/020_tool_aliases.md b/devlog/_plan/260906_d_integrations_delivery/020_tool_aliases.md index 845fd6fec7..9d1b646314 100644 --- a/devlog/_plan/260906_d_integrations_delivery/020_tool_aliases.md +++ b/devlog/_plan/260906_d_integrations_delivery/020_tool_aliases.md @@ -43,11 +43,13 @@ The exact original patch is the complete diff `git show c8240c51d664f7cfb790b6d6 Current anchors: `src/adapters/openai-chat.ts:1661` pending interface, `:1856` identity lookup, `:1873` budget opening, `:1912` argument-byte accounting, `:1679` budget closing. Replace the lookup block with: ```ts -if (typeof rawIndex === "number" - && (!Number.isSafeInteger(rawIndex) || rawIndex < 0)) { +if (rawIndex !== undefined && rawIndex !== null + && (typeof rawIndex !== "number" + || !Number.isSafeInteger(rawIndex) + || rawIndex < 0)) { return yield* terminateWithError({ ...invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage), - message: "upstream response contained invalid tool calls (invalid numeric index)", + message: "upstream response contained invalid tool calls (invalid index)", }); } const indexKey = typeof rawIndex === "number" ? `i:${rawIndex}` : undefined; @@ -126,3 +128,7 @@ Only non-negative safe-integer indexes may become an alias. Immediately after re ## Safe-integer review repair The numeric guard uses Number.isSafeInteger: parsed indices beyond the safe range can already have lost identity precision. Add a raw-wire regression containing distinct large integer literals (not JS values rounded before serialization), and retain a positive MAX_SAFE_INTEGER boundary. Capture error/no tool success plus existing reservation-release coverage. The correction must be verified in this same unit; no original source tests are removed. + +## Claimed-type boundary update + +022 supersedes the earlier non-numeric-index tolerance assumption: only missing/null indexes are absent. Every other claimed value must be a non-negative safe integer; no coercion of strings/objects/bools/arrays. Repeated ID/name/argument-field tolerance is unchanged. diff --git a/devlog/_plan/260906_d_integrations_delivery/021_tool_alias_refresh.md b/devlog/_plan/260906_d_integrations_delivery/021_tool_alias_refresh.md index 667aae674e..c8d03ce3f5 100644 --- a/devlog/_plan/260906_d_integrations_delivery/021_tool_alias_refresh.md +++ b/devlog/_plan/260906_d_integrations_delivery/021_tool_alias_refresh.md @@ -1,5 +1,7 @@ # Tool-call alias cycle P refresh +Historical refresh: its non-numeric-index policy is superseded by the explicit null/missing boundary in 022_index_type_repair.md. + Current parent: 22da7a4bc80040f66b819239c5028e578f9a1ede, after TOML delivery. Original source c8240c51d664f7cfb790b6d60679adfe0490b5c9 remains open and authored by Hako. Relevant baseline comparison is retained in scratch; implementation uses the current tree and preserves adjacent changes. Apply the original commit, then the independently reviewed 020 numeric-index amendment. Missing/non-numeric placeholders keep existing tolerance; negative/fractional numeric indexes terminate before matching. Preserve the immutable reservation key and first observed valid index alias. Add direct malformed-index activation coverage alongside all original positive/collision/UTF-8 budget cases. Update the transport structure contract as planned. diff --git a/devlog/_plan/260906_d_integrations_delivery/022_index_type_repair.md b/devlog/_plan/260906_d_integrations_delivery/022_index_type_repair.md new file mode 100644 index 0000000000..704278ae34 --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/022_index_type_repair.md @@ -0,0 +1,41 @@ +# 022 — Reject claimed invalid index types + +## Loop specification + +Class C2/C3 bounded parent repair. Source: current #3702 at d6bfb044a; late reviews PRRT_kwDOS-0Gi86fl4vM and fl4vC. Goal: a present invalid index cannot be mistaken for an absent index and routed to the last pending call. Non-goals: changing repeated ID/name/argument placeholder tolerance, parsing numeric strings, new adapters or unrelated Logs work. Remote/CI verification only; no local tests/typecheck. Same session resource bounds apply. Main owns Git/FSM/integration; one worker may edit only the adapter, its parallel-stream test and structure04. Main reclaims after two failed delegates. + +This additive repair preempts unfinished Logs planning. No previous work-phase completion marks or final criteria were removed. Detailed review synthesis is in scratch. Resume Logs after this full cycle and cascade. + +## Exact change map + +MODIFY src/adapters/openai-chat.ts, before all key matching: + +```ts +if (rawIndex !== undefined && rawIndex !== null + && (typeof rawIndex !== "number" + || !Number.isSafeInteger(rawIndex) + || rawIndex < 0)) { + return yield* terminateWithError({ + ...invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage), + message: "upstream response contained invalid tool calls (invalid index)", + }); +} +``` + +Before: only invalid numbers reject; present strings/objects/bools become no indexKey and may select the last pending call. After: only missing/null is absent; every other claimed index must be a non-negative safe integer. The existing terminateWithError closes all budget reservations before the error is yielded. Keep the alias/key precedence and immutable reservation keys unchanged. No new fields/enums/dependencies. + +MODIFY tests/adapters/openai/openai-chat-parallel-stream.test.ts: retain all Hako and safe-integer cases. Update expected diagnostic wording. Add labeled table cases for numeric string, empty string, true/false, object and array, with pending complete JSON calls so a silent fallback could otherwise produce success; assert one terminal502, no tool/done event and released reservations. Include explicit missing/null positive continuation through a later valid numeric alias. Use tuple wrappers for array-valued cases so test.each cannot mistake an index array for argument tuples. + +MODIFY docs-site/src/content/docs/reference/adapters.md: specify non-negative safe integers; explicitly reject non-numeric values and negative/fractional/unsafe numbers; missing/null remain absent-index placeholders. Do not call valid JSON numbers malformed JSON. + +MODIFY structure/04_transports-and-sidecars.md: align the same index contract and source/test ownership. + +MODIFY 020_tool_aliases.md: carry the corrected guard and compatibility boundary. Annotate 021's former non-numeric-placeholder policy as superseded by this repair; retain its historical source snapshot. + +## Verification and exit + +- Independent plan and implementation review; original source authorship retained. +- Exact-head pinned remote typecheck/full suite/docs build, hosted CI registration and no unresolved findings. Full final integrated CI remains mandatory under c-2; build readiness is not merge permission. +- Existing numeric/unsafe/UTF-8/collision cases remain green; new claimed-type cases actually observe pending allocations before early failure, and null/missing positive cases still assemble one correct tool. +- Cascade new parent into Cursor with a merge preserving both authors' commits and both structure sections; fast-forward the still-unpublished Logs branch to updated Cursor. Verify both ancestry edges. Do not mark the updated Cursor head verified until its own new evidence exists. +- Main returns to parent for the repair receipt/D, then resumes original Logs planning. Shipping #3702 still requires strict merge verification and actual dev ancestry before source #3673 closes. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 64d5278ec6..5705113e9e 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -53,8 +53,10 @@ Streaming tool calls retain their identity when a provider first sends an ID, then associates that ID with an index, and later sends index-only argument fragments. Those fragments assemble into one call with the original name and complete arguments; parallel calls retain separate identities. -Numeric streamed tool-call indexes must be non-negative safe integers; malformed numeric -indexes terminate the stream with an upstream error before identity matching. +When present, streamed tool-call indexes must be non-negative safe integers. Non-numeric +values and negative, fractional, or unsafe numbers terminate the stream with an upstream +error before identity matching. Missing and null indexes remain absent-index placeholders; +numeric strings are not coerced. ## `ollama-native` diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 66db05167b..0abb3277ae 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1849,14 +1849,16 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const rawId = rawToolCall.id; const idDelta = typeof rawId === "string" ? rawId : ""; const rawIndex = rawToolCall.index; - // Invalid numeric indexes must not fall through to ID or last-call matching. + // Only missing/null indexes are absent; every claimed index must be valid. // Unsafe integers can collapse distinct wire indexes onto the same JS number. // Reject before an alias can bind or any pending call can consume the fragment. - if (typeof rawIndex === "number" - && (!Number.isSafeInteger(rawIndex) || rawIndex < 0)) { + if (rawIndex !== undefined && rawIndex !== null + && (typeof rawIndex !== "number" + || !Number.isSafeInteger(rawIndex) + || rawIndex < 0)) { return yield* terminateWithError({ ...invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage), - message: "upstream response contained invalid tool calls (invalid numeric index)", + message: "upstream response contained invalid tool calls (invalid index)", }); } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index be179a2682..a7b563378a 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -1505,13 +1505,15 @@ Native Composer/MCP behavior and text-only historical replay remain unchanged. ## Chat streamed tool-call identity `src/adapters/openai-chat.ts` retains a call's first observed non-negative safe integer -index as an alias when the call started by ID. Numeric indexes that are negative, -non-integer or outside JavaScript's safe-integer range terminate the stream before -any key, alias, ID or last-call matching. `Number.MAX_SAFE_INTEGER` is accepted; +index as an alias when the call started by ID. Every present, non-null index must +be a number in that range: strings (including numeric and empty strings), booleans, +objects, arrays, negative numbers, fractions and unsafe integers terminate the stream +before any key, alias, ID or last-call matching. `Number.MAX_SAFE_INTEGER` is accepted; larger integers are rejected because distinct wire literals can parse to the same number. The invalid-index error releases all pending call reservations without emitting those calls or a successful completion; invalid indexes are never treated as absent. -Missing and non-numeric index placeholders retain their existing tolerance. +Only missing and null indexes are absent-index placeholders. Repeated ID, name and +argument string-field tolerance retains its existing rules. For valid indexes, lookup preserves direct-key precedence, then index alias, then ID fallback. The initial key continues to own all translator budget reservations @@ -1519,7 +1521,8 @@ and release; learning an alias creates no additional owner. Unassociated index-o fragments are not guessed onto pending ID-only calls. `tests/adapters/openai/openai-chat-parallel-stream.test.ts` covers late aliases, parallel/colliding identities, distinct unsafe raw JSON index literals, the maximum -safe-integer boundary, invalid numeric indexes and UTF-8 byte-limit boundaries. +safe-integer boundary, invalid index types, missing/null continuations and UTF-8 +byte-limit boundaries. ## Sidecars diff --git a/tests/adapters/openai/openai-chat-parallel-stream.test.ts b/tests/adapters/openai/openai-chat-parallel-stream.test.ts index 85f49e8f21..9e47b3a742 100644 --- a/tests/adapters/openai/openai-chat-parallel-stream.test.ts +++ b/tests/adapters/openai/openai-chat-parallel-stream.test.ts @@ -269,11 +269,18 @@ describe("openai-chat parallel tool call stream assembly", () => { }); test.each([ - [-1, undefined], - [-1, "call_a"], - [0.5, undefined], - [0.5, "call_a"], - ] as const)("invalid numeric index %s with ID %s aborts without reassigning pending calls", async (index, id) => { + ["negative, no ID", -1, undefined], + ["negative, matching ID", -1, "call_a"], + ["fractional, no ID", 0.5, undefined], + ["fractional, matching ID", 0.5, "call_a"], + ["numeric string, no ID", "0", undefined], + ["numeric string, matching ID", "0", "call_a"], + ["empty string", "", undefined], + ["true", true, undefined], + ["false", false, undefined], + ["object", {}, undefined], + ["array", [], undefined], + ] as const)("invalid index (%s) aborts without reassigning pending calls", async (_label, index, id) => { const budget = createTestTranslatorBudget(); const response = new Response(sse([ chunkOf([ @@ -304,7 +311,7 @@ describe("openai-chat parallel tool call stream assembly", () => { type: "error", status: 502, errorType: "upstream_error", - message: "upstream response contained invalid tool calls (invalid numeric index)", + message: "upstream response contained invalid tool calls (invalid index)", })]); expect(events.at(-1)?.type).toBe("error"); expect(events.some(event => event.type === "done")).toBe(false); @@ -313,6 +320,25 @@ describe("openai-chat parallel tool call stream assembly", () => { expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); }); + test.each([ + ["missing", undefined], + ["null", null], + ] as const)("%s index placeholders preserve continuation through a later valid alias", async (_label, index) => { + const budget = createTestTranslatorBudget(); + const events = await collect(sse([ + chunkOf([{ index, id: "call_a", function: { name: "read", arguments: '{"p":' } }]), + chunkOf([{ index, id: "call_a", function: { arguments: '"x"' } }]), + chunkOf([{ index: 7, id: "call_a", function: { arguments: "}" } }]), + chunkOf([{ index, function: { arguments: " " } }]), + chunkOf([{ index: 7, function: { arguments: " " } }]), + chunkOf([], "tool_calls"), + ]), budget); + expect(assembled(events)).toEqual([{ id: "call_a", name: "read", args: '{"p":"x"} ' }]); + expect(events.some(event => event.type === "error")).toBe(false); + expect(events.at(-1)?.type).toBe("done"); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + test("rejects distinct unsafe raw JSON indexes before they collapse into one call", async () => { const budget = createTestTranslatorBudget(); // Keep both index literals on the wire: constructing JS numbers before JSON.stringify @@ -349,7 +375,7 @@ data: [DONE] type: "error", status: 502, errorType: "upstream_error", - message: "upstream response contained invalid tool calls (invalid numeric index)", + message: "upstream response contained invalid tool calls (invalid index)", }); expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); }); From 1d4da9f9b754c48c522720515ef5b2a90753a0e4 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:30:44 +0900 Subject: [PATCH 085/236] fix(cli): report empty Aside profile diagnostics --- src/cli/integrations.ts | 4 +++- tests/cli/cli-headless-parity.test.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index 921326d1a3..be957d56cf 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -192,7 +192,9 @@ export async function handleClientIntegrationCommand( const rows = (result as { clients?: Array> }).clients; const profiles = (result as { profiles?: Array> }).profiles; printData(result, wantsJson, profiles - ? profiles.map(row => `${String(row.profileId)} ${String(row.name ?? "Aside")}: ${row.enabled ? "on" : "off"} (${String(row.state)})${row.current ? " [current]" : ""}`) + ? profiles.length > 0 + ? profiles.map(row => `${String(row.profileId)} ${String(row.name ?? "Aside")}: ${row.enabled ? "on" : "off"} (${String(row.state)})${row.current ? " [current]" : ""}`) + : [String((result as { error?: string }).error ?? "No Aside profiles found.")] : rows ? rows.map(row => `${String(row.clientId)}: ${String(row.state)}${row.installed ? "" : " (not installed)"}`) : summaryLines(result)); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index ff81916653..5fc2b88da3 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -959,3 +959,12 @@ describe("Aside profile integration CLI", () => { expect(runtime.requests[0]).toEqual({ path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true } }); }); }); + +test("Aside status prints the empty-profile diagnostic for humans", async () => { + const runtime = fakeRuntime(() => ({ profiles: [], error: "Open Aside to create a profile" })); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleClientIntegrationCommand(["status", "--client", "aside"], runtime.deps)).toBe(0); + expect(log.mock.calls.flat().join("\n")).toContain("Open Aside to create a profile"); + } finally { log.mockRestore(); } +}); From b83abd6630734f129674a9fbe4cf63fb096562fa Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:35:06 +0900 Subject: [PATCH 086/236] docs: plan verified A stack closeout and strict merge gates --- devlog/_plan/260906_a_final_closeout/000_plan.md | 5 +++++ devlog/_plan/260906_a_final_closeout/001_gate_audit.md | 3 +++ devlog/_plan/260906_a_final_closeout/010_landing.md | 10 ++++++++++ 3 files changed, 18 insertions(+) create mode 100644 devlog/_plan/260906_a_final_closeout/000_plan.md create mode 100644 devlog/_plan/260906_a_final_closeout/001_gate_audit.md create mode 100644 devlog/_plan/260906_a_final_closeout/010_landing.md diff --git a/devlog/_plan/260906_a_final_closeout/000_plan.md b/devlog/_plan/260906_a_final_closeout/000_plan.md new file mode 100644 index 0000000000..2f9e6e89b7 --- /dev/null +++ b/devlog/_plan/260906_a_final_closeout/000_plan.md @@ -0,0 +1,5 @@ +# A final verified landing + +All five feature implementations and two additional verification repairs have independent review and remote regression evidence. Three feature carries and the Windows foundation are already on dev. Remaining chain:3708 (bounded macOS cleanup/replay-fixture foundation) →3692 (Command Code affinity) →3694 (effective capabilities). + +This final cycle preserves the original owner objective: all five source PRs dispositioned, credited changes on dev, and fresh final dev verification. No local suites/typecheck/build. Existing owner-authorized admin merge applies only after every actual current-head producer passed; a queued aggregation-only job may be evaluated with its exact allowlist and recorded honestly. No pending functional test or failed test is waived. diff --git a/devlog/_plan/260906_a_final_closeout/001_gate_audit.md b/devlog/_plan/260906_a_final_closeout/001_gate_audit.md new file mode 100644 index 0000000000..b3149e0741 --- /dev/null +++ b/devlog/_plan/260906_a_final_closeout/001_gate_audit.md @@ -0,0 +1,3 @@ +# Final gate audit resolution + +Accepted the independent audit finding: merge automation must not ignore a failed/cancelled aggregate or skipped applicable producer. The helper now enumerates all24 applicable producer names for the pinned manual-all workflow and requires every one completed/successful. No job-level skip is applicable to this workflow invocation. Exactly one ci aggregator must be successful or only queued; all other states reject. Queued aggregation is accepted only after the full producer predicate has been independently established and recorded. Prior three A merge records were rechecked and satisfy this stronger condition; no failed/skipped producer was previously bypassed. diff --git a/devlog/_plan/260906_a_final_closeout/010_landing.md b/devlog/_plan/260906_a_final_closeout/010_landing.md new file mode 100644 index 0000000000..8360c9203a --- /dev/null +++ b/devlog/_plan/260906_a_final_closeout/010_landing.md @@ -0,0 +1,10 @@ +# Final actions and acceptance + +1. Refresh each remaining PR head/base/review threads and its retained full CI. Match all actual producer jobs, including every Windows shard, both macOS shards and unsharded control, Linux shards, types/privacy and smokes. Preserve real failed prior runs and their tested repairs. +2. Retarget each child before the parent's admin merge because repository auto-deletion is enabled. Use merge commits and match-head guards; preserve original author commits and co-author trailers. Verify each landing SHA is an ancestor of freshly fetched dev. +3. Immediately close each superseded original after checking it did not gain unique new changes. Source3679's rebase08d25 has verified identical patch; other originals are refreshed normally. Source3672/3679/3568 are already closed. Source3581/3671 close after their landing. Related3661 remains open for its explicitly excluded residual scope. +4. Merge latest dev into this own closeout branch only after code landings. Move only completed A unit directories from devlog/_plan to devlog/_fin, preserving historical contents. Add a concise public outcome table with source/carry/merge/CI/author proof and scope limitations. Never copy ignored logs or private investigations. +5. Publish a docs-only closeout PR using the repository template. Verify its changed paths, privacy and metadata; admin merge when checks allow. Runtime/tests/dependencies must be byte-identical to the last code merge. Existing code CI may prove that identical runtime tree; documentation metadata alone is not runtime test evidence. +6. Require final dev push-CI producer success on the last code head, verify any subsequent docs-only difference and final ancestry, and refresh all source/carry states. Complete the landing criterion and goal only after the durable ledger is complete and the FSM has closed. No release, deployment, service or account changes. + +Independent final audit checks this plan and later actual evidence. Any new valid implementation or CI finding returns to a narrowly scoped repair; it is not discarded to finish the goal. From f4bb6daa6b2d2d86a5ba92ce8818b4f5e5c13c67 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 04:48:44 +0900 Subject: [PATCH 087/236] test(server): budget direct-management probe startup and requests --- .../020_direct_transport_watchdog.md | 7 ++++++ .../local-management-direct-transport.test.ts | 22 +++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 devlog/_plan/260906_a_macos_verification/020_direct_transport_watchdog.md diff --git a/devlog/_plan/260906_a_macos_verification/020_direct_transport_watchdog.md b/devlog/_plan/260906_a_macos_verification/020_direct_transport_watchdog.md new file mode 100644 index 0000000000..708a1ce380 --- /dev/null +++ b/devlog/_plan/260906_a_macos_verification/020_direct_transport_watchdog.md @@ -0,0 +1,7 @@ +# Focused verification watchdog correction + +ClassC1: one test file, no production behavior or public API change. WindowsCIjob101361741694 hit the fixture's flat3000ms childwatchdog before routing assertions. The same child performs imports, an unbounded control fetch, two750ms probes and a2000ms read. The log cannot identify which stage consumed time. + +Use the existing CI-watchdog owner for a derived whole-child budget:3000ms startup +2000ms bounded control +750ms identity +750ms readiness +2000ms read +1000ms exit =9500ms. OnCI the existing30s/45s floor applies. Give the test itself the child budget plus1000ms cleanup. Add fixed child phase markers and bounded phase/request-count diagnostics, never capability values. Keep every exact routing/header assertion and existing per-operation budgets. Bound only the previously unbounded control fetch. + +Verify remotely on pinnedBun: originalfilechecks, an explicit3500ms pre-import delay underCI that succeeds withthecorrectbudget and fails withtheold3000ms guard, and an intentional memory-read misroute that fails the unchangedproxy/capability assertions despite valid-looking responses. No local execution. This is a causal verifier fix within the ongoing final landing repair loop, not an unconditional rerun or production timeout increase. diff --git a/tests/server/local-management-direct-transport.test.ts b/tests/server/local-management-direct-transport.test.ts index 2fda9cbcb3..c8c74ce4fa 100644 --- a/tests/server/local-management-direct-transport.test.ts +++ b/tests/server/local-management-direct-transport.test.ts @@ -6,9 +6,13 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { directLocalHttpFetch } from "../../src/server/direct-local-http"; import { repoPath, repoRoot } from "../helpers/repo-root"; +import { watchdogMs } from "../helpers/ci-watchdog"; const PID = 4242; const SECRET = "A".repeat(43); +const CONTROL_TIMEOUT_MS = 2_000; +// Startup/imports + control + two liveness probes + capability read + process exit. +const DIRECT_CHILD_BUDGET_MS = watchdogMs(3_000 + CONTROL_TIMEOUT_MS + 750 + 750 + 2_000 + 1_000); async function listen(server: Server, hostname = "127.0.0.1"): Promise { return await new Promise((resolve, reject) => { @@ -299,14 +303,22 @@ describe("local management direct transport", () => { const localClientUrl = pathToFileURL(repoPath("src", "server", "local-management-read-client.ts")).href; const capabilityUrl = pathToFileURL(repoPath("src", "lib", "local-management-capability.ts")).href; const childSource = ` + const phase = name => console.error("DIRECT_PHASE:" + name); + phase("imports"); const liveness = await import(${JSON.stringify(proxyLivenessUrl)}); const client = await import(${JSON.stringify(localClientUrl)}); const capability = await import(${JSON.stringify(capabilityUrl)}); const port = ${targetPort}; const pid = ${PID}; - const control = await fetch(\`http://127.0.0.1:\${port}/__proxy-control\`).then(response => response.json()); + phase("control"); + const control = await fetch(\`http://127.0.0.1:\${port}/__proxy-control\`, { + signal: AbortSignal.timeout(${CONTROL_TIMEOUT_MS}), + }).then(response => response.json()); + phase("identity"); const identity = await liveness.proxyIdentityAt(port, { hostname: "127.0.0.1", expectedPid: pid }); + phase("readiness"); const readiness = await liveness.probeReadiness(port, { hostname: "127.0.0.1", expectedPid: pid }); + phase("memory"); const read = await client.fetchBoundLocalManagementRead( { hostname: "127.0.0.1", port, pid, source: "runtime" }, capability.LOCAL_MANAGEMENT_READ_PATHS.systemMemory, @@ -319,6 +331,7 @@ describe("local management direct transport", () => { const memory = read.kind === "response" ? await read.response.json() : null; const result = { control, identity, readiness, readKind: read.kind, memory }; console.log(JSON.stringify(result)); + phase("complete"); if (control?.via !== "proxy" || identity?.pid !== pid || readiness?.ready !== true || read.kind !== "response" || memory?.pid !== pid) { process.exitCode = 2; } @@ -341,13 +354,14 @@ describe("local management direct transport", () => { const childWatchdog = setTimeout(() => { childTimedOut = true; child.kill(); - }, 3_000); + }, DIRECT_CHILD_BUDGET_MS); const [exitCode, stdout, stderr] = await Promise.all([ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), ]).finally(() => clearTimeout(childWatchdog)); - if (childTimedOut) throw new Error("direct-transport child timed out"); + const phase = [...stderr.matchAll(/DIRECT_PHASE:(imports|control|identity|readiness|memory|complete)/g)].at(-1)?.[1] ?? "startup"; + if (childTimedOut) throw new Error(`direct-transport child timed out (phase=${phase}; targetRequests=${targetPaths.length}; proxyRequests=${proxyPaths.length})`); if (exitCode !== 0) { throw new Error(`direct-transport child failed (${exitCode}): ${stderr.trim()}\n${stdout.trim()}`); } @@ -375,5 +389,5 @@ describe("local management direct transport", () => { if (proxyPort !== 0) await close(proxy); if (targetPort !== 0) await close(target); } - }); + }, DIRECT_CHILD_BUDGET_MS + 1_000); }); From f13cf27a22d975db2927e71960cec6e5fea02288 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:32:56 -0600 Subject: [PATCH 088/236] feat(logs): add composable filter controls --- gui/src/i18n/de.ts | 20 +++++ gui/src/i18n/en.ts | 20 +++++ gui/src/i18n/fr.ts | 20 +++++ gui/src/i18n/ja.ts | 20 +++++ gui/src/i18n/ko.ts | 20 +++++ gui/src/i18n/ru.ts | 20 +++++ gui/src/i18n/tr.ts | 20 +++++ gui/src/i18n/zh-TW.ts | 20 +++++ gui/src/i18n/zh.ts | 20 +++++ gui/src/pages/Logs.tsx | 108 ++++++----------------- gui/src/pages/logs-filter-bar.tsx | 123 +++++++++++++++++++++++++++ gui/src/styles.css | 2 + gui/tests/logs-auto-refresh.test.tsx | 11 +-- gui/tests/logs-filter-bar.test.ts | 81 ++++++++++++++++++ 14 files changed, 419 insertions(+), 86 deletions(-) create mode 100644 gui/src/pages/logs-filter-bar.tsx create mode 100644 gui/tests/logs-filter-bar.test.ts diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index cf04d02f67..b670ccda4c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -665,6 +665,26 @@ export const de: Record = { "logs.noRequests": "Noch keine Anfragen.", "logs.loadError": "Anfrageprotokolle konnten nicht geladen werden.", "logs.filter.surface.label": "Oberfläche", + "logs.filter.model.all": "Alle Modelle", + "logs.filter.provider.label": "Anbieter", + "logs.filter.provider.all": "Alle Anbieter", + "logs.filter.status.label": "Status", + "logs.filter.status.all": "Alle Status", + "logs.filter.status.success": "Erfolg (2xx)", + "logs.filter.status.errors": "Fehler (4xx/5xx)", + "logs.filter.time.label": "Zeit", + "logs.filter.time.all": "Alle Zeiten", + "logs.filter.time.15m": "Letzte 15 Min.", + "logs.filter.time.1h": "Letzte Stunde", + "logs.filter.time.24h": "Letzter Tag", + "logs.filter.speed.label": "Geschwindigkeit", + "logs.filter.speed.all": "Alle Geschwindigkeiten", + "logs.filter.speed.slow": "< 15 Tok/s", + "logs.filter.speed.medium": "15–< 50 Tok/s", + "logs.filter.speed.fast": "≥ 50 Tok/s", + "logs.filter.reset": "Filter zurücksetzen", + "logs.filter.showingCount": "{count} von {total} angezeigt", + "logs.noMatchingRequests": "Keine passenden Anfragen.", "logs.filter.surface.all": "Alle", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 5d0c0b0983..a3bff471c0 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -698,6 +698,26 @@ export const en = { "logs.noRequests": "No requests yet.", "logs.loadError": "Could not load request logs.", "logs.filter.surface.label": "Surface", + "logs.filter.model.all": "All models", + "logs.filter.provider.label": "Provider", + "logs.filter.provider.all": "All providers", + "logs.filter.status.label": "Status", + "logs.filter.status.all": "All statuses", + "logs.filter.status.success": "Success (2xx)", + "logs.filter.status.errors": "Errors (4xx/5xx)", + "logs.filter.time.label": "Time", + "logs.filter.time.all": "All time", + "logs.filter.time.15m": "Last 15m", + "logs.filter.time.1h": "Last 1h", + "logs.filter.time.24h": "Last 1d", + "logs.filter.speed.label": "Speed", + "logs.filter.speed.all": "All speeds", + "logs.filter.speed.slow": "< 15 tok/s", + "logs.filter.speed.medium": "15–< 50 tok/s", + "logs.filter.speed.fast": "≥ 50 tok/s", + "logs.filter.reset": "Reset filters", + "logs.filter.showingCount": "Showing {count} of {total}", + "logs.noMatchingRequests": "No matching requests.", "logs.filter.surface.all": "All", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index ac0d9f17c3..79e8fd5805 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -679,6 +679,26 @@ export const fr: Record = { "logs.noRequests": "Aucune requête pour le moment.", "logs.loadError": "Impossible de charger les journaux des requêtes.", "logs.filter.surface.label": "Interface", + "logs.filter.model.all": "Tous les modèles", + "logs.filter.provider.label": "Fournisseur", + "logs.filter.provider.all": "Tous les fournisseurs", + "logs.filter.status.label": "Statut", + "logs.filter.status.all": "Tous les statuts", + "logs.filter.status.success": "Réussites (2xx)", + "logs.filter.status.errors": "Erreurs (4xx/5xx)", + "logs.filter.time.label": "Temps", + "logs.filter.time.all": "Toutes les périodes", + "logs.filter.time.15m": "15 dernières min", + "logs.filter.time.1h": "Dernière heure", + "logs.filter.time.24h": "Dernier jour", + "logs.filter.speed.label": "Vitesse", + "logs.filter.speed.all": "Toutes les vitesses", + "logs.filter.speed.slow": "< 15 jetons/s", + "logs.filter.speed.medium": "15–< 50 jetons/s", + "logs.filter.speed.fast": "≥ 50 jetons/s", + "logs.filter.reset": "Réinitialiser les filtres", + "logs.filter.showingCount": "{count} sur {total} affichées", + "logs.noMatchingRequests": "Aucune requête correspondante.", "logs.filter.surface.all": "Toutes", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 7c6eab5675..6467a9ed4b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -641,6 +641,26 @@ export const ja: Record = { "logs.noRequests": "まだリクエストがありません。", "logs.loadError": "リクエストログを読み込めませんでした。", "logs.filter.surface.label": "サーフェス", + "logs.filter.model.all": "すべてのモデル", + "logs.filter.provider.label": "プロバイダー", + "logs.filter.provider.all": "すべてのプロバイダー", + "logs.filter.status.label": "ステータス", + "logs.filter.status.all": "すべてのステータス", + "logs.filter.status.success": "成功 (2xx)", + "logs.filter.status.errors": "エラー (4xx/5xx)", + "logs.filter.time.label": "時間", + "logs.filter.time.all": "すべての時間", + "logs.filter.time.15m": "過去15分", + "logs.filter.time.1h": "過去1時間", + "logs.filter.time.24h": "過去1日", + "logs.filter.speed.label": "速度", + "logs.filter.speed.all": "すべての速度", + "logs.filter.speed.slow": "< 15 トークン/秒", + "logs.filter.speed.medium": "15–< 50 トークン/秒", + "logs.filter.speed.fast": "≥ 50 トークン/秒", + "logs.filter.reset": "フィルターをリセット", + "logs.filter.showingCount": "{total} 件中 {count} 件を表示", + "logs.noMatchingRequests": "一致するリクエストはありません。", "logs.filter.surface.all": "すべて", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 21d9ab45eb..7d5a918439 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -684,6 +684,26 @@ export const ko: Record = { "logs.noRequests": "아직 요청이 없습니다.", "logs.loadError": "요청 로그를 불러오지 못했습니다.", "logs.filter.surface.label": "표면", + "logs.filter.model.all": "모든 모델", + "logs.filter.provider.label": "공급자", + "logs.filter.provider.all": "모든 공급자", + "logs.filter.status.label": "상태", + "logs.filter.status.all": "모든 상태", + "logs.filter.status.success": "성공 (2xx)", + "logs.filter.status.errors": "오류 (4xx/5xx)", + "logs.filter.time.label": "시간", + "logs.filter.time.all": "전체 시간", + "logs.filter.time.15m": "최근 15분", + "logs.filter.time.1h": "최근 1시간", + "logs.filter.time.24h": "최근 1일", + "logs.filter.speed.label": "속도", + "logs.filter.speed.all": "모든 속도", + "logs.filter.speed.slow": "< 15 토큰/초", + "logs.filter.speed.medium": "15–< 50 토큰/초", + "logs.filter.speed.fast": "≥ 50 토큰/초", + "logs.filter.reset": "필터 초기화", + "logs.filter.showingCount": "{total}개 중 {count}개 표시", + "logs.noMatchingRequests": "일치하는 요청이 없습니다.", "logs.filter.surface.all": "전체", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c97b62e9ac..82c3ea4e8a 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -682,6 +682,26 @@ export const ru: Record = { "logs.noRequests": "Запросов пока нет.", "logs.loadError": "Не удалось загрузить журнал запросов.", "logs.filter.surface.label": "Источник", + "logs.filter.model.all": "Все модели", + "logs.filter.provider.label": "Провайдер", + "logs.filter.provider.all": "Все провайдеры", + "logs.filter.status.label": "Статус", + "logs.filter.status.all": "Все статусы", + "logs.filter.status.success": "Успешные (2xx)", + "logs.filter.status.errors": "Ошибки (4xx/5xx)", + "logs.filter.time.label": "Время", + "logs.filter.time.all": "За всё время", + "logs.filter.time.15m": "Последние 15 мин", + "logs.filter.time.1h": "Последний час", + "logs.filter.time.24h": "Последний день", + "logs.filter.speed.label": "Скорость", + "logs.filter.speed.all": "Все скорости", + "logs.filter.speed.slow": "< 15 ток/с", + "logs.filter.speed.medium": "15–< 50 ток/с", + "logs.filter.speed.fast": "≥ 50 ток/с", + "logs.filter.reset": "Сбросить фильтры", + "logs.filter.showingCount": "Показано {count} из {total}", + "logs.noMatchingRequests": "Подходящих запросов нет.", "logs.filter.surface.all": "Все", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 7a6f5107c0..1b5724021a 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -689,6 +689,26 @@ export const tr: Record = { "logs.noRequests": "Henüz istek yok.", "logs.loadError": "İstek günlükleri yüklenemedi.", "logs.filter.surface.label": "Yüzey", + "logs.filter.model.all": "Tüm modeller", + "logs.filter.provider.label": "Sağlayıcı", + "logs.filter.provider.all": "Tüm sağlayıcılar", + "logs.filter.status.label": "Durum", + "logs.filter.status.all": "Tüm durumlar", + "logs.filter.status.success": "Başarılı (2xx)", + "logs.filter.status.errors": "Hatalar (4xx/5xx)", + "logs.filter.time.label": "Zaman", + "logs.filter.time.all": "Tüm zamanlar", + "logs.filter.time.15m": "Son 15 dk", + "logs.filter.time.1h": "Son 1 saat", + "logs.filter.time.24h": "Son 1 gün", + "logs.filter.speed.label": "Hız", + "logs.filter.speed.all": "Tüm hızlar", + "logs.filter.speed.slow": "< 15 belirteç/sn", + "logs.filter.speed.medium": "15–< 50 belirteç/sn", + "logs.filter.speed.fast": "≥ 50 belirteç/sn", + "logs.filter.reset": "Filtreleri sıfırla", + "logs.filter.showingCount": "{total} içinden {count} gösteriliyor", + "logs.noMatchingRequests": "Eşleşen istek yok.", "logs.filter.surface.all": "Tümü", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ed0aeba2d4..f543e948a5 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -534,6 +534,26 @@ export const zhTW: Record = { "logs.noRequests": "暫無請求。", "logs.loadError": "無法載入請求日誌。", "logs.filter.surface.label": "介面", + "logs.filter.model.all": "所有模型", + "logs.filter.provider.label": "提供者", + "logs.filter.provider.all": "所有提供者", + "logs.filter.status.label": "狀態", + "logs.filter.status.all": "所有狀態", + "logs.filter.status.success": "成功 (2xx)", + "logs.filter.status.errors": "錯誤 (4xx/5xx)", + "logs.filter.time.label": "時間", + "logs.filter.time.all": "所有時間", + "logs.filter.time.15m": "最近 15 分鐘", + "logs.filter.time.1h": "最近 1 小時", + "logs.filter.time.24h": "最近 1 天", + "logs.filter.speed.label": "速度", + "logs.filter.speed.all": "所有速度", + "logs.filter.speed.slow": "< 15 權杖/秒", + "logs.filter.speed.medium": "15–< 50 權杖/秒", + "logs.filter.speed.fast": "≥ 50 權杖/秒", + "logs.filter.reset": "重設篩選", + "logs.filter.showingCount": "顯示 {count}/{total}", + "logs.noMatchingRequests": "沒有相符的請求。", "logs.filter.surface.all": "全部", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 44450d8785..c0dbd81a5c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -677,6 +677,26 @@ export const zh: Record = { "logs.noRequests": "暂无请求。", "logs.loadError": "无法加载请求日志。", "logs.filter.surface.label": "界面", + "logs.filter.model.all": "所有模型", + "logs.filter.provider.label": "提供商", + "logs.filter.provider.all": "所有提供商", + "logs.filter.status.label": "状态", + "logs.filter.status.all": "所有状态", + "logs.filter.status.success": "成功 (2xx)", + "logs.filter.status.errors": "错误 (4xx/5xx)", + "logs.filter.time.label": "时间", + "logs.filter.time.all": "所有时间", + "logs.filter.time.15m": "最近 15 分钟", + "logs.filter.time.1h": "最近 1 小时", + "logs.filter.time.24h": "最近 1 天", + "logs.filter.speed.label": "速度", + "logs.filter.speed.all": "所有速度", + "logs.filter.speed.slow": "< 15 令牌/秒", + "logs.filter.speed.medium": "15–< 50 令牌/秒", + "logs.filter.speed.fast": "≥ 50 令牌/秒", + "logs.filter.reset": "重置筛选", + "logs.filter.showingCount": "显示 {count}/{total}", + "logs.noMatchingRequests": "没有匹配的请求。", "logs.filter.surface.all": "全部", "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 40b3a265c8..a41b6bdb62 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -1,9 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useI18n, LOCALES, type TFn } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; import { formatTokens } from "../format-tokens"; -import { hashLogConversationQuery, matchesLogConversationId } from "../log-conversation-id"; +import { hashLogConversationQuery } from "../log-conversation-id"; import { statusCodeInfo } from "../status-codes"; import { IconX } from "../icons"; import { modelLabel } from "../model-display"; @@ -12,6 +12,8 @@ import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import { EmptyState, Notice } from "../ui"; import Debug from "./Debug"; +import { LogsFilterBar } from "./logs-filter-bar"; +import { DEFAULT_LOG_FILTER_STATE, extractLogFilterOptions, filterLogs, hasActiveLogFilters, type LogFilterState } from "./logs-filter"; import type { LogsTab } from "./logs-tab-keydown"; import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; @@ -19,9 +21,7 @@ import { modelTitle, type ModelTitleTierOutcome } from "./logs-model-title"; import { speedLabel } from "./logs-speed-label"; import { formatEstimatedUsd, formatEstimatedUsdValue, summarizeEstimatedCosts } from "./logs-cost-format"; import { cacheSplit, isCursorUsageProvider, tokensTitle } from "./logs-token-title"; -import type { LogSurface, LogSurfaceFilter } from "./logs-surface-filter"; -import { logMatchesSurface } from "./logs-surface-filter"; -import { logMatchesModelQuery } from "./logs-model-filter"; +import type { LogSurface } from "./logs-surface-filter"; import { sanitizeLogEntryRouteDecision, validCachedRouteDecision, @@ -31,6 +31,8 @@ function logsCacheKey(apiBase: string): string { return `ocx.logs.list.v1:${apiBase}`; } +const EMPTY_LOGS: LogEntry[] = []; + interface UsageBreakdown { inputTokens: number; outputTokens: number; @@ -373,11 +375,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { { error: null, count: 0 }, ); const [detail, setDetail] = useState(null); - const [surfaceFilter, setSurfaceFilter] = useState("all"); - const [interceptedHelpersOnly, setInterceptedHelpersOnly] = useState(false); - const [conversationFilter, setConversationFilter] = useState(""); - const [modelFilter, setModelFilter] = useState(""); - const [conversationQueryHash, setConversationQueryHash] = useState(); + const [filters, setFilters] = useState(DEFAULT_LOG_FILTER_STATE); const scrollContainerRef = useRef(null); const logRetryRef = useRef<{ key: string; failures: number; nextAttemptAt: number; error: unknown }>( { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }, @@ -472,7 +470,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { }, ); const logsState = logsResource.state; - const logs = logsState.data ?? cachedLogs ?? []; + const logs = logsState.data ?? cachedLogs ?? EMPTY_LOGS; const fetchLogs = logsResource.refresh; const retryLogs = useCallback(() => { logRetryRef.current = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }; @@ -499,26 +497,23 @@ export default function Logs({ apiBase }: { apiBase: string }) { || (!autoRefresh && settledFailure); const detailInfo = detail ? statusCodeInfo(detail.status, locale) : null; - const conversationQuery = conversationFilter.trim(); + const conversationQuery = filters.conversationId.trim(); useEffect(() => { let cancelled = false; if (!conversationQuery) { - setConversationQueryHash(undefined); + setFilters(prev => prev.conversationQueryHash === undefined ? prev : { ...prev, conversationQueryHash: undefined }); return; } void hashLogConversationQuery(conversationQuery).then(hash => { - if (!cancelled) setConversationQueryHash(hash); + if (!cancelled) setFilters(prev => prev.conversationQueryHash === hash ? prev : { ...prev, conversationQueryHash: hash }); }); return () => { cancelled = true; }; }, [conversationQuery]); - const filteredLogs = logs.filter(log => ( - logMatchesSurface(log, surfaceFilter) - && (!interceptedHelpersOnly || Boolean(log.shadowCallRewrittenFrom)) - && logMatchesModelQuery(log, modelFilter) - && (!conversationQuery || matchesLogConversationId(log.conversationId, conversationQuery, conversationQueryHash)) - )); + const filterOptions = useMemo(() => extractLogFilterOptions(logs), [logs]); + const activeFilters = hasActiveLogFilters(filters); + const filteredLogs = useMemo(() => filterLogs(logs, filters), [logs, filters]); const conversationTotals = conversationQuery ? summarizeFilteredLogs(filteredLogs) : null; // TanStack Virtual returns unstable function identities; React Compiler skips this call. @@ -597,65 +592,16 @@ export default function Logs({ apiBase }: { apiBase: string }) { hidden={tab !== "logs"} > -
    - {t("logs.filter.surface.label")} -
    - {(["all", "claude", "codex", "grok"] as const).map(surface => ( - - ))} -
    - {/* - "Intercepted", not "helper". The marker only exists when Shadow Call Intercept - rewrote the request, so a helper request that was not intercepted looks exactly like - ordinary traffic here. A broader label would promise a classification this data - cannot support. - */} - - - - {conversationQuery && ( - - )} -
    + setFilters(DEFAULT_LOG_FILTER_STATE)} + /> {conversationTotals && (
    @@ -714,7 +660,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { {logsState.kind === "failed-cold" ? null : logsState.showSkeleton && logs.length === 0 ? ( ) : filteredLogs.length === 0 ? ( - + 0 && activeFilters ? t("logs.noMatchingRequests") : t("logs.noRequests")} /> ) : ( <>
    @@ -864,7 +810,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { t={t} onClose={() => setDetail(null)} onFilterConversation={id => { - setConversationFilter(id); + setFilters(prev => ({ ...prev, conversationId: id })); setDetail(null); }} /> diff --git a/gui/src/pages/logs-filter-bar.tsx b/gui/src/pages/logs-filter-bar.tsx new file mode 100644 index 0000000000..047440f109 --- /dev/null +++ b/gui/src/pages/logs-filter-bar.tsx @@ -0,0 +1,123 @@ +import type { TFn } from "../i18n/shared"; +import { IconX } from "../icons"; +import { formatProviderDisplayName } from "../provider-icons"; +import type { LogFilterState, LogStatusFilter, LogTimeWindow } from "./logs-filter"; + +interface LogsFilterBarProps { + filters: LogFilterState; + options: { models: string[]; providers: string[] }; + hasActiveFilters: boolean; + filteredCount: number; + totalCount: number; + t: TFn; + onFilterChange: (next: LogFilterState) => void; + onResetFilters: () => void; +} + +export function LogsFilterBar({ + filters, options, hasActiveFilters, filteredCount, totalCount, t, onFilterChange, onResetFilters, +}: LogsFilterBarProps) { + const currentSpeed = filters.maxTokPerSec === 15 + ? "slow" + : filters.minTokPerSec === 15 && filters.maxTokPerSec === 50 + ? "medium" + : filters.minTokPerSec === 50 ? "fast" : "all"; + + const handleSpeedChange = (value: string) => { + const bounds = value === "slow" + ? { minTokPerSec: undefined, maxTokPerSec: 15 } + : value === "medium" + ? { minTokPerSec: 15, maxTokPerSec: 50 } + : value === "fast" + ? { minTokPerSec: 50, maxTokPerSec: undefined } + : { minTokPerSec: undefined, maxTokPerSec: undefined }; + onFilterChange({ ...filters, ...bounds }); + }; + + return ( +
    +
    + {t("logs.filter.surface.label")} +
    + {(["all", "claude", "codex", "grok"] as const).map(surface => ( + + ))} +
    + + + + + + + + + + + + +
    + +
    + + + {hasActiveFilters && ( +
    + {t("logs.filter.showingCount", { count: filteredCount, total: totalCount })} + +
    + )} +
    +
    + ); +} diff --git a/gui/src/styles.css b/gui/src/styles.css index 654d384068..7c10871761 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2149,6 +2149,8 @@ table.logs-table { gap: var(--space-2); margin-bottom: var(--space-3); } +.logs-filter-status { display: inline-flex; align-items: center; gap: var(--space-2); margin-left: auto; } +.logs-toolbar-secondary { margin-top: calc(var(--space-1) * -1); } .logs-segmented { display: inline-flex; diff --git a/gui/tests/logs-auto-refresh.test.tsx b/gui/tests/logs-auto-refresh.test.tsx index b0dbda2501..8038f7550c 100644 --- a/gui/tests/logs-auto-refresh.test.tsx +++ b/gui/tests/logs-auto-refresh.test.tsx @@ -542,8 +542,9 @@ test("Logs: an intercepted helper row is badged and filterable", async () => { await flushMicrotasks(); // The badge names the ORIGINAL helper model, which is the attribution that was being lost. - expect(container.textContent).toContain("I · gpt-5.6-luna"); - expect(container.textContent).toContain("gpt-test"); + const tableText = () => container.querySelector(".logs-table tbody")?.textContent ?? ""; + expect(tableText()).toContain("I · gpt-5.6-luna"); + expect(tableText()).toContain("gpt-test"); const toggle = [...container.querySelectorAll("input[type=checkbox]")].find( input => input.closest("label")?.textContent?.includes("Intercepted helpers only"), @@ -557,8 +558,8 @@ test("Logs: an intercepted helper row is badged and filterable", async () => { }); // Filtered: the marked row stays, the ordinary one goes. - expect(container.textContent).toContain("I · gpt-5.6-luna"); - expect(container.textContent).not.toContain("gpt-test"); + expect(tableText()).toContain("I · gpt-5.6-luna"); + expect(tableText()).not.toContain("gpt-test"); await act(async () => { toggle!.click(); }); await act(async () => { @@ -566,7 +567,7 @@ test("Logs: an intercepted helper row is badged and filterable", async () => { await Promise.resolve(); }); - expect(container.textContent).toContain("gpt-test"); + expect(tableText()).toContain("gpt-test"); await act(async () => { root.unmount(); }); }); diff --git a/gui/tests/logs-filter-bar.test.ts b/gui/tests/logs-filter-bar.test.ts new file mode 100644 index 0000000000..40a6b0027c --- /dev/null +++ b/gui/tests/logs-filter-bar.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { DEFAULT_LOG_FILTER_STATE, type LogFilterState } from "../src/pages/logs-filter"; +import { LogsFilterBar } from "../src/pages/logs-filter-bar"; + +test("Logs mounts the shared rich-filter predicate and filter bar", async () => { + const source = await Bun.file(new URL("../src/pages/Logs.tsx", import.meta.url)).text(); + expect(source).toContain('import { LogsFilterBar } from "./logs-filter-bar";'); + expect(source).toContain("filterLogs(logs, filters)"); + expect(source).toContain("extractLogFilterOptions(logs)"); + expect(source).not.toContain("logMatchesSurface(log, surfaceFilter)"); + expect(source).not.toContain("logMatchesModelQuery(log, modelFilter)"); +}); + +test("LogsFilterBar exposes every engine filter field and reset affordance", async () => { + const source = await Bun.file(new URL("../src/pages/logs-filter-bar.tsx", import.meta.url)).text(); + for (const key of [ + "filters.surface", "filters.interceptedOnly", "filters.provider", "filters.model", + "filters.timeWindow", "filters.minTokPerSec", "filters.maxTokPerSec", "filters.status", + "filters.conversationId", + ]) { + expect(source).toContain(key); + } + expect(source).toContain('t("logs.filter.reset")'); +}); + +test("LogsFilterBar renders labeled controls, count, and reset interaction", async () => { + const win = new Window({ url: "http://localhost/#logs" }); + const previous = { + document: globalThis.document, + window: globalThis.window, + navigator: globalThis.navigator, + actEnvironment: (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT, + }; + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + try { + const container = document.createElement("div"); + document.body.append(container); + const updates: LogFilterState[] = []; + const root = createRoot(container); + const filters = { ...DEFAULT_LOG_FILTER_STATE }; + await act(async () => { + root.render(createElement(LanguageProvider, null, + createElement(LogsFilterBar, { + filters: { ...filters, status: "errors" }, + options: { models: ["gpt-test"], providers: ["openai"] }, + hasActiveFilters: true, + filteredCount: 1, + totalCount: 2, + t: ((key: string, vars?: Record) => key === "logs.filter.showingCount" ? `Showing ${vars?.count} of ${vars?.total}` : key) as never, + onFilterChange: next => updates.push(next), + onResetFilters: () => updates.push(filters), + }), + )); + }); + expect(container.querySelector('select[aria-label="logs.filter.status.label"]')).not.toBeNull(); + expect(container.querySelector('select[aria-label="logs.filter.provider.label"]')).not.toBeNull(); + expect(container.textContent).toContain("Showing 1 of 2"); + const reset = [...container.querySelectorAll("button")].find(button => button.textContent?.includes("logs.filter.reset")); + expect(reset).toBeTruthy(); + await act(async () => { reset!.click(); }); + expect(updates.at(-1)).toEqual(filters); + await act(async () => { root.unmount(); }); + } finally { + Object.defineProperties(globalThis, { + document: { configurable: true, value: previous.document }, + window: { configurable: true, value: previous.window }, + navigator: { configurable: true, value: previous.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previous.actEnvironment; + win.close(); + } +}); From 0073dd331b075578d1616d39a915bf87f00befaa Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:03:12 -0600 Subject: [PATCH 089/236] fix(gui): address logs filter review findings --- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/tr.ts | 10 ++++----- gui/src/pages/Logs.tsx | 30 +++++++++++++++++++++++++- gui/src/pages/logs-filter-bar.tsx | 4 ++++ gui/src/pages/logs-filter.ts | 5 ++++- gui/src/pages/logs-surface-keydown.ts | 24 +++++++++++++++++++++ gui/tests/logs-filter-bar.test.ts | 31 +++++++++++++++++++++++++++ gui/tests/logs-filter.test.ts | 13 +++++++---- 8 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 gui/src/pages/logs-surface-keydown.ts diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 79e8fd5805..84b6e3108f 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -697,7 +697,7 @@ export const fr: Record = { "logs.filter.speed.medium": "15–< 50 jetons/s", "logs.filter.speed.fast": "≥ 50 jetons/s", "logs.filter.reset": "Réinitialiser les filtres", - "logs.filter.showingCount": "{count} sur {total} affichées", + "logs.filter.showingCount": "Affichage de {count} sur {total}", "logs.noMatchingRequests": "Aucune requête correspondante.", "logs.filter.surface.all": "Toutes", "logs.filter.surface.claude": "Claude", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1b5724021a..3b637bbb47 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -703,9 +703,9 @@ export const tr: Record = { "logs.filter.time.24h": "Son 1 gün", "logs.filter.speed.label": "Hız", "logs.filter.speed.all": "Tüm hızlar", - "logs.filter.speed.slow": "< 15 belirteç/sn", - "logs.filter.speed.medium": "15–< 50 belirteç/sn", - "logs.filter.speed.fast": "≥ 50 belirteç/sn", + "logs.filter.speed.slow": "< 15 token/sn", + "logs.filter.speed.medium": "15–< 50 token/sn", + "logs.filter.speed.fast": "≥ 50 token/sn", "logs.filter.reset": "Filtreleri sıfırla", "logs.filter.showingCount": "{total} içinden {count} gösteriliyor", "logs.noMatchingRequests": "Eşleşen istek yok.", @@ -738,9 +738,9 @@ export const tr: Record = { "logs.col.provider": "Sağlayıcı", "logs.col.status": "Durum", "logs.col.tokens": "Jetonlar", - "logs.col.tokPerSec": "jeton/sn", + "logs.col.tokPerSec": "token/sn", "logs.col.estimatedCost": "~$", - "logs.metric.tokPerSecTitle": "Çıktı jetonu / saniye", + "logs.metric.tokPerSecTitle": "Tam istek süresince saniye başına çıktı tokenı", "logs.metric.estimatedCostTitle": "Tahmini API liste fiyatı", "usage.cost.total": "API liste fiyatı eşdeğeri", "usage.cost.disclaimer": "Fatura makbuzu değildir.", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index a41b6bdb62..19b5f19bc4 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -257,6 +257,10 @@ function formatTokPerSecond(result: TokPerSecondResult | undefined, localeTag?: } const LOGS_POLL_INTERVAL_MS = 2000; +// Relative time filters must advance even when the polled snapshot is unchanged. Keep the +// refresh independent from the network poll so an active 15m/1h/24h window expires rows while +// the proxy is idle. +const LOGS_FILTER_CLOCK_INTERVAL_MS = 30_000; const LOGS_POLL_BACKOFF_MAX_EXPONENT = 4; /** Consecutive failed polls before a stale table is called out. */ const STALE_POLL_FAILURE_LIMIT = 3; @@ -376,6 +380,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { ); const [detail, setDetail] = useState(null); const [filters, setFilters] = useState(DEFAULT_LOG_FILTER_STATE); + const [filterClockNow, setFilterClockNow] = useState(() => Date.now()); const scrollContainerRef = useRef(null); const logRetryRef = useRef<{ key: string; failures: number; nextAttemptAt: number; error: unknown }>( { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }, @@ -499,6 +504,13 @@ export default function Logs({ apiBase }: { apiBase: string }) { const detailInfo = detail ? statusCodeInfo(detail.status, locale) : null; const conversationQuery = filters.conversationId.trim(); + useEffect(() => { + if (filters.timeWindow === "all" || tab !== "logs") return; + setFilterClockNow(Date.now()); + const timer = window.setInterval(() => setFilterClockNow(Date.now()), LOGS_FILTER_CLOCK_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [filters.timeWindow, tab]); + useEffect(() => { let cancelled = false; if (!conversationQuery) { @@ -512,8 +524,24 @@ export default function Logs({ apiBase }: { apiBase: string }) { }, [conversationQuery]); const filterOptions = useMemo(() => extractLogFilterOptions(logs), [logs]); + // A selected option can disappear when the bounded log snapshot rolls over. Native + // controls render such a value as an unlabeled/blank selection while the - // filter still excludes every row. Clear only the vanished model/provider identity; - // all other filters remain intact and the control returns to its explicit "All" option. + // Native selects require the exact current option spelling even though filtering + // compares normalized identities. Reconcile casing changes on ring rollover, and + // clear only identities that disappeared; keep every other filter intact. useEffect(() => { - const model = filters.model.trim().toLowerCase(); - const provider = filters.provider.trim().toLowerCase(); - const modelStillPresent = !model || filterOptions.models.some(option => option.trim().toLowerCase() === model); - const providerStillPresent = !provider || filterOptions.providers.some(option => option.trim().toLowerCase() === provider); - if (modelStillPresent && providerStillPresent) return; - setFilters(previous => ({ - ...previous, - ...(modelStillPresent ? {} : { model: "" }), - ...(providerStillPresent ? {} : { provider: "" }), - })); + setFilters(previous => { + const model = previous.model.trim().toLowerCase(); + const provider = previous.provider.trim().toLowerCase(); + const nextModel = model + ? filterOptions.models.find(option => option.trim().toLowerCase() === model) ?? "" + : ""; + const nextProvider = provider + ? filterOptions.providers.find(option => option.trim().toLowerCase() === provider) ?? "" + : ""; + if (previous.model === nextModel && previous.provider === nextProvider) return previous; + return { ...previous, model: nextModel, provider: nextProvider }; + }); }, [filterOptions, filters.model, filters.provider]); const activeFilters = hasActiveLogFilters(filters); const filteredLogs = useMemo(() => filterLogs(logs, filters, filterClockNow), [logs, filters, filterClockNow]); diff --git a/gui/tests/logs-auto-refresh.test.tsx b/gui/tests/logs-auto-refresh.test.tsx index a9cccc613c..e40fad1cda 100644 --- a/gui/tests/logs-auto-refresh.test.tsx +++ b/gui/tests/logs-auto-refresh.test.tsx @@ -790,6 +790,44 @@ test("Logs: ring rollover clears only vanished model and provider selections", a } }); +test("Logs: casing-only rollover retains model and provider selection with current option spellings", async () => { + let rows = [ + { ...sampleLog, requestId: "selected", model: "GPT-5", provider: "OpenAI" }, + { ...sampleLog, requestId: "other-model", model: "model-other", provider: "OpenAI" }, + { ...sampleLog, requestId: "other-provider", model: "GPT-5", provider: "xai" }, + ]; + serveLogSnapshot(() => rows); + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + await changeLogSelect(container, "Model", "GPT-5"); + await changeLogSelect(container, "Provider", "OpenAI"); + await changeLogSelect(container, "Status", "success"); + const select = (label: string) => container.querySelector(`select[aria-label="${label}"]`)!; + expect(select("Model").value).toBe("GPT-5"); + expect(select("Provider").value).toBe("OpenAI"); + expect(visibleRequestIds(container)).toEqual(["selected"]); + + rows = rows.map(row => ({ ...row, model: row.model === "GPT-5" ? "gpt-5" : row.model })); + await advanceSilentRefresh(); + expect(select("Model").value).toBe("gpt-5"); + expect(select("Model").selectedOptions[0]?.value).toBe("gpt-5"); + expect(select("Provider").value).toBe("OpenAI"); + expect(visibleRequestIds(container)).toEqual(["selected"]); + + rows = rows.map(row => ({ ...row, provider: row.provider === "OpenAI" ? "openai" : row.provider })); + await advanceSilentRefresh(); + expect(select("Provider").value).toBe("openai"); + expect(select("Provider").selectedOptions[0]?.value).toBe("openai"); + expect(select("Model").value).toBe("gpt-5"); + expect(select("Status").value).toBe("success"); + expect(visibleRequestIds(container)).toEqual(["selected"]); + expect(container.querySelector(".logs-filter-status")?.textContent).toContain("Showing 1 of 3"); + } finally { + await act(async () => { root.unmount(); }); + } +}); + test("Logs: detail conversation action and reset use the same filter state", async () => { const digest = jest.spyOn(crypto.subtle, "digest") .mockResolvedValueOnce(new Uint8Array(32).fill(17).buffer) From 8b5dbde0209f80ececeb3b0743fc911bae18931f Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:15:07 +0900 Subject: [PATCH 095/236] fix(lab): keep producer deadline failures authoritative until close --- .../034_verification_followup.md | 3 + scripts/test-layout/layout.json | 1 + src/lab/fabric/producer-isolate.ts | 130 ++++-- tests/fixtures/test-layout-expected.json | 1 + .../lab/lab-fabric-producer-deadline.test.ts | 423 ++++++++++++++++++ 5 files changed, 512 insertions(+), 46 deletions(-) create mode 100644 devlog/_plan/260906_lane_b_catalog_stack/034_verification_followup.md create mode 100644 tests/lab/lab-fabric-producer-deadline.test.ts diff --git a/devlog/_plan/260906_lane_b_catalog_stack/034_verification_followup.md b/devlog/_plan/260906_lane_b_catalog_stack/034_verification_followup.md new file mode 100644 index 0000000000..4b220a619d --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/034_verification_followup.md @@ -0,0 +1,3 @@ +# Verification follow-up + +The ordering CI run reported an unrelated Lab supervision test failure. A bounded verification prerequisite is reviewed separately from the catalog change. Detailed pre-publication analysis and the implementation plan remain in ignored scratch under the repository security-working-note policy. Product limits and existing assertions are not relaxed. The original ordering branch and failed outputs remain preserved; no success is claimed at this planning checkpoint. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 045707d947..ce7d767d65 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -718,6 +718,7 @@ "lab-evidence-sanitization.test.ts": "lab", "lab-fabric-outcome-validation.test.ts": "lab", "lab-fabric-persistence-boundary.test.ts": "lab", + "lab-fabric-producer-deadline.test.ts": "lab", "lab-fabric-task.test.ts": "lab", "lab-installation-salt-cache.test.ts": "lab", "lab-ledger-mutation-lock.test.ts": "lab", diff --git a/src/lab/fabric/producer-isolate.ts b/src/lab/fabric/producer-isolate.ts index 3ab672b9bd..70291adffc 100644 --- a/src/lab/fabric/producer-isolate.ts +++ b/src/lab/fabric/producer-isolate.ts @@ -84,6 +84,11 @@ function killChild(child: ChildProcess): void { export async function runIsolatedFabricProducer(request: IsolateRequest): Promise { const now = request.now ?? (() => Date.now()); let lastActivityAt = now(); + // Budget enforcement must not follow wall-clock adjustments; telemetry still does. + const budgetNow = request.now ?? (() => performance.now()); + const startedAt = request.now ? lastActivityAt : budgetNow(); + const totalDeadline = startedAt + request.totalTimeoutMs; + let inactivityDeadline = startedAt + request.inactivityTimeoutMs; return await new Promise((resolve, reject) => { let child: ChildProcess; @@ -104,48 +109,74 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis let stdoutBuffer = ""; let stderrBytes = 0; let settled = false; + let childClosed = false; let receivedResult: SyntheticPatchV1 | undefined; let killReason: FabricTaskError | undefined; const finish = (fn: () => void) => { - if (settled) return; + // A latched failure owns settlement, but scratch cleanup must wait for close. + if (settled || (killReason && !childClosed)) return; settled = true; clearTimeout(totalTimer); clearTimeout(inactivityTimer); - fn(); + if (killReason) reject(killReason); + else fn(); }; const settleTimeout = (error: FabricTaskError) => { - if (settled) return; + if (settled || killReason) return; killReason = error; - killChild(child); + if (childClosed) finish(() => reject(error)); + else killChild(child); + }; + + const expiredDeadline = (at: number): FabricTaskError | undefined => { + // Choose the earliest deadline, regardless of which timer/data callback ran first. + if (at >= inactivityDeadline && inactivityDeadline <= totalDeadline) { + return new FabricTaskError("inactivity timeout exceeded", "inactivity_timeout", "environment"); + } + if (at >= totalDeadline) { + return new FabricTaskError("total timeout exceeded", "timeout", "environment"); + } + return undefined; + }; + + const onInactivityTimeout = () => { + settleTimeout(expiredDeadline(budgetNow()) + ?? new FabricTaskError("inactivity timeout exceeded", "inactivity_timeout", "environment")); }; const armInactivity = () => { clearTimeout(inactivityTimer); - inactivityTimer = setTimeout(() => { - settleTimeout(new FabricTaskError("inactivity timeout exceeded", "inactivity_timeout", "environment")); - }, request.inactivityTimeoutMs); + inactivityTimer = setTimeout(onInactivityTimeout, request.inactivityTimeoutMs); }; - let inactivityTimer: ReturnType = setTimeout(() => { - settleTimeout(new FabricTaskError("inactivity timeout exceeded", "inactivity_timeout", "environment")); - }, request.inactivityTimeoutMs); + let inactivityTimer: ReturnType = setTimeout(onInactivityTimeout, request.inactivityTimeoutMs); const totalTimer = setTimeout(() => { - settleTimeout(new FabricTaskError("total timeout exceeded", "timeout", "environment")); + settleTimeout(expiredDeadline(budgetNow()) + ?? new FabricTaskError("total timeout exceeded", "timeout", "environment")); }, request.totalTimeoutMs); const handleProtocolLine = (line: string) => { + if (settled || killReason) return; try { const message = parseProducerProtocolLine(line); - if (message.type === "activity") { - lastActivityAt = now(); - armInactivity(); - return; + if (message.type === "activity" || message.type === "result") { + const at = budgetNow(); + const expired = expiredDeadline(at); + if (expired) { + settleTimeout(expired); + return; + } + if (message.type === "activity") { + lastActivityAt = request.now ? at : now(); + inactivityDeadline = at + request.inactivityTimeoutMs; + armInactivity(); + return; + } } if (message.type === "result") { - if (settled) return; receivedResult = message.patch; finish(() => resolve({ patch: message.patch, lastActivityAt })); return; @@ -176,6 +207,7 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis }; const consumeStdout = (chunk: string) => { + if (settled || killReason) return; stdoutBuffer += chunk; if (Buffer.byteLength(stdoutBuffer, "utf8") > FABRIC_PRODUCER_PROTOCOL_MAX_BYTES) { settleTimeout(new FabricTaskError("producer protocol output exceeded limit", "budget_exhausted", "environment")); @@ -207,16 +239,50 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis } }); + child.stderr?.on("error", (error) => { + settleTimeout(new FabricTaskError(error.message, "harness_failure", "harness")); + }); + child.on("error", (error) => { finish(() => reject(new FabricTaskError(error.message, "harness_failure", "harness"))); }); child.stdin?.on("error", (error: NodeJS.ErrnoException) => { - if (settled || error.code === "EPIPE") return; + if (settled || killReason || error.code === "EPIPE") return; killChild(child); finish(() => reject(new FabricTaskError(error.message, "harness_failure", "harness"))); }); + child.on("close", (code, signal) => { + childClosed = true; + if (settled) return; + if (killReason) { + finish(() => reject(killReason!)); + return; + } + if (receivedResult) { + finish(() => resolve({ patch: receivedResult!, lastActivityAt })); + return; + } + if (stdoutBuffer.trim()) { + try { + handleProtocolLine(stdoutBuffer.trim()); + if (settled) return; + } catch { + /* fall through */ + } + } + if (signal === "SIGKILL") { + finish(() => reject(new FabricTaskError("total timeout exceeded", "timeout", "environment"))); + return; + } + finish(() => reject(new FabricTaskError( + code === 0 ? "isolated producer returned no result" : `isolated producer exited (${code ?? signal ?? "unknown"})`, + "harness_failure", + "harness", + ))); + }); + const payload = JSON.stringify({ harnessKind: request.harnessKind, executorModulePath: request.executorModulePath, @@ -242,6 +308,7 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis child.stdin?.write(payload); child.stdin?.end(); } catch (error) { + if (killReason) return; killChild(child); finish(() => reject(new FabricTaskError( error instanceof Error ? error.message : String(error), @@ -250,35 +317,6 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis ))); return; } - - child.on("close", (code, signal) => { - if (settled) return; - if (killReason) { - finish(() => reject(killReason!)); - return; - } - if (receivedResult) { - finish(() => resolve({ patch: receivedResult!, lastActivityAt })); - return; - } - if (stdoutBuffer.trim()) { - try { - handleProtocolLine(stdoutBuffer.trim()); - if (receivedResult) return; - } catch { - /* fall through */ - } - } - if (signal === "SIGKILL") { - finish(() => reject(new FabricTaskError("total timeout exceeded", "timeout", "environment"))); - return; - } - finish(() => reject(new FabricTaskError( - code === 0 ? "isolated producer returned no result" : `isolated producer exited (${code ?? signal ?? "unknown"})`, - "harness_failure", - "harness", - ))); - }); }); } diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7e7dd7d126..ad415d8a6f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -555,6 +555,7 @@ "lab-evidence-sanitization.test.ts": "lab", "lab-fabric-outcome-validation.test.ts": "lab", "lab-fabric-persistence-boundary.test.ts": "lab", + "lab-fabric-producer-deadline.test.ts": "lab", "lab-fabric-task.test.ts": "lab", "lab-installation-salt-cache.test.ts": "lab", "lab-ledger-mutation-lock.test.ts": "lab", diff --git a/tests/lab/lab-fabric-producer-deadline.test.ts b/tests/lab/lab-fabric-producer-deadline.test.ts new file mode 100644 index 0000000000..f4895b90cf --- /dev/null +++ b/tests/lab/lab-fabric-producer-deadline.test.ts @@ -0,0 +1,423 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import * as childProcess from "node:child_process"; +import { EventEmitter } from "node:events"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { setImmediate as nextTurn } from "node:timers"; +import { runIsolatedFabricProducer } from "../../src/lab/fabric/producer-isolate"; +import type { IsolatedProducerResult } from "../../src/lab/fabric/producer-protocol"; +import { FabricTaskError, type FabricTaskRunResult, type SyntheticPatchV1 } from "../../src/lab/fabric/types"; +import { runFabricSyntheticPatchTaskForRoute } from "../../src/lab/fabric/executor"; +import { createLabDestination } from "../../src/lab/live/destination"; +import { fabricCorrectPatchExecutor, fabricMockRoute } from "../helpers/fabric-task-test"; + +// Hand-written valid fixture: an always-reject supervisor must fail the controls. +const PATCH: SyntheticPatchV1 = { + schemaVersion: 1, + operations: [{ op: "replace", path: "src/value.txt", contentUtf8: "after\n" }], +}; +const RESULT = JSON.stringify({ type: "result", patch: PATCH }); +const ACTIVITY = '{"type":"activity"}\n'; +const START = 1_000; +const IDLE_MS = 100; +const TOTAL_MS = 250; + +class DeadlineChild extends EventEmitter { + readonly stdin = new PassThrough(); + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly signals: Array = []; + closed = false; + + kill(signal?: NodeJS.Signals | number): boolean { + this.signals.push(signal); + return true; // Buffered data can arrive after kill; only the test emits close. + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.emit("close", 0, null); + } +} + +type CapturedTimer = { callback: () => void; delay: number; cleared: boolean }; +type Outcome = + | { status: "pending" } + | { status: "resolved"; value: T } + | { status: "rejected"; error: unknown }; + +// Drain promise adoption and stream nextTicks, without sleeping or advancing time. +const drain = () => new Promise((resolve) => nextTurn(resolve)); + +function installTimers(restorers: Array<() => void>) { + const timers: CapturedTimer[] = []; + const handles = new Map, CapturedTimer>(); + const setSpy = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void, delay: number) => { + const timer = { callback, delay, cleared: false }; + // Only timer identity/unref are consumed by this supervisor; no real handle. + const handle = { unref() { return this; } } as unknown as ReturnType; + timers.push(timer); + handles.set(handle, timer); + return handle; + }) as typeof setTimeout); + restorers.push(() => setSpy.mockRestore()); + const clearSpy = spyOn(globalThis, "clearTimeout").mockImplementation((handle) => { + const timer = handles.get(handle as ReturnType); + if (timer) timer.cleared = true; + }); + restorers.push(() => clearSpy.mockRestore()); + return timers; +} + +type ExpectedFailure = + | [code: "inactivity_timeout" | "timeout"] + | [code: "harness_failure", attribution: "harness", message: string]; + +type Harness = { + child: DeadlineChild; + timers: CapturedTimer[]; + at: (time: number) => void; + result: (newline?: boolean) => void; + pending: () => Promise; + failure: (...expected: ExpectedFailure) => Promise; + success: (lastActivityAt?: number) => Promise; +}; + +async function withProducer(body: (h: Harness) => Promise, totalTimeoutMs = TOTAL_MS) { + const scratchRoot = mkdtempSync(join(tmpdir(), "ocx-fabric-deadline-")); + const child = new DeadlineChild(); + const originals = { spawn: childProcess.spawn, set: globalThis.setTimeout, clear: globalThis.clearTimeout }; + const restorers: Array<() => void> = []; + let time = START; + let outcome: Outcome = { status: "pending" }; + try { + // Repository namespace-spy precedent; never delegates to the original spawn. + const spawnSpy = spyOn(childProcess, "spawn").mockImplementation(() => child as unknown as childProcess.ChildProcess); + restorers.push(() => spawnSpy.mockRestore()); + const timers = installTimers(restorers); + void runIsolatedFabricProducer({ + scratchRoot, harnessKind: "deterministic_correct", totalTimeoutMs, + inactivityTimeoutMs: IDLE_MS, now: () => time, + }).then( + (value) => { outcome = { status: "resolved", value }; }, + (error: unknown) => { outcome = { status: "rejected", error }; }, + ); + expect(spawnSpy).toHaveBeenCalledTimes(1); + expect(spawnSpy.mock.results[0]?.value).toBe(child); + expect(child.stdout.listenerCount("data")).toBe(1); + expect(child.listenerCount("close")).toBe(1); + expect(timers.map(({ delay }) => delay).sort((a, b) => a - b)).toEqual([IDLE_MS, totalTimeoutMs]); + await body({ + child, timers, at: (value) => { time = value; }, + result: (newline = true) => { child.stdout.write(RESULT + (newline ? "\n" : "")); }, + pending: async () => { await drain(); expect(outcome.status).toBe("pending"); }, + failure: async (...expected) => { + const [code] = expected; + const attribution = code === "harness_failure" ? expected[1] : "environment"; + const message = code === "harness_failure" ? expected[2] + : code === "inactivity_timeout" ? "inactivity timeout exceeded" : "total timeout exceeded"; + child.close(); + await drain(); + expect(outcome.status).toBe("rejected"); + if (outcome.status !== "rejected") throw new Error("producer did not reject after close"); + expect(outcome.error).toBeInstanceOf(FabricTaskError); + expect(outcome.error).toMatchObject({ code, attribution, message }); + expect(timers.every(({ cleared }) => cleared)).toBe(true); + }, + success: async (lastActivityAt = START) => { + await drain(); + expect(outcome).toEqual({ status: "resolved", value: { patch: PATCH, lastActivityAt } }); + expect(child.signals).toEqual([]); + expect(timers.every(({ cleared }) => cleared)).toBe(true); + }, + }); + expect(spawnSpy).toHaveBeenCalledTimes(1); + } finally { + // Always reap the fake before removing its scratch, including failed assertions. + try { + child.close(); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + } finally { + for (const restore of restorers.reverse()) restore(); + rmSync(scratchRoot, { recursive: true, force: true }); + expect(childProcess.spawn).toBe(originals.spawn); + expect(globalThis.setTimeout).toBe(originals.set); + expect(globalThis.clearTimeout).toBe(originals.clear); + } + } +} + +describe("isolated fabric producer deadline admission", () => { + test("idle timer then buffered result cannot settle before child close", async () => { + await withProducer(async (h) => { + h.at(1_100); + h.timers[0]!.callback(); + expect(h.child.signals).toEqual(["SIGKILL"]); + await h.pending(); + h.result(); + await h.pending(); + await h.failure("inactivity_timeout"); + }); + }); + + for (const time of [1_100, 1_101]) { + test(`result at ${time} rejects even when no timer callback ran`, async () => { + await withProducer(async (h) => { + h.at(time); + h.result(); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + await h.failure("inactivity_timeout"); + }); + }); + } + + test("late activity and result in the same chunk cannot renew idle", async () => { + await withProducer(async (h) => { + h.at(1_101); + h.child.stdout.write(ACTIVITY + RESULT + "\n"); + await h.pending(); + expect(h.timers).toHaveLength(2); + expect(h.child.signals).toEqual(["SIGKILL"]); + await h.failure("inactivity_timeout"); + }); + }); + + test("total deadline is fixed despite accepted activity", async () => { + await withProducer(async (h) => { + for (const time of [1_090, 1_180]) { + h.at(time); + h.child.stdout.write(ACTIVITY); + await h.pending(); + expect(h.child.signals).toEqual([]); + } + h.at(1_250); + h.result(); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + await h.failure("timeout"); + }); + }); + + test("a delayed total callback chooses the earlier elapsed idle deadline", async () => { + await withProducer(async (h) => { + h.at(1_251); + h.timers[1]!.callback(); + await h.pending(); + await h.failure("inactivity_timeout"); + }); + }); + + test("inactivity wins an exact deadline tie even if total callback runs first", async () => { + await withProducer(async (h) => { + for (const time of [1_090, 1_150]) { + h.at(time); + h.child.stdout.write(ACTIVITY); + await h.pending(); + } + h.at(1_250); + h.timers[1]!.callback(); + await h.pending(); + await h.failure("inactivity_timeout"); + }); + }); + + test("first timeout survives later timers, protocol and process/stream errors", async () => { + await withProducer(async (h) => { + h.at(1_100); + h.timers[0]!.callback(); + await h.pending(); + h.at(1_251); + const laterEvents = [ + () => h.timers[1]!.callback(), + () => h.child.stdout.write('{"type":"error","code":"sandbox_violation","message":"later protocol error","attribution":"harness"}\n'), + () => h.child.stdout.write("not-json\n"), + () => h.child.stdout.emit("error", new Error("later stdout error")), + () => h.child.stderr.emit("error", new Error("later stderr error")), + () => h.child.stdin.emit("error", new Error("later stdin error")), + () => h.child.emit("error", new Error("later child error")), + () => h.child.stdout.write(ACTIVITY), + () => h.result(), + () => h.timers[0]!.callback(), + ]; + for (const event of laterEvents) { + event(); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + } + expect(h.timers).toHaveLength(2); + await h.failure("inactivity_timeout"); + }); + }); + + test("valid result just before idle boundary succeeds", async () => { + await withProducer(async (h) => { + h.at(1_099); + h.result(); + await h.success(); + }); + }); + + test("stderr failure stays authoritative until close across later data, errors and timers", async () => { + await withProducer(async (h) => { + h.child.stderr.emit("error", new Error("first stderr read failure")); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + h.at(1_251); + const laterEvents = [ + () => h.child.stdout.write(ACTIVITY + RESULT + "\n"), + () => h.child.stdout.write('{"type":"error","code":"sandbox_violation","message":"later protocol error","attribution":"harness"}\n'), + () => h.child.stderr.emit("error", new Error("second stderr error")), + () => h.child.stdout.emit("error", new Error("later stdout error")), + () => h.child.stdin.emit("error", new Error("later stdin error")), + () => h.child.emit("error", new Error("later child error")), + () => h.timers[0]!.callback(), + () => h.timers[1]!.callback(), + ]; + for (const event of laterEvents) { + event(); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + } + expect(h.timers).toHaveLength(2); + await h.failure("harness_failure", "harness", "first stderr read failure"); + }); + }); + + test("valid activity renews idle and reports its accepted timestamp", async () => { + await withProducer(async (h) => { + h.at(1_090); + h.child.stdout.write(ACTIVITY); + await h.pending(); + expect(h.timers).toHaveLength(3); + expect(h.timers[0]!.cleared).toBe(true); + expect(h.timers[1]!.cleared).toBe(false); + h.at(1_189); + h.result(); + await h.success(1_090); + }); + }); + + test("valid result just before the fixed total deadline succeeds", async () => { + await withProducer(async (h) => { + for (const time of [1_090, 1_180]) { + h.at(time); + h.child.stdout.write(ACTIVITY); + await h.pending(); + } + h.at(1_249); + h.result(); + await h.success(1_180); + }); + }); + + for (const closeAt of [1_099, 1_100, 1_101]) { + test(`unterminated result is admitted at close time ${closeAt}`, async () => { + await withProducer(async (h) => { + h.at(1_099); + h.result(false); + await h.pending(); + h.at(closeAt); + h.child.close(); + if (closeAt < 1_100) await h.success(); + else await h.failure("inactivity_timeout"); + }); + }); + } +}); + +test("trusted route keeps scratch until stderr-failed child closes, then cleans it", async () => { + const configDir = mkdtempSync(join(tmpdir(), "ocx-fabric-consumer-deadline-")); + const child = new DeadlineChild(); + const originals = { spawn: childProcess.spawn, set: globalThis.setTimeout, clear: globalThis.clearTimeout }; + const restorers: Array<() => void> = []; + const proxyNames = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; + const proxyEnv = proxyNames.map((name) => [name, process.env[name]] as const); + const outer: { outcome: Outcome } = { outcome: { status: "pending" } }; + try { + for (const name of proxyNames) delete process.env[name]; + // Resolve through the existing destination contract before capturing producer timers. + const destination = await createLabDestination({ + baseUrl: "https://api.example.com/v1", labRunApproval: true, configDir, + resolve: async () => [{ address: "93.184.216.34", family: 4 }], + }); + const spawnSpy = spyOn(childProcess, "spawn").mockImplementation(() => child as unknown as childProcess.ChildProcess); + restorers.push(() => spawnSpy.mockRestore()); + const timers = installTimers(restorers); + void runFabricSyntheticPatchTaskForRoute({ + routeContext: fabricMockRoute(), destination, configDir, now: () => START, + patchExecutor: fabricCorrectPatchExecutor(), + }).then( + (value) => { outer.outcome = { status: "resolved", value }; }, + (error: unknown) => { outer.outcome = { status: "rejected", error }; }, + ); + expect(spawnSpy).toHaveBeenCalledTimes(1); + expect(spawnSpy.mock.results[0]?.value).toBe(child); + const scratchRoot = spawnSpy.mock.calls[0]?.[2]?.env?.OCX_FABRIC_SCRATCH_ROOT; + expect(typeof scratchRoot).toBe("string"); + if (!scratchRoot) throw new Error("producer spawn omitted its scratch root"); + expect(child.listenerCount("close")).toBe(1); + expect(timers).toHaveLength(2); + expect(existsSync(scratchRoot)).toBe(true); + await drain(); + expect(outer.outcome.status).toBe("pending"); + + child.stderr.emit("error", new Error("consumer stderr failure")); + const assertPendingScratch = async () => { + await drain(); + expect(outer.outcome.status).toBe("pending"); + expect(child.closed).toBe(false); + expect(child.signals).toEqual(["SIGKILL"]); + expect(existsSync(scratchRoot)).toBe(true); + expect(readFileSync(join(scratchRoot, "src/value.txt"), "utf8")).toBe("before\n"); + }; + await assertPendingScratch(); + const afterFailure = [ + () => child.stdout.write(ACTIVITY + RESULT + "\n"), + () => child.stderr.emit("error", new Error("later stderr failure")), + () => timers[0]!.callback(), + () => timers[1]!.callback(), + ]; + for (const event of afterFailure) { + event(); + await assertPendingScratch(); + } + child.close(); + await drain(); + expect(outer.outcome.status).toBe("resolved"); + if (outer.outcome.status !== "resolved") throw new Error("route did not settle after child close"); + expect(outer.outcome.value).toMatchObject({ + executionAuthority: "trusted_route", + outcome: { + outcome: "inconclusive", + failure: { class: "harness_failure", code: "harness_failure", attribution: "harness", retryable: false }, + verifier: { passed: false, reason: "harness_failure" }, + usage: { outputBytes: 0, patchOperations: 0, filesTouched: 0 }, + }, + }); + expect(existsSync(scratchRoot)).toBe(false); + expect(timers.every(({ cleared }) => cleared)).toBe(true); + expect(spawnSpy).toHaveBeenCalledTimes(1); + } finally { + try { + child.close(); + await drain(); + child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); + } finally { + for (const restore of restorers.reverse()) restore(); + for (const [name, value] of proxyEnv) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + rmSync(configDir, { recursive: true, force: true }); + expect(childProcess.spawn).toBe(originals.spawn); + expect(globalThis.setTimeout).toBe(originals.set); + expect(globalThis.clearTimeout).toBe(originals.clear); + } + } +}); From 30e79d003d6380f94d59958b7761a780adc6f5d9 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:19:24 +0900 Subject: [PATCH 096/236] feat(gui): manage Aside sync per profile with scoped recovery --- .../260906_aside_profiles/020_profiles_gui.md | 14 + .../public/screenshots/aside-profiles.jpg | Bin 0 -> 46201 bytes gui/src/i18n/de.ts | 18 +- gui/src/i18n/en.ts | 18 +- gui/src/i18n/fr.ts | 18 +- gui/src/i18n/ja.ts | 18 +- gui/src/i18n/ko.ts | 18 +- gui/src/i18n/ru.ts | 18 +- gui/src/i18n/tr.ts | 18 +- gui/src/i18n/zh-TW.ts | 18 +- gui/src/i18n/zh.ts | 18 +- gui/src/pages/Integrations.tsx | 3 +- .../pages/integrations/AsideProfilesPage.tsx | 166 ++++++++ .../integrations/FileIntegrationPage.tsx | 70 ++-- .../integrations/IntegrationsOverview.tsx | 3 +- gui/src/pages/integrations/RestoreDialog.tsx | 9 +- .../pages/integrations/aside-profile-api.ts | 46 +++ .../integrations/aside-profile-contract.ts | 55 +++ gui/src/pages/integrations/integration-api.ts | 78 +++- gui/src/pages/integrations/refusal-copy.ts | 24 +- gui/src/styles-integrations.css | 17 + gui/tests/aside-profiles-page.test.tsx | 386 ++++++++++++++++++ gui/tests/integrations-api.test.ts | 21 + src/cli/dispatch.ts | 2 +- src/cli/integrations.ts | 2 +- src/cli/runtime-api.ts | 7 +- src/integrations/aside-profiles.ts | 12 +- src/integrations/owned-refresh.ts | 4 + tests/cli/cli-headless-parity.test.ts | 82 ++++ tests/clients/aside-profiles.test.ts | 20 + 30 files changed, 1128 insertions(+), 55 deletions(-) create mode 100644 docs-site/public/screenshots/aside-profiles.jpg create mode 100644 gui/src/pages/integrations/AsideProfilesPage.tsx create mode 100644 gui/src/pages/integrations/aside-profile-api.ts create mode 100644 gui/src/pages/integrations/aside-profile-contract.ts create mode 100644 gui/tests/aside-profiles-page.test.tsx diff --git a/devlog/_plan/260906_aside_profiles/020_profiles_gui.md b/devlog/_plan/260906_aside_profiles/020_profiles_gui.md index 16e5798e04..6f35ffd552 100644 --- a/devlog/_plan/260906_aside_profiles/020_profiles_gui.md +++ b/devlog/_plan/260906_aside_profiles/020_profiles_gui.md @@ -13,3 +13,17 @@ UPDATE guides/integrations.md and operating CLI docs with all-profile default, - Verification: remote focused GUI/API tests, GUI lint/i18n/build, root typecheck and required CI. Browser QA on local dev UI against three synthetic profiles, never real user profile mutation: initial mixed state, global enable, one profile disable, return to list after details, correct request selector, reload retains off state, failed profile does not imply sibling success, keyboard switches and narrow viewport. Capture actual screenshot for PR body using existing browser plugin, view it, and fix layout if needed. Screenshot contains synthetic labels only. A PR mentioning GUI includes screenshot. No local test suite or local typecheck; local dev server/browser probes are permitted. Terminal: verify every PR current head and all applicable hosted checks; native stack registration, owner-authorized admin merge, async merge completion, fetch dev and prove every merge SHA ancestry. Resolve CI or reviews rather than bypass evidence. No release or live service deployment. All original Grok/Pi/Codex and added Aside-profile criteria must be met before host goal completion. + +## P revalidation at1d4da9f9b + +Backend primary routes are now dedicated nested profile paths; server-only POST /aside/sync owns synchronization and preserves exclusions. The UI Sync-now button uses it, while the bulk switch sets all desired flags and per-row switches set one. Shared Switch already supports mixed state and aria-pressed. Preserve existing monochrome tokens/ClientMark (variance2/motion1/densityD5). StatusDTO carries desired enabled plus actual state, counts and per-profile errors; show partial/error states explicitly. FileIntegrationPage and RestoreDialog/Overview actions carry optional profile IDs into paths and cache keys. + +Main owns AsideProfilesPage, integration-api profile DTO/functions, parentpage/FileIntegrationPage/RestoreDialog/Overview wiring and CSS. A disjoint locale/test worker may own gui/src/i18n/{en,de,fr,ko,zh,zh-TW,ru,ja,tr}.ts and new gui/tests/aside-profiles-page.test.tsx after exactkeys/APIcontract are fixed. All GUI checks run remotely; local Vite and browser probes only. Capture wide+narrow realcomponent screenshots against synthetic three-profile management fixtures, not real accounts. + +A audit passed: DTO error precedes empty rendering; stale errors remain visible; always refetch after refused mutations because intent may already be saved; use mixed Switch and void refresh semantics. Exact locale keys are locked in ignored .tmp/aside-profiles/ui-keys.json. Main exports loadAsideProfiles and syncAsideProfiles from integration-api, and adds profileId as last optional argument to existing state/toggle/history/restore/delete functions. Toggle rejects207 partial only through returnedokfalse; UI reports it and refetches. + +Implementation modularization: profile DTO validation and load/sync readers live in new aside-profile-api.ts, reusing the existing integration transport/error owner without a circular re-export. Existing integration-api functions keep their non-Aside signatures and unscoped cache identities; profile scope is an optional final argument. Scoped successful state/toggle/restore/delete responses and journal rows must match the requested profile. + +Review fold-back: per-profile refusal/recovery outcomes remain typed and visible in bulk and Sync-now failures, including snapshotPath/residual; failed Aside restore reconciles owner resources while retaining the dialog/error; list and detail share one pure profile-status validator. Add a localized no-snapshot recovery warning and the corresponding regression cases. No source permission or confirmation boundary is weakened. + +B verification: the final interface provides bulk and individual desired-state switches, actual applied counts, per-profile retry/refusal and recovery details, profile-scoped history/restore/delete, and nine-locale copy. Remote checks passed: 74 interface/API cases, then 54 affected interface/cache cases after type narrowing, GUI build and both lint commands; 27 engine cases with real failed compensation, 62 CLI cases, and root typecheck. Initial failing compensation fixture was corrected to fail ownership after a successful file write, then fail rollback. Browser probes on three synthetic profiles covered bulk enable, exclusion persistence, scoped Undo, keyboard control, narrow layout without horizontal overflow, and per-profile external-edit refusal. Screenshot: docs-site/public/screenshots/aside-profiles.jpg. No real Aside profile files were written. Hosted exact-head CI and stack landing remain open. diff --git a/docs-site/public/screenshots/aside-profiles.jpg b/docs-site/public/screenshots/aside-profiles.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7e788463214e1fbd613ac33a37ead18f15b8c738 GIT binary patch literal 46201 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 literal 0 HcmV?d00001 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index cf04d02f67..a295a3295d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1044,6 +1044,21 @@ export const de: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.aside.profilesTitle": "Aside-Profile", + "integrations.aside.profilesHint": "Wähle, welche Profile die ausgewählten Modelle erhalten. Das aktive Aside-Profil bleibt unverändert.", + "integrations.aside.all": "Alle Profile synchronisieren", + "integrations.aside.syncNow": "Jetzt synchronisieren", + "integrations.aside.applied": "Bei {count} von {total} Profilen angewendet", + "integrations.aside.current": "Aktuelles Profil", + "integrations.aside.profile": "Profil {id}", + "integrations.aside.toggle": "{name} synchronisieren", + "integrations.aside.details": "{name} verwalten", + "integrations.aside.back": "Alle Aside-Profile", + "integrations.aside.empty": "Öffne Aside und erstelle ein Profil, um es zu verbinden.", + "integrations.aside.partial": "Einige Profile benötigen Aufmerksamkeit. Deine Synchronisierungsauswahl ist gespeichert. Prüfe den Status der einzelnen Profile.", + "integrations.aside.pending": "Synchronisierungsauswahl gespeichert; Dateiaktualisierung steht aus.", + "integrations.aside.retry": "Für {name} erneut versuchen", + "integrations.aside.loadError": "Aside-Profile konnten nicht geladen werden. Versuche es erneut, um ihren Status zu prüfen.", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Die Codex-Anbindung wird vom Proxy-Dienst verwaltet. Beim Start von opencodex wird sie angewendet; beim Stoppen des Dienstes wird das native Routing wiederhergestellt.", "integrations.codex.openService": "Dienststeuerung öffnen", @@ -1162,6 +1177,7 @@ export const de: Record = { "integrations.bulk.success": "Angewendete Client-Integrationen wurden deaktiviert.", "integrations.retention.degraded": "Die Sicherungsbereinigung ist im Rückstand; ältere Sicherungen könnten noch auf dem Datenträger liegen.", "integrations.error.residual": "Die Datei könnte sich in einem Zwischenzustand befinden: {message} Stellen Sie sie aus {path} wieder her.", + "integrations.error.residualNoSnapshot": "{message} Die automatische Wiederherstellung wurde nicht abgeschlossen. Prüfe die Client-Konfiguration, bevor du es erneut versuchst.", "integrations.error.recover": "{message} Eine Sicherung liegt unter {path}.", "integrations.kind.apply": "Angewendet", "integrations.kind.disable": "Deaktiviert", @@ -1186,7 +1202,7 @@ export const de: Record = { "integrations.semantics.mcode": "Verwaltet nur custom_provider.opencodex. Standardmodell und MiniMax-Anmeldung bleiben unverändert.", "integrations.semantics.zcode": "Verwaltet nur provider.opencodex in ~/.zcode/v2/config.json. Z.ai-Anmeldung und andere Provider bleiben unverändert. ZCode nach Änderungen neu starten.", "integrations.semantics.prime": "Verwaltet nur providers.opencodex in der models.json von Prime Agent — ~/.prime/agent, sofern PRIME_AGENT_CODING_AGENT_DIR sie nicht umleitet. Andere Provider und Modell-Overrides bleiben unverändert. Gilt für neue Sitzungen.", - "integrations.semantics.aside": "Verwaltet nur providers.opencodex in der models.json von Aside für das angemeldete Konto (~/.aside/u/). Andere Provider bleiben unverändert. Aside überschreibt diese Datei im laufenden Betrieb, daher nach dem Anwenden vollständig beenden und neu öffnen.", + "integrations.semantics.aside": "Verwaltet nur providers.opencodex in der models.json dieses Profils. Andere Provider bleiben unverändert. Beende Aside nach dem Anwenden vollständig und öffne es erneut.", "codexAuth.mainAccount": "Hauptkonto", "codexAuth.logLabel": "Log-Kennung", "codexAuth.codexApp": "Codex App", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 5d0c0b0983..2b57424437 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1551,6 +1551,21 @@ export const en = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.aside.profilesTitle": "Aside profiles", + "integrations.aside.profilesHint": "Choose which profiles receive the selected models. Aside’s active profile stays unchanged.", + "integrations.aside.all": "Sync all profiles", + "integrations.aside.syncNow": "Sync now", + "integrations.aside.applied": "{count} of {total} profiles applied", + "integrations.aside.current": "Current profile", + "integrations.aside.profile": "Profile {id}", + "integrations.aside.toggle": "Sync {name}", + "integrations.aside.details": "Manage {name}", + "integrations.aside.back": "All Aside profiles", + "integrations.aside.empty": "Open Aside and create a profile to connect it.", + "integrations.aside.partial": "Some profiles need attention. Your sync choices are saved; check each profile’s state.", + "integrations.aside.pending": "Sync choice saved; file update pending.", + "integrations.aside.retry": "Retry {name}", + "integrations.aside.loadError": "Could not load Aside profiles. Retry to check their state.", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex wiring is owned by the proxy service. Starting opencodex applies it; stopping the service restores native routing.", "integrations.codex.openService": "Open service controls", @@ -1709,6 +1724,7 @@ export const en = { "integrations.bulk.success": "Applied client integrations were disabled.", "integrations.retention.degraded": "Backup cleanup is behind; older backups may still be on disk.", "integrations.error.residual": "The file may be in an intermediate state: {message} Restore it from {path}.", + "integrations.error.residualNoSnapshot": "{message} Automatic recovery did not finish. Check the client configuration before retrying.", "integrations.error.recover": "{message} A backup is at {path}.", "integrations.kind.apply": "Applied", "integrations.kind.disable": "Disabled", @@ -1733,7 +1749,7 @@ export const en = { "integrations.semantics.mcode": "Manages only custom_provider.opencodex. Your default model and MiniMax login stay unchanged.", "integrations.semantics.zcode": "Manages only provider.opencodex in ~/.zcode/v2/config.json. Your Z.ai login and other providers stay unchanged. Restart ZCode after changes.", "integrations.semantics.prime": "Manages only providers.opencodex in Prime Agent's models.json — ~/.prime/agent unless PRIME_AGENT_CODING_AGENT_DIR redirects it. Your other providers and model overrides stay unchanged. Applies to new sessions.", - "integrations.semantics.aside": "Manages only providers.opencodex in Aside's models.json for the signed-in account (~/.aside/u/). Your other providers stay unchanged. Aside rewrites this file while running, so fully quit and reopen it after applying.", + "integrations.semantics.aside": "Manages only providers.opencodex in this profile’s models.json. Your other providers stay unchanged. Fully quit and reopen Aside after applying.", "codexAuth.mainAccount": "Main Account", "codexAuth.logLabel": "Log label", "codexAuth.codexApp": "Codex App", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index ac0d9f17c3..2cbab0a955 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1523,6 +1523,21 @@ export const fr: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.aside.profilesTitle": "Profils Aside", + "integrations.aside.profilesHint": "Choisissez les profils qui recevront les modèles sélectionnés. Le profil actif dans Aside reste inchangé.", + "integrations.aside.all": "Synchroniser tous les profils", + "integrations.aside.syncNow": "Synchroniser maintenant", + "integrations.aside.applied": "Appliqué à {count} profils sur {total}", + "integrations.aside.current": "Profil actuel", + "integrations.aside.profile": "Profil {id}", + "integrations.aside.toggle": "Synchroniser {name}", + "integrations.aside.details": "Gérer {name}", + "integrations.aside.back": "Tous les profils Aside", + "integrations.aside.empty": "Ouvrez Aside et créez un profil pour le connecter.", + "integrations.aside.partial": "Certains profils nécessitent votre attention. Vos choix de synchronisation sont enregistrés ; vérifiez l’état de chaque profil.", + "integrations.aside.pending": "Choix de synchronisation enregistré ; mise à jour du fichier en attente.", + "integrations.aside.retry": "Réessayer pour {name}", + "integrations.aside.loadError": "Impossible de charger les profils Aside. Réessayez pour vérifier leur état.", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Le câblage de Codex est géré par le service proxy. Le démarrage d’opencodex l’applique ; l’arrêt du service rétablit le routage natif.", "integrations.codex.openService": "Ouvrir les commandes du service", @@ -1641,6 +1656,7 @@ export const fr: Record = { "integrations.bulk.success": "Les intégrations client appliquées ont été désactivées.", "integrations.retention.degraded": "Le nettoyage des sauvegardes est en retard ; d’anciennes sauvegardes peuvent encore se trouver sur le disque.", "integrations.error.residual": "Le fichier peut être dans un état intermédiaire : {message} Restaurez-le depuis {path}.", + "integrations.error.residualNoSnapshot": "{message} La récupération automatique n’a pas abouti. Vérifiez la configuration du client avant de réessayer.", "integrations.error.recover": "{message} Une sauvegarde se trouve dans {path}.", "integrations.kind.apply": "Appliqué", "integrations.kind.disable": "Désactivé", @@ -1665,7 +1681,7 @@ export const fr: Record = { "integrations.semantics.mcode": "Gère uniquement custom_provider.opencodex. Votre modèle par défaut et votre connexion MiniMax restent inchangés.", "integrations.semantics.zcode": "Gère uniquement provider.opencodex dans ~/.zcode/v2/config.json. Votre connexion Z.ai et les autres fournisseurs restent inchangés. Redémarrez ZCode après toute modification.", "integrations.semantics.prime": "Gère uniquement providers.opencodex dans le models.json de Prime Agent — ~/.prime/agent, sauf si PRIME_AGENT_CODING_AGENT_DIR le redirige. Vos autres fournisseurs et surcharges de modèles restent inchangés. S'applique aux nouvelles sessions.", - "integrations.semantics.aside": "Gère uniquement providers.opencodex dans le models.json d'Aside pour le compte connecté (~/.aside/u/). Vos autres fournisseurs restent inchangés. Aside réécrit ce fichier pendant son exécution : quittez-le complètement et relancez-le après application.", + "integrations.semantics.aside": "Gère uniquement providers.opencodex dans le fichier models.json de ce profil. Vos autres fournisseurs restent inchangés. Quittez complètement Aside et relancez-le après application.", "codexAuth.mainAccount": "Compte principal", "codexAuth.logLabel": "Libellé du journal", "codexAuth.codexApp": "Application Codex", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 7c6eab5675..9ad7767549 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1478,6 +1478,21 @@ export const ja: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.aside.profilesTitle": "Asideのプロファイル", + "integrations.aside.profilesHint": "選択したモデルを同期するプロファイルを選んでください。Asideで使用中のプロファイルは変わりません。", + "integrations.aside.all": "すべてのプロファイルを同期", + "integrations.aside.syncNow": "今すぐ同期", + "integrations.aside.applied": "{total}件中{count}件のプロファイルに適用済み", + "integrations.aside.current": "使用中のプロファイル", + "integrations.aside.profile": "プロファイル {id}", + "integrations.aside.toggle": "{name}を同期", + "integrations.aside.details": "{name}を管理", + "integrations.aside.back": "Asideの全プロファイル", + "integrations.aside.empty": "Asideを開き、接続するプロファイルを作成してください。", + "integrations.aside.partial": "一部のプロファイルに確認が必要です。同期設定は保存されています。各プロファイルの状態を確認してください。", + "integrations.aside.pending": "同期設定を保存しました。ファイルの更新待ちです。", + "integrations.aside.retry": "{name}を再試行", + "integrations.aside.loadError": "Asideのプロファイルを読み込めませんでした。再試行して状態を確認してください。", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex の接続はプロキシサービスが管理します。opencodex を起動すると適用され、サービスを停止するとネイティブのルーティングに戻ります。", "integrations.codex.openService": "サービス制御を開く", @@ -1596,6 +1611,7 @@ export const ja: Record = { "integrations.bulk.success": "適用済みのクライアント連携を無効にしました。", "integrations.retention.degraded": "バックアップの整理が遅れています。古いバックアップがディスクに残っている可能性があります。", "integrations.error.residual": "ファイルが中間状態のままの可能性があります: {message} {path} から復元してください。", + "integrations.error.residualNoSnapshot": "{message} 自動復旧が完了しませんでした。再試行する前にクライアントの設定を確認してください。", "integrations.error.recover": "{message} バックアップは {path} にあります。", "integrations.kind.apply": "適用", "integrations.kind.disable": "解除", @@ -1620,7 +1636,7 @@ export const ja: Record = { "integrations.semantics.mcode": "custom_provider.opencodex のみを管理します。既定モデルと MiniMax ログインは変更しません。", "integrations.semantics.zcode": "~/.zcode/v2/config.json の provider.opencodex のみを管理します。Z.ai ログインと他のプロバイダーは変更しません。変更後は ZCode を再起動してください。", "integrations.semantics.prime": "Prime Agent の models.json 内の providers.opencodex のみを管理します。場所は ~/.prime/agent ですが、PRIME_AGENT_CODING_AGENT_DIR が設定されている場合はそちらが優先されます。他のプロバイダーとモデルオーバーライドは変更しません。新しいセッションから適用されます。", - "integrations.semantics.aside": "サインイン中のアカウントの Aside models.json 内の providers.opencodex のみを管理します。場所は ~/.aside/u/<アカウント> です。他のプロバイダーは変更しません。Aside は実行中にこのファイルを書き換えるため、適用後は Aside を完全に終了して再度開いてください。", + "integrations.semantics.aside": "このプロファイルの models.json 内の providers.opencodex のみを管理します。他のプロバイダーは変更しません。適用後は Aside を完全に終了してから開き直してください。", "codexAuth.mainAccount": "メインアカウント", "codexAuth.logLabel": "ログラベル", "codexAuth.codexApp": "Codex App", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 21d9ab45eb..8bfa8f8b41 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1068,6 +1068,21 @@ export const ko: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.aside.profilesTitle": "Aside 프로필", + "integrations.aside.profilesHint": "선택한 모델을 동기화할 프로필을 고르세요. Aside에서 사용 중인 프로필은 바뀌지 않습니다.", + "integrations.aside.all": "모든 프로필 동기화", + "integrations.aside.syncNow": "지금 동기화", + "integrations.aside.applied": "{total}개 중 {count}개 적용됨", + "integrations.aside.current": "사용 중", + "integrations.aside.profile": "프로필 {id}", + "integrations.aside.toggle": "{name} 동기화", + "integrations.aside.details": "{name} 관리", + "integrations.aside.back": "전체 Aside 프로필", + "integrations.aside.empty": "Aside를 열고 연결할 프로필을 만드세요.", + "integrations.aside.partial": "일부 프로필을 확인해 주세요. 동기화 설정은 저장됐습니다. 각 프로필의 상태를 확인하세요.", + "integrations.aside.pending": "동기화 설정 저장됨 · 파일 반영 대기 중", + "integrations.aside.retry": "{name} 다시 시도", + "integrations.aside.loadError": "Aside 프로필을 불러오지 못했습니다. 다시 시도해 상태를 확인하세요.", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 연결은 프록시 서비스가 관리합니다. opencodex를 시작하면 적용되고 서비스를 중지하면 기본 라우팅으로 복원됩니다.", "integrations.codex.openService": "서비스 제어 열기", @@ -1186,6 +1201,7 @@ export const ko: Record = { "integrations.bulk.success": "적용된 클라이언트 연동을 해제했습니다.", "integrations.retention.degraded": "백업 정리가 밀려 있습니다 — 오래된 백업이 남아 있을 수 있습니다.", "integrations.error.residual": "파일이 중간 상태로 남았을 수 있습니다: {message} {path}에서 복원하세요.", + "integrations.error.residualNoSnapshot": "{message} 자동 복구를 마치지 못했습니다. 다시 시도하기 전에 클라이언트 설정을 확인하세요.", "integrations.error.recover": "{message} 백업 위치는 {path}입니다.", "integrations.kind.apply": "적용", "integrations.kind.disable": "해제", @@ -1210,7 +1226,7 @@ export const ko: Record = { "integrations.semantics.mcode": "custom_provider.opencodex만 관리하며 기본 모델과 MiniMax 로그인은 변경하지 않습니다.", "integrations.semantics.zcode": "~/.zcode/v2/config.json의 provider.opencodex만 관리하며 Z.ai 로그인과 다른 프로바이더는 변경하지 않습니다. 변경 후 ZCode를 재시작하세요.", "integrations.semantics.prime": "Prime Agent의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.prime/agent이며 PRIME_AGENT_CODING_AGENT_DIR가 설정되면 그쪽이 우선합니다. 다른 프로바이더와 모델 오버라이드는 변경하지 않습니다. 새 세션부터 적용됩니다.", - "integrations.semantics.aside": "로그인된 계정의 Aside models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.aside/u/<계정>이며 다른 프로바이더는 변경하지 않습니다. Aside는 실행 중에 이 파일을 다시 쓰기 때문에 적용한 뒤 Aside를 완전히 종료하고 다시 여세요.", + "integrations.semantics.aside": "이 프로필의 models.json에서 providers.opencodex만 관리합니다. 다른 프로바이더는 그대로 유지됩니다. 적용 후 Aside를 완전히 종료하고 다시 여세요.", "codexAuth.mainAccount": "메인 계정", "codexAuth.logLabel": "로그 라벨", "codexAuth.codexApp": "Codex App", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c97b62e9ac..bee9f3714f 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1534,6 +1534,21 @@ export const ru: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.aside.profilesTitle": "Профили Aside", + "integrations.aside.profilesHint": "Выберите профили, в которые будут добавлены выбранные модели. Активный профиль Aside не изменится.", + "integrations.aside.all": "Синхронизировать все профили", + "integrations.aside.syncNow": "Синхронизировать сейчас", + "integrations.aside.applied": "Применено к профилям: {count} из {total}", + "integrations.aside.current": "Текущий профиль", + "integrations.aside.profile": "Профиль {id}", + "integrations.aside.toggle": "Синхронизировать {name}", + "integrations.aside.details": "Управление: {name}", + "integrations.aside.back": "Все профили Aside", + "integrations.aside.empty": "Откройте Aside и создайте профиль для подключения.", + "integrations.aside.partial": "Некоторые профили требуют внимания. Настройки синхронизации сохранены; проверьте состояние каждого профиля.", + "integrations.aside.pending": "Настройка синхронизации сохранена; обновление файла ожидается.", + "integrations.aside.retry": "Повторить для {name}", + "integrations.aside.loadError": "Не удалось загрузить профили Aside. Повторите попытку, чтобы проверить их состояние.", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Подключением Codex управляет прокси-сервис. При запуске opencodex оно применяется, а при остановке сервиса восстанавливается нативная маршрутизация.", "integrations.codex.openService": "Открыть управление сервисом", @@ -1652,6 +1667,7 @@ export const ru: Record = { "integrations.bulk.success": "Применённые интеграции клиентов отключены.", "integrations.retention.degraded": "Очистка резервных копий отстаёт; старые копии могут всё ещё находиться на диске.", "integrations.error.residual": "Файл может остаться в промежуточном состоянии: {message} Восстановите его из {path}.", + "integrations.error.residualNoSnapshot": "{message} Автоматическое восстановление не завершено. Проверьте настройки клиента перед повторной попыткой.", "integrations.error.recover": "{message} Резервная копия находится в {path}.", "integrations.kind.apply": "Применено", "integrations.kind.disable": "Отключено", @@ -1676,7 +1692,7 @@ export const ru: Record = { "integrations.semantics.mcode": "Управляет только custom_provider.opencodex. Модель по умолчанию и вход MiniMax не меняются.", "integrations.semantics.zcode": "Управляет только provider.opencodex в ~/.zcode/v2/config.json. Вход Z.ai и другие провайдеры не меняются. Перезапустите ZCode после изменений.", "integrations.semantics.prime": "Управляет только providers.opencodex в models.json Prime Agent — ~/.prime/agent, если PRIME_AGENT_CODING_AGENT_DIR не переопределяет путь. Другие провайдеры и переопределения моделей не меняются. Применяется к новым сессиям.", - "integrations.semantics.aside": "Управляет только providers.opencodex в models.json Aside для выполнившего вход аккаунта (~/.aside/u/<аккаунт>). Другие провайдеры не меняются. Aside перезаписывает этот файл во время работы, поэтому после применения полностью закройте и снова откройте его.", + "integrations.semantics.aside": "Управляет только providers.opencodex в файле models.json этого профиля. Другие провайдеры остаются без изменений. После применения полностью закройте и снова откройте Aside.", "codexAuth.mainAccount": "Основной аккаунт", "codexAuth.logLabel": "Метка журнала", "codexAuth.codexApp": "Codex App", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 7a6f5107c0..124f3a41c6 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1541,6 +1541,21 @@ export const tr: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.aside.profilesTitle": "Aside profilleri", + "integrations.aside.profilesHint": "Seçili modellerin hangi profillere aktarılacağını seçin. Aside’ın etkin profili değişmez.", + "integrations.aside.all": "Tüm profilleri eşitle", + "integrations.aside.syncNow": "Şimdi eşitle", + "integrations.aside.applied": "{total} profilden {count} tanesine uygulandı", + "integrations.aside.current": "Geçerli profil", + "integrations.aside.profile": "Profil {id}", + "integrations.aside.toggle": "{name} profilini eşitle", + "integrations.aside.details": "{name} profilini yönet", + "integrations.aside.back": "Tüm Aside profilleri", + "integrations.aside.empty": "Bağlamak için Aside’ı açıp bir profil oluşturun.", + "integrations.aside.partial": "Bazı profillerle ilgilenmeniz gerekiyor. Eşitleme tercihleriniz kaydedildi; her profilin durumunu kontrol edin.", + "integrations.aside.pending": "Eşitleme tercihi kaydedildi; dosya güncellemesi bekleniyor.", + "integrations.aside.retry": "{name} için yeniden dene", + "integrations.aside.loadError": "Aside profilleri yüklenemedi. Durumlarını kontrol etmek için yeniden deneyin.", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex bağlantısı proxy servisine aittir.", "integrations.codex.openService": "Servis kontrollerini aç", @@ -1659,6 +1674,7 @@ export const tr: Record = { "integrations.bulk.success": "Uygulanan istemci entegrasyonları devre dışı bırakıldı.", "integrations.retention.degraded": "Yedek temizliği geride kaldı.", "integrations.error.residual": "{path} konumunda {message}", + "integrations.error.residualNoSnapshot": "{message} Otomatik kurtarma tamamlanamadı. Yeniden denemeden önce istemci yapılandırmasını kontrol edin.", "integrations.error.recover": "{path} kurtarılırken {message}", "integrations.kind.apply": "Uygulandı", "integrations.kind.disable": "Devre Dışı Bırakıldı", @@ -1682,7 +1698,7 @@ export const tr: Record = { "integrations.semantics.mcode": "Yalnızca custom_provider.opencodex bölümünü yönetir. Varsayılan model ve MiniMax oturumu değişmez.", "integrations.semantics.zcode": "Yalnızca ~/.zcode/v2/config.json içindeki provider.opencodex bölümünü yönetir. Z.ai oturumu ve diğer sağlayıcılar değişmez. Değişikliklerden sonra ZCode'u yeniden başlatın.", "integrations.semantics.prime": "Yalnızca Prime Agent'ın models.json dosyasındaki providers.opencodex bölümünü yönetir — PRIME_AGENT_CODING_AGENT_DIR ayarlı değilse ~/.prime/agent. Diğer sağlayıcılar ve model geçersiz kılmaları değişmez. Yeni oturumlarda geçerli olur.", - "integrations.semantics.aside": "Yalnızca oturum açmış hesabın Aside models.json dosyasındaki providers.opencodex bölümünü yönetir (~/.aside/u/). Diğer sağlayıcılar değişmez. Aside çalışırken bu dosyayı yeniden yazar; bu nedenle uyguladıktan sonra Aside'ı tamamen kapatıp yeniden açın.", + "integrations.semantics.aside": "Yalnızca bu profilin models.json dosyasındaki providers.opencodex bölümünü yönetir. Diğer sağlayıcılarınız değişmez. Uyguladıktan sonra Aside’ı tamamen kapatıp yeniden açın.", "integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.", "codexAuth.mainAccount": "Ana Hesap", "codexAuth.logLabel": "Günlük etiketi", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ed0aeba2d4..5d35128824 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2129,6 +2129,21 @@ export const zhTW: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.aside.profilesTitle": "Aside 設定檔", + "integrations.aside.profilesHint": "選擇要接收所選模型的設定檔。Aside 目前使用的設定檔不會改變。", + "integrations.aside.all": "同步所有設定檔", + "integrations.aside.syncNow": "立即同步", + "integrations.aside.applied": "已套用 {count}/{total} 個設定檔", + "integrations.aside.current": "目前的設定檔", + "integrations.aside.profile": "設定檔 {id}", + "integrations.aside.toggle": "同步 {name}", + "integrations.aside.details": "管理 {name}", + "integrations.aside.back": "所有 Aside 設定檔", + "integrations.aside.empty": "開啟 Aside 並建立設定檔以進行連線。", + "integrations.aside.partial": "部分設定檔需要處理。同步設定已儲存,請檢查各設定檔的狀態。", + "integrations.aside.pending": "同步設定已儲存,等待更新檔案。", + "integrations.aside.retry": "重試 {name}", + "integrations.aside.loadError": "無法載入 Aside 設定檔。請重試以查看狀態。", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 連線由代理服務管理。啟動 opencodex 時套用;停止服務時還原原生路由。", "integrations.codex.openService": "開啟服務控制", @@ -2247,6 +2262,7 @@ export const zhTW: Record = { "integrations.bulk.success": "已套用的用戶端整合已停用。", "integrations.retention.degraded": "備份清理進度落後;磁碟上可能仍有較舊的備份。", "integrations.error.residual": "檔案可能處於中間狀態:{message} 請從 {path} 還原。", + "integrations.error.residualNoSnapshot": "{message} 自動復原未完成。請先檢查用戶端設定,再重試。", "integrations.error.recover": "{message} 備份位於 {path}。", "integrations.kind.apply": "已套用", "integrations.kind.disable": "已停用", @@ -2271,7 +2287,7 @@ export const zhTW: Record = { "integrations.semantics.mcode": "僅管理 custom_provider.opencodex,不會變更預設模型或 MiniMax 登入狀態。", "integrations.semantics.zcode": "僅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不會變更 Z.ai 登入狀態或其他供應商。變更後請重新啟動 ZCode。", "integrations.semantics.prime": "僅管理 Prime Agent 的 models.json 中的 providers.opencodex;預設位於 ~/.prime/agent,若設定 PRIME_AGENT_CODING_AGENT_DIR 則以其為準。不會變更其他供應商或模型覆寫設定。對新工作階段生效。", - "integrations.semantics.aside": "僅管理已登入帳號的 Aside models.json 中的 providers.opencodex,位於 ~/.aside/u/<帳號>。不會變更其他供應商。Aside 在執行時會重寫該檔案,因此套用後請完全結束並重新開啟 Aside。", + "integrations.semantics.aside": "僅管理此設定檔的 models.json 中的 providers.opencodex。其他供應商維持不變。套用後請完全結束並重新開啟 Aside。", "codexAuth.pinned": "已固定", "codexAuth.pinnedHint": "你手動選取了此帳號,因此較高的選擇順序不會越過它。此固定會持續到該帳號用盡、你改選其他帳號,或你變更任一選擇順序為止。", "codexAuth.requestUserInput": "在 Default 模式中要求輸入", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 44450d8785..d6819f9104 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1061,6 +1061,21 @@ export const zh: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.aside.profilesTitle": "Aside 配置文件", + "integrations.aside.profilesHint": "选择要接收所选模型的配置文件。Aside 当前使用的配置文件不会改变。", + "integrations.aside.all": "同步所有配置文件", + "integrations.aside.syncNow": "立即同步", + "integrations.aside.applied": "已应用 {count}/{total} 个配置文件", + "integrations.aside.current": "当前配置文件", + "integrations.aside.profile": "配置文件 {id}", + "integrations.aside.toggle": "同步 {name}", + "integrations.aside.details": "管理 {name}", + "integrations.aside.back": "所有 Aside 配置文件", + "integrations.aside.empty": "打开 Aside 并创建配置文件以进行连接。", + "integrations.aside.partial": "部分配置文件需要处理。同步设置已保存,请检查各配置文件的状态。", + "integrations.aside.pending": "同步设置已保存,等待更新文件。", + "integrations.aside.retry": "重试 {name}", + "integrations.aside.loadError": "无法加载 Aside 配置文件。请重试以查看状态。", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 连接由代理服务管理。启动 opencodex 时应用该连接;停止服务时恢复原生路由。", "integrations.codex.openService": "打开服务控制", @@ -1179,6 +1194,7 @@ export const zh: Record = { "integrations.bulk.success": "已禁用已应用的客户端集成。", "integrations.retention.degraded": "备份清理进度滞后;磁盘上可能仍有较旧的备份。", "integrations.error.residual": "文件可能处于中间状态:{message} 请从 {path} 恢复。", + "integrations.error.residualNoSnapshot": "{message} 自动恢复未完成。请先检查客户端配置,再重试。", "integrations.error.recover": "{message} 备份位于 {path}。", "integrations.kind.apply": "已应用", "integrations.kind.disable": "已停用", @@ -1203,7 +1219,7 @@ export const zh: Record = { "integrations.semantics.mcode": "仅管理 custom_provider.opencodex,不会更改默认模型或 MiniMax 登录状态。", "integrations.semantics.zcode": "仅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不会更改 Z.ai 登录状态或其他提供商。更改后请重启 ZCode。", "integrations.semantics.prime": "仅管理 Prime Agent 的 models.json 中的 providers.opencodex;默认位于 ~/.prime/agent,若设置 PRIME_AGENT_CODING_AGENT_DIR 则以其为准。不会更改其他提供商或模型覆盖设置。对新会话生效。", - "integrations.semantics.aside": "仅管理已登录账号的 Aside models.json 中的 providers.opencodex,位于 ~/.aside/u/<账号>。不会更改其他提供商。Aside 在运行时会重写该文件,因此应用后请完全退出并重新打开 Aside。", + "integrations.semantics.aside": "仅管理此配置文件的 models.json 中的 providers.opencodex。其他提供商保持不变。应用后请完全退出并重新打开 Aside。", "codexAuth.mainAccount": "主账号", "codexAuth.logLabel": "日志标签", "codexAuth.codexApp": "Codex App", diff --git a/gui/src/pages/Integrations.tsx b/gui/src/pages/Integrations.tsx index b173771106..b7d7d86fc2 100644 --- a/gui/src/pages/Integrations.tsx +++ b/gui/src/pages/Integrations.tsx @@ -8,6 +8,7 @@ import Claude from "./Claude"; import Grok from "./Grok"; import CursorIntegrationPage from "./integrations/CursorIntegrationPage"; import IntegrationsOverview from "./integrations/IntegrationsOverview"; +import AsideProfilesPage from "./integrations/AsideProfilesPage"; import FileIntegrationPage, { type FileIntegrationClientId, } from "./integrations/FileIntegrationPage"; @@ -197,7 +198,7 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec {definition.id === "grok" && } {definition.id === "cursor" && } {FILE_CLIENTS.has(definition.id as FileIntegrationClientId) && ( - : (null); + const [pending, setPending] = useState(null); + const pendingRef = useRef(false); + const [failure, setFailure] = useState(null); + const [profileFailures, setProfileFailures] = useState>(new Map()); + const fetchProfiles = useCallback((signal: AbortSignal) => loadAsideProfiles(apiBase, signal), [apiBase]); + const resource = useDataSurface(`aside-profiles:${apiBase}`, [apiBase], fetchProfiles, { + enabled: active, + isEmpty: value => !value.error && value.profiles.length === 0, + sessionCacheKey: `ocx.integrations.aside-profiles.v1:${apiBase}`, + }); + const data = resource.state.data; + const profiles = data?.profiles ?? []; + const loadError = resource.state.showError || Boolean(data?.error); + const busy = pending !== null || resource.state.refreshing; + const label = (profile: AsideProfileStatus) => profile.name || t("integrations.aside.profile", { id: profile.profileId }); + + const reconcileOutcomes = (outcomes: AsideProfileOutcome[]) => setProfileFailures(previous => { + const next = new Map(previous); + for (const row of outcomes) { + if (row.ok) next.delete(row.profileId); + else next.set(row.profileId, describeAsideProfileOutcome(t, row)); + } + return next; + }); + const failed = (error: unknown, profileId?: number) => { + if (error instanceof IntegrationApiError && "results" in error.body && error.body.results?.length) { + reconcileOutcomes(error.body.results); + setFailure(t("integrations.aside.partial")); + } else if (profileId !== undefined) { + setProfileFailures(previous => new Map(previous).set(profileId, describeRefusal(t, error))); + } else setFailure(describeRefusal(t, error)); + }; + + const mutate = async (enabled: boolean, profileId?: number) => { + if (pendingRef.current) return; + pendingRef.current = true; + setPending(profileId ?? "all"); + setFailure(null); + try { + const result = await toggleIntegration(apiBase, "aside", enabled, undefined, undefined, profileId); + reconcileOutcomes(result.results ?? (profileId === undefined ? profiles.map(row => row.profileId) : [profileId]) + .map(id => ({ profileId: id, ok: true }))); + } + catch (error) { failed(error, profileId); } + finally { + // Even a refused writer may have durably saved desired sync preferences. + resource.refresh(); + pendingRef.current = false; + setPending(null); + } + }; + const sync = async () => { + if (pendingRef.current) return; + pendingRef.current = true; + setPending("sync"); + setFailure(null); + try { + const result = await syncAsideProfiles(apiBase); + reconcileOutcomes(result.results); + if (!result.ok) setFailure(t("integrations.aside.partial")); + } catch (error) { failed(error); } + finally { + resource.refresh(); + pendingRef.current = false; + setPending(null); + } + }; + + if (selected) return ( +
    + + {profileFailures.has(selected.profileId) && {profileFailures.get(selected.profileId)}} + +
    + ); + + return ( +
    +
    + +

    {t("integrations.aside.profilesTitle")}

    +
    +

    {t("integrations.aside.profilesHint")}

    +
    + 0 && data.enabledCount < data.total)} + onClick={() => void mutate(!data?.allEnabled)} disabled={busy || loadError || profiles.length === 0} + label={t("integrations.aside.all")} showLabel /> + {t("integrations.aside.applied", { count: data?.appliedCount ?? 0, total: data?.total ?? 0 })} + +
    + {failure && {failure}} + {[...profileFailures].filter(([id]) => !profiles.some(row => row.profileId === id)).map(([id, detail]) => ( + {t("integrations.aside.profile", { id })}: {detail} + ))} + {loadError && ( + + {t("integrations.aside.loadError")} + {data?.error && {data.error}} + + + )} + {resource.state.showSkeleton && } + {!resource.state.showSkeleton && !loadError && data && profiles.length === 0 &&

    {t("integrations.aside.empty")}

    } + {profiles.length > 0 && ( +
    + {profiles.map(profile => { + const name = label(profile); + const applied = profile.state === "current" || profile.state === "stale"; + const needsUpdate = profile.enabled !== applied || (profile.enabled && profile.state === "stale"); + const locked = (!profile.installed || profile.state === "unsafe" || profile.state === "conflict") && !profile.enabled; + return ( +
    +
    + +
    + {profile.profileId} + {profile.current && {t("integrations.aside.current")}} +
    + {needsUpdate && {t("integrations.aside.pending")}} + {profile.error && {profile.error}} + {profileFailures.has(profile.profileId) && {profileFailures.get(profile.profileId)}} +
    +
    + + {needsUpdate && } + void mutate(!profile.enabled, profile.profileId)} + disabled={busy || loadError || locked} label={t("integrations.aside.toggle", { name })} /> +
    +
    + ); + })} +
    + )} +
    + ); +} diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 8bbe794954..51db75bd9f 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -78,12 +78,17 @@ export default function FileIntegrationPage({ apiBase, client, active = true, + profileId, + profileLabel, }: { apiBase: string; client: FileIntegrationClientId; active?: boolean; + profileId?: number; + profileLabel?: string; }) { const t = useT(); + const scopeKey = profileId === undefined ? client : `${client}:${profileId}`; const [pending, setPending] = useState(false); const [failure, setFailure] = useState(null); const [restoring, setRestoring] = useState(null); @@ -93,36 +98,38 @@ export default function FileIntegrationPage({ const [overwriting, setOverwriting] = useState(false); const fetchState = useCallback( - (signal: AbortSignal) => loadIntegrationState(apiBase, client, signal), - [apiBase, client], + (signal: AbortSignal) => loadIntegrationState(apiBase, client, signal, profileId), + [apiBase, client, profileId], ); const fetchHistory = useCallback( - async (signal: AbortSignal) => (await loadIntegrationJournal(apiBase, client, signal)).operations, - [apiBase, client], + async (signal: AbortSignal) => (await loadIntegrationJournal(apiBase, client, signal, profileId)).operations, + [apiBase, client, profileId], ); const stateResource = useDataSurface( - `integration-state:${apiBase}:${client}`, - [apiBase, client], + `integration-state:${apiBase}:${scopeKey}`, + [apiBase, client, profileId], fetchState, { isEmpty: () => false, enabled: active, - sessionCacheKey: `ocx.integrations.state.v1:${apiBase}:${client}`, + sessionCacheKey: `ocx.integrations.state.v1:${apiBase}:${scopeKey}`, }, ); const historyResource = useDataSurface( - `integration-journal:${apiBase}:${client}`, - [apiBase, client], + `integration-journal:${apiBase}:${scopeKey}`, + [apiBase, client, profileId], fetchHistory, { isEmpty: rows => rows.length === 0, enabled: active, - sessionCacheKey: `ocx.integrations.client-journal.v1:${apiBase}:${client}`, + sessionCacheKey: `ocx.integrations.client-journal.v1:${apiBase}:${scopeKey}`, }, ); - const status = stateResource.state.data ?? null; + const candidate = stateResource.state.data ?? null; + const status = candidate && (profileId === undefined + || (candidate.profileId === profileId && typeof candidate.enabled === "boolean")) ? candidate : null; const history = historyResource.state.data ?? []; const refresh = () => { @@ -135,11 +142,11 @@ export default function FileIntegrationPage({ setPending(true); setFailure(null); try { - await toggleIntegration(apiBase, client, enabled); - refresh(); + await toggleIntegration(apiBase, client, enabled, undefined, undefined, profileId); } catch (error) { setFailure(describeRefusal(t, error)); } finally { + refresh(); setPending(false); } }; @@ -157,10 +164,11 @@ export default function FileIntegrationPage({ if (!status) return; setFailure(null); try { - await toggleIntegration(apiBase, client, true, undefined, true); + await toggleIntegration(apiBase, client, true, undefined, true, profileId); refresh(); } catch (error) { setFailure(describeRefusal(t, error)); + refresh(); throw error; } }; @@ -174,12 +182,12 @@ export default function FileIntegrationPage({ * to remove the block; updating a stale block is a separate action with its * own button below. */ - const toggle = () => void mutate(!(status && (status.state === "current" || status.state === "stale"))); + const toggle = () => void mutate(!(status && (profileId !== undefined ? status.enabled : (status.state === "current" || status.state === "stale")))); if (!status) { return (
    - {stateResource.state.kind === "failed-cold" + {stateResource.state.showError ? {t("integrations.error.load")} :

    {t("common.loading")}

    }
    @@ -187,34 +195,42 @@ export default function FileIntegrationPage({ } const applied = status.state === "current" || status.state === "stale"; - // Conflict and unsafe are never auto-resolved: the switch is locked and the - // user is told why, because the alternative is deleting an edit we do not own. - const locked = !status.installed || status.state === "conflict" || status.state === "unsafe"; + const enabled = profileId !== undefined ? status.enabled === true : applied; + const profileUnavailable = profileId !== undefined && (stateResource.state.showError || stateResource.state.refreshing); + // A profile may stop future sync even when its file cannot be changed; the + // writer still refuses unsafe deletion and reports the actual state separately. + const locked = (!status.installed || status.state === "conflict" || status.state === "unsafe") + && !(profileId !== undefined && enabled); return (
    -

    {t(TAB_LABEL_KEY[client])}

    +

    {profileLabel ?? t(TAB_LABEL_KEY[client])}

    + {profileId !== undefined && stateResource.state.showError && ( + {t("integrations.aside.loadError")} + + + )} {/* Updating a stale block is its own action. Folding it into the switch made "off" mean "refresh", which is the opposite of what the control said it would do. */} - {status.state === "stale" && ( + {status.state === "stale" && enabled && ( +
    )}
    diff --git a/gui/tests/logs-auto-refresh.test.tsx b/gui/tests/logs-auto-refresh.test.tsx index e40fad1cda..a4bef33462 100644 --- a/gui/tests/logs-auto-refresh.test.tsx +++ b/gui/tests/logs-auto-refresh.test.tsx @@ -108,7 +108,7 @@ afterEach(() => { } }); -async function mountLogs(): Promise<{ root: Root; container: HTMLElement }> { +async function mountLogs(apiBase = "http://localhost"): Promise<{ root: Root; container: HTMLElement }> { const { createRoot } = await import("react-dom/client"); const container = document.createElement("div"); document.body.append(container); @@ -117,7 +117,7 @@ async function mountLogs(): Promise<{ root: Root; container: HTMLElement }> { root = createRoot(container); root.render( - + , ); }); @@ -849,6 +849,8 @@ test("Logs: detail conversation action and reset use the same filter state", asy await act(async () => { container.querySelector(".logs-filter-status button")!.click(); }); expect(visibleRequestIds(container)).toEqual(["other", "hashed"]); expect(container.querySelector(".logs-filter-status")).toBeNull(); + expect(document.activeElement).toBe(container.querySelector("#logs-surface-all")); + expect(container.querySelector("#logs-surface-all")?.getAttribute("aria-checked")).toBe("true"); const detail = container.querySelector('.log-detail-btn[aria-label="Details: other"]')!; expect(detail).not.toBeNull(); await act(async () => { detail.click(); }); @@ -943,3 +945,259 @@ test("Logs: a cold empty snapshot shows no requests rather than no matches", asy await act(async () => { root.unmount(); }); } }); + +const PROXY_NOW = 1_800_000_000_000; + +function proxyLogEnvelope(generatedAt: unknown, logs: unknown[]) { + return { generatedAt, timeZone: "UTC", total: logs.length, logs }; +} + +async function renderLogsAt(root: Root, apiBase: string): Promise { + await act(async () => { + root.render(); + }); + await flushMicrotasks(); +} + +test.each([-6, 6])("Logs: proxy clock handles browser skew of %sh, wall jumps and paused expiry", async hours => { + let wallNow = PROXY_NOW + hours * 60 * 60_000; + let monotonic = 1000; + const wall = jest.spyOn(Date, "now").mockImplementation(() => wallNow); + const monotonicClock = jest.spyOn(performance, "now").mockImplementation(() => monotonic); + const clock = trackFilterClock(); + let requests = 0; + const rows = [ + { ...sampleLog, requestId: "too-old", timestamp: PROXY_NOW - 15 * 60_000 - 1000 }, + { ...sampleLog, requestId: "fresh", timestamp: PROXY_NOW - 15 * 60_000 + 1000 }, + ]; + globalThis.fetch = (async input => { + if (!String(input).includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + requests++; + return jsonResponse(proxyLogEnvelope(PROXY_NOW, rows)); + }) as typeof fetch; + let mounted: Awaited> | undefined; + try { + mounted = await mountLogs(); + const { container } = mounted; + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["fresh", "too-old"]); + await changeLogSelect(container, "Time", "15m"); + expect(visibleRequestIds(container)).toEqual(["fresh"]); + await act(async () => { container.querySelector(".logs-auto-refresh input")!.click(); }); + await flushMicrotasks(); + const pausedRequests = requests; + wallNow += 12 * 60 * 60_000; + monotonic += 500; + await act(async () => { jest.advanceTimersByTime(30_000); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["fresh"]); + wallNow -= 24 * 60 * 60_000; + monotonic += 30_000; + await act(async () => { jest.advanceTimersByTime(30_000); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual([]); + expect(container.textContent).toContain("No matching requests."); + expect(requests).toBe(pausedRequests); + await changeLogSelect(container, "Time", "all"); + expect(visibleRequestIds(container)).toEqual(["fresh", "too-old"]); + } finally { + try { + if (mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + clock.restore(); + wall.mockRestore(); + monotonicClock.mockRestore(); + } + } +}); + +test("Logs: successful proxy samples resync immediately; legacy, malformed and failed refreshes retain the anchor", async () => { + const wall = jest.spyOn(Date, "now").mockReturnValue(PROXY_NOW - 6 * 60 * 60_000); + let monotonic = 1000; + const monotonicClock = jest.spyOn(performance, "now").mockImplementation(() => monotonic); + const clock = trackFilterClock(); + const rows = [{ ...sampleLog, requestId: "row", timestamp: PROXY_NOW - 5 * 60_000 }]; + let mode: "initial" | "resync" | "legacy" | "malformed" | "failed" = "initial"; + globalThis.fetch = (async input => { + if (!String(input).includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + if (mode === "failed") return jsonResponse({ error: "unavailable" }, 503); + if (mode === "legacy") return jsonResponse(rows); + if (mode === "malformed") return jsonResponse(proxyLogEnvelope(-1, rows)); + return jsonResponse(proxyLogEnvelope(mode === "initial" ? PROXY_NOW : PROXY_NOW + 20 * 60_000, rows)); + }) as typeof fetch; + let mounted: Awaited> | undefined; + try { + mounted = await mountLogs(); + const { container } = mounted; + await flushMicrotasks(); + await changeLogSelect(container, "Time", "15m"); + expect(visibleRequestIds(container)).toEqual(["row"]); + mode = "resync"; + await advanceSilentRefresh(); + // No 30s interval tick yet: receipt of a new server sample updates the filter. + expect(visibleRequestIds(container)).toEqual([]); + for (const next of ["legacy", "malformed", "failed"] as const) { + mode = next; + await advanceSilentRefresh(); + expect(visibleRequestIds(container)).toEqual([]); + } + monotonic += 30_000; + await act(async () => { jest.advanceTimersByTime(30_000); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual([]); + expect(container.textContent).toContain("No matching requests."); + } finally { + try { + if (mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + clock.restore(); + wall.mockRestore(); + monotonicClock.mockRestore(); + } + } +}); + +test("Logs: switching apiBase clears the old proxy anchor for a legacy envelope", async () => { + const browserNow = PROXY_NOW + 6 * 60 * 60_000; + const wall = jest.spyOn(Date, "now").mockReturnValue(browserNow); + const monotonicClock = jest.spyOn(performance, "now").mockReturnValue(1000); + globalThis.fetch = (async input => { + const url = String(input); + if (!url.includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + if (url.startsWith("http://proxy-a/")) return jsonResponse(proxyLogEnvelope(PROXY_NOW, [ + { ...sampleLog, requestId: "proxy-a", timestamp: PROXY_NOW - 60_000 }, + ])); + return jsonResponse({ logs: [ + { ...sampleLog, requestId: "legacy-old", timestamp: browserNow - 20 * 60_000 }, + { ...sampleLog, requestId: "legacy-fresh", timestamp: browserNow - 5 * 60_000 }, + ] }); + }) as typeof fetch; + let mounted: Awaited> | undefined; + try { + mounted = await mountLogs("http://proxy-a"); + const { root, container } = mounted; + await flushMicrotasks(); + await changeLogSelect(container, "Time", "15m"); + expect(visibleRequestIds(container)).toEqual(["proxy-a"]); + await renderLogsAt(root, "http://proxy-b"); + expect(container.querySelector('select[aria-label="Time"]')!.value).toBe("15m"); + expect(visibleRequestIds(container)).toEqual(["legacy-fresh"]); + } finally { + try { + if (mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + wall.mockRestore(); + monotonicClock.mockRestore(); + } + } +}); + +// The response headers have arrived, but its body reader deliberately ignores abort. +// This reaches the loader's side-effect boundary after the resource-store guard fired. +function delayedLogBody() { + let resolve!: (body: unknown) => void; + const body = new Promise(done => { resolve = done; }); + const response = jsonResponse({}); + response.json = () => body; + return { response, resolve }; +} + +test("Logs: a late body from an aborted old apiBase cannot poison the new proxy clock", async () => { + const late = delayedLogBody(); + let oldSignal: AbortSignal | undefined; + let oldRequests = 0; + const wall = jest.spyOn(Date, "now").mockReturnValue(PROXY_NOW + 6 * 60 * 60_000); + let monotonic = 1000; + const monotonicClock = jest.spyOn(performance, "now").mockImplementation(() => monotonic); + const clock = trackFilterClock(); + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (!url.includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + if (url.startsWith("http://proxy-a/")) { + oldRequests++; + oldSignal = init?.signal ?? undefined; + return late.response; + } + return jsonResponse(proxyLogEnvelope(PROXY_NOW, [ + { ...sampleLog, requestId: "proxy-b", timestamp: PROXY_NOW - 60_000 }, + ])); + }) as typeof fetch; + let mounted: Awaited> | undefined; + try { + mounted = await mountLogs("http://proxy-a"); + const { root, container } = mounted; + await flushMicrotasks(); + expect(oldRequests).toBe(1); + await renderLogsAt(root, "http://proxy-b"); + expect(oldSignal?.aborted).toBe(true); + await changeLogSelect(container, "Time", "15m"); + await act(async () => { container.querySelector(".logs-auto-refresh input")!.click(); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["proxy-b"]); + await act(async () => { late.resolve(proxyLogEnvelope(PROXY_NOW + 12 * 60 * 60_000, [])); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["proxy-b"]); + monotonic += 30_000; + await act(async () => { jest.advanceTimersByTime(30_000); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["proxy-b"]); + } finally { + try { + if (mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + clock.restore(); + wall.mockRestore(); + monotonicClock.mockRestore(); + } + } +}); + +test("Logs: aborting an in-flight refresh before pausing cannot replace the accepted clock", async () => { + const late = delayedLogBody(); + let requests = 0; + let lateSignal: AbortSignal | undefined; + const wall = jest.spyOn(Date, "now").mockReturnValue(PROXY_NOW - 6 * 60 * 60_000); + let monotonic = 1000; + const monotonicClock = jest.spyOn(performance, "now").mockImplementation(() => monotonic); + const clock = trackFilterClock(); + globalThis.fetch = (async (input, init) => { + if (!String(input).includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + requests++; + if (requests === 2) { + lateSignal = init?.signal ?? undefined; + return late.response; + } + return jsonResponse(proxyLogEnvelope(PROXY_NOW, [ + { ...sampleLog, requestId: "current", timestamp: PROXY_NOW - 60_000 }, + ])); + }) as typeof fetch; + let mounted: Awaited> | undefined; + try { + mounted = await mountLogs(); + const { container } = mounted; + await flushMicrotasks(); + await changeLogSelect(container, "Time", "15m"); + await advanceSilentRefresh(); + expect(requests).toBe(2); + await act(async () => { container.querySelector(".logs-auto-refresh input")!.click(); }); + await flushMicrotasks(); + expect(lateSignal?.aborted).toBe(true); + const pausedRequests = requests; + await act(async () => { late.resolve(proxyLogEnvelope(PROXY_NOW + 12 * 60 * 60_000, [])); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["current"]); + monotonic += 30_000; + await act(async () => { jest.advanceTimersByTime(30_000); }); + await flushMicrotasks(); + expect(visibleRequestIds(container)).toEqual(["current"]); + expect(requests).toBe(pausedRequests); + } finally { + try { + if (mounted) await act(async () => { mounted!.root.unmount(); }); + } finally { + clock.restore(); + wall.mockRestore(); + monotonicClock.mockRestore(); + } + } +}); diff --git a/gui/tests/logs-clock.test.ts b/gui/tests/logs-clock.test.ts new file mode 100644 index 0000000000..e26bca8612 --- /dev/null +++ b/gui/tests/logs-clock.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { logsClockAnchor, logsClockNow } from "../src/pages/logs-clock"; + +test.each([undefined, null, "1700000000000", -1, NaN, Infinity, -Infinity])( + "invalid generatedAt %s leaves the browser fallback in effect", generatedAt => { + const anchor = logsClockAnchor(generatedAt, 10); + expect(anchor).toBeUndefined(); + expect(logsClockNow(anchor, 100, 42_000)).toBe(42_000); + }, +); + +test("zero is a valid proxy epoch and elapsed time is monotonic", () => { + const anchor = logsClockAnchor(0, 100); + expect(anchor).toEqual({ generatedAt: 0, receivedAt: 100 }); + expect(logsClockNow(anchor, 150, 80_000)).toBe(50); + expect(logsClockNow(anchor, 160, 1)).toBe(60); +}); + +test("proxy-relative time ignores browser wall-clock skew and subsequent jumps", () => { + const anchor = logsClockAnchor(1_800_000_000_000, 500); + expect(logsClockNow(anchor, 30_500, 1_800_021_600_000)).toBe(1_800_000_030_000); + expect(logsClockNow(anchor, 30_500, 1_799_978_400_000)).toBe(1_800_000_030_000); +}); diff --git a/gui/tests/logs-filter-bar.test.ts b/gui/tests/logs-filter-bar.test.ts index 8c5ff7b64c..259c5defd4 100644 --- a/gui/tests/logs-filter-bar.test.ts +++ b/gui/tests/logs-filter-bar.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, jest, test } from "bun:test"; import { Window } from "happy-dom"; import { act, createElement, useState } from "react"; import type { Root } from "react-dom/client"; @@ -121,7 +121,7 @@ test("LogsFilterBar speed choices have non-overlapping bounds and clear both bou }); }); -test("LogsFilterBar reset clears multiple fields and removes the count and reset control", async () => { +test.each(["pointer", "keyboard"] as const)("LogsFilterBar %s reset restores focus to All and clears every field", async activation => { await withFilterBar({ ...DEFAULT_LOG_FILTER_STATE, surface: "grok", status: "errors", provider: "xai", model: "model-a", timeWindow: "1h", minTokPerSec: 50, interceptedOnly: true, @@ -132,7 +132,23 @@ test("LogsFilterBar reset clears multiple fields and removes the count and reset expect(reset).not.toBeNull(); reset.focus(); expect(document.activeElement).toBe(reset); - await act(async () => { reset.click(); }); + const all = ui.container.querySelector("#logs-surface-all")!; + const focus = jest.spyOn(all, "focus"); + try { + // Native buttons dispatch click with detail=0 for keyboard activation. + // Browser QA separately exercises Enter/Space's native event synthesis. + await act(async () => { + reset.dispatchEvent(new ui.win.MouseEvent("click", { + bubbles: true, cancelable: true, detail: activation === "keyboard" ? 0 : 1, + })); + }); + expect(document.activeElement).toBe(all); + expect(all.getAttribute("aria-checked")).toBe("true"); + expect(all.tabIndex).toBe(0); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + } finally { + focus.mockRestore(); + } expect(ui.filters()).toEqual(DEFAULT_LOG_FILTER_STATE); expect(ui.container.querySelector(".logs-filter-status")).toBeNull(); expect(ui.container.querySelector('input[type="search"]')!.value).toBe(""); diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 5d909283f2..dfb3c74df0 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -111,6 +111,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise { @@ -24,16 +24,23 @@ describe("log timestamp timezone (#725)", () => { expect(() => new Intl.DateTimeFormat("en-US", { timeZone: body.timeZone as string })).not.toThrow(); }); - test("/api/logs envelope includes a usable timeZone", async () => { + test("/api/logs envelope includes the proxy clock and a usable timeZone", async () => { const url = new URL("http://localhost/api/logs"); + const before = Date.now(); const response = await handleManagementAPI(new Request(url), url, config); + const after = Date.now(); expect(response?.status).toBe(200); const body = await response!.json() as { timeZone?: unknown; + generatedAt?: unknown; total?: unknown; logs?: unknown; }; expect(typeof body.timeZone).toBe("string"); + expect(typeof body.generatedAt).toBe("number"); + expect(Number.isFinite(body.generatedAt)).toBe(true); + expect(body.generatedAt as number).toBeGreaterThanOrEqual(before); + expect(body.generatedAt as number).toBeLessThanOrEqual(after); expect(typeof body.total).toBe("number"); expect(Array.isArray(body.logs)).toBe(true); expect(() => new Intl.DateTimeFormat("en-US", { timeZone: body.timeZone as string })).not.toThrow(); From 06397f056cd708bbe3d43f1e3d852f264bb8a86b Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:41:39 +0900 Subject: [PATCH 115/236] fix(responses): reject mismatched namespace call kinds --- src/responses/namespace-tool-compat.ts | 3 ++ tests/responses/namespace-tool-compat.test.ts | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index d13e19422a..049d74de09 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -416,6 +416,9 @@ export function restoreRoutedNamespaceCalls( ) { const identity = aliases.get(value.name); if (identity + // Custom declarations may be lowered to function calls upstream, but an + // ordinary function declaration never authorizes a custom call payload. + && (value.type !== "custom_tool_call" || identity.kind === "custom") && (!Object.hasOwn(value, "namespace") || value.namespace === identity.namespace)) { restored.name = identity.name; restored.namespace = identity.namespace; diff --git a/tests/responses/namespace-tool-compat.test.ts b/tests/responses/namespace-tool-compat.test.ts index 622fe09411..c8917aac7a 100644 --- a/tests/responses/namespace-tool-compat.test.ts +++ b/tests/responses/namespace-tool-compat.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { restoreRoutedCustomCalls, rewriteRoutedCustomToolsForUpstream } from "../../src/responses/custom-tool-compat"; import { createRoutedNamespaceCallRestoreRewrite, restoreRoutedNamespaceCalls, @@ -523,6 +524,39 @@ describe("Responses namespace tool compatibility", () => { describe("dotted namespace restoration uses the declaration collision boundary", () => { const ping = { type: "namespace", name: "mcp", tools: [{ type: "function", name: "ping", parameters: {} }] }; + test.each(["mcp.ping", "mcp__ping"])("preserves a custom call whose alias %s declares an ordinary function", name => { + const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools: [ping] }); + expect(aliases.get(name)?.kind).toBe("function"); + for (const namespace of [undefined, "mcp"]) { + const call = { type: "custom_tool_call", name, call_id: "call_ping", input: "raw custom input", + ...(namespace === undefined ? {} : { namespace }) }; + expect(restoreRoutedNamespaceCalls(call, aliases)).toEqual({ value: call, changed: false }); + expect(restoreRoutedNamespaceCalls(call, aliases).value).toBe(call); + const text = JSON.stringify({ type: "response.completed", response: { output: [call] } }, null, 2); + expect(restoreRoutedNamespaceCallsInJson(text, aliases)).toBe(text); + expect(createRoutedNamespaceCallRestoreRewrite(aliases)(text)).toBe(text); + } + }); + + test.each(["mcp.run", "mcp__run"])("restores the declared custom tool after upstream function downgrade via %s", name => { + const namespaced = rewriteRoutedNamespaceToolsForUpstream({ + tools: [{ type: "namespace", name: "mcp", tools: [{ type: "custom", name: "run", description: "Run raw input" }] }], + }); + const downgraded = rewriteRoutedCustomToolsForUpstream(namespaced.body, false); + expect(downgraded.body).toMatchObject({ tools: [{ type: "function", name: "mcp__run" }] }); + expect(downgraded.names.has("mcp__run")).toBe(true); + expect(namespaced.aliases.get(name)?.kind).toBe("custom"); + const call = { type: "function_call", name, id: "fc_run", call_id: "call_run", arguments: '{"input":"echo ready"}' }; + const restored = restoreRoutedNamespaceCalls(call, namespaced.aliases); + expect(restored).toEqual({ changed: true, value: { ...call, name: "run", namespace: "mcp" } }); + expect(restoreRoutedCustomCalls({ output: [restored.value] }, downgraded.names).value).toEqual({ + output: [{ type: "custom_tool_call", name: "run", namespace: "mcp", id: "ctc_run", call_id: "call_run", input: "echo ready" }], + }); + const nativeCustom = { type: "custom_tool_call", name, input: "raw custom input" }; + expect(restoreRoutedNamespaceCalls(nativeCustom, namespaced.aliases).value) + .toEqual({ ...nativeCustom, name: "run", namespace: "mcp" }); + }); + test("restores the dotted spelling after canonical tool-choice authorization", () => { const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools: [ping], tool_choice: { type: "function", namespace: "mcp", name: "ping" } }); expect(restoreRoutedNamespaceCalls({ type: "function_call", name: "mcp.ping", arguments: "{}" }, aliases).value) From 3d955317148a4d48f7c82ba8ba2dbb58c04e5f2a Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:41:39 +0900 Subject: [PATCH 116/236] fix(cli): report Aside sync when the proxy is unavailable --- src/cli/dispatch.ts | 48 +++++++++++---------- src/cli/runtime-api.ts | 2 +- tests/cli/cli-dispatch.test.ts | 77 +++++++++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 23 deletions(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index e7c5e9fcca..6d018536c7 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -14,6 +14,7 @@ import type { CliHead } from "./root"; import type { ReadyArgs } from "./ready"; import type { LivenessIo, LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; +import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh"; import { hasHelpFlag, printSubcommandUsage, printUsage } from "./help"; import { setIntegrationEnabled, shouldSyncCodexOnStart } from "../codex/desired-state"; import { syncModelsToCodex } from "../codex/sync"; @@ -389,31 +390,36 @@ const commandRunners: Record = { // `ocx sync` is a direct CLI path; it does not call the management // `/api/sync` route. Refresh already-connected file integrations here too, // after Codex has published the catalog that supplies its capabilities. - if (synced.status !== "refused" && live) { - try { - const config = deps.loadConfig(); - const { refreshOwnedCatalogIntegrations } = await import("../integrations/catalog-refresh"); - const results = await refreshOwnedCatalogIntegrations({ - models: async () => { - const { loadExportModels } = await import("../server/management/model-rows"); - return loadExportModels(config); - }, - config, - port: live.port, - }, ["mcode", "pi"]); + if (synced.status !== "refused") { + const results: OwnedIntegrationRefreshOutcome[] = []; + if (live) { try { - const { refreshAsideProfilesThroughServer } = await import("./aside-profiles"); - results.push(...await refreshAsideProfilesThroughServer({ findLiveProxy: async () => live })); + const config = deps.loadConfig(); + const { refreshOwnedCatalogIntegrations } = await import("../integrations/catalog-refresh"); + results.push(...await refreshOwnedCatalogIntegrations({ + models: async () => { + const { loadExportModels } = await import("../server/management/model-rows"); + return loadExportModels(config); + }, + config, + port: live.port, + }, ["mcode", "pi"])); } catch (error) { - console.warn(`Aside profiles were not refreshed: ${error instanceof Error ? error.message : String(error)}`); - } - for (const result of results) { - const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`; - if (result.changed) console.log(`${label} integration refreshed from the current catalog.`); - else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}${result.residual ? " Recovery did not finish." : ""}${result.snapshotPath ? ` Backup: ${result.snapshotPath}` : ""}`); + console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } + } + // Even without a live proxy, report why Aside could not sync. Its server + // owner is never bypassed, and another client's failure cannot hide it. + try { + const { refreshAsideProfilesThroughServer } = await import("./aside-profiles"); + results.push(...await refreshAsideProfilesThroughServer({ findLiveProxy: async () => live })); } catch (error) { - console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); + console.warn(`Aside profiles were not refreshed: ${error instanceof Error ? error.message : String(error)}`); + } + for (const result of results) { + const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`; + if (result.changed) console.log(`${label} integration refreshed from the current catalog.`); + else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}${result.residual ? " Recovery did not finish." : ""}${result.snapshotPath ? ` Backup: ${result.snapshotPath}` : ""}`); } } return code; diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index bf4a98fd7e..7b05d56b9f 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -44,7 +44,7 @@ export class RuntimeApiError extends Error { export async function runtimeBaseUrl(deps: RuntimeApiDeps = {}): Promise { if (deps.baseUrl) return deps.baseUrl.replace(/\/$/, ""); - const live = await findLiveProxy(); + const live = await (deps.findLiveProxy ?? findLiveProxy)(); if (!live) throw new RuntimeApiError("Proxy is not running. Start it with: ocx start", 503, null); return `http://${probeHostname(live.hostname)}:${live.port}`; } diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 88ef3ed85f..f3d102d1b9 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -3,13 +3,14 @@ import { CLI_COMMANDS } from "../../src/cli/registry"; import { DISPATCH_ALIASES, DISPATCH_COMMANDS, dispatchCommand, resolveDispatchCommand, decideStartWithLiveOwner } from "../../src/cli/dispatch"; import type { CliDispatchDeps } from "../../src/cli/dispatch"; import { runGuiCommand } from "../../src/cli/gui"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigDir } from "../../src/config"; import { getAccountSet, removeCredential, saveCredential } from "../../src/oauth/store"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; +import type { OcxConfig } from "../../src/types"; /** Minimal fake deps. dispatchCommand only touches deps for real command * runners, which these tests never invoke, so an empty object is enough. */ @@ -94,6 +95,80 @@ describe("dispatchCommand exit codes", () => { } }); + test.each(["applied", "catalog-only", "refused"] as const)( + "sync with no live proxy reports Aside unavailability after Codex %s without local fallback", async status => { + const home = mkdtempSync(join(tmpdir(), "ocx-dispatch-aside-offline-")); + const previous = { OPENCODEX_HOME: process.env.OPENCODEX_HOME, CODEX_HOME: process.env.CODEX_HOME }; + const syncModule = await import("../../src/codex/sync"); + const catalogModule = await import("../../src/integrations/catalog-refresh"); + const livenessModule = await import("../../src/server/proxy-liveness"); + const warnings: string[] = []; + const logs: string[] = []; + const sync = spyOn(syncModule, "syncModelsToCodex").mockResolvedValue({ + status, ok: status !== "refused", added: 0, catalogPath: null, catalogExists: false, + catalogWritten: false, cacheSynced: false, message: "fixture Codex sync result", + }); + // The real Aside helper/runtime client must run. Fence the independent local + // writer and unscoped discovery so this regression cannot reach user files + // or a developer's real proxy if either dispatch boundary regresses. + const localRefresh = spyOn(catalogModule, "refreshOwnedCatalogIntegrations").mockResolvedValue([]); + // A globally discoverable proxy must not override the injected null result. + const unscopedDiscovery = spyOn(livenessModule, "findLiveProxy").mockResolvedValue({ + pid: null, port: 65534, hostname: "127.0.0.1", source: "config", + }); + const http = spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected runtime HTTP request")); + const warn = spyOn(console, "warn").mockImplementation((...args) => { warnings.push(args.map(String).join(" ")); }); + const log = spyOn(console, "log").mockImplementation((...args) => { logs.push(args.map(String).join(" ")); }); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(home, "codex"); + mkdirSync(process.env.CODEX_HOME); + const config = { + port: 10100, providers: {}, defaultProvider: "openai", + asideProfileSync: { allProfiles: true, profiles: {} }, + } as OcxConfig; + const configPath = join(home, "config.json"); + const before = JSON.stringify(config); + writeFileSync(configPath, before); + let discoveries = 0; + const args = ["sync"]; + const deps = { + ...fakeDeps, args, loadConfig: () => config, + findLiveProxy: async () => { discoveries += 1; return null; }, + }; + const code = await dispatchCommand({ kind: "command", command: "sync", args }, deps); + // An Aside warning does not change a successful Codex sync's exit code. + expect(code).toBe(status === "refused" ? 1 : 0); + expect(discoveries).toBe(1); + expect(sync).toHaveBeenCalledTimes(1); + expect(unscopedDiscovery).not.toHaveBeenCalled(); + expect(http).not.toHaveBeenCalled(); + expect(localRefresh).not.toHaveBeenCalled(); + if (status === "refused") { + expect(warnings).toEqual([]); + } else { + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("Aside profiles were not refreshed:"); + expect(warnings[0]).toContain("Proxy is not running"); + expect(warnings[0]).toContain("ocx start"); + } + expect(logs.join("\n")).not.toContain("integration refreshed"); + expect(readFileSync(configPath, "utf8")).toBe(before); + expect(readdirSync(home).sort()).toEqual(["codex", "config.json"]); + expect(readdirSync(join(home, "codex"))).toEqual([]); + } finally { + sync.mockRestore(); localRefresh.mockRestore(); unscopedDiscovery.mockRestore(); http.mockRestore(); + warn.mockRestore(); log.mockRestore(); error.mockRestore(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(home); + } + }, + ); + test("returns 0 for help forms", async () => { expect(await dispatchCommand({ kind: "help", command: "help", args: ["help"] }, fakeDeps)).toBe(0); expect(await dispatchCommand({ kind: "help", command: "--help", args: ["--help"] }, fakeDeps)).toBe(0); From 2d2c4b86050f028682cbecd8220de86ab272d851 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:41:39 +0900 Subject: [PATCH 117/236] fix(responses): reject mismatched namespace call kinds --- src/responses/namespace-tool-compat.ts | 3 ++ tests/responses/namespace-tool-compat.test.ts | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index d13e19422a..049d74de09 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -416,6 +416,9 @@ export function restoreRoutedNamespaceCalls( ) { const identity = aliases.get(value.name); if (identity + // Custom declarations may be lowered to function calls upstream, but an + // ordinary function declaration never authorizes a custom call payload. + && (value.type !== "custom_tool_call" || identity.kind === "custom") && (!Object.hasOwn(value, "namespace") || value.namespace === identity.namespace)) { restored.name = identity.name; restored.namespace = identity.namespace; diff --git a/tests/responses/namespace-tool-compat.test.ts b/tests/responses/namespace-tool-compat.test.ts index 622fe09411..c8917aac7a 100644 --- a/tests/responses/namespace-tool-compat.test.ts +++ b/tests/responses/namespace-tool-compat.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { restoreRoutedCustomCalls, rewriteRoutedCustomToolsForUpstream } from "../../src/responses/custom-tool-compat"; import { createRoutedNamespaceCallRestoreRewrite, restoreRoutedNamespaceCalls, @@ -523,6 +524,39 @@ describe("Responses namespace tool compatibility", () => { describe("dotted namespace restoration uses the declaration collision boundary", () => { const ping = { type: "namespace", name: "mcp", tools: [{ type: "function", name: "ping", parameters: {} }] }; + test.each(["mcp.ping", "mcp__ping"])("preserves a custom call whose alias %s declares an ordinary function", name => { + const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools: [ping] }); + expect(aliases.get(name)?.kind).toBe("function"); + for (const namespace of [undefined, "mcp"]) { + const call = { type: "custom_tool_call", name, call_id: "call_ping", input: "raw custom input", + ...(namespace === undefined ? {} : { namespace }) }; + expect(restoreRoutedNamespaceCalls(call, aliases)).toEqual({ value: call, changed: false }); + expect(restoreRoutedNamespaceCalls(call, aliases).value).toBe(call); + const text = JSON.stringify({ type: "response.completed", response: { output: [call] } }, null, 2); + expect(restoreRoutedNamespaceCallsInJson(text, aliases)).toBe(text); + expect(createRoutedNamespaceCallRestoreRewrite(aliases)(text)).toBe(text); + } + }); + + test.each(["mcp.run", "mcp__run"])("restores the declared custom tool after upstream function downgrade via %s", name => { + const namespaced = rewriteRoutedNamespaceToolsForUpstream({ + tools: [{ type: "namespace", name: "mcp", tools: [{ type: "custom", name: "run", description: "Run raw input" }] }], + }); + const downgraded = rewriteRoutedCustomToolsForUpstream(namespaced.body, false); + expect(downgraded.body).toMatchObject({ tools: [{ type: "function", name: "mcp__run" }] }); + expect(downgraded.names.has("mcp__run")).toBe(true); + expect(namespaced.aliases.get(name)?.kind).toBe("custom"); + const call = { type: "function_call", name, id: "fc_run", call_id: "call_run", arguments: '{"input":"echo ready"}' }; + const restored = restoreRoutedNamespaceCalls(call, namespaced.aliases); + expect(restored).toEqual({ changed: true, value: { ...call, name: "run", namespace: "mcp" } }); + expect(restoreRoutedCustomCalls({ output: [restored.value] }, downgraded.names).value).toEqual({ + output: [{ type: "custom_tool_call", name: "run", namespace: "mcp", id: "ctc_run", call_id: "call_run", input: "echo ready" }], + }); + const nativeCustom = { type: "custom_tool_call", name, input: "raw custom input" }; + expect(restoreRoutedNamespaceCalls(nativeCustom, namespaced.aliases).value) + .toEqual({ ...nativeCustom, name: "run", namespace: "mcp" }); + }); + test("restores the dotted spelling after canonical tool-choice authorization", () => { const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools: [ping], tool_choice: { type: "function", namespace: "mcp", name: "ping" } }); expect(restoreRoutedNamespaceCalls({ type: "function_call", name: "mcp.ping", arguments: "{}" }, aliases).value) From 4778f4815fcf0f2e5c390e328b15d0e4bec49248 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:41:39 +0900 Subject: [PATCH 118/236] fix(cli): report Aside sync when the proxy is unavailable --- src/cli/dispatch.ts | 48 +++++++++++---------- src/cli/runtime-api.ts | 2 +- tests/cli/cli-dispatch.test.ts | 77 +++++++++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 23 deletions(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 3b690f420c..e222339c24 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -14,6 +14,7 @@ import type { CliHead } from "./root"; import type { ReadyArgs } from "./ready"; import type { LivenessIo, LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; +import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh"; import { hasHelpFlag, printSubcommandUsage, printUsage } from "./help"; import { setIntegrationEnabled, shouldSyncCodexOnStart } from "../codex/desired-state"; import { syncModelsToCodex } from "../codex/sync"; @@ -389,31 +390,36 @@ const commandRunners: Record = { // `ocx sync` is a direct CLI path; it does not call the management // `/api/sync` route. Refresh already-connected file integrations here too, // after Codex has published the catalog that supplies its capabilities. - if (synced.status !== "refused" && live) { - try { - const config = deps.loadConfig(); - const { refreshOwnedCatalogIntegrations } = await import("../integrations/catalog-refresh"); - const results = await refreshOwnedCatalogIntegrations({ - models: async () => { - const { loadExportModels } = await import("../server/management/model-rows"); - return loadExportModels(config); - }, - config, - port: live.port, - }, ["mcode", "pi"]); + if (synced.status !== "refused") { + const results: OwnedIntegrationRefreshOutcome[] = []; + if (live) { try { - const { refreshAsideProfilesThroughServer } = await import("./aside-profiles"); - results.push(...await refreshAsideProfilesThroughServer({ findLiveProxy: async () => live })); + const config = deps.loadConfig(); + const { refreshOwnedCatalogIntegrations } = await import("../integrations/catalog-refresh"); + results.push(...await refreshOwnedCatalogIntegrations({ + models: async () => { + const { loadExportModels } = await import("../server/management/model-rows"); + return loadExportModels(config); + }, + config, + port: live.port, + }, ["mcode", "pi"])); } catch (error) { - console.warn(`Aside profiles were not refreshed: ${error instanceof Error ? error.message : String(error)}`); - } - for (const result of results) { - const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`; - if (result.changed) console.log(`${label} integration refreshed from the current catalog.`); - else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}`); + console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } + } + // Even without a live proxy, report why Aside could not sync. Its server + // owner is never bypassed, and another client's failure cannot hide it. + try { + const { refreshAsideProfilesThroughServer } = await import("./aside-profiles"); + results.push(...await refreshAsideProfilesThroughServer({ findLiveProxy: async () => live })); } catch (error) { - console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); + console.warn(`Aside profiles were not refreshed: ${error instanceof Error ? error.message : String(error)}`); + } + for (const result of results) { + const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`; + if (result.changed) console.log(`${label} integration refreshed from the current catalog.`); + else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}`); } } return code; diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index f6d7353280..da919db765 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -44,7 +44,7 @@ export class RuntimeApiError extends Error { export async function runtimeBaseUrl(deps: RuntimeApiDeps = {}): Promise { if (deps.baseUrl) return deps.baseUrl.replace(/\/$/, ""); - const live = await findLiveProxy(); + const live = await (deps.findLiveProxy ?? findLiveProxy)(); if (!live) throw new RuntimeApiError("Proxy is not running. Start it with: ocx start", 503, null); return `http://${probeHostname(live.hostname)}:${live.port}`; } diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 88ef3ed85f..f3d102d1b9 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -3,13 +3,14 @@ import { CLI_COMMANDS } from "../../src/cli/registry"; import { DISPATCH_ALIASES, DISPATCH_COMMANDS, dispatchCommand, resolveDispatchCommand, decideStartWithLiveOwner } from "../../src/cli/dispatch"; import type { CliDispatchDeps } from "../../src/cli/dispatch"; import { runGuiCommand } from "../../src/cli/gui"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigDir } from "../../src/config"; import { getAccountSet, removeCredential, saveCredential } from "../../src/oauth/store"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; +import type { OcxConfig } from "../../src/types"; /** Minimal fake deps. dispatchCommand only touches deps for real command * runners, which these tests never invoke, so an empty object is enough. */ @@ -94,6 +95,80 @@ describe("dispatchCommand exit codes", () => { } }); + test.each(["applied", "catalog-only", "refused"] as const)( + "sync with no live proxy reports Aside unavailability after Codex %s without local fallback", async status => { + const home = mkdtempSync(join(tmpdir(), "ocx-dispatch-aside-offline-")); + const previous = { OPENCODEX_HOME: process.env.OPENCODEX_HOME, CODEX_HOME: process.env.CODEX_HOME }; + const syncModule = await import("../../src/codex/sync"); + const catalogModule = await import("../../src/integrations/catalog-refresh"); + const livenessModule = await import("../../src/server/proxy-liveness"); + const warnings: string[] = []; + const logs: string[] = []; + const sync = spyOn(syncModule, "syncModelsToCodex").mockResolvedValue({ + status, ok: status !== "refused", added: 0, catalogPath: null, catalogExists: false, + catalogWritten: false, cacheSynced: false, message: "fixture Codex sync result", + }); + // The real Aside helper/runtime client must run. Fence the independent local + // writer and unscoped discovery so this regression cannot reach user files + // or a developer's real proxy if either dispatch boundary regresses. + const localRefresh = spyOn(catalogModule, "refreshOwnedCatalogIntegrations").mockResolvedValue([]); + // A globally discoverable proxy must not override the injected null result. + const unscopedDiscovery = spyOn(livenessModule, "findLiveProxy").mockResolvedValue({ + pid: null, port: 65534, hostname: "127.0.0.1", source: "config", + }); + const http = spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected runtime HTTP request")); + const warn = spyOn(console, "warn").mockImplementation((...args) => { warnings.push(args.map(String).join(" ")); }); + const log = spyOn(console, "log").mockImplementation((...args) => { logs.push(args.map(String).join(" ")); }); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(home, "codex"); + mkdirSync(process.env.CODEX_HOME); + const config = { + port: 10100, providers: {}, defaultProvider: "openai", + asideProfileSync: { allProfiles: true, profiles: {} }, + } as OcxConfig; + const configPath = join(home, "config.json"); + const before = JSON.stringify(config); + writeFileSync(configPath, before); + let discoveries = 0; + const args = ["sync"]; + const deps = { + ...fakeDeps, args, loadConfig: () => config, + findLiveProxy: async () => { discoveries += 1; return null; }, + }; + const code = await dispatchCommand({ kind: "command", command: "sync", args }, deps); + // An Aside warning does not change a successful Codex sync's exit code. + expect(code).toBe(status === "refused" ? 1 : 0); + expect(discoveries).toBe(1); + expect(sync).toHaveBeenCalledTimes(1); + expect(unscopedDiscovery).not.toHaveBeenCalled(); + expect(http).not.toHaveBeenCalled(); + expect(localRefresh).not.toHaveBeenCalled(); + if (status === "refused") { + expect(warnings).toEqual([]); + } else { + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("Aside profiles were not refreshed:"); + expect(warnings[0]).toContain("Proxy is not running"); + expect(warnings[0]).toContain("ocx start"); + } + expect(logs.join("\n")).not.toContain("integration refreshed"); + expect(readFileSync(configPath, "utf8")).toBe(before); + expect(readdirSync(home).sort()).toEqual(["codex", "config.json"]); + expect(readdirSync(join(home, "codex"))).toEqual([]); + } finally { + sync.mockRestore(); localRefresh.mockRestore(); unscopedDiscovery.mockRestore(); http.mockRestore(); + warn.mockRestore(); log.mockRestore(); error.mockRestore(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(home); + } + }, + ); + test("returns 0 for help forms", async () => { expect(await dispatchCommand({ kind: "help", command: "help", args: ["help"] }, fakeDeps)).toBe(0); expect(await dispatchCommand({ kind: "help", command: "--help", args: ["--help"] }, fakeDeps)).toBe(0); From 2221aed73cf8ef5b24452f22eda369c6539b2273 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:49:52 +0900 Subject: [PATCH 119/236] fix(logs): reconcile filters when accepting refreshed rows --- .../042_logs_review_corrections.md | 4 ++ gui/src/pages/Logs.tsx | 33 +++++---- gui/tests/logs-auto-refresh.test.tsx | 71 +++++++++++++++++++ gui/tests/logs-filter-bar.test.ts | 4 +- 4 files changed, 93 insertions(+), 19 deletions(-) diff --git a/devlog/_plan/260906_d_integrations_delivery/042_logs_review_corrections.md b/devlog/_plan/260906_d_integrations_delivery/042_logs_review_corrections.md index 9141d9527f..7568eed82e 100644 --- a/devlog/_plan/260906_d_integrations_delivery/042_logs_review_corrections.md +++ b/devlog/_plan/260906_d_integrations_delivery/042_logs_review_corrections.md @@ -27,3 +27,7 @@ Prior D accepted the Cursor guidance candidate92f848e8, with final c-4 shipping ## Delegation and verification Main owns server route/test, docs, Git/CI/FSM and native browser QA. Inherited Huygens owns only the GUI page/filter bar/optional clock helper and the corresponding GUI tests. Nash audits before B; independent implementation review follows. All application test/typecheck/lint/build commands run remotely; no local suite. Remote GUI checks include all GUI tests, lint/i18n/build, plus root typecheck/full suite and docs build. Final receipts state any queued GitHub jobs explicitly rather than marking them passed. + +## Check-phase React Doctor correction + +A cold pinned0.9.11 scan with the actual6005 base available reported two concrete diagnostics: rollover selection was adjusted in a post-render effect, and the rendered filter-bar test assigned an external observer during render. Move reconciliation into acceptance of the latest valid log response, preserving permanent reset when an identity disappears and current spelling when it remains. Keep component rendering pure by observing test state from an effect/event. No rule suppression is planned. Re-run the cold changed-scope scan before the regression suites. The earlier same-head hosted success is retained as an observed run result; it is not evidence that these diagnostics were absent. diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index e175096c95..2632ae8788 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -472,6 +472,22 @@ export default function Logs({ apiBase }: { apiBase: string }) { // The resource-store generation guard runs only after this loader returns. // Guard these local side effects here as fetch/body readers may ignore abort. if (!isCurrent()) throw signal.reason ?? new DOMException("Obsolete log request", "AbortError"); + // Reconcile when the accepted snapshot changes, using the latest user state + // rather than filters captured when the request started. Persist disappearance + // as All so a later ring cannot resurrect a cleared selection. + const options = extractLogFilterOptions(next); + setFilters(previous => { + const model = previous.model.trim().toLowerCase(); + const provider = previous.provider.trim().toLowerCase(); + const nextModel = model + ? options.models.find(option => option.trim().toLowerCase() === model) ?? "" + : ""; + const nextProvider = provider + ? options.providers.find(option => option.trim().toLowerCase() === provider) ?? "" + : ""; + if (previous.model === nextModel && previous.provider === nextProvider) return previous; + return { ...previous, model: nextModel, provider: nextProvider }; + }); const sample = logsClockAnchor(Array.isArray(body) ? undefined : body.generatedAt, receivedAt); if (sample) clock.anchor = sample; setFilterClockNow(logsClockNow(clock.anchor, receivedAt, Date.now())); @@ -554,23 +570,6 @@ export default function Logs({ apiBase }: { apiBase: string }) { }, [conversationQuery]); const filterOptions = useMemo(() => extractLogFilterOptions(logs), [logs]); - // Native selects require the exact current option spelling even though filtering - // compares normalized identities. Reconcile casing changes on ring rollover, and - // clear only identities that disappeared; keep every other filter intact. - useEffect(() => { - setFilters(previous => { - const model = previous.model.trim().toLowerCase(); - const provider = previous.provider.trim().toLowerCase(); - const nextModel = model - ? filterOptions.models.find(option => option.trim().toLowerCase() === model) ?? "" - : ""; - const nextProvider = provider - ? filterOptions.providers.find(option => option.trim().toLowerCase() === provider) ?? "" - : ""; - if (previous.model === nextModel && previous.provider === nextProvider) return previous; - return { ...previous, model: nextModel, provider: nextProvider }; - }); - }, [filterOptions, filters.model, filters.provider]); const activeFilters = hasActiveLogFilters(filters); const filteredLogs = useMemo(() => filterLogs(logs, filters, filterClockNow), [logs, filters, filterClockNow]); const conversationTotals = conversationQuery ? summarizeFilteredLogs(filteredLogs) : null; diff --git a/gui/tests/logs-auto-refresh.test.tsx b/gui/tests/logs-auto-refresh.test.tsx index a4bef33462..44366f146b 100644 --- a/gui/tests/logs-auto-refresh.test.tsx +++ b/gui/tests/logs-auto-refresh.test.tsx @@ -1131,12 +1131,16 @@ test("Logs: a late body from an aborted old apiBase cannot poison the new proxy await renderLogsAt(root, "http://proxy-b"); expect(oldSignal?.aborted).toBe(true); await changeLogSelect(container, "Time", "15m"); + await changeLogSelect(container, "Model", "gpt-test"); + await changeLogSelect(container, "Provider", "openai"); await act(async () => { container.querySelector(".logs-auto-refresh input")!.click(); }); await flushMicrotasks(); expect(visibleRequestIds(container)).toEqual(["proxy-b"]); await act(async () => { late.resolve(proxyLogEnvelope(PROXY_NOW + 12 * 60 * 60_000, [])); }); await flushMicrotasks(); expect(visibleRequestIds(container)).toEqual(["proxy-b"]); + expect(container.querySelector('select[aria-label="Model"]')!.value).toBe("gpt-test"); + expect(container.querySelector('select[aria-label="Provider"]')!.value).toBe("openai"); monotonic += 30_000; await act(async () => { jest.advanceTimersByTime(30_000); }); await flushMicrotasks(); @@ -1201,3 +1205,70 @@ test("Logs: aborting an in-flight refresh before pausing cannot replace the acce } } }); + +test("Logs: reappearing options do not resurrect selections cleared by a successful rollover", async () => { + const original = { ...sampleLog, requestId: "original", model: "model-a", provider: "openai" }; + const replacement = { ...sampleLog, requestId: "replacement", model: "model-b", provider: "xai" }; + let rows = [original]; + serveLogSnapshot(() => rows); + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + await changeLogSelect(container, "Model", "model-a"); + await changeLogSelect(container, "Provider", "openai"); + await changeLogSelect(container, "Status", "success"); + rows = [replacement]; + await advanceSilentRefresh(); + const select = (label: string) => container.querySelector(`select[aria-label="${label}"]`)!; + expect(select("Model").value).toBe(""); + expect(select("Provider").value).toBe(""); + expect(visibleRequestIds(container)).toEqual(["replacement"]); + rows = [original, replacement]; + await advanceSilentRefresh(); + expect(select("Model").value).toBe(""); + expect(select("Provider").value).toBe(""); + expect(select("Status").value).toBe("success"); + expect(visibleRequestIds(container)).toEqual(["replacement", "original"]); + } finally { + await act(async () => { root.unmount(); }); + } +}); + +test("Logs: a pending refresh reconciles the user's latest selection rather than its starting selection", async () => { + const late = delayedLogBody(); + const original = [ + { ...sampleLog, requestId: "a", model: "model-a", provider: "openai", status: 500 }, + { ...sampleLog, requestId: "b", model: "model-b", provider: "xai", status: 500 }, + ]; + let requests = 0; + globalThis.fetch = (async input => { + if (!String(input).includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + requests++; + return requests === 1 ? jsonResponse(original) : late.response; + }) as typeof fetch; + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + await changeLogSelect(container, "Model", "model-a"); + await changeLogSelect(container, "Provider", "openai"); + await advanceSilentRefresh(); + expect(requests).toBe(2); + await changeLogSelect(container, "Model", "model-b"); + await changeLogSelect(container, "Provider", "xai"); + await changeLogSelect(container, "Status", "errors"); + expect(visibleRequestIds(container)).toEqual(["b"]); + await act(async () => { + late.resolve([ + { ...original[0]!, requestId: "other", model: "model-other" }, + { ...original[1]!, requestId: "current", model: "MODEL-B", provider: "XAI" }, + ]); + }); + await flushMicrotasks(); + expect(container.querySelector('select[aria-label="Model"]')!.value).toBe("MODEL-B"); + expect(container.querySelector('select[aria-label="Provider"]')!.value).toBe("XAI"); + expect(container.querySelector('select[aria-label="Status"]')!.value).toBe("errors"); + expect(visibleRequestIds(container)).toEqual(["current"]); + } finally { + await act(async () => { root.unmount(); }); + } +}); diff --git a/gui/tests/logs-filter-bar.test.ts b/gui/tests/logs-filter-bar.test.ts index 259c5defd4..06ae773e8e 100644 --- a/gui/tests/logs-filter-bar.test.ts +++ b/gui/tests/logs-filter-bar.test.ts @@ -1,6 +1,6 @@ import { expect, jest, test } from "bun:test"; import { Window } from "happy-dom"; -import { act, createElement, useState } from "react"; +import { act, createElement, useEffect, useState } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; import type { TFn } from "../src/i18n/shared"; @@ -57,7 +57,7 @@ async function withFilterBar( document.body.append(container); function Harness() { const [filters, setFilters] = useState(initial); - current = filters; + useEffect(() => { current = filters; }, [filters]); return createElement(LogsFilterBar, { filters, options: { models: ["model-a", "model-a-plus"], providers: ["openai", "xai"] }, hasActiveFilters: hasActiveLogFilters(filters), filteredCount: 1, totalCount: 2, From 89666aeebe226f3a811f6a08694ba7860281ba1d Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:50:00 +0900 Subject: [PATCH 120/236] fix(clients): reload selection after initial model discovery --- .../010_export_selection.md | 2 +- src/cli/export-command.ts | 4 +- src/cli/opencode.ts | 8 +- tests/cli/cli-export-command.test.ts | 81 +++++++++++++++++++ tests/providers/opencode-cli.test.ts | 70 +++++++++++++++- 5 files changed, 158 insertions(+), 7 deletions(-) diff --git a/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md b/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md index a34b3184b6..3b687e9b1c 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md @@ -4,7 +4,7 @@ Loop: spec-satisfaction repair; trigger: selectedModels ignored by export projec MODIFY src/server/management/model-rows.ts: import filterCatalogVisibleModels. In loadExportModels compute visible routed row identities from filterCatalogVisibleModels(rows.filter(row => !row.native), config); return only !row.disabled and (row.native || visible set contains row), then map toExportModel. Preserve native visibility semantics. MODIFY src/cli/opencode.ts: use same canonical filter once for rows with non-native provider/id identity, then exclude those not retained before seen.add. Do not infer provider identities for legacy rows missing them; keep existing disabled and Direct-native checks. Preserve order, custom/combo aliases and per-row metadata; do not duplicate allowlist matching. -MODIFY tests/server/management-client-config-route.test.ts and tests/cli/cli-export-command.test.ts: fixtures with xai selectedModels=[grok-4.6], full three-model roster, blocklist override, empty allowlist, slash-bearing ids, disabled duplicate. Render both pi and aside through production loader and CLI projection. Expect only selected ids; management still offers all ids. +MODIFY tests/server/management-client-config-route.test.ts and tests/cli/cli-export-command.test.ts: fixtures with xai selectedModels=[grok-4.6], full three-model roster, blocklist override, empty allowlist, slash-bearing ids, disabled duplicate. Render both pi and aside through production loader and CLI projection. A nonempty allowlist retains only selected IDs; a ready provider with an empty allowlist retains its full otherwise-visible roster; pending initial selection keeps routed rows hidden. Management still offers all IDs. CLI consumers must reload their configured state after discovery because the request can persist the initial selection. MODIFY docs-site/src/content/docs/guides/integrations.md: explain selected list applies to generated catalogs. Verifier: standalone synthetic imports of loadExportModels/exportModelsFromProxyRows plus Pi/Aside serializers; no bun:test. CI runs existing focused regressions, typecheck and full platform suite. Before C record exact source SHA and probe output. Stop after export boundaries agree; next cycle refreshes old owned files. diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 73e47552d4..c576435432 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -171,12 +171,14 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep rejectArgs(args, USAGE); const spec = EXPORT_CLIENTS[client]; - const config = (deps.configImpl ?? loadConfig)(); const root = await runtimeBaseUrl(deps); const rows = await runtimeRequest("/api/models", {}, { ...deps, baseUrl: root }); if (!Array.isArray(rows)) { throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows); } + // Discovery can persist pending -> ready selection. Read from the caller's + // config source after the response, rather than filtering with a stale snapshot. + const config = (deps.configImpl ?? loadConfig)(); const models = exportModelsFromProxyRows(rows, config); // The text is the client's OWN format — YAML, TOML and JSON5 clients would // otherwise receive a JSON rendering their parser reads differently. diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 9e4ed8246c..adcdea1095 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -637,14 +637,14 @@ export function opencodeNotFoundHint( } export async function cmdOpencode(args: string[]): Promise { - const config = loadConfig(); - const live = await ensureProxyForOpencode(config); + const startupConfig = loadConfig(); + const live = await ensureProxyForOpencode(startupConfig); if (!live) { console.error("❌ Proxy did not become healthy after starting."); return 1; } - const apiKey = opencodeApiKey(config); + const apiKey = opencodeApiKey(startupConfig); let proxyModels: OpencodeProxyModelRow[]; try { proxyModels = await fetchOpencodeProxyModels(live, apiKey); @@ -653,6 +653,8 @@ export async function cmdOpencode(args: string[]): Promise { console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`); return 1; } + // /api/models may have completed and persisted initial provider selection. + const config = loadConfig(); const catalog = opencodeCatalogFromProxyRows(proxyModels, config); const blocks = buildOpencodeProviderBlocksFromCatalog(live.port, catalog, live.hostname, config); const baseUrl = blocks.v1.options.baseURL; diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts index 3e8fcbfcfd..4c9808677a 100644 --- a/tests/cli/cli-export-command.test.ts +++ b/tests/cli/cli-export-command.test.ts @@ -432,6 +432,87 @@ describe("export row filtering", () => { }); describe("export allowlist parity", () => { + test("the first export rereads selection completed during model discovery", async () => { + const previous = process.env.OPENCODEX_HOME; + const home = tempDir(); + const path = join(home, "config.json"); + const pending = config({ + defaultProvider: "pending", fastRows: false, + providers: { pending: { + adapter: "openai-chat", baseUrl: "https://fixture.example.test/v1", liveModels: false, + models: ["chosen", "other"], + initialModelSelection: { version: 1, registrationId: crypto.randomUUID(), status: "pending" }, + } }, + }); + const ready = structuredClone(pending); + ready.providers.pending!.initialModelSelection!.status = "ready"; + ready.providers.pending!.selectedModels = ["chosen"]; + const rows = ["chosen", "other"].map(id => ({ provider: "pending", id, namespaced: `pending/${id}` })); + expect(exportModelsFromProxyRows(rows, pending)).toEqual([]); + let requests = 0; + try { + process.env.OPENCODEX_HOME = home; + writeFileSync(path, JSON.stringify(pending)); + const code = await handleExportCommand(["--client", "pi", "--json"], { + baseUrl: "http://127.0.0.1:10123", + fetchImpl: async input => { + expect(String(input)).toBe("http://127.0.0.1:10123/api/models"); + requests += 1; + // The server publishes its finalized selection before returning the rows. + writeFileSync(path, JSON.stringify(ready)); + return Response.json(rows); + }, + }); + expect(code).toBe(0); + expect(requests).toBe(1); + expect(JSON.parse(stdout()).providers.opencodex.models.map((row: { id: string }) => row.id)) + .toEqual(["pending/chosen"]); + expect(pending.providers.pending!.initialModelSelection!.status).toBe("pending"); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + } + }); + + test("post-discovery filtering retains injected config provenance instead of reading local policy", async () => { + const previous = process.env.OPENCODEX_HOME; + const home = tempDir(); + const path = join(home, "config.json"); + const local = config({ providers: { custom: { + adapter: "openai-chat", baseUrl: "https://local.example.test/v1", selectedModels: ["local-only"], + } } }); + const remote = config({ providers: { custom: { + adapter: "openai-chat", baseUrl: "https://remote.example.test/v1", selectedModels: ["remote-only"], + initialModelSelection: { version: 1, registrationId: crypto.randomUUID(), status: "pending" }, + } } }); + const ready = structuredClone(remote); + ready.providers.custom!.initialModelSelection!.status = "ready"; + let resolved = remote; + const events: string[] = []; + try { + process.env.OPENCODEX_HOME = home; + const localBytes = JSON.stringify(local); + writeFileSync(path, localBytes); + const code = await handleExportCommand(["--client", "pi", "--json"], { + baseUrl: "http://127.0.0.1:10123", + configImpl: () => { events.push("config"); return structuredClone(resolved); }, + fetchImpl: async () => { + events.push("fetch"); + resolved = ready; + return Response.json(["local-only", "remote-only"].map(id => ({ provider: "custom", id, namespaced: `custom/${id}` }))); + }, + }); + expect(code).toBe(0); + expect(events).toEqual(["fetch", "config"]); + expect(JSON.parse(stdout()).providers.opencodex.models.map((row: { id: string }) => row.id)) + .toEqual(["custom/remote-only"]); + expect(readFileSync(path, "utf8")).toBe(localBytes); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + } + }); + test("filters the full management roster before deduplication and keeps other providers", () => { const cfg = config(); cfg.providers.xai = { diff --git a/tests/providers/opencode-cli.test.ts b/tests/providers/opencode-cli.test.ts index e1aed8a045..4483e5ea99 100644 --- a/tests/providers/opencode-cli.test.ts +++ b/tests/providers/opencode-cli.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as childProcess from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearModelCache } from "../../src/codex/model-cache"; @@ -16,6 +17,7 @@ import { buildOpencodeProviderBlockFromCatalog, buildOpencodeProviderBlocksFromCatalog, buildOpencodeV2ProviderBlock, + cmdOpencode, fetchOpencodeProxyModels, isOpencodeRuntimeConfigError, mergeOpencodeRuntimeConfig, @@ -33,6 +35,7 @@ import { serializeOpencodeRuntimeConfig, } from "../../src/cli/opencode"; import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; function cfg(extra?: Partial): OcxConfig { return { @@ -236,6 +239,69 @@ describe("ocx opencode proxy model catalog", () => { const RESOLVED = "proxy-only-resolved-key"; const PROVIDER = "proxyenv"; + test("the first launcher reads selection persisted during /api/models before building both provider blocks", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-opencode-discovery-selection-")); + const envKeys = ["OPENCODEX_HOME", "CODEX_HOME", "XDG_CONFIG_HOME", OPENCODE_CONFIG_CONTENT_ENV]; + const previous = Object.fromEntries(envKeys.map(key => [key, process.env[key]])); + const configPath = join(home, "config.json"); + const pending = cfg({ + defaultProvider: "pending", fastRows: false, + providers: { pending: { + adapter: "openai-chat", baseUrl: "https://fixture.example.test/v1", liveModels: false, + models: ["chosen", "other"], + initialModelSelection: { version: 1, registrationId: crypto.randomUUID(), status: "pending" }, + } }, + }); + const ready = structuredClone(pending); + ready.providers.pending!.initialModelSelection!.status = "ready"; + ready.providers.pending!.selectedModels = ["chosen"]; + const rows = ["chosen", "other"].map(id => ({ provider: "pending", id, namespaced: `pending/${id}` })); + expect(opencodeCatalogFromProxyRows(rows, pending)).toEqual([]); + const liveness = await import("../../src/server/proxy-liveness"); + const finder = spyOn(liveness, "findLiveProxy").mockResolvedValue({ + port: 10123, hostname: "127.0.0.1", pid: null, source: "config", + }); + const fetcher = spyOn(globalThis, "fetch").mockImplementation(async input => { + expect(String(input)).toBe("http://127.0.0.1:10123/api/models"); + expect(JSON.parse(readFileSync(configPath, "utf8")).providers.pending.initialModelSelection.status).toBe("pending"); + writeFileSync(configPath, JSON.stringify(ready)); + return Response.json(rows); + }); + let inline = ""; + // Exercise cmdOpencode through env construction without launching an installed + // OpenCode or proxy process. All config reads still use the actual temp files. + const spawn = spyOn(childProcess, "spawn").mockImplementation((...args) => { + inline = args[2]?.env?.[OPENCODE_CONFIG_CONTENT_ENV] ?? ""; + const child = new childProcess.ChildProcess(); + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }); + const stderr = spyOn(console, "error").mockImplementation(() => {}); + try { + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(home, "codex"); + process.env.XDG_CONFIG_HOME = join(home, "xdg"); + delete process.env[OPENCODE_CONFIG_CONTENT_ENV]; + mkdirSync(process.env.CODEX_HOME); + writeFileSync(configPath, JSON.stringify(pending)); + expect(await cmdOpencode([])).toBe(0); + expect(finder).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(spawn).toHaveBeenCalledTimes(1); + const injected = JSON.parse(inline); + expect(Object.keys(injected.provider.opencodex.models)).toEqual(["pending/chosen"]); + expect(Object.keys(injected.providers.opencodex.models)).toEqual(["pending/chosen"]); + expect(pending.providers.pending!.initialModelSelection!.status).toBe("pending"); + } finally { + finder.mockRestore(); fetcher.mockRestore(); spawn.mockRestore(); stderr.mockRestore(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(home); + } + }); + test("uses /api/models namespaced selectors and resolves env-backed provider keys only in the proxy", async () => { const originalFetch = globalThis.fetch; let requestedAuth: string | undefined; From bbd9a217bc0800cf130aeefa69a97b5aeb77c5bb Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:50:00 +0900 Subject: [PATCH 121/236] fix(responses): preserve original custom kind through namespace lowering --- src/adapters/openai-responses.ts | 2 +- src/responses/namespace-tool-compat.ts | 10 +++- tests/responses/namespace-tool-compat.test.ts | 8 +-- .../openai-responses-passthrough.test.ts | 55 +++++++++++++++++++ 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index c4bbae4a09..1faa9c0cbb 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2429,7 +2429,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Codex 0.147 emits private namespace tool groups, while public/third-party Responses // gateways accept only flat tool variants. Run after custom/tool-search lowering so // namespace children already carry their final public kind before they are promoted. - const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody); + const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames); outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 049d74de09..24b26e8a2e 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -341,7 +341,10 @@ function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Se * `__` wire identity as the chat adapters. The returned request-local aliases * are the only names response restoration is allowed to expand. */ -export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { +export function rewriteRoutedNamespaceToolsForUpstream( + body: unknown, + convertedCustomToolNames?: ReadonlySet, +): { body: unknown; aliases: Map; } { @@ -376,6 +379,11 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { && !ambiguousDotted.has(dotted) && !plan.bareWireNames.has(dotted) && !aliases.has(dotted)) aliases.set(dotted, identity); } + // The adapter lowers custom tools before namespaces. Preserve their declared + // kind only in already-authorized response aliases; wire selectors remain lowered. + for (const identity of aliases.values()) { + if (convertedCustomToolNames?.has(namespacedToolName(identity.namespace, identity.name))) identity.kind = "custom"; + } return { body: { ...body, diff --git a/tests/responses/namespace-tool-compat.test.ts b/tests/responses/namespace-tool-compat.test.ts index c8917aac7a..629c3474a7 100644 --- a/tests/responses/namespace-tool-compat.test.ts +++ b/tests/responses/namespace-tool-compat.test.ts @@ -539,11 +539,11 @@ describe("dotted namespace restoration uses the declaration collision boundary", }); test.each(["mcp.run", "mcp__run"])("restores the declared custom tool after upstream function downgrade via %s", name => { - const namespaced = rewriteRoutedNamespaceToolsForUpstream({ + const downgraded = rewriteRoutedCustomToolsForUpstream({ tools: [{ type: "namespace", name: "mcp", tools: [{ type: "custom", name: "run", description: "Run raw input" }] }], - }); - const downgraded = rewriteRoutedCustomToolsForUpstream(namespaced.body, false); - expect(downgraded.body).toMatchObject({ tools: [{ type: "function", name: "mcp__run" }] }); + }, false); + const namespaced = rewriteRoutedNamespaceToolsForUpstream(downgraded.body, downgraded.names); + expect(namespaced.body).toMatchObject({ tools: [{ type: "function", name: "mcp__run" }] }); expect(downgraded.names.has("mcp__run")).toBe(true); expect(namespaced.aliases.get(name)?.kind).toBe("custom"); const call = { type: "function_call", name, id: "fc_run", call_id: "call_run", arguments: '{"input":"echo ready"}' }; diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index 07256e83e7..9697ba6656 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -21,6 +21,8 @@ import { import { createTranslatorBudget } from "../../src/lib/translator-budget"; import type { OcxConfig } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; +import { restoreRoutedNamespaceCalls } from "../../src/responses/namespace-tool-compat"; +import { restoreRoutedCustomCalls } from "../../src/responses/custom-tool-compat"; const createResponsesPassthroughAdapter = (...args: Parameters) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); @@ -3714,6 +3716,59 @@ describe("routed namespace and custom-tool identity", () => { const frame = (event: string, payload: Record): string => `event: ${event}\ndata: ${JSON.stringify({ type: event, ...payload })}`; + test.each(["function_call", "custom_tool_call"])("adapter preserves original custom kind for upstream %s after actual lowering order", type => { + const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); + const built = adapter.buildRequest({ + modelId: "routed-model", context: { messages: [] }, stream: false, options: {}, + _rawBody: { model: "routed-model", input: "read", tools: rawTools }, + }, { headers: new Headers() }); + const aliases = built.convertedRoutedNamespaceToolAliases; + const names = built.convertedRoutedCustomToolNames; + if (!aliases || !names) throw new Error("Missing adapter conversion provenance"); + expect([...names]).toEqual([`${customNamespace}__read`]); + expect(JSON.parse(built.body).tools).toMatchObject([ + { type: "function", name: `${customNamespace}__read` }, + { type: "function", name: `${functionNamespace}__read` }, + ]); + for (const separator of ["__", "."]) { + const name = `${customNamespace}${separator}read`; + expect(aliases.get(name)?.kind).toBe("custom"); + expect(aliases.get(`${functionNamespace}${separator}read`)?.kind).toBe("function"); + const call = type === "function_call" ? { ...customUpstreamItem, name } : { + type, name, id: "ctc_custom_read", call_id: "call_custom_read", input: "freeform payload", status: "completed", + }; + const restored = restoreRoutedNamespaceCalls({ output: [call] }, aliases); + expect(restored.changed).toBe(true); + expect(restoreRoutedCustomCalls(restored.value, names).value).toEqual({ output: [{ + type: "custom_tool_call", name: "read", namespace: customNamespace, + id: "ctc_custom_read", call_id: "call_custom_read", input: "freeform payload", status: "completed", + }] }); + const mismatched = { type: "custom_tool_call", name: `${functionNamespace}${separator}read`, input: "opaque payload" }; + expect(restoreRoutedNamespaceCalls(mismatched, aliases)).toEqual({ value: mismatched, changed: false }); + } + }); + + test("adapter custom provenance does not add excluded or colliding namespace aliases", () => { + const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); + const build = (tools: unknown[], tool_choice: unknown) => adapter.buildRequest({ + modelId: "routed-model", context: { messages: [] }, stream: false, options: {}, + _rawBody: { model: "routed-model", input: "read", tools, tool_choice }, + }, { headers: new Headers() }); + expect(build(rawTools, "none").convertedRoutedNamespaceToolAliases?.size).toBe(0); + const selected = build(rawTools, { type: "function", namespace: functionNamespace, name: "read" }); + expect([...selected.convertedRoutedNamespaceToolAliases!.keys()]) + .toEqual([`${functionNamespace}__read`, `${functionNamespace}.read`]); + const customSelected = build(rawTools, { type: "custom", namespace: customNamespace, name: "read" }); + expect([...customSelected.convertedRoutedNamespaceToolAliases!.keys()]) + .toEqual([`${customNamespace}__read`, `${customNamespace}.read`]); + expect(customSelected.convertedRoutedNamespaceToolAliases?.get(`${customNamespace}__read`)?.kind).toBe("custom"); + const collision = build([...rawTools, { type: "function", name: `${customNamespace}.read`, parameters: {} }], "auto"); + expect(collision.convertedRoutedNamespaceToolAliases?.has(`${customNamespace}.read`)).toBe(false); + expect(collision.convertedRoutedNamespaceToolAliases?.get(`${customNamespace}__read`)?.kind).toBe("custom"); + expect(() => build([...rawTools, { type: "function", name: `${customNamespace}__read`, parameters: {} }], "auto")) + .toThrow("namespace tool wire-name collision"); + }); + test("round-trips same-named namespaced custom and function calls through JSON and SSE", async () => { const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); const built = adapter.buildRequest({ From 5d8254feffd1a8a053fcfce6834cfb3eecb92619 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:50:00 +0900 Subject: [PATCH 122/236] docs(plan): clarify explicit client refresh ownership --- devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md index dd7266a694..8ae79ae938 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md @@ -2,7 +2,7 @@ Depends on 010 filtered loader. Loop spec-satisfaction repair. Goal: a model visibility/selection change and explicit sync refresh existing connected Pi/Aside files. No adoption of unowned/manual files, no recreation of removed blocks, no override of drift. -NEW src/integrations/catalog-refresh.ts: bounded helper refreshOwnedCatalogIntegrations(input) iterates explicit client list [pi, aside] (sync may include mcode), passes lazy cached models loader to refreshOwnedIntegration, catches per-client errors and returns existing outcome shape. Use existing ownership store, mutation flight and coordinated writer; never bypass fingerprints. +NEW src/integrations/catalog-refresh.ts: bounded helper refreshOwnedCatalogIntegrations(input, clientIds) defaults clientIds to [pi, aside]; callers may supply an explicit list including mcode. It passes a lazy cached models loader to refreshOwnedIntegration, catches per-client errors and returns existing outcome shape. Use existing ownership store, mutation flight and coordinated writer; never bypass fingerprints. The later Aside-profile layer delegates Aside to its server-owned profile engine; direct CLI sync passes [mcode, pi] here and invokes the Aside server helper once separately, including an explicit unavailable-server diagnostic. MODIFY src/server/management/model-routes.ts: local async convergence helper calls existing convergeCodexCatalog then new owned refresh for pi/aside with port from URL/config and lazy loadExportModels(config); attach clientIntegrations outcome to disabled-models, model-visibility, selected-models and model-preset writes. Keep successful config persistence even when one file refuses refresh; return warning outcome. MODIFY src/server/management/config-routes.ts and src/cli/dispatch.ts: expand current MCode-only owned refresh to mcode/pi/aside via helper; preserve native Grok/Desktop gates and refused-sync behavior. MODIFY existing tests/clients/sync-client-integrations.test.ts and tests/server/management-integration-routes.test.ts: fake IO/store or isolated home seeds owned pi/aside with two models, refresh with selected one, assert hidden row removed and other provider fields preserved. Prove unowned, removed and drifted configs untouched; one failure does not block other client. Add route-driven visibility refresh coverage using injected convergence. From 1e5acbd04f640f25cedf209ba5f0a6a7a2bf2845 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:50:00 +0900 Subject: [PATCH 123/236] test(responses): keep replay credentials stable across clock ticks --- .../server-agent-task-recovery-replay.test.ts | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts index fd572e9fd8..b7018b1c23 100644 --- a/tests/server/server-agent-task-recovery-replay.test.ts +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -6,7 +6,7 @@ import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../sr import { conversationIdFromResponsesRequest } from "../../src/server/request-log-conversation"; import type { OcxParsedRequest } from "../../src/types"; import { recoverEncryptedAgentTask, resetAgentTaskRecoveryState, restoreCachedEncryptedAgentTasks } from "../../src/server/responses/agent-task-recovery"; -import { codexHeaders, encryptedInput, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; +import { codexHeaders, encryptedInput, fakeChatGptJwt, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); test("replay reuses admitted recovery after a tool result without another network call", async () => { @@ -38,6 +38,35 @@ test("replay does not recover unseen envelopes, other parents, or other callers" expect(calls).toBe(1); }); +test("a rotated token for the same account cannot reuse the previous credential's recovery", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response(recoverySse(calls === 1 ? "Original credential assignment." : "Rotated credential assignment.")); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const exp = Math.floor(Date.now() / 1000) + 3_600; + const headers = codexHeaders("acct-caller"); + headers.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp })}`); + const rotatedHeaders = new Headers(headers); + rotatedHeaders.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp: exp + 1 })}`); + const original = new Request("http://localhost/v1/responses", { headers }); + const rotated = new Request("http://localhost/v1/responses", { headers: rotatedHeaders }); + expect(await recoverEncryptedAgentTask(original, encryptedInput(), {}, config)).toBe(true); + const missed = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(rotated, missed, config)).toBe(0); + expect(missed).toEqual(encryptedInput()); + expect(calls).toBe(1); + const replay = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(original, replay, config)).toBe(1); + expect(JSON.stringify(replay)).toContain("Original credential assignment."); + // The rotated credential is valid, but must perform its own admitted recovery. + const fresh = encryptedInput(); + expect(await recoverEncryptedAgentTask(rotated, fresh, {}, config)).toBe(true); + expect(JSON.stringify(fresh)).toContain("Rotated credential assignment."); + expect(calls).toBe(2); +}); + test("Responses handler restores a cached task in a continued child turn", async () => { const { post, providerResponse } = await import("../helpers/agent-task-recovery"); let recoveries = 0; @@ -51,8 +80,9 @@ test("Responses handler restores a cached task in a continued child turn", async return providerResponse(); }) as typeof fetch; const config = routedConfig({ enabled: true }); - expect((await post(config, "xai/grok-4.5", encryptedInput(), codexHeaders())).status).toBe(200); - expect((await post(config, "xai/grok-4.5", [...encryptedInput(), { type: "message", role: "user", content: "Continue the original task." }], codexHeaders())).status).toBe(200); + const headers = codexHeaders(); + expect((await post(config, "xai/grok-4.5", encryptedInput(), headers)).status).toBe(200); + expect((await post(config, "xai/grok-4.5", [...encryptedInput(), { type: "message", role: "user", content: "Continue the original task." }], headers)).status).toBe(200); expect(recoveries).toBe(1); expect(bodies).toHaveLength(2); expect(bodies[1]).toContain("Read nonce.txt exactly."); @@ -77,10 +107,11 @@ test("MESSAGE recovery reaches the provider and survives tool-result replay", as return providerResponse(); }) as typeof fetch; const config = routedConfig({ enabled: true }); - expect((await post(config, "xai/grok-4.5", encryptedMessage(), codexHeaders())).status).toBe(200); + const headers = codexHeaders(); + expect((await post(config, "xai/grok-4.5", encryptedMessage(), headers)).status).toBe(200); expect((await post(config, "xai/grok-4.5", [...encryptedMessage(), { type: "message", role: "user", content: "Continue after the tool result.", - }], codexHeaders())).status).toBe(200); + }], headers)).status).toBe(200); expect(recoveries).toBe(1); expect(bodies).toHaveLength(2); for (const body of bodies) { From 848543e613db6b7a9e467fdd8f082ec5295fcbff Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:50:00 +0900 Subject: [PATCH 124/236] fix(responses): preserve original custom kind through namespace lowering --- src/adapters/openai-responses.ts | 2 +- src/responses/namespace-tool-compat.ts | 10 +++- tests/responses/namespace-tool-compat.test.ts | 8 +-- .../openai-responses-passthrough.test.ts | 55 +++++++++++++++++++ 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index c4bbae4a09..1faa9c0cbb 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2429,7 +2429,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Codex 0.147 emits private namespace tool groups, while public/third-party Responses // gateways accept only flat tool variants. Run after custom/tool-search lowering so // namespace children already carry their final public kind before they are promoted. - const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody); + const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames); outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 049d74de09..24b26e8a2e 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -341,7 +341,10 @@ function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Se * `__` wire identity as the chat adapters. The returned request-local aliases * are the only names response restoration is allowed to expand. */ -export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { +export function rewriteRoutedNamespaceToolsForUpstream( + body: unknown, + convertedCustomToolNames?: ReadonlySet, +): { body: unknown; aliases: Map; } { @@ -376,6 +379,11 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { && !ambiguousDotted.has(dotted) && !plan.bareWireNames.has(dotted) && !aliases.has(dotted)) aliases.set(dotted, identity); } + // The adapter lowers custom tools before namespaces. Preserve their declared + // kind only in already-authorized response aliases; wire selectors remain lowered. + for (const identity of aliases.values()) { + if (convertedCustomToolNames?.has(namespacedToolName(identity.namespace, identity.name))) identity.kind = "custom"; + } return { body: { ...body, diff --git a/tests/responses/namespace-tool-compat.test.ts b/tests/responses/namespace-tool-compat.test.ts index c8917aac7a..629c3474a7 100644 --- a/tests/responses/namespace-tool-compat.test.ts +++ b/tests/responses/namespace-tool-compat.test.ts @@ -539,11 +539,11 @@ describe("dotted namespace restoration uses the declaration collision boundary", }); test.each(["mcp.run", "mcp__run"])("restores the declared custom tool after upstream function downgrade via %s", name => { - const namespaced = rewriteRoutedNamespaceToolsForUpstream({ + const downgraded = rewriteRoutedCustomToolsForUpstream({ tools: [{ type: "namespace", name: "mcp", tools: [{ type: "custom", name: "run", description: "Run raw input" }] }], - }); - const downgraded = rewriteRoutedCustomToolsForUpstream(namespaced.body, false); - expect(downgraded.body).toMatchObject({ tools: [{ type: "function", name: "mcp__run" }] }); + }, false); + const namespaced = rewriteRoutedNamespaceToolsForUpstream(downgraded.body, downgraded.names); + expect(namespaced.body).toMatchObject({ tools: [{ type: "function", name: "mcp__run" }] }); expect(downgraded.names.has("mcp__run")).toBe(true); expect(namespaced.aliases.get(name)?.kind).toBe("custom"); const call = { type: "function_call", name, id: "fc_run", call_id: "call_run", arguments: '{"input":"echo ready"}' }; diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index 07256e83e7..9697ba6656 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -21,6 +21,8 @@ import { import { createTranslatorBudget } from "../../src/lib/translator-budget"; import type { OcxConfig } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; +import { restoreRoutedNamespaceCalls } from "../../src/responses/namespace-tool-compat"; +import { restoreRoutedCustomCalls } from "../../src/responses/custom-tool-compat"; const createResponsesPassthroughAdapter = (...args: Parameters) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); @@ -3714,6 +3716,59 @@ describe("routed namespace and custom-tool identity", () => { const frame = (event: string, payload: Record): string => `event: ${event}\ndata: ${JSON.stringify({ type: event, ...payload })}`; + test.each(["function_call", "custom_tool_call"])("adapter preserves original custom kind for upstream %s after actual lowering order", type => { + const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); + const built = adapter.buildRequest({ + modelId: "routed-model", context: { messages: [] }, stream: false, options: {}, + _rawBody: { model: "routed-model", input: "read", tools: rawTools }, + }, { headers: new Headers() }); + const aliases = built.convertedRoutedNamespaceToolAliases; + const names = built.convertedRoutedCustomToolNames; + if (!aliases || !names) throw new Error("Missing adapter conversion provenance"); + expect([...names]).toEqual([`${customNamespace}__read`]); + expect(JSON.parse(built.body).tools).toMatchObject([ + { type: "function", name: `${customNamespace}__read` }, + { type: "function", name: `${functionNamespace}__read` }, + ]); + for (const separator of ["__", "."]) { + const name = `${customNamespace}${separator}read`; + expect(aliases.get(name)?.kind).toBe("custom"); + expect(aliases.get(`${functionNamespace}${separator}read`)?.kind).toBe("function"); + const call = type === "function_call" ? { ...customUpstreamItem, name } : { + type, name, id: "ctc_custom_read", call_id: "call_custom_read", input: "freeform payload", status: "completed", + }; + const restored = restoreRoutedNamespaceCalls({ output: [call] }, aliases); + expect(restored.changed).toBe(true); + expect(restoreRoutedCustomCalls(restored.value, names).value).toEqual({ output: [{ + type: "custom_tool_call", name: "read", namespace: customNamespace, + id: "ctc_custom_read", call_id: "call_custom_read", input: "freeform payload", status: "completed", + }] }); + const mismatched = { type: "custom_tool_call", name: `${functionNamespace}${separator}read`, input: "opaque payload" }; + expect(restoreRoutedNamespaceCalls(mismatched, aliases)).toEqual({ value: mismatched, changed: false }); + } + }); + + test("adapter custom provenance does not add excluded or colliding namespace aliases", () => { + const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); + const build = (tools: unknown[], tool_choice: unknown) => adapter.buildRequest({ + modelId: "routed-model", context: { messages: [] }, stream: false, options: {}, + _rawBody: { model: "routed-model", input: "read", tools, tool_choice }, + }, { headers: new Headers() }); + expect(build(rawTools, "none").convertedRoutedNamespaceToolAliases?.size).toBe(0); + const selected = build(rawTools, { type: "function", namespace: functionNamespace, name: "read" }); + expect([...selected.convertedRoutedNamespaceToolAliases!.keys()]) + .toEqual([`${functionNamespace}__read`, `${functionNamespace}.read`]); + const customSelected = build(rawTools, { type: "custom", namespace: customNamespace, name: "read" }); + expect([...customSelected.convertedRoutedNamespaceToolAliases!.keys()]) + .toEqual([`${customNamespace}__read`, `${customNamespace}.read`]); + expect(customSelected.convertedRoutedNamespaceToolAliases?.get(`${customNamespace}__read`)?.kind).toBe("custom"); + const collision = build([...rawTools, { type: "function", name: `${customNamespace}.read`, parameters: {} }], "auto"); + expect(collision.convertedRoutedNamespaceToolAliases?.has(`${customNamespace}.read`)).toBe(false); + expect(collision.convertedRoutedNamespaceToolAliases?.get(`${customNamespace}__read`)?.kind).toBe("custom"); + expect(() => build([...rawTools, { type: "function", name: `${customNamespace}__read`, parameters: {} }], "auto")) + .toThrow("namespace tool wire-name collision"); + }); + test("round-trips same-named namespaced custom and function calls through JSON and SSE", async () => { const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); const built = adapter.buildRequest({ From b4e535ee73f3a2ee94d35897a507f438b9a55b21 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:50:00 +0900 Subject: [PATCH 125/236] fix(clients): reload selection after initial model discovery --- .../010_export_selection.md | 2 +- src/cli/export-command.ts | 4 +- src/cli/opencode.ts | 8 +- tests/cli/cli-export-command.test.ts | 81 +++++++++++++++++++ tests/providers/opencode-cli.test.ts | 70 +++++++++++++++- 5 files changed, 158 insertions(+), 7 deletions(-) diff --git a/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md b/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md index a34b3184b6..3b687e9b1c 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md @@ -4,7 +4,7 @@ Loop: spec-satisfaction repair; trigger: selectedModels ignored by export projec MODIFY src/server/management/model-rows.ts: import filterCatalogVisibleModels. In loadExportModels compute visible routed row identities from filterCatalogVisibleModels(rows.filter(row => !row.native), config); return only !row.disabled and (row.native || visible set contains row), then map toExportModel. Preserve native visibility semantics. MODIFY src/cli/opencode.ts: use same canonical filter once for rows with non-native provider/id identity, then exclude those not retained before seen.add. Do not infer provider identities for legacy rows missing them; keep existing disabled and Direct-native checks. Preserve order, custom/combo aliases and per-row metadata; do not duplicate allowlist matching. -MODIFY tests/server/management-client-config-route.test.ts and tests/cli/cli-export-command.test.ts: fixtures with xai selectedModels=[grok-4.6], full three-model roster, blocklist override, empty allowlist, slash-bearing ids, disabled duplicate. Render both pi and aside through production loader and CLI projection. Expect only selected ids; management still offers all ids. +MODIFY tests/server/management-client-config-route.test.ts and tests/cli/cli-export-command.test.ts: fixtures with xai selectedModels=[grok-4.6], full three-model roster, blocklist override, empty allowlist, slash-bearing ids, disabled duplicate. Render both pi and aside through production loader and CLI projection. A nonempty allowlist retains only selected IDs; a ready provider with an empty allowlist retains its full otherwise-visible roster; pending initial selection keeps routed rows hidden. Management still offers all IDs. CLI consumers must reload their configured state after discovery because the request can persist the initial selection. MODIFY docs-site/src/content/docs/guides/integrations.md: explain selected list applies to generated catalogs. Verifier: standalone synthetic imports of loadExportModels/exportModelsFromProxyRows plus Pi/Aside serializers; no bun:test. CI runs existing focused regressions, typecheck and full platform suite. Before C record exact source SHA and probe output. Stop after export boundaries agree; next cycle refreshes old owned files. diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 73e47552d4..c576435432 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -171,12 +171,14 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep rejectArgs(args, USAGE); const spec = EXPORT_CLIENTS[client]; - const config = (deps.configImpl ?? loadConfig)(); const root = await runtimeBaseUrl(deps); const rows = await runtimeRequest("/api/models", {}, { ...deps, baseUrl: root }); if (!Array.isArray(rows)) { throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows); } + // Discovery can persist pending -> ready selection. Read from the caller's + // config source after the response, rather than filtering with a stale snapshot. + const config = (deps.configImpl ?? loadConfig)(); const models = exportModelsFromProxyRows(rows, config); // The text is the client's OWN format — YAML, TOML and JSON5 clients would // otherwise receive a JSON rendering their parser reads differently. diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 9e4ed8246c..adcdea1095 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -637,14 +637,14 @@ export function opencodeNotFoundHint( } export async function cmdOpencode(args: string[]): Promise { - const config = loadConfig(); - const live = await ensureProxyForOpencode(config); + const startupConfig = loadConfig(); + const live = await ensureProxyForOpencode(startupConfig); if (!live) { console.error("❌ Proxy did not become healthy after starting."); return 1; } - const apiKey = opencodeApiKey(config); + const apiKey = opencodeApiKey(startupConfig); let proxyModels: OpencodeProxyModelRow[]; try { proxyModels = await fetchOpencodeProxyModels(live, apiKey); @@ -653,6 +653,8 @@ export async function cmdOpencode(args: string[]): Promise { console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`); return 1; } + // /api/models may have completed and persisted initial provider selection. + const config = loadConfig(); const catalog = opencodeCatalogFromProxyRows(proxyModels, config); const blocks = buildOpencodeProviderBlocksFromCatalog(live.port, catalog, live.hostname, config); const baseUrl = blocks.v1.options.baseURL; diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts index 3e8fcbfcfd..4c9808677a 100644 --- a/tests/cli/cli-export-command.test.ts +++ b/tests/cli/cli-export-command.test.ts @@ -432,6 +432,87 @@ describe("export row filtering", () => { }); describe("export allowlist parity", () => { + test("the first export rereads selection completed during model discovery", async () => { + const previous = process.env.OPENCODEX_HOME; + const home = tempDir(); + const path = join(home, "config.json"); + const pending = config({ + defaultProvider: "pending", fastRows: false, + providers: { pending: { + adapter: "openai-chat", baseUrl: "https://fixture.example.test/v1", liveModels: false, + models: ["chosen", "other"], + initialModelSelection: { version: 1, registrationId: crypto.randomUUID(), status: "pending" }, + } }, + }); + const ready = structuredClone(pending); + ready.providers.pending!.initialModelSelection!.status = "ready"; + ready.providers.pending!.selectedModels = ["chosen"]; + const rows = ["chosen", "other"].map(id => ({ provider: "pending", id, namespaced: `pending/${id}` })); + expect(exportModelsFromProxyRows(rows, pending)).toEqual([]); + let requests = 0; + try { + process.env.OPENCODEX_HOME = home; + writeFileSync(path, JSON.stringify(pending)); + const code = await handleExportCommand(["--client", "pi", "--json"], { + baseUrl: "http://127.0.0.1:10123", + fetchImpl: async input => { + expect(String(input)).toBe("http://127.0.0.1:10123/api/models"); + requests += 1; + // The server publishes its finalized selection before returning the rows. + writeFileSync(path, JSON.stringify(ready)); + return Response.json(rows); + }, + }); + expect(code).toBe(0); + expect(requests).toBe(1); + expect(JSON.parse(stdout()).providers.opencodex.models.map((row: { id: string }) => row.id)) + .toEqual(["pending/chosen"]); + expect(pending.providers.pending!.initialModelSelection!.status).toBe("pending"); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + } + }); + + test("post-discovery filtering retains injected config provenance instead of reading local policy", async () => { + const previous = process.env.OPENCODEX_HOME; + const home = tempDir(); + const path = join(home, "config.json"); + const local = config({ providers: { custom: { + adapter: "openai-chat", baseUrl: "https://local.example.test/v1", selectedModels: ["local-only"], + } } }); + const remote = config({ providers: { custom: { + adapter: "openai-chat", baseUrl: "https://remote.example.test/v1", selectedModels: ["remote-only"], + initialModelSelection: { version: 1, registrationId: crypto.randomUUID(), status: "pending" }, + } } }); + const ready = structuredClone(remote); + ready.providers.custom!.initialModelSelection!.status = "ready"; + let resolved = remote; + const events: string[] = []; + try { + process.env.OPENCODEX_HOME = home; + const localBytes = JSON.stringify(local); + writeFileSync(path, localBytes); + const code = await handleExportCommand(["--client", "pi", "--json"], { + baseUrl: "http://127.0.0.1:10123", + configImpl: () => { events.push("config"); return structuredClone(resolved); }, + fetchImpl: async () => { + events.push("fetch"); + resolved = ready; + return Response.json(["local-only", "remote-only"].map(id => ({ provider: "custom", id, namespaced: `custom/${id}` }))); + }, + }); + expect(code).toBe(0); + expect(events).toEqual(["fetch", "config"]); + expect(JSON.parse(stdout()).providers.opencodex.models.map((row: { id: string }) => row.id)) + .toEqual(["custom/remote-only"]); + expect(readFileSync(path, "utf8")).toBe(localBytes); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + } + }); + test("filters the full management roster before deduplication and keeps other providers", () => { const cfg = config(); cfg.providers.xai = { diff --git a/tests/providers/opencode-cli.test.ts b/tests/providers/opencode-cli.test.ts index e1aed8a045..4483e5ea99 100644 --- a/tests/providers/opencode-cli.test.ts +++ b/tests/providers/opencode-cli.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as childProcess from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearModelCache } from "../../src/codex/model-cache"; @@ -16,6 +17,7 @@ import { buildOpencodeProviderBlockFromCatalog, buildOpencodeProviderBlocksFromCatalog, buildOpencodeV2ProviderBlock, + cmdOpencode, fetchOpencodeProxyModels, isOpencodeRuntimeConfigError, mergeOpencodeRuntimeConfig, @@ -33,6 +35,7 @@ import { serializeOpencodeRuntimeConfig, } from "../../src/cli/opencode"; import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; function cfg(extra?: Partial): OcxConfig { return { @@ -236,6 +239,69 @@ describe("ocx opencode proxy model catalog", () => { const RESOLVED = "proxy-only-resolved-key"; const PROVIDER = "proxyenv"; + test("the first launcher reads selection persisted during /api/models before building both provider blocks", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-opencode-discovery-selection-")); + const envKeys = ["OPENCODEX_HOME", "CODEX_HOME", "XDG_CONFIG_HOME", OPENCODE_CONFIG_CONTENT_ENV]; + const previous = Object.fromEntries(envKeys.map(key => [key, process.env[key]])); + const configPath = join(home, "config.json"); + const pending = cfg({ + defaultProvider: "pending", fastRows: false, + providers: { pending: { + adapter: "openai-chat", baseUrl: "https://fixture.example.test/v1", liveModels: false, + models: ["chosen", "other"], + initialModelSelection: { version: 1, registrationId: crypto.randomUUID(), status: "pending" }, + } }, + }); + const ready = structuredClone(pending); + ready.providers.pending!.initialModelSelection!.status = "ready"; + ready.providers.pending!.selectedModels = ["chosen"]; + const rows = ["chosen", "other"].map(id => ({ provider: "pending", id, namespaced: `pending/${id}` })); + expect(opencodeCatalogFromProxyRows(rows, pending)).toEqual([]); + const liveness = await import("../../src/server/proxy-liveness"); + const finder = spyOn(liveness, "findLiveProxy").mockResolvedValue({ + port: 10123, hostname: "127.0.0.1", pid: null, source: "config", + }); + const fetcher = spyOn(globalThis, "fetch").mockImplementation(async input => { + expect(String(input)).toBe("http://127.0.0.1:10123/api/models"); + expect(JSON.parse(readFileSync(configPath, "utf8")).providers.pending.initialModelSelection.status).toBe("pending"); + writeFileSync(configPath, JSON.stringify(ready)); + return Response.json(rows); + }); + let inline = ""; + // Exercise cmdOpencode through env construction without launching an installed + // OpenCode or proxy process. All config reads still use the actual temp files. + const spawn = spyOn(childProcess, "spawn").mockImplementation((...args) => { + inline = args[2]?.env?.[OPENCODE_CONFIG_CONTENT_ENV] ?? ""; + const child = new childProcess.ChildProcess(); + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }); + const stderr = spyOn(console, "error").mockImplementation(() => {}); + try { + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(home, "codex"); + process.env.XDG_CONFIG_HOME = join(home, "xdg"); + delete process.env[OPENCODE_CONFIG_CONTENT_ENV]; + mkdirSync(process.env.CODEX_HOME); + writeFileSync(configPath, JSON.stringify(pending)); + expect(await cmdOpencode([])).toBe(0); + expect(finder).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(spawn).toHaveBeenCalledTimes(1); + const injected = JSON.parse(inline); + expect(Object.keys(injected.provider.opencodex.models)).toEqual(["pending/chosen"]); + expect(Object.keys(injected.providers.opencodex.models)).toEqual(["pending/chosen"]); + expect(pending.providers.pending!.initialModelSelection!.status).toBe("pending"); + } finally { + finder.mockRestore(); fetcher.mockRestore(); spawn.mockRestore(); stderr.mockRestore(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(home); + } + }); + test("uses /api/models namespaced selectors and resolves env-backed provider keys only in the proxy", async () => { const originalFetch = globalThis.fetch; let requestedAuth: string | undefined; From c11591b6d1a56c968be65bcd7f23fbbf89a53256 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:50:00 +0900 Subject: [PATCH 126/236] docs(plan): clarify explicit client refresh ownership --- devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md index dd7266a694..8ae79ae938 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md @@ -2,7 +2,7 @@ Depends on 010 filtered loader. Loop spec-satisfaction repair. Goal: a model visibility/selection change and explicit sync refresh existing connected Pi/Aside files. No adoption of unowned/manual files, no recreation of removed blocks, no override of drift. -NEW src/integrations/catalog-refresh.ts: bounded helper refreshOwnedCatalogIntegrations(input) iterates explicit client list [pi, aside] (sync may include mcode), passes lazy cached models loader to refreshOwnedIntegration, catches per-client errors and returns existing outcome shape. Use existing ownership store, mutation flight and coordinated writer; never bypass fingerprints. +NEW src/integrations/catalog-refresh.ts: bounded helper refreshOwnedCatalogIntegrations(input, clientIds) defaults clientIds to [pi, aside]; callers may supply an explicit list including mcode. It passes a lazy cached models loader to refreshOwnedIntegration, catches per-client errors and returns existing outcome shape. Use existing ownership store, mutation flight and coordinated writer; never bypass fingerprints. The later Aside-profile layer delegates Aside to its server-owned profile engine; direct CLI sync passes [mcode, pi] here and invokes the Aside server helper once separately, including an explicit unavailable-server diagnostic. MODIFY src/server/management/model-routes.ts: local async convergence helper calls existing convergeCodexCatalog then new owned refresh for pi/aside with port from URL/config and lazy loadExportModels(config); attach clientIntegrations outcome to disabled-models, model-visibility, selected-models and model-preset writes. Keep successful config persistence even when one file refuses refresh; return warning outcome. MODIFY src/server/management/config-routes.ts and src/cli/dispatch.ts: expand current MCode-only owned refresh to mcode/pi/aside via helper; preserve native Grok/Desktop gates and refused-sync behavior. MODIFY existing tests/clients/sync-client-integrations.test.ts and tests/server/management-integration-routes.test.ts: fake IO/store or isolated home seeds owned pi/aside with two models, refresh with selected one, assert hidden row removed and other provider fields preserved. Prove unowned, removed and drifted configs untouched; one failure does not block other client. Add route-driven visibility refresh coverage using injected convergence. From 67d0104d3ef8e582637cc57e1cf4f20ce4bf2559 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:50:00 +0900 Subject: [PATCH 127/236] test(responses): keep replay credentials stable across clock ticks --- .../server-agent-task-recovery-replay.test.ts | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts index fd572e9fd8..b7018b1c23 100644 --- a/tests/server/server-agent-task-recovery-replay.test.ts +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -6,7 +6,7 @@ import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../sr import { conversationIdFromResponsesRequest } from "../../src/server/request-log-conversation"; import type { OcxParsedRequest } from "../../src/types"; import { recoverEncryptedAgentTask, resetAgentTaskRecoveryState, restoreCachedEncryptedAgentTasks } from "../../src/server/responses/agent-task-recovery"; -import { codexHeaders, encryptedInput, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; +import { codexHeaders, encryptedInput, fakeChatGptJwt, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); test("replay reuses admitted recovery after a tool result without another network call", async () => { @@ -38,6 +38,35 @@ test("replay does not recover unseen envelopes, other parents, or other callers" expect(calls).toBe(1); }); +test("a rotated token for the same account cannot reuse the previous credential's recovery", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response(recoverySse(calls === 1 ? "Original credential assignment." : "Rotated credential assignment.")); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const exp = Math.floor(Date.now() / 1000) + 3_600; + const headers = codexHeaders("acct-caller"); + headers.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp })}`); + const rotatedHeaders = new Headers(headers); + rotatedHeaders.set("authorization", `Bearer ${fakeChatGptJwt("acct-caller", { exp: exp + 1 })}`); + const original = new Request("http://localhost/v1/responses", { headers }); + const rotated = new Request("http://localhost/v1/responses", { headers: rotatedHeaders }); + expect(await recoverEncryptedAgentTask(original, encryptedInput(), {}, config)).toBe(true); + const missed = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(rotated, missed, config)).toBe(0); + expect(missed).toEqual(encryptedInput()); + expect(calls).toBe(1); + const replay = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(original, replay, config)).toBe(1); + expect(JSON.stringify(replay)).toContain("Original credential assignment."); + // The rotated credential is valid, but must perform its own admitted recovery. + const fresh = encryptedInput(); + expect(await recoverEncryptedAgentTask(rotated, fresh, {}, config)).toBe(true); + expect(JSON.stringify(fresh)).toContain("Rotated credential assignment."); + expect(calls).toBe(2); +}); + test("Responses handler restores a cached task in a continued child turn", async () => { const { post, providerResponse } = await import("../helpers/agent-task-recovery"); let recoveries = 0; @@ -51,8 +80,9 @@ test("Responses handler restores a cached task in a continued child turn", async return providerResponse(); }) as typeof fetch; const config = routedConfig({ enabled: true }); - expect((await post(config, "xai/grok-4.5", encryptedInput(), codexHeaders())).status).toBe(200); - expect((await post(config, "xai/grok-4.5", [...encryptedInput(), { type: "message", role: "user", content: "Continue the original task." }], codexHeaders())).status).toBe(200); + const headers = codexHeaders(); + expect((await post(config, "xai/grok-4.5", encryptedInput(), headers)).status).toBe(200); + expect((await post(config, "xai/grok-4.5", [...encryptedInput(), { type: "message", role: "user", content: "Continue the original task." }], headers)).status).toBe(200); expect(recoveries).toBe(1); expect(bodies).toHaveLength(2); expect(bodies[1]).toContain("Read nonce.txt exactly."); @@ -77,10 +107,11 @@ test("MESSAGE recovery reaches the provider and survives tool-result replay", as return providerResponse(); }) as typeof fetch; const config = routedConfig({ enabled: true }); - expect((await post(config, "xai/grok-4.5", encryptedMessage(), codexHeaders())).status).toBe(200); + const headers = codexHeaders(); + expect((await post(config, "xai/grok-4.5", encryptedMessage(), headers)).status).toBe(200); expect((await post(config, "xai/grok-4.5", [...encryptedMessage(), { type: "message", role: "user", content: "Continue after the tool result.", - }], codexHeaders())).status).toBe(200); + }], headers)).status).toBe(200); expect(recoveries).toBe(1); expect(bodies).toHaveLength(2); for (const body of bodies) { From 5097e66faa031d4e11b09c8ad79ec94ecb3f713d Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:57:24 +0900 Subject: [PATCH 128/236] test(quota): join observation work before resetting fixtures --- .../040_quota_observation_drain.md | 7 +++++++ tests/usage/quota-reset-observation.test.ts | 13 ++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 devlog/_plan/260906_a_macos_verification/040_quota_observation_drain.md diff --git a/devlog/_plan/260906_a_macos_verification/040_quota_observation_drain.md b/devlog/_plan/260906_a_macos_verification/040_quota_observation_drain.md new file mode 100644 index 0000000000..31e5091ce2 --- /dev/null +++ b/devlog/_plan/260906_a_macos_verification/040_quota_observation_drain.md @@ -0,0 +1,7 @@ +# Join asynchronous quota observations in fixtures + +The final top's Windows1 verification (run33990109175, job101372136435) found a concrete fixture ordering defect. The first two quota-reset seam assertions saw no event after six microtasks and five milliseconds. A later test that used the existing explicit drain received those earlier scheduled and surprise events instead. The fixed sleep did not join cold lazy imports or the serialized observation chain, and fixture reset replaced the capture sink while old work was still pending. + +This C1 test-only follow-up uses the existing flushQuotaObservationsForTests seam. Join observations before assertions; join before resetting a fixture or replacing its sink; and join asynchronous baseline forgetting after clearAccountQuota. Keep all event counts, reset kinds, account separation and no-notification assertions unchanged. Production quota logic and timing remain untouched. + +Verify on the remote pinned runtime with the full focused file and typecheck. Delay the existing observation/forget chain in scratch to prove the new drain still passes and the old five-millisecond fixture fails. Restore every temporary mutation. Require independent review and final exact-head CI before integration. No local tests, builds or typechecks. diff --git a/tests/usage/quota-reset-observation.test.ts b/tests/usage/quota-reset-observation.test.ts index 20cef41e31..39a18025ef 100644 --- a/tests/usage/quota-reset-observation.test.ts +++ b/tests/usage/quota-reset-observation.test.ts @@ -28,27 +28,30 @@ const HOUR = 60 * 60_000; let captured: QuotaResetEvent[] = []; -/** Let the seams' lazy import() chains settle. */ +/** Join the writer's ordered observation/forget chain, including cold imports. */ async function settle(): Promise { - for (let index = 0; index < 6; index += 1) await Promise.resolve(); - await new Promise(resolve => setTimeout(resolve, 5)); + await flushQuotaObservationsForTests(); } -beforeEach(() => { +beforeEach(async () => { + await settle(); captured = []; resetQuotaResetStoreForTests(); resetQuotaResetNotifyCacheForTests(); resetQuotaResetPollerForTests(); clearAccountQuota(); + await settle(); setQuotaResetSink(event => { captured.push(event); }); }); -afterEach(() => { +afterEach(async () => { + await settle(); setQuotaResetSink(null); resetQuotaResetPollerForTests(); clearAccountQuota(); + await settle(); }); describe("codex quota seam", () => { From d8ac1e03d6dc362d17070d68c7264fbf6cb071d1 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:59:02 +0900 Subject: [PATCH 129/236] fix(aside): tighten profile sync and history contracts --- .../src/content/docs/guides/integrations.md | 2 +- .../ocx/references/01_management_surface.md | 5 + skills/ocx/references/03_recipes.md | 2 +- src/cli/capabilities.ts | 5 +- src/integrations/aside-profile-journal.ts | 12 ++- src/server/management/aside-profile-routes.ts | 8 +- tests/server/aside-profiles-routes.test.ts | 94 ++++++++++++++++++- 7 files changed, 117 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 921003ad85..0c95908206 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -52,7 +52,7 @@ disagree about which file is meant. Its managed block owns only stay untouched. Prime Agent reads `models.json` when a session starts, so start a new session after connecting it. -Aside keeps a separate model catalog for each account-backed browser profile. OpenCodex lists +Aside keeps a separate model catalog for each registered profile, including local profiles. OpenCodex lists all registered profiles, including local profiles, and can synchronize them together or control one profile at a time. Switching an integration never changes Aside's active account. A prior Aside connection enables all profiles by default; individual exclusions survive later syncs. diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index b88570a427..d5711f3cac 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -619,6 +619,11 @@ Synchronize client catalogs, including Aside profiles through the running server |---|---| | POST | `/api/client-integrations/aside/sync` | +| Flag | Value | Meaning | +|---|---|---| +| `--restart-codex` | boolean | Restart Codex app-servers after a catalog or cache write. | +| `--restart-desktop-app` | boolean | Restart the Codex desktop app after a catalog or cache write. | + JSON mode: `none`. - The Aside refresh uses the live server; other catalog synchronization also performs local work. diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 065422d4fc..85b734a9ce 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -223,6 +223,6 @@ ocx integration client restore --client aside --profile 1 --op ``` Read `profiles[]` to find numeric profile IDs. No profile selector means a bulk toggle; an -explicit selector affects only that account-backed profile. Sync intent and actual file state +explicit selector affects only that registered profile. Sync intent and actual file state are distinct, so inspect each result after a partial bulk operation. The CLI returns nonzero for a partial refusal. Never use the overwrite or drift flags merely to suppress a refusal. diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 1bb2b83352..1b5cfd6283 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -557,7 +557,10 @@ export const CAPABILITIES: readonly Capability[] = [ command: ["sync"], summary: "Synchronize client catalogs, including Aside profiles through the running server's mutation owner.", routes: [{ method: "POST", path: "/api/client-integrations/aside/sync" }], - flags: [], + flags: [ + { name: "--restart-codex", value: "boolean", summary: "Restart Codex app-servers after a catalog or cache write." }, + { name: "--restart-desktop-app", value: "boolean", summary: "Restart the Codex desktop app after a catalog or cache write." }, + ], mutates: true, json: "none", details: ["The Aside refresh uses the live server; other catalog synchronization also performs local work."], diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts index f5cd228c90..0fd7f47183 100644 --- a/src/integrations/aside-profile-journal.ts +++ b/src/integrations/aside-profile-journal.ts @@ -19,11 +19,17 @@ export interface AsideOperation { function operationRows(ctx: AsideProfileContext, profileId?: number): AsideOperation[] { const rows: AsideOperation[] = []; + const entriesByRoot = new Map(); for (const profile of selectAsideProfiles(ctx, profileId)) { const scope = asideProfileScope(ctx, profile); const stores = scope.store.root === ctx.rootStore.root ? [scope.store] : [scope.store, ctx.rootStore]; for (const store of stores) { - for (const entry of store.listOperations("aside", Number.MAX_SAFE_INTEGER)) { + let entries = entriesByRoot.get(store.root); + if (entries === undefined) { + entries = store.listOperations("aside", Number.MAX_SAFE_INTEGER); + entriesByRoot.set(store.root, entries); + } + for (const entry of entries) { if (entry.clientId === "aside" && entry.configPath === profile.configPath) { assertAsideSnapshotEntry(entry); if (typeof entry.at !== "string") throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation timestamp is invalid"); @@ -131,7 +137,7 @@ function snapshotWasOwned(entry: JournalEntry, text: string | null, bound: Integ const record = entry.priorRecord; if (!record || text === null || record.fileFingerprint !== fingerprint(text)) return false; const state = classifyIntegration({ - fileText: text, fileIsRegular: true, parsed: parseConfig(text, "json"), record, + fileText: text, fileIsRegular: true, parsed: parseConfig(text, EXPORT_CLIENTS.aside.format), record, contribution: EXPORT_CLIENTS.aside.buildContribution(exportContextOf(bound)), configPath: entry.configPath, clientId: "aside", }).state; @@ -209,7 +215,7 @@ export function deleteAsideOperation( } await persistAsidePolicy(ctx); const stores = new Map(rows.filter(candidate => candidate.entry.opId === request.opId).map(candidate => [candidate.store.root, candidate.store])); - const tombstone = { tombstone: request.opId, at: new Date().toISOString(), by: request.principal ?? "management" }; + const tombstone = { tombstone: request.opId, at: new Date(input.io?.now() ?? Date.now()).toISOString(), by: request.principal ?? "management" }; // Retire every copy before pruning any snapshot; deduped history must not resurrect a source row. for (const store of stores.values()) store.retireOperation(tombstone); let snapshotRemoved = true; diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index 941c37bbb2..8047e6c7a8 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -12,7 +12,7 @@ import { } from "../../integrations/aside-profile-journal"; import type { WriteRefused } from "../../integrations/writer"; import type { ManagementContext } from "./context"; -import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { jsonResponse } from "../auth-cors"; export interface AsideProfileRouteOptions { @@ -52,8 +52,8 @@ function isObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -async function readProfileBody(req: Request): Promise { - try { return await readManagementJsonBody(req); } +async function readProfileBody(req: Request, optional = false): Promise { + try { return await (optional ? readOptionalManagementJsonBody(req) : readManagementJsonBody(req)); } catch (error) { rethrowManagementBodyTooLarge(error); throw new ProfileQueryError("invalid JSON body"); } } @@ -117,7 +117,7 @@ export async function handleAsideProfileRoutes( if (url.pathname === "/api/client-integrations/aside/sync") { if (req.method !== "POST") return null; if (id !== undefined) throw new ProfileQueryError("Aside sync uses the server's selected profiles"); - const body = await readProfileBody(req); + const body = await readProfileBody(req, true); if (!isObject(body) || Object.keys(body).length !== 0) throw new ProfileQueryError("Aside sync expects an empty object"); const results = await refreshAsideProfiles(options.input()); const ok = results.every(result => result.ok); diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index b129e8f701..a21f0168b6 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { handleManagementAPI } from "../../src/server/management-api"; +import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks } from "../../src/server/management/integration-routes"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; import { applyIntegration } from "../../src/integrations/writer"; @@ -58,10 +59,13 @@ afterEach(() => { function path(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } function document(id: number) { return JSON.parse(readFileSync(path(id), "utf8")); } async function api(pathname: string, method = "GET", body?: unknown) { + return rawApi(pathname, method, body === undefined ? undefined : JSON.stringify(body)); +} +async function rawApi(pathname: string, method: string, body?: string) { const url = new URL(`http://127.0.0.1:10100${pathname}`); const response = await handleManagementAPI(new Request(url, { method, headers: { Host: url.host, "content-type": "application/json" }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), + ...(body === undefined ? {} : { body }), }), url, config, { saveConfigPreservingClaudeCode: value => { saved = structuredClone(value); }, createManagementConvergeCodex: catalogConvergenceFactory(), @@ -71,6 +75,94 @@ async function api(pathname: string, method = "GET", body?: unknown) { return response; } +async function prepareAsideSync(): Promise { + config.providers.fixture!.selectedModels = ["one"]; + const enabled = await api("/api/client-integrations/aside/profiles", "PUT", { enabled: true }); + expect(enabled.status).toBe(200); + expect(await enabled.json()).toMatchObject({ ok: true }); + for (const id of [0, 1, 2]) expect(fixtureModelIds(id)).toEqual(["fixture/one"]); + // Change the runtime selection without triggering a different endpoint's sync. + config.providers.fixture!.selectedModels = ["two"]; +} + +function fixtureModelIds(id: number): string[] { + return document(id).providers.opencodex.models + .filter((model: { id: string }) => model.id.startsWith("fixture/")) + .map((model: { id: string }) => model.id); +} + +test.each([undefined, "{}"])("Aside sync accepts body %j and refreshes every enabled profile with HTTP 200", async body => { + await prepareAsideSync(); + const response = await rawApi("/api/client-integrations/aside/sync", "POST", body); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ok: true, clientId: "aside", + results: [0, 1, 2].map(profileId => ({ client: "aside", profileId, ok: true, changed: true })), + }); + for (const id of [0, 1, 2]) { + expect(fixtureModelIds(id)).toEqual(["fixture/two"]); + expect(document(id).theme).toBe("keep"); + expect(document(id).providers.personal).toEqual({ models: [] }); + } +}); + +test("bodyless Aside sync returns HTTP 207 for one conflict while refreshing its siblings", async () => { + await prepareAsideSync(); + const edited = document(1); + edited.providers.opencodex.baseUrl = "https://user-edit.example.test/v1"; + const editedBytes = JSON.stringify(edited); + writeFileSync(path(1), editedBytes); + const response = await api("/api/client-integrations/aside/sync", "POST"); + expect(response.status).toBe(207); + expect(await response.json()).toMatchObject({ + ok: false, clientId: "aside", results: [ + { client: "aside", profileId: 0, ok: true, changed: true }, + { client: "aside", profileId: 1, ok: false, state: "conflict", refusalReason: "conflict" }, + { client: "aside", profileId: 2, ok: true, changed: true }, + ], + }); + expect(readFileSync(path(1), "utf8")).toBe(editedBytes); + for (const id of [0, 2]) expect(fixtureModelIds(id)).toEqual(["fixture/two"]); + expect(await (await api("/api/client-integrations/aside/profiles/1")).json()) + .toMatchObject({ enabled: true, state: "conflict" }); +}); + +test.each(['{"enabled":true}', '{"profile":1}', '{"overwriteConflict":true}', "[]", "null", "true", "{"])( + "Aside sync rejects nonempty options or invalid JSON %s before mutation", async body => { + const before = [0, 1, 2].map(id => readFileSync(path(id), "utf8")); + const response = await rawApi("/api/client-integrations/aside/sync", "POST", body); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ code: "invalid_aside_profile", clientId: "aside" }); + expect([0, 1, 2].map(id => readFileSync(path(id), "utf8"))).toEqual(before); + expect(saved).toBeUndefined(); + expect(store.listOperations("aside")).toEqual([]); + }, +); + +test.each(["?profile=0", "?profile=invalid", "?client=pi"])("bodyless Aside sync rejects selector %s", async selector => { + expect((await api(`/api/client-integrations/aside/sync${selector}`, "POST")).status).toBe(400); + expect(saved).toBeUndefined(); + expect(store.listOperations("aside")).toEqual([]); +}); + +test("Aside sync retains the JSON body size limit before accepting an empty-body fallback", async () => { + const response = await rawApi("/api/client-integrations/aside/sync", "POST", " ".repeat(MANAGEMENT_JSON_BODY_MAX_BYTES + 1)); + expect(response.status).toBe(413); + expect(await response.json()).toMatchObject({ error: "request body too large" }); + expect(saved).toBeUndefined(); + expect(store.listOperations("aside")).toEqual([]); +}); + +test.each(["/api/client-integrations/aside/profiles", "/api/client-integrations/aside/profiles/1"])( + "Aside PUT still requires its enabled body at %s", async pathname => { + const before = [0, 1, 2].map(id => readFileSync(path(id), "utf8")); + expect((await api(pathname, "PUT")).status).toBe(400); + expect((await api(pathname, "PUT", {})).status).toBe(400); + expect([0, 1, 2].map(id => readFileSync(path(id), "utf8"))).toEqual(before); + expect(saved).toBeUndefined(); + }, +); + test("legacy connection refreshes all profiles, and an individual off survives selection refresh and reload", async () => { expect(applyIntegration({ clientId: "aside", config, port: 10100, store, env, home, models: [{ provider: "fixture", id: "one", namespaced: "fixture/one" }] }).ok).toBe(true); From c0f14b7f91b61040455d63cf0912d2cef2905618 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:59:02 +0900 Subject: [PATCH 130/236] fix(gui): reject inconsistent Aside aggregate outcomes --- gui/src/pages/integrations/integration-api.ts | 2 +- gui/tests/aside-profiles-page.test.tsx | 10 +++++++--- gui/tests/integrations-api.test.ts | 13 +++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 03d9a7cfa7..7a9139f436 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -286,7 +286,7 @@ export async function toggleIntegration( ); const outcomes = result.results === undefined ? undefined : parseAsideProfileOutcomes(result.results); if (client === "aside" && result.results !== undefined && (!outcomes - || (result.ok === true && outcomes.some(row => !row.ok)))) { + || result.ok !== outcomes.every(row => row.ok))) { throw new IntegrationApiError(502, { code: "invalid_aside_profile_response" }); } if (result.ok !== true) throw new IntegrationApiError(207, { diff --git a/gui/tests/aside-profiles-page.test.tsx b/gui/tests/aside-profiles-page.test.tsx index d3c2e54783..22c47fd582 100644 --- a/gui/tests/aside-profiles-page.test.tsx +++ b/gui/tests/aside-profiles-page.test.tsx @@ -12,7 +12,7 @@ const globals = [ const apiBase = "http://aside-profiles-test.invalid"; const profilesPath = "/api/client-integrations/aside/profiles"; const syncPath = "/api/client-integrations/aside/sync"; -let previousGlobals: Record<(typeof globals)[number], unknown>; +let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; let testWindow: Window; let container: HTMLElement; let root: Root | null = null; @@ -51,7 +51,9 @@ function success(profileId?: number) { } beforeEach(() => { - previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; clearClientResourceStoresForTests(); testWindow = new Window({ url: "http://localhost/#integrations/aside" }); Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); @@ -108,7 +110,9 @@ afterEach(async () => { clearClientResourceStoresForTests(); testWindow.close(); for (const key of globals) { - Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); } }); diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 7cc57c47af..4338d9ead8 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -196,3 +196,16 @@ test("bulk Aside refusals retain operation-specific recovery fields", async () = results: [{ profileId: 2, reason: "write_failed", snapshotPath: "/backup/profile-2", residual: true }], } }); }); + +test.each([ + { ok: false, results: [{ profileId: 2, ok: true }] }, + { ok: false, results: [] }, + { ok: true, results: [{ profileId: 0, ok: true }, { profileId: 2, ok: false }] }, +])("bulk Aside rejects a contradictory aggregate result: $ok / $results", async body => { + globalThis.fetch = (async () => Response.json(body, { status: body.ok ? 200 : 207 })) as typeof fetch; + + await expect(toggleIntegration("http://fixture", "aside", true)).rejects.toMatchObject({ + status: 502, + body: { code: "invalid_aside_profile_response" }, + }); +}); From cea9d510095605f9d07e503667fd94e80b50dfb4 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:59:02 +0900 Subject: [PATCH 131/236] fix(aside): tighten profile sync and history contracts --- .../src/content/docs/guides/integrations.md | 2 +- .../ocx/references/01_management_surface.md | 5 + skills/ocx/references/03_recipes.md | 2 +- src/cli/capabilities.ts | 5 +- src/integrations/aside-profile-journal.ts | 12 ++- src/server/management/aside-profile-routes.ts | 8 +- tests/server/aside-profiles-routes.test.ts | 94 ++++++++++++++++++- 7 files changed, 117 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 026c4f6d4d..c3b47efe21 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -52,7 +52,7 @@ disagree about which file is meant. Its managed block owns only stay untouched. Prime Agent reads `models.json` when a session starts, so start a new session after connecting it. -Aside keeps a separate model catalog for each account-backed browser profile. OpenCodex lists +Aside keeps a separate model catalog for each registered profile, including local profiles. OpenCodex lists all registered profiles, including local profiles, and can synchronize them together or control one profile at a time. Switching an integration never changes Aside's active account. A prior Aside connection enables all profiles by default; individual exclusions survive later syncs. diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index b88570a427..d5711f3cac 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -619,6 +619,11 @@ Synchronize client catalogs, including Aside profiles through the running server |---|---| | POST | `/api/client-integrations/aside/sync` | +| Flag | Value | Meaning | +|---|---|---| +| `--restart-codex` | boolean | Restart Codex app-servers after a catalog or cache write. | +| `--restart-desktop-app` | boolean | Restart the Codex desktop app after a catalog or cache write. | + JSON mode: `none`. - The Aside refresh uses the live server; other catalog synchronization also performs local work. diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 065422d4fc..85b734a9ce 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -223,6 +223,6 @@ ocx integration client restore --client aside --profile 1 --op ``` Read `profiles[]` to find numeric profile IDs. No profile selector means a bulk toggle; an -explicit selector affects only that account-backed profile. Sync intent and actual file state +explicit selector affects only that registered profile. Sync intent and actual file state are distinct, so inspect each result after a partial bulk operation. The CLI returns nonzero for a partial refusal. Never use the overwrite or drift flags merely to suppress a refusal. diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 1bb2b83352..1b5cfd6283 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -557,7 +557,10 @@ export const CAPABILITIES: readonly Capability[] = [ command: ["sync"], summary: "Synchronize client catalogs, including Aside profiles through the running server's mutation owner.", routes: [{ method: "POST", path: "/api/client-integrations/aside/sync" }], - flags: [], + flags: [ + { name: "--restart-codex", value: "boolean", summary: "Restart Codex app-servers after a catalog or cache write." }, + { name: "--restart-desktop-app", value: "boolean", summary: "Restart the Codex desktop app after a catalog or cache write." }, + ], mutates: true, json: "none", details: ["The Aside refresh uses the live server; other catalog synchronization also performs local work."], diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts index f5cd228c90..0fd7f47183 100644 --- a/src/integrations/aside-profile-journal.ts +++ b/src/integrations/aside-profile-journal.ts @@ -19,11 +19,17 @@ export interface AsideOperation { function operationRows(ctx: AsideProfileContext, profileId?: number): AsideOperation[] { const rows: AsideOperation[] = []; + const entriesByRoot = new Map(); for (const profile of selectAsideProfiles(ctx, profileId)) { const scope = asideProfileScope(ctx, profile); const stores = scope.store.root === ctx.rootStore.root ? [scope.store] : [scope.store, ctx.rootStore]; for (const store of stores) { - for (const entry of store.listOperations("aside", Number.MAX_SAFE_INTEGER)) { + let entries = entriesByRoot.get(store.root); + if (entries === undefined) { + entries = store.listOperations("aside", Number.MAX_SAFE_INTEGER); + entriesByRoot.set(store.root, entries); + } + for (const entry of entries) { if (entry.clientId === "aside" && entry.configPath === profile.configPath) { assertAsideSnapshotEntry(entry); if (typeof entry.at !== "string") throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation timestamp is invalid"); @@ -131,7 +137,7 @@ function snapshotWasOwned(entry: JournalEntry, text: string | null, bound: Integ const record = entry.priorRecord; if (!record || text === null || record.fileFingerprint !== fingerprint(text)) return false; const state = classifyIntegration({ - fileText: text, fileIsRegular: true, parsed: parseConfig(text, "json"), record, + fileText: text, fileIsRegular: true, parsed: parseConfig(text, EXPORT_CLIENTS.aside.format), record, contribution: EXPORT_CLIENTS.aside.buildContribution(exportContextOf(bound)), configPath: entry.configPath, clientId: "aside", }).state; @@ -209,7 +215,7 @@ export function deleteAsideOperation( } await persistAsidePolicy(ctx); const stores = new Map(rows.filter(candidate => candidate.entry.opId === request.opId).map(candidate => [candidate.store.root, candidate.store])); - const tombstone = { tombstone: request.opId, at: new Date().toISOString(), by: request.principal ?? "management" }; + const tombstone = { tombstone: request.opId, at: new Date(input.io?.now() ?? Date.now()).toISOString(), by: request.principal ?? "management" }; // Retire every copy before pruning any snapshot; deduped history must not resurrect a source row. for (const store of stores.values()) store.retireOperation(tombstone); let snapshotRemoved = true; diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index 941c37bbb2..8047e6c7a8 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -12,7 +12,7 @@ import { } from "../../integrations/aside-profile-journal"; import type { WriteRefused } from "../../integrations/writer"; import type { ManagementContext } from "./context"; -import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { jsonResponse } from "../auth-cors"; export interface AsideProfileRouteOptions { @@ -52,8 +52,8 @@ function isObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -async function readProfileBody(req: Request): Promise { - try { return await readManagementJsonBody(req); } +async function readProfileBody(req: Request, optional = false): Promise { + try { return await (optional ? readOptionalManagementJsonBody(req) : readManagementJsonBody(req)); } catch (error) { rethrowManagementBodyTooLarge(error); throw new ProfileQueryError("invalid JSON body"); } } @@ -117,7 +117,7 @@ export async function handleAsideProfileRoutes( if (url.pathname === "/api/client-integrations/aside/sync") { if (req.method !== "POST") return null; if (id !== undefined) throw new ProfileQueryError("Aside sync uses the server's selected profiles"); - const body = await readProfileBody(req); + const body = await readProfileBody(req, true); if (!isObject(body) || Object.keys(body).length !== 0) throw new ProfileQueryError("Aside sync expects an empty object"); const results = await refreshAsideProfiles(options.input()); const ok = results.every(result => result.ok); diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index b129e8f701..a21f0168b6 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { handleManagementAPI } from "../../src/server/management-api"; +import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks } from "../../src/server/management/integration-routes"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; import { applyIntegration } from "../../src/integrations/writer"; @@ -58,10 +59,13 @@ afterEach(() => { function path(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } function document(id: number) { return JSON.parse(readFileSync(path(id), "utf8")); } async function api(pathname: string, method = "GET", body?: unknown) { + return rawApi(pathname, method, body === undefined ? undefined : JSON.stringify(body)); +} +async function rawApi(pathname: string, method: string, body?: string) { const url = new URL(`http://127.0.0.1:10100${pathname}`); const response = await handleManagementAPI(new Request(url, { method, headers: { Host: url.host, "content-type": "application/json" }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), + ...(body === undefined ? {} : { body }), }), url, config, { saveConfigPreservingClaudeCode: value => { saved = structuredClone(value); }, createManagementConvergeCodex: catalogConvergenceFactory(), @@ -71,6 +75,94 @@ async function api(pathname: string, method = "GET", body?: unknown) { return response; } +async function prepareAsideSync(): Promise { + config.providers.fixture!.selectedModels = ["one"]; + const enabled = await api("/api/client-integrations/aside/profiles", "PUT", { enabled: true }); + expect(enabled.status).toBe(200); + expect(await enabled.json()).toMatchObject({ ok: true }); + for (const id of [0, 1, 2]) expect(fixtureModelIds(id)).toEqual(["fixture/one"]); + // Change the runtime selection without triggering a different endpoint's sync. + config.providers.fixture!.selectedModels = ["two"]; +} + +function fixtureModelIds(id: number): string[] { + return document(id).providers.opencodex.models + .filter((model: { id: string }) => model.id.startsWith("fixture/")) + .map((model: { id: string }) => model.id); +} + +test.each([undefined, "{}"])("Aside sync accepts body %j and refreshes every enabled profile with HTTP 200", async body => { + await prepareAsideSync(); + const response = await rawApi("/api/client-integrations/aside/sync", "POST", body); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ok: true, clientId: "aside", + results: [0, 1, 2].map(profileId => ({ client: "aside", profileId, ok: true, changed: true })), + }); + for (const id of [0, 1, 2]) { + expect(fixtureModelIds(id)).toEqual(["fixture/two"]); + expect(document(id).theme).toBe("keep"); + expect(document(id).providers.personal).toEqual({ models: [] }); + } +}); + +test("bodyless Aside sync returns HTTP 207 for one conflict while refreshing its siblings", async () => { + await prepareAsideSync(); + const edited = document(1); + edited.providers.opencodex.baseUrl = "https://user-edit.example.test/v1"; + const editedBytes = JSON.stringify(edited); + writeFileSync(path(1), editedBytes); + const response = await api("/api/client-integrations/aside/sync", "POST"); + expect(response.status).toBe(207); + expect(await response.json()).toMatchObject({ + ok: false, clientId: "aside", results: [ + { client: "aside", profileId: 0, ok: true, changed: true }, + { client: "aside", profileId: 1, ok: false, state: "conflict", refusalReason: "conflict" }, + { client: "aside", profileId: 2, ok: true, changed: true }, + ], + }); + expect(readFileSync(path(1), "utf8")).toBe(editedBytes); + for (const id of [0, 2]) expect(fixtureModelIds(id)).toEqual(["fixture/two"]); + expect(await (await api("/api/client-integrations/aside/profiles/1")).json()) + .toMatchObject({ enabled: true, state: "conflict" }); +}); + +test.each(['{"enabled":true}', '{"profile":1}', '{"overwriteConflict":true}', "[]", "null", "true", "{"])( + "Aside sync rejects nonempty options or invalid JSON %s before mutation", async body => { + const before = [0, 1, 2].map(id => readFileSync(path(id), "utf8")); + const response = await rawApi("/api/client-integrations/aside/sync", "POST", body); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ code: "invalid_aside_profile", clientId: "aside" }); + expect([0, 1, 2].map(id => readFileSync(path(id), "utf8"))).toEqual(before); + expect(saved).toBeUndefined(); + expect(store.listOperations("aside")).toEqual([]); + }, +); + +test.each(["?profile=0", "?profile=invalid", "?client=pi"])("bodyless Aside sync rejects selector %s", async selector => { + expect((await api(`/api/client-integrations/aside/sync${selector}`, "POST")).status).toBe(400); + expect(saved).toBeUndefined(); + expect(store.listOperations("aside")).toEqual([]); +}); + +test("Aside sync retains the JSON body size limit before accepting an empty-body fallback", async () => { + const response = await rawApi("/api/client-integrations/aside/sync", "POST", " ".repeat(MANAGEMENT_JSON_BODY_MAX_BYTES + 1)); + expect(response.status).toBe(413); + expect(await response.json()).toMatchObject({ error: "request body too large" }); + expect(saved).toBeUndefined(); + expect(store.listOperations("aside")).toEqual([]); +}); + +test.each(["/api/client-integrations/aside/profiles", "/api/client-integrations/aside/profiles/1"])( + "Aside PUT still requires its enabled body at %s", async pathname => { + const before = [0, 1, 2].map(id => readFileSync(path(id), "utf8")); + expect((await api(pathname, "PUT")).status).toBe(400); + expect((await api(pathname, "PUT", {})).status).toBe(400); + expect([0, 1, 2].map(id => readFileSync(path(id), "utf8"))).toEqual(before); + expect(saved).toBeUndefined(); + }, +); + test("legacy connection refreshes all profiles, and an individual off survives selection refresh and reload", async () => { expect(applyIntegration({ clientId: "aside", config, port: 10100, store, env, home, models: [{ provider: "fixture", id: "one", namespaced: "fixture/one" }] }).ok).toBe(true); From e60c1ff8468f0fe66bc7902a9399440ffe41932a Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 06:13:02 +0900 Subject: [PATCH 132/236] fix(aside): keep recovery diagnostics in the backend stack layer --- src/cli/dispatch.ts | 2 +- src/cli/integrations.ts | 2 +- src/cli/runtime-api.ts | 7 +- src/integrations/aside-profiles.ts | 12 ++- src/integrations/owned-refresh.ts | 9 +- tests/cli/cli-headless-parity.test.ts | 82 +++++++++++++++++++ tests/clients/aside-profiles.test.ts | 20 +++++ .../clients/sync-client-integrations.test.ts | 17 ++++ 8 files changed, 146 insertions(+), 5 deletions(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index e222339c24..6d018536c7 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -419,7 +419,7 @@ const commandRunners: Record = { for (const result of results) { const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`; if (result.changed) console.log(`${label} integration refreshed from the current catalog.`); - else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}`); + else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}${result.residual ? " Recovery did not finish." : ""}${result.snapshotPath ? ` Backup: ${result.snapshotPath}` : ""}`); } } return code; diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index be957d56cf..bcb87d5d18 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -272,7 +272,7 @@ export async function handleClientIntegrationCommand( }, deps); const batch = result as { ok?: boolean; message?: string; results?: Array> }; printData(result, wantsJson, batch.results - ? batch.results.map(row => `aside:${String(row.profileId)} ${String(row.message ?? (row.ok ? "updated" : "refused"))}`) + ? batch.results.map(row => `aside:${String(row.profileId)} ${String(row.message ?? (row.ok ? "updated" : "refused"))}${row.residual === true ? " Recovery did not finish." : ""}${typeof row.snapshotPath === "string" ? ` Backup: ${row.snapshotPath}` : ""}`) : [String(batch.message ?? `${client} ${action}d.`)]); if (batch.ok === false) throw new RuntimeApiError(batch.message ?? "Some Aside profiles could not be updated", 207, result); }); diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index da919db765..7b05d56b9f 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -79,7 +79,12 @@ function responseMessage(body: unknown, status: number): string { if (reason && reason !== primary) parts.push(`reason: ${reason}`); const hint = stringField(record, "hint"); if (hint && hint !== primary) parts.push(`hint: ${hint}`); - return parts.join("\n").slice(0, 1200); + const snapshotPath = stringField(record, "snapshotPath"); + const recovery = [ + ...(snapshotPath ? [`Backup: ${snapshotPath.slice(0, 32768)}`] : []), + ...(record.residual === true ? ["Automatic recovery did not finish; check the client configuration before retrying."] : []), + ]; + return [parts.join("\n").slice(0, 1200), ...recovery].join("\n"); } export async function runtimeRequest( diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts index bdea5a5cde..94514f253a 100644 --- a/src/integrations/aside-profiles.ts +++ b/src/integrations/aside-profiles.ts @@ -156,9 +156,19 @@ export function refreshAsideProfiles(input: AsideProfilesInput): Promise expect(log.mock.calls.flat().join("\n")).toContain("Open Aside to create a profile"); } finally { log.mockRestore(); } }); + +describe("Aside CLI recovery metadata", () => { + test.each([ + { name: "a long POSIX backup path", snapshotPath: `/tmp/aside-recovery/${"profile-2-snapshot/".repeat(80)}models.json.bak` }, + { name: "a Windows backup path with spaces and Unicode", snapshotPath: String.raw`C:\Aside Recovery\프로필 2\models.json.before-write` }, + { name: "no backup path", snapshotPath: undefined }, + ])("a refused restore preserves recovery guidance after a long message: $name", async ({ snapshotPath }) => { + const message = `Restore failed: ${"the profile file could not be replaced; ".repeat(80)}`; + const runtime = fakeRuntime(() => Response.json({ + ok: false, clientId: "aside", profileId: 2, state: "absent", + message, reason: "write_failed", residual: true, + ...(snapshotPath === undefined ? {} : { snapshotPath }), + }, { status: 500 })); + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleClientIntegrationCommand([ + "restore", "--client", "aside", "--profile", "2", "--op", "op-recovery", + ], runtime.deps)).toBe(1); + const stderr = error.mock.calls.map(call => String(call[0])).join("\n"); + expect(stderr).toContain("Restore failed:"); + // Recovery fields have their own output budget, after the bounded main message. + expect(stderr).not.toContain(message); + expect(stderr.split("\n")).toContain("Automatic recovery did not finish; check the client configuration before retrying."); + if (snapshotPath !== undefined) { + expect(stderr.split("\n")).toContain(`Backup: ${snapshotPath}`); + } else { + expect(stderr).not.toContain("Backup:"); + } + expect(log.mock.calls).toHaveLength(0); + expect(runtime.requests).toEqual([{ + path: "/api/client-integrations/aside/profiles/2/restore", method: "POST", + body: { opId: "op-recovery", confirmDrift: false }, + }]); + } finally { + log.mockRestore(); + error.mockRestore(); + } + }); + + test.each([false, true])("bulk 207 retains each profile's recovery metadata and fails nonzero (json=%s)", async wantsJson => { + const snapshotPath = "/tmp/aside-recovery/profile 2/models.json.before-write"; + const otherSnapshot = String.raw`C:\Aside Recovery\profile 7\models.json.bak`; + const longMessage = `Profile 2 write failed: ${"could not replace models.json; ".repeat(80)}`; + const result = { + ok: false, clientId: "aside", message: "Three profiles could not be updated", + results: [ + { profileId: 0, ok: true, message: "updated" }, + { profileId: 2, ok: false, message: longMessage, reason: "write_failed", snapshotPath, residual: true }, + { profileId: 7, ok: false, message: "Profile 7 is conflicted", reason: "conflict", snapshotPath: otherSnapshot, residual: false }, + { profileId: 9, ok: false, message: "Profile 9 recovery failed", reason: "write_failed", residual: true }, + ], + }; + const runtime = fakeRuntime(() => Response.json(result, { status: 207 })); + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleClientIntegrationCommand([ + "enable", "--client", "aside", ...(wantsJson ? ["--json"] : []), + ], runtime.deps)).toBe(1); + const stdout = log.mock.calls.map(call => String(call[0])).join("\n"); + if (wantsJson) { + expect(JSON.parse(stdout)).toEqual(result); + } else { + // Exact rows catch dropped/truncated paths and metadata leaking to a sibling. + expect(stdout.split("\n")).toEqual([ + "aside:0 updated", + `aside:2 ${longMessage} Recovery did not finish. Backup: ${snapshotPath}`, + `aside:7 Profile 7 is conflicted Backup: ${otherSnapshot}`, + "aside:9 Profile 9 recovery failed Recovery did not finish.", + ]); + } + expect(error.mock.calls.map(call => String(call[0])).join("\n")).toContain(result.message); + expect(runtime.requests).toEqual([{ + path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true }, + }]); + } finally { + log.mockRestore(); + error.mockRestore(); + } + }); +}); diff --git a/tests/clients/aside-profiles.test.ts b/tests/clients/aside-profiles.test.ts index 35a60426d8..800ef329f2 100644 --- a/tests/clients/aside-profiles.test.ts +++ b/tests/clients/aside-profiles.test.ts @@ -76,6 +76,26 @@ describe("Aside profile desired state, ownership and history", () => { expect(saves).toBe(0); }); + test("sync retains backup and incomplete-recovery diagnostics for a failed profile", async () => { + seedLegacy(); + const io = store.io(); + let attempts = 0; + const outcomes = await refreshAsideProfiles(input({ models: models.slice(0, 1), + store: { ...store, putRecord() { throw new Error("synthetic ownership failure"); } }, io: { + ...io, + writeText(target, text) { + if (target !== path(0)) return io.writeText(target, text); + attempts += 1; + if (attempts === 1) return io.writeText(target, text); + throw new Error("synthetic write and compensation failure"); + }, + } })); + const failure = outcomes.find(row => row.profileId === 0); + expect(failure).toMatchObject({ ok: false, refusalReason: "write_failed", residual: true }); + expect(failure?.snapshotPath).toBeString(); + expect(existsSync(failure!.snapshotPath!)).toBe(true); + }); + test("legacy connection defaults all profiles on and refresh shares one catalog load", async () => { seedLegacy(); expect((await listAsideProfileStates(input())).enabledCount).toBe(3); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index bf78bd65c6..ffc00a16f8 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -160,6 +160,23 @@ describe("ocx sync refreshes an already-owned MCode integration", () => { expect(store.listOperations("mcode")).toHaveLength(0); }); + test("retains recovery details when refresh bookkeeping and compensation both fail", async () => { + expect(applyIntegration(input(oldModels)).ok).toBe(true); + const io = store.io(); + let writes = 0; + const result = await refreshOwnedIntegration({ ...input(newModels), io: { + ...io, + writeText(path, text) { + if (path === configPath && ++writes > 1) throw new Error("synthetic rollback failure"); + io.writeText(path, text); + }, + putRecord() { throw new Error("synthetic ownership failure"); }, + } }); + expect(result).toMatchObject({ client: "mcode", ok: false, refusalReason: "write_failed", residual: true }); + expect(result?.snapshotPath).toBeString(); + expect(result?.reason).toContain("could not be rolled back"); + }); + test("refuses a foreign edit without changing bytes or appending a journal row", async () => { expect(applyIntegration(input(oldModels)).ok).toBe(true); const recordBefore = JSON.stringify(store.readRecords().mcode); From 092dd0408409ccde54d7d60d4ecd791fb9311336 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 06:41:02 +0900 Subject: [PATCH 133/236] fix(claude): apply hub-issued Desktop model identities remotely Refs #3646. Share discovery inputs with startup, keep remote apply tied to the connected credential and hub snapshot, and reject unresolved managed aliases before fallback. Thinking and cache requests remain separate. --- .../050_remote_aliases.md | 2 +- .../src/content/docs/fr/guides/claude-code.md | 30 +++ .../docs/fr/reference/proxy-formats.md | 20 +- .../src/content/docs/guides/claude-code.md | 31 +++ .../src/content/docs/ja/guides/claude-code.md | 26 ++ .../docs/ja/reference/proxy-formats.md | 19 +- .../src/content/docs/ko/guides/claude-code.md | 28 ++ .../docs/ko/reference/proxy-formats.md | 19 +- .../content/docs/reference/proxy-formats.md | 19 +- .../src/content/docs/ru/guides/claude-code.md | 27 ++ .../docs/ru/reference/proxy-formats.md | 19 +- .../src/content/docs/tr/guides/claude-code.md | 29 +++ .../docs/tr/reference/proxy-formats.md | 19 +- .../content/docs/zh-cn/guides/claude-code.md | 23 ++ .../docs/zh-cn/reference/proxy-formats.md | 16 +- .../content/docs/zh-tw/guides/claude-code.md | 25 ++ .../docs/zh-tw/reference/proxy-formats.md | 16 +- scripts/test-layout/layout.json | 2 + src/claude/agents-inject.ts | 17 +- src/claude/desktop-3p.ts | 53 +++- src/claude/desktop-discovery-inputs.ts | 44 ++++ src/claude/desktop-profile.ts | 2 +- src/claude/inbound-model-options.ts | 7 +- src/cli/claude-agent-startup-sync.ts | 4 +- src/cli/claude-desktop.ts | 78 +++++- src/cli/index.ts | 35 ++- src/client/hub-client.ts | 79 ++++++ src/server/claude-messages.ts | 67 ++--- src/server/index.ts | 36 ++- structure/09_client-integrations.md | 22 ++ .../claude-agent-startup-sync.test.ts | 60 +++++ .../claude-agents-inject.test.ts | 33 +++ .../claude-desktop-cli.test.ts | 245 +++++++++++++++++- .../claude-desktop-discovery.test.ts | 202 +++++++++++++++ .../claude-desktop-remote-hub.test.ts | 239 +++++++++++++++++ .../claude-messages-endpoint.test.ts | 126 ++++++++- .../claude-native-passthrough.test.ts | 52 ++++ tests/clients/desktop-3p.test.ts | 88 +++++++ tests/clients/desktop-profile.test.ts | 26 ++ tests/clients/remote-catalog.test.ts | 176 ++++++++++++- .../fixtures/claude-desktop-network-guard.ts | 34 +++ tests/fixtures/test-layout-expected.json | 2 + 42 files changed, 1990 insertions(+), 107 deletions(-) create mode 100644 src/claude/desktop-discovery-inputs.ts create mode 100644 tests/claude-integration/claude-desktop-discovery.test.ts create mode 100644 tests/claude-integration/claude-desktop-remote-hub.test.ts create mode 100644 tests/fixtures/claude-desktop-network-guard.ts diff --git a/devlog/_plan/260906_d_integrations_delivery/050_remote_aliases.md b/devlog/_plan/260906_d_integrations_delivery/050_remote_aliases.md index 99567f86b6..93b1a521a7 100644 --- a/devlog/_plan/260906_d_integrations_delivery/050_remote_aliases.md +++ b/devlog/_plan/260906_d_integrations_delivery/050_remote_aliases.md @@ -1,6 +1,6 @@ # 050 — Remote Desktop integration work item -This numbered entry reserves the D-lane work item associated with public issue #3646. Implementation has not started. The detailed design and review notes are retained in ignored scratch at `.tmp/d-delivery/050_remote_aliases.md`, following AGENTS.md's security-working-notes policy. +This numbered entry reserves the D-lane work item associated with public issue #3646. Implementation is under review; verification and issue disposition are still open. The detailed design and review notes are retained in ignored scratch at `.tmp/d-delivery/050_remote_aliases.md`, following AGENTS.md's security-working-notes policy. The main agent must read that local design at the work-phase's P transition, revalidate it against current source, and obtain an independent implementation review plus actual current-head CI before claiming completion. Relevant user documentation and regression coverage ship with the implementation. Only the verified published outcome belongs in this tracked unit. diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 9899797ea6..51fa603fde 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -134,6 +134,8 @@ est temporairement indisponible, la première route disponible de la famille est Vous pouvez également gérer le même profil depuis la ligne de commande : +Les instructions de modification ci-dessous concernent le profil local. L'application via un hub connecté est décrite séparément plus bas. + ```bash ocx claude desktop [apply] ocx claude desktop show [--json] @@ -204,6 +206,30 @@ l'en-tête d'admission dédié du proxy est valide. Par conséquent, l'avertisse Désactivez ce comportement avec `claudeCode.nativePassthrough: false` ; définissez une autre destination avec `claudeCode.anthropicBaseUrl`. +## Claude Desktop connecté à un hub distant + +Sur une machine connectée, `ocx claude desktop apply` ou `ocx claude desktop` récupère +l'instantané Desktop du hub et écrit son origine ainsi que ses identifiants exacts dans la +configuration Desktop locale, sans créer d'alias locaux. Les modes static/hybrid copient les +entrées ; discovery-only utilise l'origine du hub sans intégrer la liste. + +Le hub gère le profil, les familles et les valeurs par défaut. Modifiez-les sur le hub, puis +réappliquez côté client et sélectionnez à nouveau le modèle dans Desktop. Les anciens alias +créés uniquement sur le client nécessitent aussi cette opération. `show`, les modifications +locales et import/export restent locaux. En connexion distante, +`ocx claude desktop import --apply` est refusé avant l'enregistrement ; sans `--apply`, +l'importation reste locale. + +La lecture utilise l'identifiant d'accès aux données de la connexion existante, sans jeton +administrateur ni envoi de profil. Un ancien hub incompatible, une réponse invalide ou une liste +Desktop vide fait échouer l'application, sans catalogue local ni adresse de bouclage de secours. +Mettez à jour ou configurez le hub, puis réappliquez. + +Ce changement d'alias ne résout pas la demande distincte de [#3646](https://github.com/lidge-jun/opencodex/issues/3646) concernant la relecture de +`thinking` / `redacted_thinking` et le cache de prompts. L'accès au proxy seul n'active pas le +passthrough Anthropic natif ; les routes Anthropic traduites peuvent néanmoins utiliser le cache. +La fidélité de relecture et la comparaison des accès au cache restent à traiter séparément. + ## Le sélecteur /model (« Depuis la passerelle ») Claude Code 2.1.129+ découvre les modèles de passerelle via `GET /v1/models?limit=1000` et les répertorie dans @@ -248,6 +274,8 @@ utilisent l'alias haché. Les identifiants de modèle peuvent contenir `--` (la **Ordre de résolution du modèle :** retrait du marqueur `[1m]` → décodage de l'alias lisible → décodage de l'alias haché de Claude Desktop → correspondance exacte dans `modelMap` → correspondance sans date (suffixe `-20250514` retiré) → transfert direct. +Les alias Desktop gérés de type date/hash qui ne peuvent pas être résolus sont rejetés avec HTTP 400 avant suppression de date ou repli, pour Messages comme pour count-tokens. Les entrées exactes de `modelMap` et les véritables ID Anthropic reconnus conservent leur traitement habituel. + Chaque entrée porte un nom d'affichage tel que `gemini-3-pro (gemini)`, ainsi que toutes les fonctionnalités du modèle (échelle d'effort de raisonnement et types de réflexion) dans la structure officielle `ModelInfo`. Les véritables modèles Anthropic conservent leurs identifiants canoniques sur les deux interfaces. @@ -345,6 +373,8 @@ l'élision). Le contenu de remplacement préserve l'association entre l'appel d' Ordre de recherche : alias de découverte → identifiant exact → identifiant sans le suffixe de date (`-20250514`) → transfert direct. +Les alias Desktop gérés de type date/hash qui ne peuvent pas être résolus sont rejetés avec HTTP 400 avant suppression de date ou repli, pour Messages comme pour count-tokens. Les entrées exactes de `modelMap` et les véritables ID Anthropic reconnus conservent leur traitement habituel. + ## Matrice des services auxiliaires : recherche web et compréhension des images Les modèles routés ne disposent pas tous des mêmes outils hébergés ou de la même prise en charge des images. opencodex comble ces lacunes diff --git a/docs-site/src/content/docs/fr/reference/proxy-formats.md b/docs-site/src/content/docs/fr/reference/proxy-formats.md index f224e61bd2..1b14d998c0 100644 --- a/docs-site/src/content/docs/fr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md @@ -28,7 +28,7 @@ doit choisir parmi plusieurs cibles. | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | `chat.completion.chunk` SSE se terminant par `[DONE]` | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Comptage des jetons Anthropic | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | Sans objet | -| Découverte de modèles | `GET /v1/models` | L'un des trois contrats du catalogue | Sans objet | +| Découverte de modèles | `GET /v1/models` | Catalogue ou instantané Desktop explicite | Sans objet | | Voix et temps réel | `POST /v1/live`, `POST /v1/realtime/calls` | Réponse de création d'appel relayée | Une bande latérale séparée WebSocket relaie les trames dans les deux sens | | Compactage des réponses | `POST /v1/responses/compact` | Historique de remplacement JSON | Sans objet | @@ -229,8 +229,7 @@ estimation documentée du contenu du système, des messages et des outils et ret ## `GET /v1/models` -Le même itinéraire dessert trois clients qui attendent des enveloppes de catalogue incompatibles. -La variante Anthropic est prioritaire, sauf si `client_version` est également présent. +Sans `format=desktop-config`, les contrats de catalogue ordinaires sont les suivants : | Contrat | Déclencheur | Forme de niveau supérieur | Comportement de l’identifiant du modèle | | --- | --- | --- | --- | @@ -238,6 +237,21 @@ La variante Anthropic est prioritaire, sauf si `client_version` est également p | Codex catalogue | `client_version` paramètre de requête | `{ "models": [...] }` | Les entrées natives et routées contiennent les champs de catalogue Codex les plus riches, la visibilité, l'effort, WebSocket et les métadonnées multi-agents | | Liste simple OpenAI | Ni l'un ni l'autre déclencheur | `{ "object": "list", "data": [...] }` | Les identifiants natifs visibles sont nus ; les identifiants routés sont des alias ou `provider/model` | +### Instantané de configuration Desktop + +`GET /v1/models?ids=desktop&format=desktop-config` sélectionne explicitement le snapshot +Desktop, indépendamment du user-agent. La réponse est `{ "version": 1, "models": [...] }` +avec `Cache-Control: no-store`. Le client envoie `Accept: application/json`, +`anthropic-version: 2023-06-01` et ses identifiants existants d'accès aux données, sans jeton +administrateur ni envoi de profil. Les entrées sont les modèles de configuration Desktop émis +par le hub, pas les lignes du catalogue Codex. + +Avec `ids=cli` ou un paramètre `client_version`, ce format renvoie HTTP 400. Sans le sélecteur +de format, les contrats ordinaires ci-dessus restent inchangés. Si Claude est désactivé, +`{ "version": 1, "models": [] }` indique l'indisponibilité à Desktop apply, qui n'écrit aucun +profil de remplacement. Un ancien hub renvoyant un catalogue ordinaire au lieu de la version 1 +n'est pas pris en charge ; aucun identifiant local de secours n'est généré. + ## `POST /v1/live` et bande latérale en temps réel `POST /v1/live` accepte la surface de création d'appel ChatGPT/Codex App sans cadre. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 9dafc37c21..8537a4f342 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -139,6 +139,8 @@ is temporarily unavailable, the first available route in that family is used unt You can also manage the same profile from the command line: +The profile-editing instructions below describe the local profile. Connected remote apply is described separately below. + ```bash ocx claude desktop [apply] ocx claude desktop show [--json] @@ -217,6 +219,31 @@ dedicated proxy admission header is valid. This also means the Disable with `claudeCode.nativePassthrough: false`; point elsewhere with `claudeCode.anthropicBaseUrl`. +## Claude Desktop on a connected remote hub + +When this machine is connected to a hub, `ocx claude desktop apply` (or `ocx claude desktop`) +uses the hub's Desktop model snapshot. It writes the connected hub origin and the hub-issued +model IDs into the local Desktop configuration without generating replacement aliases locally. +Static and hybrid modes copy the snapshot entries; discovery-only mode uses the hub origin +without embedding the model list. + +The hub owns the Desktop profile, family assignments and defaults. Change those on the hub, +then apply again on the connected client and reselect the model in Desktop. Old aliases created +only on the client require reapply/reselection; they are not automatically migrated. Local `show`, +profile edits, and import/export remain local views and operations, not hub-profile management. +While connected, `ocx claude desktop import --apply` is unsupported and refuses the import +before saving. Import without `--apply` remains local. + +Apply reads the snapshot using the existing connection's data credential. It needs no admin token +and uploads no profile. If the hub is too old to support the snapshot, the response is invalid, +or no Desktop models are available, apply fails without substituting a local catalog or loopback +origin. Upgrade/configure the hub and apply again. + +This alias change does not fix the separate `thinking` / `redacted_thinking` replay and prompt-cache +request in [#3646](https://github.com/lidge-jun/opencodex/issues/3646). Proxy admission alone does not enable native Anthropic passthrough; translated +Anthropic routes can still use prompt caching. Replay fidelity and cache-hit comparisons remain +separate work. + ## The /model picker ("From gateway") Claude Code 2.1.129+ discovers gateway models via `GET /v1/models?limit=1000` and lists them in @@ -261,6 +288,8 @@ express fall back to the hashed alias. Model ids MAY contain `--` (resolution sp **Model resolution order:** `[1m]` marker stripped → readable alias decoded → Desktop hashed alias decoded → `modelMap` exact match → date-stripped match (`-20250514` removed) → passthrough. +Unresolved managed Desktop date/hash aliases are rejected with HTTP 400 before date-stripping or fallback, on both Messages and count-tokens. Exact operator `modelMap` entries and recognized real Anthropic model IDs retain their normal handling. + Each entry carries a display name like `gemini-3-pro (gemini)`, plus full model capabilities (reasoning-effort ladder, thinking types) in the official `ModelInfo` shape. Real Anthropic models keep their canonical ids on both surfaces. @@ -359,6 +388,8 @@ entirely). The stub keeps tool call/result pairing intact. Lookup order: discovery alias → exact id → id with date suffix stripped (`-20250514`) → passthrough. +Unresolved managed Desktop date/hash aliases are rejected with HTTP 400 before date-stripping or fallback, on both Messages and count-tokens. Exact operator `modelMap` entries and recognized real Anthropic model IDs retain their normal handling. + ## Sidecar matrix: web search and image understanding Routed models do not all have the same hosted tools or image support. opencodex fills those gaps diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 6d45bf63af..a891e05dc7 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -94,6 +94,28 @@ hook を削除します。Claude Desktop は独立した profile を使用し、 `claudeCode.nativePassthrough: false` でオフにでき、`claudeCode.anthropicBaseUrl` で別のアドレスを 指定できます。 +## リモートハブに接続した Claude Desktop + +接続中のマシンで `ocx claude desktop apply` または `ocx claude desktop` を実行すると、 +ハブの Desktop スナップショットを取得し、ハブの origin と発行済みモデル ID をそのまま +ローカル Desktop 設定に書き込みます。ローカルの別名は生成しません。static/hybrid は +モデル一覧もコピーし、discovery-only は一覧を埋め込まずハブの origin を使います。 + +プロファイル、ファミリー、デフォルトはハブ側で管理します。ハブで変更してからクライアントで +再適用し、Desktop でモデルを選び直してください。以前クライアントだけで作成した別名も +再適用・再選択が必要です。`show`、ローカル編集、import/export はローカル設定だけを扱います。 +接続中の `ocx claude desktop import --apply` は未対応で、保存前に拒否します。 +`--apply` なしの import はローカル操作のままです。 + +取得には既存の接続のデータ用認証情報を使い、管理者トークンもプロファイルのアップロードも +不要です。古いハブが未対応の場合、不正な応答や空の Desktop 一覧の場合は適用に失敗します。 +ローカル一覧やループバック URL への代替は行いません。ハブを更新・設定して再適用してください。 + +この別名変更では、[#3646](https://github.com/lidge-jun/opencodex/issues/3646) の `thinking` / `redacted_thinking` 再送とプロンプトキャッシュの +別件は修正しません。プロキシの接続認証だけではネイティブ Anthropic パススルーは有効に +なりませんが、変換された Anthropic ルートでもキャッシュは利用できます。再送の保持と +キャッシュヒットの比較は別の作業です。 + ## /model ピッカー("From gateway") Claude Code 2.1.129 以降は `GET /v1/models?limit=1000` でゲートウェイモデルを探し、デフォルトの `/model` @@ -126,6 +148,8 @@ v2 エイリアスはエスケープを展開します。読みやすい形式 **モデル解決順序:** `[1m]` 標識の削除 → 読みやすいエイリアスのデコード → Desktop ハッシュエイリアスのデコード → `modelMap` の完全一致 → 日付を削除した値との一致(`-20250514` 削除) → パススルー順です。 +解決できない管理対象 Desktop の日付・ハッシュ別名は、Messages と count-tokens の両方で日付除去やフォールバックの前に HTTP 400 で拒否します。正確に指定された `modelMap` と認識済みの実 Anthropic モデル ID は通常どおり処理します。 + 各項目には `gemini-3-pro (gemini)` のような表示名と公式 `ModelInfo` 形式の完全なモデル能力 (推論負荷段階、thinking 型)が含まれます。実際の Anthropic モデルは両画面で正式 ID を維持します。 @@ -221,6 +245,8 @@ Anthropic パススルーはそのまま維持します。 照合順序: 検索エイリアス → 完全一致 ID → 日付接尾辞を削除した ID(`-20250514`) → パススルー順です。 +解決できない管理対象 Desktop の日付・ハッシュ別名は、Messages と count-tokens の両方で日付除去やフォールバックの前に HTTP 400 で拒否します。正確に指定された `modelMap` と認識済みの実 Anthropic モデル ID は通常どおり処理します。 + ## サイドカーマトリクス: ウェブ検索と画像理解 ルーティングモデルごとに使えるホスト型ツールと画像サポート範囲が異なります。opencodex はメインモデルが diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index 93c2be4904..d6f57efdbd 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -22,7 +22,7 @@ provider events → internal adapter events → client dialect | OpenAI チャットの完了 | `POST /v1/chat/completions` | JSON | `chat.completion` `chat.completion.chunk` SSE で終わる `[DONE]` | |人間的なメッセージ | `POST /v1/messages` |人類 `message` JSON |人間的メッセージ SSE | |人間トークン数 | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` |該当なし | -|モデルの発見 | `GET /v1/models` | 3 つのカタログ契約のうちの 1 つ |該当なし | +|モデルの発見 | `GET /v1/models` | カタログまたは明示的な Desktop スナップショット |該当なし | |音声とリアルタイム | `POST /v1/live`、`POST /v1/realtime/calls` |中継されたコール作成応答 |別のサイドバンド WebSocket がフレームを両方向に中継します。 |応答の圧縮 | `POST /v1/responses/compact` |置換履歴 JSON |該当なし | @@ -153,14 +153,27 @@ admission secret も削除され、別の実際の Anthropic 認証情報は維 ## `GET /v1/models` -同じルートは、互換性のないカタログ エンベロープを予期する 3 つのクライアントにサービスを提供します。 `client_version` も存在しない限り、人間味が優先されます。 +`format=desktop-config` を指定しない場合、通常のカタログ契約は次のとおりです。 -|契約 |トリガー |トップレベルの形状 |モデル ID の動作 | | --- | --- | --- | --- | |人類モデルのリスト | `anthropic-version` ヘッダーまたは `?flavor=anthropic`、`client_version` なし | Anthropic モデル情報エントリのある `{ "data": [...] }` |クロード コードは読み取り可能な ID を受け取ります。デスクトップはプロファイル固有のエイリアス ファミリを受け取ることができます。 |Codexカタログ | `client_version` クエリパラメータ | `{ "models": [...] }` |ネイティブおよびルーティングされたエントリには、より豊富な Codex カタログ フィールド、可視性、労力、WebSocket、およびマルチエージェント メタデータが含まれています。 |プレーンな OpenAI リスト |どちらのトリガーもありません | `{ "object": "list", "data": [...] }` |表示されるネイティブ ID は裸です。ルーティング ID はエイリアスまたは `provider/model` | +### Desktop 設定スナップショット + +`GET /v1/models?ids=desktop&format=desktop-config` は user-agent に関係なく Desktop +スナップショットを明示的に選択します。応答は `{ "version": 1, "models": [...] }` で、 +`Cache-Control: no-store` を含みます。クライアントは `Accept: application/json`、 +`anthropic-version: 2023-06-01` と既存のデータ用認証情報を送ります。管理者トークンや +プロファイルのアップロードは不要です。項目はハブが発行した Desktop 設定用モデルであり、 +Codex カタログの行ではありません。 + +この形式に `ids=cli` または `client_version` を併用すると HTTP 400 になります。形式指定が +なければ上記の通常の契約を維持します。Claude が無効なら `{ "version": 1, "models": [] }` +を返し、接続中の Desktop apply は利用不可として設定を書き換えません。バージョン 1 ではなく +通常のカタログを返す古いハブは未対応で、ローカル生成 ID に切り替えることはありません。 + ## `POST /v1/live` とRealtime サイドバンド `POST /v1/live` は、ChatGPT/Codex アプリのフレームレス通話作成サーフェスを受け入れます。 `POST /v1/realtime/calls` は、OpenAI Realtime 呼び出し作成サーフェスを受け入れます。 opencodex は、適格な OpenAI ファミリ ルートを選択し、アップストリーム認証モードのコール作成リクエストを正規化し、制限付き応答を中継します。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index a7ed659c59..c337acc735 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -117,6 +117,30 @@ hook을 제거해요. Claude Desktop은 별도 profile을 사용하며 shell hoo `claudeCode.nativePassthrough: false`로 끌 수 있고, `claudeCode.anthropicBaseUrl`로 다른 주소를 지정할 수 있어요. +## 원격 허브에 연결된 Claude Desktop + +허브에 연결된 컴퓨터에서 `ocx claude desktop apply` 또는 `ocx claude desktop`을 실행하면 +허브의 Desktop 모델 스냅샷을 받아요. 로컬 별칭을 새로 만들지 않고 허브가 발급한 모델 ID와 +연결된 허브 origin을 로컬 Desktop 설정에 써요. static·hybrid 모드는 모델 목록도 복사하고, +discovery-only 모드는 목록을 넣지 않고 허브 origin을 사용해요. + +Desktop 프로필과 모델 계열 배치·기본값은 허브에서 관리해요. 허브에서 바꾼 뒤 연결된 +클라이언트에서 다시 적용하고 Desktop에서 모델을 다시 선택하세요. 과거에 클라이언트에서만 +만든 별칭은 자동 이전되지 않으므로 재적용·재선택이 필요해요. 로컬 `show`, 프로필 편집, +import/export는 로컬 설정만 다뤄요. 허브 프로필을 바꾸지 않아요. 연결 중에는 +`ocx claude desktop import --apply`를 지원하지 않으며 저장 전에 거절해요. +`--apply` 없는 import는 로컬 작업으로 남아요. + +스냅샷은 기존 연결의 데이터 자격 증명으로 읽어요. 관리자 토큰이나 프로필 업로드는 +필요하지 않아요. 구형 허브가 스냅샷을 지원하지 않거나 응답이 잘못됐거나 Desktop 모델이 +없으면 적용에 실패해요. 로컬 목록이나 루프백 주소로 대신 적용하지 않아요. +허브를 업데이트하거나 설정을 확인한 뒤 다시 적용하세요. + +이번 별칭 변경에는 [#3646](https://github.com/lidge-jun/opencodex/issues/3646)의 별도 `thinking` / `redacted_thinking` 재전송과 프롬프트 캐시 +요청은 포함되지 않아요. 프록시 접속 자격 증명만으로 네이티브 Anthropic 패스스루가 켜지지는 +않지만, 번역된 Anthropic 요청도 프롬프트 캐시를 쓸 수 있어요. 재전송 보존과 캐시 적중률 +비교는 별도 작업으로 남아요. + ## /model 선택기("From gateway") 각 항목은 `gemini-3-pro (gemini)` 같은 정직한 표시 이름과 함께, 공식 ModelInfo 형태의 모델 능력 정보(추론 강도 사다리, thinking 타입)를 실어 보냅니다 — Claude Desktop의 서드파티 @@ -159,6 +183,8 @@ v2 별칭은 이스케이프를 펼쳐요. 읽기 쉬운 형식으로 표현할 **모델 해석 순서:** `[1m]` 표식 제거 → 읽기 쉬운 별칭 디코딩 → Desktop 해시 별칭 디코딩 → `modelMap` 정확히 일치 → 날짜를 제거한 값과 일치(`-20250514` 제거) → 패스스루 순서예요. +해결되지 않은 관리 대상 Desktop 날짜·해시 별칭은 Messages와 count-tokens 모두에서 날짜 제거·폴백 전에 HTTP 400으로 거절해요. 운영자가 정확히 지정한 `modelMap` 항목과 인식된 실제 Anthropic 모델 ID는 기존 방식대로 처리해요. + 각 항목에는 `gemini-3-pro (gemini)` 같은 표시 이름과 공식 `ModelInfo` 형식의 전체 모델 기능 (reasoning-effort 단계, thinking 유형)이 들어 있어요. 실제 Anthropic 모델은 두 화면 모두에서 정식 ID를 유지해요. @@ -256,6 +282,8 @@ Anthropic 패스스루는 그대로 유지해요. 조회 순서: 검색 별칭 → 정확한 ID → 날짜 접미사를 제거한 ID(`-20250514`) → 패스스루 순서예요. +해결되지 않은 관리 대상 Desktop 날짜·해시 별칭은 Messages와 count-tokens 모두에서 날짜 제거·폴백 전에 HTTP 400으로 거절해요. 운영자가 정확히 지정한 `modelMap` 항목과 인식된 실제 Anthropic 모델 ID는 기존 방식대로 처리해요. + ## 사이드카 매트릭스: 웹 검색과 이미지 이해 라우팅 모델마다 쓸 수 있는 호스팅 도구와 이미지 지원 범위가 달라요. opencodex는 메인 모델이 diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index a3b82e4f63..12ef3fc817 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -27,7 +27,7 @@ Responses 표현이 이 연결의 중심입니다. 네이티브 호환 경로는 | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | `[DONE]`으로 끝나는 `chat.completion.chunk` SSE | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Anthropic token count | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | 해당 없음 | -| 모델 탐색 | `GET /v1/models` | 세 가지 카탈로그 계약 중 하나 | 해당 없음 | +| 모델 탐색 | `GET /v1/models` | 카탈로그 또는 명시적 Desktop 스냅샷 | 해당 없음 | | Voice and Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | 릴레이된 call-creation 응답 | 별도의 sideband WebSocket이 양방향 프레임을 릴레이함 | | Responses compaction | `POST /v1/responses/compact` | 대체 히스토리 JSON | 해당 없음 | @@ -199,8 +199,7 @@ Responses로 변환되어 일반적으로 라우팅된 뒤, Anthropic JSON 또 ## `GET /v1/models` -같은 경로가 서로 호환되지 않는 카탈로그 envelope를 기대하는 세 가지 클라이언트를 모두 처리합니다. -`client_version`이 함께 있지 않으면 Anthropic 형식이 우선합니다. +`format=desktop-config`를 지정하지 않으면 다음 기본 카탈로그 계약을 사용합니다. | 계약 | 트리거 | 최상위 형식 | 모델 id 동작 | | --- | --- | --- | --- | @@ -208,6 +207,20 @@ Responses로 변환되어 일반적으로 라우팅된 뒤, Anthropic JSON 또 | Codex 카탈로그 | `client_version` 쿼리 파라미터 | `{ "models": [...] }` | 네이티브 및 라우팅 항목은 더 풍부한 Codex 카탈로그 필드, 표시 여부, effort, WebSocket, 다중 에이전트 메타데이터를 담음 | | 일반 OpenAI list | 어느 트리거도 아님 | `{ "object": "list", "data": [...] }` | 보이는 네이티브 id는 그대로이며, 라우팅 id는 alias 또는 `provider/model` | +### Desktop 설정 스냅샷 + +`GET /v1/models?ids=desktop&format=desktop-config`는 user-agent와 관계없이 Desktop +스냅샷을 선택합니다. 응답은 `{ "version": 1, "models": [...] }`이며 +`Cache-Control: no-store`를 포함합니다. 연결된 클라이언트는 `Accept: application/json`, +`anthropic-version: 2023-06-01`과 기존 데이터 자격 증명을 보냅니다. 관리자 토큰이나 프로필 +업로드는 필요하지 않습니다. 항목은 Codex 카탈로그 행이 아니라 허브가 발급한 Desktop 설정용 모델입니다. + +이 형식에 `ids=cli` 또는 `client_version`을 함께 보내면 HTTP 400을 반환합니다. 형식 선택자가 +없으면 위의 기본 응답 계약을 유지합니다. Claude가 꺼져 있으면 +`{ "version": 1, "models": [] }`를 반환하며, 연결된 Desktop apply는 사용 불가로 처리하고 +대체 프로필을 쓰지 않습니다. 버전 1 대신 일반 카탈로그를 반환하는 구형 허브는 지원하지 않으며, +클라이언트가 로컬에서 만든 ID로 대신 적용하지 않습니다. + ## `POST /v1/live`와 Realtime sideband `POST /v1/live`는 ChatGPT/Codex App Frameless call-creation 표면을 받습니다. diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 59e4e5f10e..ba17102d34 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -28,7 +28,7 @@ should select among several targets. | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | `chat.completion.chunk` SSE ending in `[DONE]` | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Anthropic token count | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | Not applicable | -| Model discovery | `GET /v1/models` | One of three catalog contracts | Not applicable | +| Model discovery | `GET /v1/models` | Catalog or explicit Desktop snapshot | Not applicable | | Voice and Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | Relayed call-creation response | A separate sideband WebSocket relays frames in both directions | | Responses compaction | `POST /v1/responses/compact` | Replacement-history JSON | Not applicable | @@ -304,8 +304,7 @@ documented estimate over system content, messages, and tools and return: ## `GET /v1/models` -The same route serves three clients that expect incompatible catalog envelopes. Anthropic flavor -wins unless `client_version` is also present. +Without `format=desktop-config`, the ordinary catalog contracts are: | Contract | Trigger | Top-level shape | Model-id behavior | | --- | --- | --- | --- | @@ -313,6 +312,20 @@ wins unless `client_version` is also present. | Codex catalog | `client_version` query parameter | `{ "models": [...] }` | Native and routed entries carry the richer Codex catalog fields, visibility, effort, WebSocket, and multi-agent metadata | | Plain OpenAI list | Neither trigger | `{ "object": "list", "data": [...] }` | Visible native ids are bare; routed ids are aliases or `provider/model` | +### Desktop configuration snapshot + +`GET /v1/models?ids=desktop&format=desktop-config` explicitly selects the Desktop snapshot, +independently of user-agent detection. The response is `{ "version": 1, "models": [...] }` +with `Cache-Control: no-store`. The connected client sends `Accept: application/json`, +`anthropic-version: 2023-06-01` and its existing data credential; no admin token or profile +upload is involved. Entries are the hub-issued Desktop configuration models, not Codex catalog rows. + +Combining this format with `ids=cli` or any `client_version` returns HTTP 400. Without the +format selector, the ordinary contracts above remain unchanged. When Claude is disabled, +the snapshot is `{ "version": 1, "models": [] }`; connected Desktop apply treats this as +unavailable and does not write a replacement profile. Old hubs returning an ordinary catalog +instead of version 1 are unsupported; the client does not fall back to locally generated IDs. + ## `POST /v1/live` and Realtime sideband `POST /v1/live` accepts the ChatGPT/Codex App Frameless call-creation surface. diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 3f4285cfe6..546af2d4fa 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -101,6 +101,29 @@ Proxy admission secret в любом provider-заголовке удаляет Отключается параметром `claudeCode.nativePassthrough: false`; другой адрес задаётся через `claudeCode.anthropicBaseUrl`. +## Claude Desktop через удалённый хаб + +На подключённой машине `ocx claude desktop apply` или `ocx claude desktop` получает снимок +Desktop с хаба и записывает его origin и точные выданные им ID моделей в локальную конфигурацию. +Локальные псевдонимы заново не создаются. Режимы static/hybrid копируют список моделей; +discovery-only использует origin хаба без встроенного списка. + +Профиль, распределение по семействам и значения по умолчанию принадлежат хабу. Измените их +на хабе, повторите применение на клиенте и заново выберите модель в Desktop. Это нужно и для +старых псевдонимов, созданных только на клиенте. `show`, локальное редактирование и import/export +остаются локальными операциями. При подключении `ocx claude desktop import --apply` +не поддерживается и отклоняется до сохранения; import без `--apply` остаётся локальным. + +Снимок читается с учётными данными существующего подключения для доступа к данным, без +администраторского токена и загрузки профиля на хаб. Старый несовместимый хаб, некорректный ответ +или пустой список Desktop приводят к отказу применения, без подстановки локального каталога или +loopback-адреса. Обновите или настройте хаб и повторите применение. + +Это изменение псевдонимов не исправляет отдельный запрос [#3646](https://github.com/lidge-jun/opencodex/issues/3646) о повторной передаче +`thinking` / `redacted_thinking` и кеше промптов. Сам по себе доступ к прокси не включает нативный +проброс Anthropic, но преобразованные маршруты Anthropic могут использовать кеш. Сохранение +блоков при повторной передаче и сравнение попаданий в кеш остаются отдельной работой. + ## Селектор /model («From gateway») Claude Code 2.1.129+ обнаруживает модели шлюза через `GET /v1/models?limit=1000` и показывает их @@ -136,6 +159,8 @@ user-agent `claude-code/*` получает читаемую CLI-форму, а декодирование Desktop-хеша → точное совпадение в `modelMap` → совпадение без даты (удаляется `-20250514`) → проброс. +Неразрешённые управляемые псевдонимы Desktop с датой/хешем отклоняются с HTTP 400 до удаления даты или fallback, как для Messages, так и для count-tokens. Точные записи оператора в `modelMap` и распознанные реальные ID Anthropic обрабатываются как обычно. + Каждая запись содержит отображаемое имя вида `gemini-3-pro (gemini)` и полные возможности модели (шкала уровней рассуждений, типы thinking) в официальном формате `ModelInfo`. Настоящие модели Anthropic сохраняют канонические id в обоих интерфейсах. @@ -237,6 +262,8 @@ Anthropic и автоматически срабатывает при упоми Порядок поиска: алиас обнаружения → точный id → id без датировочного суффикса (`-20250514`) → проброс. +Неразрешённые управляемые псевдонимы Desktop с датой/хешем отклоняются с HTTP 400 до удаления даты или fallback, как для Messages, так и для count-tokens. Точные записи оператора в `modelMap` и распознанные реальные ID Anthropic обрабатываются как обычно. + ## Матрица сайдкаров: веб-поиск и понимание изображений Не у всех маршрутизируемых моделей одинаковый набор серверных (hosted) инструментов и поддержка diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index 573c5ff32c..95f4fe4bc7 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -29,7 +29,7 @@ control и safety ответа всё равно происходят на гр | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | `chat.completion.chunk` SSE, заканчивающийся `[DONE]` | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Подсчёт токенов Anthropic | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | Не применяется | -| Обнаружение моделей | `GET /v1/models` | Один из трёх контрактов каталога | Не применяется | +| Обнаружение моделей | `GET /v1/models` | Каталог или явно запрошенный снимок Desktop | Не применяется | | Голос и Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | Ответ создания вызова после ретрансляции | Отдельный sideband WebSocket ретранслирует frame'ы в обе стороны | | Компактизация Responses | `POST /v1/responses/compact` | JSON истории-замены | Не применяется | @@ -207,8 +207,7 @@ passthrough. Native-eligible-запрос пересылается в count-endp ## `GET /v1/models` -Один и тот же маршрут обслуживает три клиента, ожидающих несовместимые envelope'ы каталога. -Форма Anthropic имеет приоритет, если только одновременно не присутствует `client_version`. +Без `format=desktop-config` действуют следующие обычные контракты каталога: | Контракт | Триггер | Форма верхнего уровня | Поведение id модели | | --- | --- | --- | --- | @@ -216,6 +215,20 @@ passthrough. Native-eligible-запрос пересылается в count-endp | Каталог Codex | Query-параметр `client_version` | `{ "models": [...] }` | Нативные и маршрутизируемые записи несут более богатые поля каталога Codex: visibility, effort, WebSocket и multi-agent metadata | | Обычный список OpenAI | Ни один триггер не сработал | `{ "object": "list", "data": [...] }` | Видимые native-id идут без префикса; routed-id — как alias или `provider/model` | +### Снимок конфигурации Desktop + +`GET /v1/models?ids=desktop&format=desktop-config` явно выбирает снимок Desktop независимо +от user-agent. Ответ — `{ "version": 1, "models": [...] }` с `Cache-Control: no-store`. +Клиент отправляет `Accept: application/json`, `anthropic-version: 2023-06-01` и существующие +учётные данные для доступа к данным; администраторский токен и загрузка профиля не нужны. +Элементы — модели конфигурации Desktop, выданные хабом, а не строки каталога Codex. + +Этот формат вместе с `ids=cli` или любым `client_version` возвращает HTTP 400. Без выбора +формата обычные контракты выше не меняются. При выключенном Claude ответ имеет вид +`{ "version": 1, "models": [] }`: подключённый Desktop apply считает модели недоступными и +не записывает заменяющий профиль. Старые хабы с обычным каталогом вместо версии 1 не +поддерживаются; перехода к локально созданным ID нет. + ## `POST /v1/live` и Realtime sideband `POST /v1/live` принимает surface Frameless call-creation из ChatGPT/Codex App. diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 2b092dc621..88ed62f6e4 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -161,6 +161,8 @@ ailedeki ilk kullanılabilir rota kullanılır. Aynı profili komut satırından da yönetebilirsiniz: +Aşağıdaki profil düzenleme yönergeleri yerel profil içindir. Bağlı uzak hub üzerinden uygulama aşağıda ayrıca açıklanır. + ```bash ocx claude desktop [apply] ocx claude desktop show [--json] @@ -247,6 +249,29 @@ ile görünmediği anlamına gelir. `claudeCode.nativePassthrough: false` ile devre dışı bırakın; `claudeCode.anthropicBaseUrl` ile başka bir yeri işaret edin. +## Uzak hub'a bağlı Claude Desktop + +Bağlı makinede `ocx claude desktop apply` veya `ocx claude desktop`, hub'ın Desktop anlık +görüntüsünü alır ve hub origin'ini ve verdiği model kimliklerini yerel Desktop yapılandırmasına +aynen yazar. Yerel takma ad üretmez. static/hybrid model listesini de kopyalar; +discovery-only listeyi gömmeden hub origin'ini kullanır. + +Profil, aile atamaları ve varsayılanlar hub'da yönetilir. Hub'da değiştirin, istemcide yeniden +uygulayın ve Desktop'ta modeli yeniden seçin. Yalnızca istemcide oluşturulmuş eski takma adlar +için de yeniden uygulama/seçim gerekir. `show`, yerel düzenleme ve import/export yerel kalır. +Bağlıyken `ocx claude desktop import --apply` desteklenmez ve kaydetmeden reddedilir; +`--apply` olmadan import yerel bir işlemdir. + +Okuma, mevcut bağlantının veri erişim kimlik bilgilerini kullanır; yönetici belirteci veya profil +yüklemesi gerekmez. Eski hub desteği yoksa, yanıt geçersizse veya Desktop listesi boşsa uygulama +başarısız olur; yerel katalog ya da loopback adresi kullanılmaz. Hub'ı güncelleyin veya +yapılandırın, ardından yeniden uygulayın. + +Bu takma ad değişikliği, [#3646](https://github.com/lidge-jun/opencodex/issues/3646)'daki ayrı `thinking` / `redacted_thinking` yeniden gönderim ve +istem önbelleği talebini çözmez. Proxy erişimi tek başına yerel Anthropic geçişini etkinleştirmez; +çevrilen Anthropic rotaları yine de önbellek kullanabilir. Yeniden gönderim doğruluğu ve önbellek +isabetlerinin karşılaştırılması ayrı iş olarak kalır. + ## /model seçici ("From gateway") Claude Code 2.1.129+, `GET /v1/models?limit=1000` aracılığıyla ağ geçidi @@ -303,6 +328,8 @@ slug'lar karma forma geri döner. çözülür → Desktop karma takma adı çözülür → `modelMap` tam eşleşmesi → tarih kaldırılmış eşleşme (`-20250514` kaldırılır) → doğrudan geçiş. +Çözümlenemeyen yönetilen Desktop tarih/hash takma adları, hem Messages hem count-tokens için tarih kaldırma veya yedek rotadan önce HTTP 400 ile reddedilir. Tam `modelMap` girdileri ve tanınan gerçek Anthropic model kimlikleri olağan şekilde işlenir. + Her girdi, `gemini-3-pro (gemini)` gibi bir görünen adın yanı sıra resmi `ModelInfo` biçiminde tam model yeteneklerini (akıl yürütme çabası merdiveni, düşünme türleri) taşır. Gerçek Anthropic modelleri her iki yüzeyde de kurallı @@ -422,6 +449,8 @@ yeniden yazar: Arama sırası: keşif takma adı → tam kimlik → tarih soneki kaldırılmış kimlik (`-20250514` kaldırılır) → doğrudan geçiş. +Çözümlenemeyen yönetilen Desktop tarih/hash takma adları, hem Messages hem count-tokens için tarih kaldırma veya yedek rotadan önce HTTP 400 ile reddedilir. Tam `modelMap` girdileri ve tanınan gerçek Anthropic model kimlikleri olağan şekilde işlenir. + ## Sidecar matrisi: web araması ve görsel anlama Yönlendirilen modellerin tümü aynı barındırılan araçlara veya görsel desteğine diff --git a/docs-site/src/content/docs/tr/reference/proxy-formats.md b/docs-site/src/content/docs/tr/reference/proxy-formats.md index 13d921719e..5453b9b6e9 100644 --- a/docs-site/src/content/docs/tr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/tr/reference/proxy-formats.md @@ -31,7 +31,7 @@ genel model kimliği birkaç hedef arasından seçim yapması gerektiğinde | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | `[DONE]` ile biten `chat.completion.chunk` SSE | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Anthropic belirteç sayısı | `POST /v1/messages/count_tokens` | `{ "input_tokens": sayi }` | Geçerli değil | -| Model keşfi | `GET /v1/models` | Üç katalog sözleşmesinden biri | Geçerli değil | +| Model keşfi | `GET /v1/models` | Katalog veya açıkça istenen Desktop anlık görüntüsü | Geçerli değil | | Ses ve Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | İletilen çağrı oluşturma yanıtı | Ayrı bir yan bant WebSocket her iki yönde de çerçeveleri iletir | | Responses sıkıştırması | `POST /v1/responses/compact` | Değiştirme geçmişi JSON'ı | Geçerli değil | @@ -235,8 +235,7 @@ yerel belgelenmiş tahmini kullanır ve şunu döndürür: ## `GET /v1/models` -Aynı rota uyumsuz katalog zarfları bekleyen üç istemciye hizmet verir. -`client_version` da mevcut olmadıkça Anthropic türü kazanır. +`format=desktop-config` belirtilmezse aşağıdaki olağan katalog sözleşmeleri kullanılır: | Sözleşme | Tetikleyici | Üst düzey şekil | Model kimliği davranışı | | --- | --- | --- | --- | @@ -244,6 +243,20 @@ Aynı rota uyumsuz katalog zarfları bekleyen üç istemciye hizmet verir. | Codex kataloğu | `client_version` sorgu parametresi | `{ "models": [...] }` | Yerel ve yönlendirilen girdiler daha zengin Codex katalog alanlarını, görünürlüğü, çabayı, WebSocket ve çoklu ajan meta verilerini taşır | | Düz OpenAI listesi | Hiçbir tetikleyici yok | `{ "object": "list", "data": [...] }` | Görünür yerel kimlikler yalındır; yönlendirilen kimlikler takma adlar veya `sağlayıcı/model`'dir | +### Desktop yapılandırma anlık görüntüsü + +`GET /v1/models?ids=desktop&format=desktop-config`, user-agent'tan bağımsız olarak Desktop +anlık görüntüsünü seçer. Yanıt `{ "version": 1, "models": [...] }` ve `Cache-Control: no-store` +başlığıdır. İstemci `Accept: application/json`, `anthropic-version: 2023-06-01` ve mevcut veri +erişim kimlik bilgilerini gönderir; yönetici belirteci veya profil yüklemesi gerekmez. +Girdiler Codex katalog satırları değil, hub'ın verdiği Desktop yapılandırma modelleridir. + +Bu biçim `ids=cli` veya herhangi bir `client_version` ile kullanılırsa HTTP 400 döner. Biçim +seçicisi yoksa yukarıdaki olağan sözleşmeler değişmez. Claude kapalıysa +`{ "version": 1, "models": [] }` döner; bağlı Desktop apply bunu kullanılamaz sayar ve yeni +profil yazmaz. Sürüm 1 yerine olağan katalog döndüren eski hub'lar desteklenmez; yerel üretilmiş +kimliklere geçilmez. + ## `POST /v1/live` ve Realtime yan bandı `POST /v1/live`, ChatGPT/Codex App Frameless çağrı oluşturma yüzeyini kabul diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index e2db5ddec4..5f5274c74f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -88,6 +88,25 @@ Anthropic。若任一提供方请求头包含代理准入密钥,该密钥会 可以设置 `claudeCode.nativePassthrough: false` 来禁用;也可以通过 `claudeCode.anthropicBaseUrl` 指向其他位置。 +## 连接远程 hub 的 Claude Desktop + +已连接的机器运行 `ocx claude desktop apply` 或 `ocx claude desktop` 时,会读取 hub 的 +Desktop 快照,将 hub origin 和 hub 发放的完整模型 ID 原样写入本机 Desktop 配置,不再本地 +生成别名。static/hybrid 模式也复制模型列表;discovery-only 模式使用 hub origin,不嵌入列表。 + +Desktop 配置、模型家族分组及默认值由 hub 管理。在 hub 上修改后,请在客户端重新应用, +并在 Desktop 中重新选择模型。以前只在客户端生成的别名也需要重新应用、重新选择,不会自动 +迁移。`show`、本地编辑和 import/export 仍只操作本地配置。连接期间不支持 +`ocx claude desktop import --apply`,会在保存前拒绝;不带 `--apply` 的 import 仍是本地操作。 + +读取使用现有连接的数据访问凭证,不需要管理员令牌,也不上传配置。旧版 hub 不支持快照、 +响应无效或 Desktop 列表为空时,应用会失败,不会改用本地目录或回环地址。 +请更新或配置 hub 后重新应用。 + +本次别名修改不解决 [#3646](https://github.com/lidge-jun/opencodex/issues/3646) 中独立的 `thinking` / `redacted_thinking` 重放与提示缓存请求。 +只有代理接入凭证不会启用原生 Anthropic 透传,但经过转换的 Anthropic 路由仍可使用提示缓存。 +重放保真和缓存命中率对比仍是独立工作。 + ## /model 选择器(“From gateway”) 每个条目带有诚实的显示名(如 `gemini-3-pro (gemini)`),并以官方 ModelInfo 形态附带模型能力 信息(推理强度梯度、thinking 类型),使 Claude Desktop 的第三方网关模式能够启用推理强度选择 @@ -126,6 +145,8 @@ v1 别名按字面解码(历史上 model ID 中包含的两字符序列 `~s` / **模型解析顺序:**移除 `[1m]` 标记 → 解码易读别名 → 解码 Desktop 哈希别名 → `modelMap` 精确匹配 → 移除日期后的匹配(移除 `-20250514`)→ 透传。 +未解析的受管理 Desktop 日期/哈希别名会在去除日期或回退之前被拒绝,Messages 和 count-tokens 均返回 HTTP 400。操作员的精确 `modelMap` 条目及已识别的真实 Anthropic 模型 ID 保持正常处理。 + 每个条目都带有类似 `gemini-3-pro (gemini)` 的显示名称,以及官方 `ModelInfo` 结构中的完整 模型能力(推理强度阶梯、思考类型)。真正的 Anthropic 模型在两个界面上都保留其规范 ID。 @@ -217,6 +238,8 @@ opencodex 会在**已路由**请求中将该技能内容替换为一个短占位 查找顺序:发现别名 → 精确 ID → 移除日期后缀的 ID(`-20250514`)→ 透传。 +未解析的受管理 Desktop 日期/哈希别名会在去除日期或回退之前被拒绝,Messages 和 count-tokens 均返回 HTTP 400。操作员的精确 `modelMap` 条目及已识别的真实 Anthropic 模型 ID 保持正常处理。 + ## Sidecar 矩阵:Web Search 与图像理解 不同路由模型拥有的托管工具和图像能力并不相同。opencodex 会在主模型回答前补齐这些能力: diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index d087650071..883e925eff 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -27,7 +27,7 @@ Responses 表示是这座桥的中心。原生兼容的路由可以跳过部分 | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | 以 `chat.completion.chunk` SSE 结尾并带 `[DONE]` | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Anthropic token count | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | 不适用 | -| 模型发现 | `GET /v1/models` | 三种目录契约之一 | 不适用 | +| 模型发现 | `GET /v1/models` | 目录或显式 Desktop 快照 | 不适用 | | 语音和 Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | 转发的调用创建响应 | 独立的 sideband WebSocket 双向转发帧 | | Responses compaction | `POST /v1/responses/compact` | 替换历史 JSON | 不适用 | @@ -170,7 +170,7 @@ choice 增量、带 `finish_reason` 的终止 choice,以及 `data: [DONE]`。 ## `GET /v1/models` -同一路由要服务三种期望不兼容目录封装的客户端。除非同时存在 `client_version`,否则 Anthropic 形态优先。 +未指定 `format=desktop-config` 时,使用以下普通目录契约: | 契约 | 触发条件 | 顶层形态 | 模型 ID 行为 | | --- | --- | --- | --- | @@ -178,6 +178,18 @@ choice 增量、带 `finish_reason` 的终止 choice,以及 `data: [DONE]`。 | Codex catalog | `client_version` 查询参数 | `{ "models": [...] }` | 原生和路由条目携带更丰富的 Codex catalog 字段、可见性、effort、WebSocket 和 multi-agent 元数据 | | Plain OpenAI list | 两个触发条件都没有 | `{ "object": "list", "data": [...] }` | 可见的原生 ID 是裸值;路由 ID 是别名或 `provider/model` | +### Desktop 配置快照 + +`GET /v1/models?ids=desktop&format=desktop-config` 显式选择 Desktop 快照,不依赖 +user-agent。响应为 `{ "version": 1, "models": [...] }`,带有 `Cache-Control: no-store`。 +客户端发送 `Accept: application/json`、`anthropic-version: 2023-06-01` 及现有数据访问凭证; +不需要管理员令牌,也不上传配置。条目是 hub 发放的 Desktop 配置模型,不是 Codex 目录行。 + +此格式与 `ids=cli` 或任意 `client_version` 一起使用时返回 HTTP 400。不指定格式时,上述普通 +契约保持不变。Claude 关闭时返回 `{ "version": 1, "models": [] }`;已连接的 Desktop apply +会视为不可用,不写入替代配置。返回普通目录而非版本 1 的旧 hub 不受支持,客户端不会回退到 +本地生成的 ID。 + ## `POST /v1/live` 和 Realtime sideband `POST /v1/live` 接受 ChatGPT/Codex App 的 Frameless call-creation 表面。 diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index f07b2f97c6..91300ad4d1 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -112,6 +112,8 @@ Claude Desktop 使用與 Claude Code 分開的設定檔。在儀表板開啟 **C 你也可以用命令列管理同一份設定檔: +以下設定檔編輯說明適用於本機設定檔;連接遠端 hub 時的套用方式另見下節。 + ```bash ocx claude desktop [apply] ocx claude desktop show [--json] @@ -168,6 +170,25 @@ Anthropic。若任一供應商標頭含有代理許可密鑰,該密鑰會被 可以設定 `claudeCode.nativePassthrough: false` 來停用;也可以透過 `claudeCode.anthropicBaseUrl` 指向其他位置。 +## 連接遠端 hub 的 Claude Desktop + +已連接的機器執行 `ocx claude desktop apply` 或 `ocx claude desktop` 時,會讀取 hub 的 +Desktop 快照,將 hub origin 和 hub 發出的完整模型 ID 原樣寫入本機 Desktop 設定,不再於本機 +產生別名。static/hybrid 模式也複製模型清單;discovery-only 模式使用 hub origin,不嵌入清單。 + +Desktop 設定檔、模型家族分組及預設值由 hub 管理。在 hub 上修改後,請在客戶端重新套用, +並在 Desktop 中重新選擇模型。以前只在客戶端產生的別名也需要重新套用、重新選擇,不會自動 +移轉。`show`、本機編輯及 import/export 仍只操作本機設定。連接期間不支援 +`ocx claude desktop import --apply`,會在儲存前拒絕;不帶 `--apply` 的 import 仍是本機操作。 + +讀取使用現有連線的資料存取憑證,不需要管理員權杖,也不會上傳設定檔。舊版 hub 不支援快照、 +回應無效或 Desktop 清單為空時,套用會失敗,不會改用本機目錄或回環位址。 +請更新或設定 hub 後重新套用。 + +本次別名修改不解決 [#3646](https://github.com/lidge-jun/opencodex/issues/3646) 中獨立的 `thinking` / `redacted_thinking` 重播與提示快取請求。 +只有代理存取憑證不會啟用原生 Anthropic 透傳,但經過轉換的 Anthropic 路由仍可使用提示快取。 +重播保真與快取命中率比較仍是獨立工作。 + ## /model 選擇器(“From gateway”) Claude Code 2.1.129+ 透過 `GET /v1/models?limit=1000` 發現閘道器模型,並在原生 `/model` @@ -199,6 +220,8 @@ user-agent 會獲得易讀的 CLI 形式,其他用戶端會獲得 Desktop 雜 **模型解析順序:**移除 `[1m]` 標記 → 解碼易讀別名 → 解碼 Desktop 雜湊別名 → `modelMap` 精確匹配 → 移除日期後的匹配(移除 `-20250514`)→ 透傳。 +無法解析的受管理 Desktop 日期/雜湊別名會在移除日期或回退之前遭到拒絕,Messages 和 count-tokens 均回傳 HTTP 400。操作員的精確 `modelMap` 項目及已識別的真實 Anthropic 模型 ID 維持正常處理。 + 每個條目都帶有類似 `gemini-3-pro (gemini)` 的顯示名稱,以及官方 `ModelInfo` 結構中的完整 模型能力(推理強度階梯、思考型別)。真正的 Anthropic 模型在兩個介面上都保留其規範 ID。 @@ -290,6 +313,8 @@ opencodex 會在**已路由**請求中將該技能內容替換為一個短佔位 查詢順序:發現別名 → 精確 ID → 移除日期字尾的 ID(`-20250514`)→ 透傳。 +無法解析的受管理 Desktop 日期/雜湊別名會在移除日期或回退之前遭到拒絕,Messages 和 count-tokens 均回傳 HTTP 400。操作員的精確 `modelMap` 項目及已識別的真實 Anthropic 模型 ID 維持正常處理。 + ## Sidecar 矩陣:Web Search 與圖像理解 不同路由模型擁有的託管工具和圖像能力並不相同。opencodex 會在主模型回答前補齊這些能力: diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index e77a327776..cf60f265c8 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -22,7 +22,7 @@ Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯 | OpenAI Chat Completions | `POST /v1/chat/completions` | `chat.completion` JSON | `chat.completion.chunk` SSE,以 `[DONE]` 結束 | | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Anthropic token 計數 | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | 不適用 | -| 模型探索 | `GET /v1/models` | 三種目錄契約之一 | 不適用 | +| 模型探索 | `GET /v1/models` | 目錄或明確指定的 Desktop 快照 | 不適用 | | 語音與 Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | 中繼的 call-creation 回應 | 一個獨立的 sideband WebSocket 雙向中繼 frame | | Responses compaction | `POST /v1/responses/compact` | 取代歷史 JSON | 不適用 | @@ -152,7 +152,7 @@ Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯 ## `GET /v1/models` -相同路由服務三個期待不相容目錄封裝的客戶端。除非也存在 `client_version`,否則 Anthropic flavor 勝出。 +未指定 `format=desktop-config` 時,使用以下一般目錄契約: | 契約 | 觸發 | 頂層結構 | 模型 id 行為 | | --- | --- | --- | --- | @@ -160,6 +160,18 @@ Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯 | Codex 目錄 | `client_version` query 參數 | `{ "models": [...] }` | 原生與路由項目帶有更豐富的 Codex 目錄欄位、可見性、effort、WebSocket 與多代理中繼資料 | | 普通 OpenAI 清單 | 無觸發 | `{ "object": "list", "data": [...] }` | 可見的原生 id 為裸 id;路由 id 為別名或 `provider/model` | +### Desktop 設定快照 + +`GET /v1/models?ids=desktop&format=desktop-config` 明確選擇 Desktop 快照,不依賴 +user-agent。回應為 `{ "version": 1, "models": [...] }`,帶有 `Cache-Control: no-store`。 +客戶端送出 `Accept: application/json`、`anthropic-version: 2023-06-01` 及現有資料存取憑證; +不需要管理員權杖,也不上傳設定檔。項目是 hub 發出的 Desktop 設定模型,不是 Codex 目錄列。 + +此格式與 `ids=cli` 或任何 `client_version` 一起使用時回傳 HTTP 400。未指定格式時,上述一般 +契約維持不變。Claude 關閉時回傳 `{ "version": 1, "models": [] }`;已連接的 Desktop apply +會視為無法使用,不寫入替代設定。回傳一般目錄而非版本 1 的舊 hub 不受支援,客戶端不會改用 +本機產生的 ID。 + ## `POST /v1/live` 與 Realtime sideband `POST /v1/live` 接受 ChatGPT/Codex App Frameless call-creation 介面。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e816ca4b72..4764861142 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -286,8 +286,10 @@ "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", "claude-desktop-config-path.test.ts": "claude-integration", + "claude-desktop-discovery.test.ts": "claude-integration", "claude-desktop-native-context.test.ts": "claude-integration", "claude-desktop-policy.test.ts": "claude-integration", + "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", diff --git a/src/claude/agents-inject.ts b/src/claude/agents-inject.ts index d3cec47ecb..bcd8c7a044 100644 --- a/src/claude/agents-inject.ts +++ b/src/claude/agents-inject.ts @@ -20,6 +20,7 @@ import { AUTO_CONTEXT_OFF, shouldMarkOneMillion, stripOneMillionMarker, withOneM import { claudeConfigDir } from "./gateway-cache"; import { DEFAULT_SUBAGENT_MODELS, hasOwnProvider } from "../config"; import { effectiveBlockedSkillNames, resolveInboundModel } from "./inbound"; +import { AnthropicRequestError } from "./inbound-records"; import { knownModelIdsForProvider } from "../router"; import { decodeRoutedModelIdOrThrow } from "../providers/slug-codec"; @@ -97,11 +98,17 @@ export function buildClaudeAgentDefs(config: OcxConfig, windows: Record { const unmarked = stripOneMillionMarker(model); - const nativePassthrough = config.claudeCode?.nativePassthrough !== false - && !unmarked.includes("/") - && /^(claude|anthropic)(?:-|$)/i.test(unmarked) - && resolveInboundModel(unmarked, config.claudeCode) === unmarked; - return nativePassthrough ? [] : blockedSkills; + try { + const nativePassthrough = config.claudeCode?.nativePassthrough !== false + && !unmarked.includes("/") + && /^(claude|anthropic)(?:-|$)/i.test(unmarked) + && resolveInboundModel(unmarked, config.claudeCode) === unmarked; + return nativePassthrough ? [] : blockedSkills; + } catch (error) { + // A stale Desktop selector must not break the roster or acquire native exemptions. + if (error instanceof AnthropicRequestError) return blockedSkills; + throw error; + } }; const defs: ClaudeAgentDef[] = []; const usedNames = new Set(); diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index c5e0172ad1..35d1d936b8 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -8,6 +8,7 @@ import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./deskto import { reconcileDesktopProfile, renderDesktopProfile, + validDateAlias, type DesktopProfileModel, } from "./desktop-profile"; import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../codex/catalog"; @@ -144,6 +145,7 @@ export interface Desktop3pRemovalResult { let desktop3pRegistry = new Map(); let desktop3pAliasesByRoute = new Map(); +let desktop3pRealAnthropicIds = new Set(); /** Derive a stable letter-first, three-character base36 code from a route key. */ export function deriveDesktop3pCode(route: string): string { @@ -192,7 +194,7 @@ function collectDesktop3pModels( routedModels: Array, profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, -): { models: Desktop3pModelEntry[]; registry: Map } { +): { models: Desktop3pModelEntry[]; registry: Map; realAnthropicIds: Set } { const registry = new Map(); const models: Desktop3pModelEntry[] = []; const candidates: Desktop3pRoutedModel[] = [ @@ -205,6 +207,9 @@ function collectDesktop3pModels( }), ...routedModels, ]; + const realAnthropicIds = new Set(candidates + .filter(model => model.provider === "anthropic" && model.id.startsWith("claude-")) + .map(model => model.id)); if (profile) { const profileModels = candidates.map(({ provider, id, contextWindow }) => ({ @@ -242,7 +247,7 @@ function collectDesktop3pModels( registry.set(legacy, model.route); } desktop3pAliasesByRoute = aliasesByRoute; - return { models, registry }; + return { models, registry, realAnthropicIds }; } for (const { provider, id, contextWindow } of candidates) { @@ -285,7 +290,7 @@ function collectDesktop3pModels( if (models[0]) models[0].isFamilyDefault = true; desktop3pAliasesByRoute = new Map(candidates.map(({ provider, id }) => [`${provider}/${id}`, desktop3pAlias(provider, id)])); - return { models, registry }; + return { models, registry, realAnthropicIds }; } /** Build and install the registry used to decode Desktop aliases. */ @@ -295,8 +300,9 @@ export function buildDesktop3pRegistry( profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, ): Map { - const { registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); + const { registry, realAnthropicIds } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); desktop3pRegistry = registry; + desktop3pRealAnthropicIds = realAnthropicIds; return registry; } @@ -307,8 +313,9 @@ export function generateDesktop3pModels( profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, ): Desktop3pModelEntry[] { - const { models, registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); + const { models, registry, realAnthropicIds } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); desktop3pRegistry = registry; + desktop3pRealAnthropicIds = realAnthropicIds; return models; } @@ -317,6 +324,12 @@ export function resolveDesktop3pAlias(alias: string): string | null { return desktop3pRegistry.get(alias) ?? null; } +/** Only missing IDs in the emitted Desktop namespaces are managed-alias errors. */ +export function isUnresolvedDesktop3pAlias(id: string): boolean { + if (desktop3pRegistry.has(id) || desktop3pRealAnthropicIds.has(id)) return false; + return validDateAlias(id) || /^claude-opus-4-(?:8-)?[a-z][a-z0-9]{2}$/.test(id); +} + /** Alias selected by the installed profile registry, falling back to the legacy hash shape. */ export function activeDesktop3pAlias(provider: string, modelId: string): string { return desktop3pAliasesByRoute.get(`${provider}/${modelId}`) ?? desktop3pAlias(provider, modelId); @@ -568,6 +581,34 @@ export function writeDesktop3pConfig( mode: Desktop3pConfigMode = "static", profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, +): { written: boolean; path: string; reason?: string; fingerprint?: string } { + return writeDesktop3pConfigWithGenerator(() => ( + generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile, nativeContextCap) + )); +} + +/** Write the hub's exact entries without constructing a client-local alias registry. */ +export function writeRemoteDesktop3pConfig(options: { + baseUrl: string; + apiKey: string; + mode: Desktop3pConfigMode; + models: Desktop3pModelEntry[]; +}): { written: boolean; path: string; reason?: string; fingerprint?: string } { + return writeDesktop3pConfigWithGenerator(() => { + assertDesktop3pModelsValid(options.models); + return { + inferenceProvider: "gateway", + inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: options.baseUrl, + inferenceGatewayApiKey: options.apiKey, + modelDiscoveryEnabled: options.mode !== "static", + ...(options.mode === "discovery" ? {} : { inferenceModels: options.models }), + }; + }); +} + +function writeDesktop3pConfigWithGenerator( + generate: () => object, ): { written: boolean; path: string; reason?: string; fingerprint?: string } { const libraryPath = resolveDesktop3pConfigLibraryPath(); const metadataPath = join(libraryPath, "_meta.json"); @@ -585,7 +626,7 @@ export function writeDesktop3pConfig( ? metadata.entries.map(current => current === existing ? entry : current) : [...metadata.entries, entry]; - const generated = generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile, nativeContextCap); + const generated = generate(); const preserved = readDesktopProfileForeignKeys(configPath); const configJson = JSON.stringify({ ...preserved, ...generated }, null, 2) + "\n"; const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16); diff --git a/src/claude/desktop-discovery-inputs.ts b/src/claude/desktop-discovery-inputs.ts new file mode 100644 index 0000000000..2ab91a32cc --- /dev/null +++ b/src/claude/desktop-discovery-inputs.ts @@ -0,0 +1,44 @@ +import type { OcxConfig } from "../types"; +import { + filterCatalogVisibleModels, + nativeContextLimits, + orderForSubagents, + type CatalogModel, + type NativeContextLimits, +} from "../codex/catalog"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../codex/catalog/native-models"; +import { + availableAccountGatedNativeModels, + type CodexModelEntitlementSnapshot, +} from "../codex/model-entitlements"; +import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; +import { providerCodexAccountMode } from "../providers/registry"; +import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; + +export interface DesktopDiscoveryInputs { + nativeSlugs: string[]; + routedModels: CatalogModel[]; + nativeContextCap: NativeContextLimits; +} + +/** Project captured discovery state without reading caches or installing aliases. */ +export function buildDesktopDiscoveryInputs(options: { + config: OcxConfig; + models: readonly CatalogModel[]; + modelEntitlements: CodexModelEntitlementSnapshot; + desktopNativeCandidates: readonly string[]; +}): DesktopDiscoveryInputs { + const { config, models, modelEntitlements, desktopNativeCandidates } = options; + const eligibleAccountIds = providerCodexAccountMode( + OPENAI_CODEX_PROVIDER_ID, + config.providers[OPENAI_CODEX_PROVIDER_ID], + ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; + const available = availableAccountGatedNativeModels(modelEntitlements, eligibleAccountIds); + return { + nativeSlugs: desktopNativeCandidates.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || available.has(slug) + )), + routedModels: orderForSubagents(filterCatalogVisibleModels([...models], config), config.subagentModels), + nativeContextCap: nativeContextLimits(config), + }; +} diff --git a/src/claude/desktop-profile.ts b/src/claude/desktop-profile.ts index 2bd0edf59a..d9e40ae364 100644 --- a/src/claude/desktop-profile.ts +++ b/src/claude/desktop-profile.ts @@ -83,7 +83,7 @@ function isRealAnthropicRoute(route: string): boolean { return route.startsWith("anthropic/claude-"); } -function validDateAlias(alias: string): boolean { +export function validDateAlias(alias: string): boolean { const match = DATE_ALIAS.exec(alias); if (!match) return false; const year = Number(match[1]!.slice(0, 4)); diff --git a/src/claude/inbound-model-options.ts b/src/claude/inbound-model-options.ts index e6ff9dbce7..b966b4c35f 100644 --- a/src/claude/inbound-model-options.ts +++ b/src/claude/inbound-model-options.ts @@ -2,8 +2,8 @@ import type { OcxClaudeCodeConfig } from "../types"; import { isAnthropicOutputSchema } from "../adapters/anthropic-output-schema"; import { resolveAlias } from "./alias"; import { stripOneMillionMarker } from "./context-windows"; -import { resolveDesktop3pAlias } from "./desktop-3p"; -import { isRec, type Rec } from "./inbound-records"; +import { isUnresolvedDesktop3pAlias, resolveDesktop3pAlias } from "./desktop-3p"; +import { AnthropicRequestError, isRec, type Rec } from "./inbound-records"; function isClaudeClassifierModel(model: string): boolean { const stripped = model.replace(/-\d{8}$/, ""); @@ -54,6 +54,9 @@ export function resolveInboundModel(model: string, cc?: OcxClaudeCodeConfig): st const map = cc?.modelMap ?? {}; const exact = map[model]; if (typeof exact === "string" && exact.length > 0) return exact; + if (isUnresolvedDesktop3pAlias(model)) { + throw new AnthropicRequestError("Unknown Claude Desktop alias; reapply the Desktop profile from the connected hub"); + } const stripped = model.replace(/-\d{8}$/, ""); const dateless = map[stripped]; if (typeof dateless === "string" && dateless.length > 0) return dateless; diff --git a/src/cli/claude-agent-startup-sync.ts b/src/cli/claude-agent-startup-sync.ts index 772a88ddee..71acbb1911 100644 --- a/src/cli/claude-agent-startup-sync.ts +++ b/src/cli/claude-agent-startup-sync.ts @@ -10,7 +10,7 @@ export interface ClaudeAgentStartupSyncDeps { } /** - * Keep the public readiness gate pending until both startup reconciliations have settled. + * Keep readiness pending until the roster and optional Desktop registry have settled. * * The Codex sync remains the authority for ready versus failed. Claude roster repair is * deliberately best-effort (#2200), but readiness must not become observable between the @@ -22,6 +22,7 @@ export async function reconcileClientStartupBeforeReady( readinessGate: ReadinessGate, syncCodex: (deferredGate: ReadinessGate) => Promise, syncClaudeRoster: () => Promise, + syncDesktopRegistry?: () => Promise, ): Promise { let codexReady = false; const deferredGate: ReadinessGate = { @@ -32,6 +33,7 @@ export async function reconcileClientStartupBeforeReady( const result = await syncCodex(deferredGate); await syncClaudeRoster(); + await syncDesktopRegistry?.(); if (codexReady) readinessGate.markReady(); return result; } diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 3d4ad70852..953ca979df 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -1,7 +1,10 @@ import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { loadConfig, saveConfigPreservingClaudeCode } from "../config"; -import { setIntegrationEnabled } from "../codex/desired-state"; +import { claudeDesktopIntegrationEnabledNow, setIntegrationEnabled } from "../codex/desired-state"; +import { readClientConnectionState, type ClientConnectionState } from "../client/state"; +import { downloadDesktop3pModels, HubClientError, normalizeHubOrigin } from "../client/hub-client"; +import { readServiceApiTokenState } from "../lib/service-secrets"; import { DESKTOP_FAMILIES, moveDesktopRoute, @@ -10,7 +13,8 @@ import { type DesktopFamily, type DesktopProfile, } from "../claude/desktop-profile"; -import { writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; +import { writeDesktop3pConfig, writeRemoteDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; +import { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } from "../claude/desktop-policy"; import { filterCatalogVisibleModels, desktopVisibleNativeSlugs, nativeContextLimits } from "../codex/catalog"; import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api"; import { findLiveProxy } from "../server/proxy-liveness"; @@ -33,6 +37,8 @@ function printDesktopHelp(): void { } export interface ApplyProfileDeps { + downloadDesktop3pModelsImpl?: typeof downloadDesktop3pModels; + writeRemoteDesktop3pConfigImpl?: typeof writeRemoteDesktop3pConfig; findLiveProxyImpl?: typeof findLiveProxy; postApplyImpl?: ( mode: Desktop3pConfigMode, @@ -41,11 +47,55 @@ export interface ApplyProfileDeps { probeClaudeDesktopPolicy?: typeof import("../claude/desktop-policy").probeClaudeDesktopPolicy; } +async function applyConnectedDesktopProfile( + mode: Desktop3pConfigMode, + connection: Extract, + deps: ApplyProfileDeps, +): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { + const token = readServiceApiTokenState(); + if (connection.value.pendingOperation) return { ok: false, path: "", reason: "client_rotation_pending" }; + if (token.kind !== "present" || token.fingerprint !== connection.value.tokenFingerprint) { + return { ok: false, path: "", reason: "client_token_mismatch" }; + } + let baseUrl: string; + try { baseUrl = normalizeHubOrigin(connection.value.serverUrl); } + catch { return { ok: false, path: "", reason: "client_connection_invalid" }; } + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return { ok: false, path: "", reason: desired.message }; + let snapshot: Awaited>; + try { + snapshot = await (deps.downloadDesktop3pModelsImpl ?? downloadDesktop3pModels)(baseUrl, token.token); + } catch (error) { + return { ok: false, path: "", reason: error instanceof HubClientError ? error.code : "desktop_download_failed" }; + } + const current = readClientConnectionState(); + const currentToken = readServiceApiTokenState(); + if (current.kind !== "connected" || current.value.pendingOperation + || current.value.serverUrl !== connection.value.serverUrl + || current.value.apiKeyId !== connection.value.apiKeyId + || current.value.tokenFingerprint !== connection.value.tokenFingerprint + || current.value.connectedAt !== connection.value.connectedAt + || currentToken.kind !== "present" || currentToken.fingerprint !== connection.value.tokenFingerprint) { + return { ok: false, path: "", reason: "client_connection_changed" }; + } + if (!claudeDesktopIntegrationEnabledNow()) return { ok: false, path: "", reason: "desired_state_changed" }; + if (snapshot.models.length === 0) return { ok: false, path: "", reason: "desktop_unavailable" }; + const result = (deps.writeRemoteDesktop3pConfigImpl ?? writeRemoteDesktop3pConfig)({ + baseUrl, apiKey: currentToken.token, mode, models: snapshot.models, + }); + const warning = result.written + ? claudeDesktopPolicyWarning((deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)()) : undefined; + return { ok: result.written, path: result.path, reason: result.reason, ...(warning ? { warning } : {}) }; +} + export async function applyProfile( - profile: DesktopProfile, + profile: DesktopProfile | undefined, mode: Desktop3pConfigMode, deps: ApplyProfileDeps = {}, ): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { + const connection = readClientConnectionState(); + if (connection.kind === "connected") return applyConnectedDesktopProfile(mode, connection, deps); + if (connection.kind !== "disconnected") return { ok: false, path: "", reason: "client_connection_invalid" }; // Explicit apply is an enable action. Persist intent before any Desktop write // so a process crash cannot leave a gateway profile that startup immediately removes. const desired = setIntegrationEnabled("claude-desktop", true); @@ -86,7 +136,6 @@ export async function applyProfile( // The toggle can persist OFF while fetchAllModels was awaiting (same race the // management writers fence). Re-read persisted intent immediately before the // writer; a lost race is a discriminated skip, not a write. - const { claudeDesktopIntegrationEnabledNow } = await import("../codex/desired-state"); if (!claudeDesktopIntegrationEnabledNow()) { return { ok: false, path: "", reason: "desired_state_changed" }; } @@ -104,7 +153,6 @@ export async function applyProfile( state.profile, nativeContextLimits(config), ); - const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../claude/desktop-policy"); const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)(); const warning = result.written ? claudeDesktopPolicyWarning(policyState) : undefined; return { @@ -134,12 +182,9 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf const parsedMode = parseDesktop3pModeArgs(legacyFlags); if ("error" in parsedMode) { console.error(parsedMode.error); return 2; } try { - const config = loadConfig(); - const state = await buildClaudeDesktopState(config); - const result = await applyProfile(state.profile, parsedMode.mode, deps); + const result = await applyProfile(undefined, parsedMode.mode, deps); if (!result.ok) { console.error(`설정 적용 실패: ${result.reason ?? "unknown error"}`); - console.error("프로필은 저장되었지만 Claude Desktop 설정 파일에는 반영되지 않았습니다. 프록시 상태를 확인한 뒤 다시 적용해 주세요."); return 1; } console.log(`Claude Desktop 설정을 적용했습니다: ${result.path}`); @@ -155,6 +200,16 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf } try { + const connection = readClientConnectionState(); + if (command === "import" && argv.includes("--apply") && connection.kind !== "disconnected") { + throw new CliUsageError(connection.kind === "connected" + ? "Connected Desktop apply uses the hub profile. Import on the hub, then run ocx claude desktop apply here." + : "Client connection state is invalid; refusing import --apply."); + } + const localView = connection.kind === "connected"; + if (localView && ["show", "export", "move", "default", "import"].includes(command ?? "")) { + console.warn("Local client profile only; connected Desktop apply uses the hub profile."); + } const config = loadConfig(); // `status` is API-backed and must NOT build local state first: the whole point of the // route the GUI polls (/api/claude-desktop/status) is the applied-vs-desired comparison, @@ -178,7 +233,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf const rest = argv.slice(1); const wantsJson = takeJsonFlag(rest); if (rest.length > 0) throw new CliUsageError("Usage: ocx claude desktop show [--json]"); - if (wantsJson) console.log(JSON.stringify(state)); + if (wantsJson) console.log(JSON.stringify(localView ? { ...state, scope: "local" } : state)); else { for (const family of DESKTOP_FAMILIES) { console.log(`${family.toUpperCase()}${state.profile.defaults[family] ? ` (default: ${state.profile.defaults[family]})` : ""}`); @@ -224,6 +279,9 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf if (!source || flags.some(flag => flag !== "--apply")) throw new CliUsageError("Usage: ocx claude desktop import [--apply]"); const profile = parseDesktopProfile(JSON.parse(readFileSync(resolve(source), "utf8"))); const reconciled = (await buildClaudeDesktopState(config, profile)).profile; + if (flags.includes("--apply") && readClientConnectionState().kind !== "disconnected") { + throw new CliUsageError("Client connection changed; refusing import --apply. Connected Desktop apply uses the hub profile."); + } config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconciled }; saveConfigPreservingClaudeCode(config); if (flags.includes("--apply")) { diff --git a/src/cli/index.ts b/src/cli/index.ts index 06478ba3a3..4c4e94e37b 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -459,8 +459,8 @@ async function handleStart(options: { block?: boolean } = {}) { reportShellHookFailure(reconcileShellHook(systemEnv.injected)); await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start // Codex sync owns the ready/failed verdict, but its successful transition is - // deferred until the best-effort Claude roster reconciliation settles. This - // keeps /readyz closed across both startup writes without making an optional + // deferred until the best-effort Claude roster and Desktop registry settle. This + // keeps /readyz closed across startup initialization without making an optional // Claude integration failure prevent the proxy from starting. const startupSync = await reconcileClientStartupBeforeReady( readinessGate, @@ -468,6 +468,26 @@ async function handleStart(options: { block?: boolean } = {}) { () => systemEnv.injected ? Promise.resolve(null) : syncClaudeAgentDefsAtProxyStartup(config, port), + async () => { + try { + const { fetchAllModels } = await import("../server/management-api"); + const { desktopVisibleNativeSlugs } = await import("../codex/catalog"); + const { resolveCodexModelEntitlements } = await import("../codex/model-entitlements"); + const { buildDesktopDiscoveryInputs } = await import("../claude/desktop-discovery-inputs"); + const [models, modelEntitlements] = await Promise.all([ + fetchAllModels(config), + resolveCodexModelEntitlements(config, { clientVersion: null }), + ]); + const inputs = buildDesktopDiscoveryInputs({ + config, models, modelEntitlements, + desktopNativeCandidates: desktopVisibleNativeSlugs(config), + }); + buildDesktop3pRegistry( + inputs.nativeSlugs, inputs.routedModels, + config.claudeCode?.desktopProfile, inputs.nativeContextCap, + ); + } catch { /* best-effort — registry rebuilds on the next /v1/models call */ } + }, ); if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native."); // #1046: one warning per startup, after BOTH writes. The server's cache @@ -482,17 +502,6 @@ async function handleStart(options: { block?: boolean } = {}) { if (!currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) { historyGuardian = startHistoryMigrationGuardian(); } - // Build Desktop 3P alias registry so inbound claude-opus-4-8-{code} aliases (and legacy claude-opus-4-{code}) decode correctly. - try { - const { fetchAllModels } = await import("../server/management-api"); - const { visibleNativeSlugs, filterCatalogVisibleModels } = await import("../codex/catalog"); - const models = filterCatalogVisibleModels(await fetchAllModels(config), config); - buildDesktop3pRegistry( - [...visibleNativeSlugs(config)], - models.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })), - config.claudeCode?.desktopProfile, - ); - } catch { /* best-effort — registry rebuilds on first /v1/models call */ } // Grok Build auto-registration: additive fenced block in ~/.grok/config.toml so an installed // grok CLI can pick opencodex-routed models without manual config. No-op when ~/.grok is // absent or the bind is non-loopback; removed again by stop/eject/uninstall/shutdown. diff --git a/src/client/hub-client.ts b/src/client/hub-client.ts index 6f0a9e7c07..e6e90b3abf 100644 --- a/src/client/hub-client.ts +++ b/src/client/hub-client.ts @@ -1,6 +1,8 @@ import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download"; import { readBoundedResponseBytes } from "../lib/bounded-body"; import { clearableDeadline } from "../lib/abort"; +import type { Desktop3pModelEntry } from "../claude/desktop-3p"; +import { assertDesktop3pModelsValid } from "../claude/desktop-3p-guard"; /** * A pairing grant may cross loopback or authenticated HTTPS, and nothing else. @@ -30,6 +32,8 @@ import { const READY_BODY_LIMIT = 64 * 1024; const MANAGEMENT_BODY_LIMIT = 128 * 1024; const DEFAULT_TIMEOUT_MS = 5_000; +const DESKTOP_SNAPSHOT_MAX_BYTES = 1024 * 1024; +const DESKTOP_SNAPSHOT_MAX_ENTRIES = 2000; export type OneTimeConnectCredential = | { kind: "admin"; value: Uint8Array } @@ -97,6 +101,7 @@ async function fetchBounded( }); headerDeadline?.clear(); if (response.status >= 300 && response.status < 400 && response.status !== 304) { + try { await response.body?.cancel(); } catch { /* best effort */ } throw new HubClientError("redirect_refused", "Hub request redirect was refused", response.status); } return response; @@ -115,6 +120,7 @@ async function boundedText( ): Promise { const declared = Number(response.headers.get("content-length") ?? "0"); if (Number.isFinite(declared) && declared > maxBytes) { + try { await response.body?.cancel(); } catch { /* best effort */ } throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status); } const result = await readBoundedResponseBytes(response, { @@ -465,6 +471,79 @@ export async function downloadClientCatalog( return { kind: "fresh", body, ...(keyId ? { keyId } : {}) }; } +function desktopSnapshotModels(value: unknown): Desktop3pModelEntry[] { + const invalid = () => new HubClientError("desktop_snapshot_invalid", "Hub Desktop model snapshot was invalid"); + if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid(); + const raw = value as Record; + if (raw.version !== 1) { + throw new HubClientError("desktop_snapshot_unsupported", "Hub Desktop model snapshot format is unsupported"); + } + if (!Array.isArray(raw.models) || raw.models.length > DESKTOP_SNAPSHOT_MAX_ENTRIES) throw invalid(); + const models: Desktop3pModelEntry[] = raw.models.map((row: unknown) => { + if (!row || typeof row !== "object" || Array.isArray(row)) throw invalid(); + const entry = row as Record; + const family = entry.anthropicFamilyTier; + if (typeof entry.name !== "string" || typeof entry.labelOverride !== "string" + || (family !== "opus" && family !== "fable" && family !== "sonnet" && family !== "haiku") + || (Object.hasOwn(entry, "isFamilyDefault") && typeof entry.isFamilyDefault !== "boolean") + || (Object.hasOwn(entry, "supports1m") && entry.supports1m !== true) + || (Object.hasOwn(entry, "prefer1m") && entry.prefer1m !== true)) throw invalid(); + return { + name: entry.name, + labelOverride: entry.labelOverride, + anthropicFamilyTier: family, + ...(typeof entry.isFamilyDefault === "boolean" ? { isFamilyDefault: entry.isFamilyDefault } : {}), + ...(entry.supports1m === true ? { supports1m: true as const } : {}), + ...(entry.prefer1m === true ? { prefer1m: true as const } : {}), + }; + }); + try { assertDesktop3pModelsValid(models); } catch { throw invalid(); } + return models; +} + +export async function downloadDesktop3pModels( + serverUrl: string, + admissionToken: string, + options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {}, +): Promise<{ version: 1; models: Desktop3pModelEntry[] }> { + const origin = normalizeHubOrigin(serverUrl); + if (!isPairingTransportPermitted(origin)) { + throw new HubClientError("insecure_http_refused", "Desktop model snapshots require HTTPS or loopback HTTP"); + } + try { + const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/models?ids=desktop&format=desktop-config`, { + method: "GET", + headers: new Headers({ + Accept: "application/json", + "anthropic-version": "2023-06-01", + "x-opencodex-api-key": admissionToken, + }), + }, options.timeoutMs, "headers"); + if (!response.ok || response.status === 304) { + try { await response.body?.cancel(); } catch { /* best effort */ } + throw new HubClientError(`desktop_snapshot_http_${response.status}`, "Hub Desktop model snapshot request failed", response.status); + } + if (!jsonCompatibleContentType(response)) { + try { await response.body?.cancel(); } catch { /* best effort */ } + throw new HubClientError("desktop_snapshot_invalid", "Hub Desktop model snapshot was invalid"); + } + const body = await boundedText(response, DESKTOP_SNAPSHOT_MAX_BYTES, { + inactivityTimeoutMs: safeTimeout(options.timeoutMs), + }); + return { version: 1, models: desktopSnapshotModels(parseJson(body, "desktop_snapshot_invalid")) }; + } catch (error) { + // Existing low-level errors can carry a cause containing remote JSON or fetch details. + // Expose only the fixed category/message, never that cause or a remote field value. + if (error instanceof HubClientError) { + const message = error.code === "desktop_snapshot_invalid" ? "Hub Desktop model snapshot was invalid" + : error.code === "desktop_snapshot_unsupported" ? "Hub Desktop model snapshot format is unsupported" + : "Hub Desktop model snapshot request failed"; + throw new HubClientError(error.code, message, error.status); + } + throw new HubClientError("unreachable", "Hub Desktop model snapshot request did not complete"); + } +} + export async function probeClientKeyId( serverUrl: string, admissionToken: string, diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index bf01cae50e..8d4bdbba21 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -1066,36 +1066,41 @@ export async function handleClaudeCountTokens( if (typeof raw.model !== "string" || raw.model.length === 0) { return anthropicErrorResponse(400, "model is required"); } - let model = raw.model; - // Case-insensitive [1m] strip (audit 021 #7 — the CLI matches /\[1m\]/i). - const stripped = stripOneMillionMarker(model); - if (stripped !== model) { - model = stripped; - raw.model = model; - } - // ocx-route override (devlog 072): keep count_tokens consistent with messages. - const countRoute = extractOcxRouteDirective(raw); - if (countRoute) { - model = stripOneMillionMarker(countRoute); - raw.model = model; - } - // Fast-only: count_tokens never parsed an effort row, so it must not start. It returns a - // token estimate and sends no tier, so only the IDENTITY is corrected - without this the - // synthetic id reaches native passthrough as a model Anthropic has never heard of. - const countFastRow = parseFastOnlyRowId( - config, () => decodeClaudeFastSelector(model, config.claudeCode), - ); - if (countFastRow) { - model = countFastRow.baseId; - raw.model = model; - } - captureClaudeInbound("count_tokens", raw, resolveInboundModel(model, config.claudeCode), req.headers.get("anthropic-beta") ?? undefined); - if (wantsNativePassthrough(req, config, requestPolicy, model)) { - return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens"); + try { + let model = raw.model; + // Case-insensitive [1m] strip (audit 021 #7 — the CLI matches /\[1m\]/i). + const stripped = stripOneMillionMarker(model); + if (stripped !== model) { + model = stripped; + raw.model = model; + } + // ocx-route override (devlog 072): keep count_tokens consistent with messages. + const countRoute = extractOcxRouteDirective(raw); + if (countRoute) { + model = stripOneMillionMarker(countRoute); + raw.model = model; + } + // Fast-only: count_tokens never parsed an effort row, so it must not start. It returns a + // token estimate and sends no tier, so only the IDENTITY is corrected - without this the + // synthetic id reaches native passthrough as a model Anthropic has never heard of. + const countFastRow = parseFastOnlyRowId( + config, () => decodeClaudeFastSelector(model, config.claudeCode), + ); + if (countFastRow) { + model = countFastRow.baseId; + raw.model = model; + } + captureClaudeInbound("count_tokens", raw, resolveInboundModel(model, config.claudeCode), req.headers.get("anthropic-beta") ?? undefined); + if (wantsNativePassthrough(req, config, requestPolicy, model)) { + return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens"); + } + const inputTokens = estimateClaudeRequestTokens(raw, model); + return new Response(JSON.stringify({ input_tokens: inputTokens }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + if (error instanceof AnthropicRequestError) return anthropicErrorResponse(400, error.message); + throw error; } - const inputTokens = estimateClaudeRequestTokens(raw, model); - return new Response(JSON.stringify({ input_tokens: inputTokens }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); } diff --git a/src/server/index.ts b/src/server/index.ts index f2f285e037..b51156abe7 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -183,7 +183,8 @@ export { disableResponsesRequestTimeout, linkAbortSignal } from "./responses"; import { handleClaudeCountTokens, handleClaudeMessages } from "./claude-messages"; import { handleChatCompletions } from "./chat-completions"; import { anthropicErrorResponse } from "../claude/outbound"; -import { buildDesktop3pRegistry } from "../claude/desktop-3p"; +import { buildDesktop3pRegistry, generateDesktop3pModels } from "../claude/desktop-3p"; +import { buildDesktopDiscoveryInputs } from "../claude/desktop-discovery-inputs"; import { runClaudeAuthModeMigration } from "../claude/auth-mode-migration"; import { bindNativeMainStartupLifecycle, @@ -1358,6 +1359,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server [...slugs]), )]; - const desktopNativeSlugs = desktopVisibleNativeSlugs(config).filter(slug => ( - !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug) - )); - const goEnabled = filterCatalogVisibleModels(goModels, config); - const goOrdered = orderForSubagents(goEnabled, config.subagentModels); + const desktopInputs = buildDesktopDiscoveryInputs({ + config, models: goModels, modelEntitlements, + desktopNativeCandidates: desktopVisibleNativeSlugs(config), + }); + const desktopNativeSlugs = desktopInputs.nativeSlugs; + const goOrdered = desktopInputs.routedModels; // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with // Anthropic-style headers; 003 G1-G8 + devlog 131). Entries use the official // ModelInfo shape incl. capabilities (effort ladder / thinking) — Desktop 3P can @@ -1443,7 +1449,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })), + desktopInputs.routedModels, config.claudeCode?.desktopProfile, + desktopInputs.nativeContextCap, ); const { buildAnthropicModelInfos } = await import("../claude/model-info"); const { resolveAutoContext } = await import("../claude/context-windows"); @@ -1503,7 +1519,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server ({ } as OcxConfig); describe("Claude agent roster proxy-start synchronization (#2200)", () => { + test("keeps readiness pending until the fourth registry callback settles", async () => { + const gate = createReadinessGate(); + let releaseRegistry!: () => void; + let enterRegistry!: () => void; + const entered = new Promise(resolve => { enterRegistry = resolve; }); + const pending = new Promise(resolve => { releaseRegistry = resolve; }); + const result = { ran: true }; + const startup = reconcileClientStartupBeforeReady( + gate, + async deferred => { deferred.markReady(); return result; }, + async () => undefined, + async () => { enterRegistry(); await pending; }, + ); + await entered; + expect(gate.getStatus()).toBe("pending"); + releaseRegistry(); + expect(await startup).toBe(result); + expect(gate.getStatus()).toBe("ready"); + }); + + test("registry initialization cannot reverse a failed Codex readiness verdict", async () => { + const gate = createReadinessGate(); + let registryRan = false; + await reconcileClientStartupBeforeReady( + gate, + async deferred => { deferred.markFailed(); return { ran: true }; }, + async () => undefined, + async () => { expect(gate.getStatus()).toBe("failed"); registryRan = true; }, + ); + expect(registryRan).toBe(true); + expect(gate.getStatus()).toBe("failed"); + }); + + test("a best-effort registry callback can handle failure before readiness opens", async () => { + const gate = createReadinessGate(); + let handled = false; + await reconcileClientStartupBeforeReady( + gate, + async deferred => { deferred.markReady(); }, + async () => undefined, + async () => { + try { throw new Error("registry unavailable"); } + catch { handled = true; expect(gate.getStatus()).toBe("pending"); } + }, + ); + expect(handled).toBe(true); + expect(gate.getStatus()).toBe("ready"); + }); + + test("an unhandled registry callback error remains visible and cannot mark ready", async () => { + const gate = createReadinessGate(); + await expect(reconcileClientStartupBeforeReady( + gate, + async deferred => { deferred.markReady(); }, + async () => undefined, + async () => { throw new Error("unexpected registry failure"); }, + )).rejects.toThrow("unexpected registry failure"); + expect(gate.getStatus()).toBe("pending"); + }); + test("keeps readiness pending until the best-effort roster fence settles", async () => { const gate = createReadinessGate(); let releaseRoster!: () => void; diff --git a/tests/claude-integration/claude-agents-inject.test.ts b/tests/claude-integration/claude-agents-inject.test.ts index 9eb36e85b1..4528b25683 100644 --- a/tests/claude-integration/claude-agents-inject.test.ts +++ b/tests/claude-integration/claude-agents-inject.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildClaudeAgentDefs, injectClaudeAgentDefs, syncClaudeAgentDefs } from "../../src/claude/agents-inject"; import { buildClaudeContextWindows } from "../../src/claude/context-windows"; +import { buildDesktop3pRegistry } from "../../src/claude/desktop-3p"; import { fetchProviderModels } from "../../src/codex/catalog/provider-fetch"; import { OAUTH_PROVIDERS } from "../../src/oauth"; import type { OcxConfig } from "../../src/types"; @@ -338,3 +339,35 @@ describe("syncClaudeAgentDefs ownership contract (audit 071 #2/#3)", () => { expect(readdirSync(join(dir, "agents"))).toEqual([]); }); }); + + +test("stale Desktop selectors retain the roster and configured blocked skills", () => { + const model = "claude-opus-4-8-20260702"; + buildDesktop3pRegistry([], []); + try { + const defs = buildClaudeAgentDefs(cfg({ + subagentModels: [model], + claudeCode: { blockedSkills: ["restricted-test-skill"] }, + }), {}, tempDir()); + const stale = defs.find(def => def.model === model); + expect(stale).toBeDefined(); + expect(stale!.blockedSkills).toEqual(["restricted-test-skill"]); + expect(defs.length).toBeGreaterThan(0); + } finally { buildDesktop3pRegistry([], []); } +}); + +test("unexpected resolver errors still propagate from roster construction", () => { + const model = "claude-opus-4-8-20260702"; + const modelMap: Record = {}; + // Fault injection at the resolver's exact-map read; only its expected request + // error may be converted into conservative blocked-skill policy. + Object.defineProperty(modelMap, model, { + get() { throw new Error("injected-resolver-failure"); }, + }); + buildDesktop3pRegistry([], []); + try { + expect(() => buildClaudeAgentDefs(cfg({ + subagentModels: [model], claudeCode: { modelMap, blockedSkills: ["restricted-test-skill"] }, + }), {}, tempDir())).toThrow("injected-resolver-failure"); + } finally { buildDesktop3pRegistry([], []); } +}); diff --git a/tests/claude-integration/claude-desktop-cli.test.ts b/tests/claude-integration/claude-desktop-cli.test.ts index 6c810b53da..a89111e848 100644 --- a/tests/claude-integration/claude-desktop-cli.test.ts +++ b/tests/claude-integration/claude-desktop-cli.test.ts @@ -1,16 +1,24 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { applyProfile, handleClaudeDesktopCommand } from "../../src/cli/claude-desktop"; +import { applyProfile, handleClaudeDesktopCommand, type ApplyProfileDeps } from "../../src/cli/claude-desktop"; +import * as managementApi from "../../src/server/management-api"; import { buildClaudeDesktopState } from "../../src/server/management-api"; -import { loadConfig, saveConfig } from "../../src/config"; +import { getConfigPath, loadConfig, saveConfig } from "../../src/config"; +import { emptyDesktopProfile } from "../../src/claude/desktop-profile"; +import { writeRemoteDesktop3pConfig } from "../../src/claude/desktop-3p"; +import { readClientConnectionState } from "../../src/client/state"; +import { HubClientError } from "../../src/client/hub-client"; +import { claudeDesktopIntegrationEnabledNow, setIntegrationEnabled } from "../../src/codex/desired-state"; +import { serviceApiTokenBackupPath, serviceApiTokenFilePath, writeServiceApiTokenFile } from "../../src/lib/service-secrets"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let dir = ""; let previousHome: string | undefined; let previousDesktopDir: string | undefined; +let restoreLocalBuild: (() => void) | undefined; beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; @@ -28,6 +36,8 @@ beforeEach(() => { }); afterEach(() => { + restoreLocalBuild?.(); + restoreLocalBuild = undefined; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousDesktopDir === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; @@ -35,6 +45,235 @@ afterEach(() => { removeTreeWithRetry(dir); }); +const remoteModels = [{ + name: "claude-opus-4-8-20260203", labelOverride: "Hub-selected model", anthropicFamilyTier: "sonnet" as const, + isFamilyDefault: true, supports1m: true as const, +}]; + +function connectDesktopFixture(blockLocalBuild = true): void { + const { fingerprint } = writeServiceApiTokenFile("ocx_desktop_fixture_token"); + const config = loadConfig(); + config.runtimeRole = "client"; + config.client = { + serverUrl: "https://hub.example.test", managementUrl: "https://hub.example.test", managementTransport: "direct", + selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "desktop-key", + tokenFingerprint: fingerprint, protocolVersion: 1, connectedAt: "2026-09-06T00:00:00.000Z", + }; + saveConfig(config); + expect(readClientConnectionState().kind).toBe("connected"); + if (blockLocalBuild) { + const spy = spyOn(managementApi, "buildClaudeDesktopState").mockImplementation(async () => { + throw new Error("connected apply must not build local Desktop state"); + }); + restoreLocalBuild = () => spy.mockRestore(); + } +} + +function pendingRotation(): NonNullable["pendingOperation"]> { + return { kind: "rotate", rotationId: "rotation-fixture", newKeyIssuedAt: "2026-09-06T01:00:00.000Z", oldKeyBackupPath: serviceApiTokenBackupPath() }; +} + +function oldDesktopFile(): string { + mkdirSync(join(dir, "desktop"), { recursive: true }); + const path = join(dir, "desktop", "existing.json"); + writeFileSync(path, "existing Desktop bytes"); + return path; +} + +test.each([ + ["--static", "static"], ["--hybrid", "hybrid"], ["--discovery-only", "discovery"], +] as const)("connected CLI %s applies exact hub IDs without local reconciliation", async (flag, mode) => { + connectDesktopFixture(); + setIntegrationEnabled("claude-desktop", false); + const log = spyOn(console, "log").mockImplementation(() => {}); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + let writtenPath = ""; + let downloads = 0; + try { + expect(await handleClaudeDesktopCommand(["apply", flag], { + downloadDesktop3pModelsImpl: async (url, token) => { + downloads++; + expect(url).toBe("https://hub.example.test"); + expect(token).toBe("ocx_desktop_fixture_token"); + expect(claudeDesktopIntegrationEnabledNow()).toBe(true); + return { version: 1, models: remoteModels }; + }, + writeRemoteDesktop3pConfigImpl: options => { + expect(options).toEqual({ baseUrl: "https://hub.example.test", apiKey: "ocx_desktop_fixture_token", mode, models: remoteModels }); + const result = writeRemoteDesktop3pConfig(options); + writtenPath = result.path; + return result; + }, + findLiveProxyImpl: async () => { throw new Error("must not look for local proxy"); }, + postApplyImpl: async () => { throw new Error("must not call local management"); }, + probeClaudeDesktopPolicy: () => "absent", + })).toBe(0); + expect(downloads).toBe(1); + const written = JSON.parse(readFileSync(writtenPath, "utf8")); + expect(written.inferenceGatewayBaseUrl).toBe("https://hub.example.test"); + expect(written.inferenceGatewayApiKey).toBe("ocx_desktop_fixture_token"); + expect(written.inferenceModels).toEqual(mode === "discovery" ? undefined : remoteModels); + expect(loadConfig().claudeCode?.desktopProfile).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + } finally { log.mockRestore(); warn.mockRestore(); error.mockRestore(); } +}); + +test.each(["absent", "unsafe", "mismatch", "pending", "invalid", "mismatched"])( + "connected apply rejects %s state before download or writing", async fault => { + connectDesktopFixture(); + const oldPath = oldDesktopFile(); + writeFileSync(serviceApiTokenBackupPath(), "backup must remain"); + const config = loadConfig(); + if (fault === "absent" || fault === "unsafe") unlinkSync(serviceApiTokenFilePath()); + if (fault === "unsafe") mkdirSync(serviceApiTokenFilePath()); + if (fault === "mismatch") writeFileSync(serviceApiTokenFilePath(), "different-token"); + if (fault === "pending") { config.client!.pendingOperation = pendingRotation(); saveConfig(config); } + if (fault === "invalid") writeFileSync(getConfigPath(), "{invalid-config"); + if (fault === "mismatched") writeFileSync(getConfigPath(), JSON.stringify({ ...config, runtimeRole: "hub" })); + let downloads = 0; + let writes = 0; + const result = await applyProfile(emptyDesktopProfile(), "static", { + downloadDesktop3pModelsImpl: async () => { downloads++; return { version: 1, models: remoteModels }; }, + writeRemoteDesktop3pConfigImpl: () => { writes++; return { written: true, path: oldPath }; }, + }); + expect(result.ok).toBe(false); + expect(downloads).toBe(0); + expect(writes).toBe(0); + expect(readFileSync(oldPath, "utf8")).toBe("existing Desktop bytes"); + expect(readFileSync(serviceApiTokenBackupPath(), "utf8")).toBe("backup must remain"); + }, +); + +test.each(["empty", "failed"])("connected CLI handles %s snapshot without claiming a saved local profile", async outcome => { + connectDesktopFixture(); + const oldPath = oldDesktopFile(); + const error = spyOn(console, "error").mockImplementation(() => {}); + let writes = 0; + try { + expect(await handleClaudeDesktopCommand(["apply"], { + downloadDesktop3pModelsImpl: async () => { + if (outcome === "failed") throw new HubClientError("desktop_snapshot_unsupported", "remote-marker"); + return { version: 1, models: [] }; + }, + writeRemoteDesktop3pConfigImpl: () => { writes++; return { written: true, path: oldPath }; }, + })).toBe(1); + expect(writes).toBe(0); + expect(readFileSync(oldPath, "utf8")).toBe("existing Desktop bytes"); + expect(loadConfig().claudeCode?.desktopProfile).toBeUndefined(); + const output = error.mock.calls.flat().join(" "); + expect(output).toContain(outcome === "empty" ? "desktop_unavailable" : "desktop_snapshot_unsupported"); + expect(output).not.toContain("프로필은 저장"); + expect(output).not.toContain("remote-marker"); + expect(output).not.toContain("ocx_desktop_fixture_token"); + } finally { error.mockRestore(); } +}); + +test.each(["off", "server", "key", "fingerprint", "connectedAt", "disconnect", "pending", "token", "invalid"])( + "connected apply fences a %s transition during download", async transition => { + connectDesktopFixture(); + const oldPath = oldDesktopFile(); + writeFileSync(serviceApiTokenBackupPath(), "backup must remain"); + let started!: () => void; + const downloading = new Promise(resolve => { started = resolve; }); + let release!: () => void; + const downloadGate = new Promise(resolve => { release = resolve; }); + let writes = 0; + const applying = applyProfile(emptyDesktopProfile(), "static", { + downloadDesktop3pModelsImpl: async () => { started(); await downloadGate; return { version: 1, models: remoteModels }; }, + writeRemoteDesktop3pConfigImpl: () => { writes++; return { written: true, path: oldPath }; }, + }); + await downloading; + try { + const config = loadConfig(); + if (transition === "off") setIntegrationEnabled("claude-desktop", false); + else if (transition === "token") writeFileSync(serviceApiTokenFilePath(), "different-token"); + else if (transition === "invalid") writeFileSync(getConfigPath(), "{invalid-config"); + else { + if (transition === "server") config.client!.serverUrl = "https://other.example.test"; + if (transition === "key") config.client!.apiKeyId = "other-key"; + if (transition === "fingerprint") config.client!.tokenFingerprint = "1".repeat(64); + if (transition === "connectedAt") config.client!.connectedAt = "2026-09-06T02:00:00.000Z"; + if (transition === "pending") config.client!.pendingOperation = pendingRotation(); + if (transition === "disconnect") { config.runtimeRole = "standalone"; delete config.client; } + saveConfig(config); + } + } finally { release(); } + expect(await applying).toMatchObject({ ok: false, reason: transition === "off" ? "desired_state_changed" : "client_connection_changed" }); + expect(writes).toBe(0); + expect(readFileSync(oldPath, "utf8")).toBe("existing Desktop bytes"); + expect(readFileSync(serviceApiTokenBackupPath(), "utf8")).toBe("backup must remain"); + if (transition === "off") expect(claudeDesktopIntegrationEnabledNow()).toBe(false); + }, +); + +test("remote import --apply refuses before saving or building a local profile", async () => { + connectDesktopFixture(); + const source = join(dir, "import.json"); + writeFileSync(source, JSON.stringify(emptyDesktopProfile())); + const before = readFileSync(getConfigPath(), "utf8"); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleClaudeDesktopCommand(["import", source, "--apply"])).toBe(2); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + expect(error.mock.calls.flat().join(" ")).toContain("hub profile"); + } finally { error.mockRestore(); } +}); + +test("import --apply also refuses a connection established while local reconciliation awaited", async () => { + const localState = await buildClaudeDesktopState(loadConfig()); + const source = join(dir, "import.json"); + writeFileSync(source, JSON.stringify(emptyDesktopProfile())); + let builds = 0; + const build = spyOn(managementApi, "buildClaudeDesktopState").mockImplementation(async () => { + if (++builds === 2) connectDesktopFixture(false); + return localState; + }); + const error = spyOn(console, "error").mockImplementation(() => {}); + let downloads = 0; + try { + expect(await handleClaudeDesktopCommand(["import", source, "--apply"], { + downloadDesktop3pModelsImpl: async () => { downloads++; return { version: 1, models: [] }; }, + })).toBe(2); + expect(builds).toBe(2); + expect(downloads).toBe(0); + expect(loadConfig().claudeCode?.desktopProfile).toBeUndefined(); + expect(readClientConnectionState().kind).toBe("connected"); + } finally { build.mockRestore(); error.mockRestore(); } +}); + +test("connected show/export and local edits identify the local profile view", async () => { + connectDesktopFixture(false); + const log = spyOn(console, "log").mockImplementation(() => {}); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(await handleClaudeDesktopCommand(["show", "--json"])).toBe(0); + expect(JSON.parse(String(log.mock.calls.at(-1)?.[0])).scope).toBe("local"); + const target = join(dir, "export.json"); + expect(await handleClaudeDesktopCommand(["export", target])).toBe(0); + expect(JSON.parse(readFileSync(target, "utf8")).version).toBe(1); + expect(await handleClaudeDesktopCommand(["move", "mock/test-model", "sonnet"])).toBe(0); + expect(await handleClaudeDesktopCommand(["default", "sonnet", "mock/test-model"])).toBe(0); + expect(warn.mock.calls).toHaveLength(4); + expect(warn.mock.calls.every(call => String(call[0]).includes("Local client profile only"))).toBe(true); + } finally { log.mockRestore(); warn.mockRestore(); } +}); + +test("a disconnected hub retains local apply instead of downloading a remote snapshot", async () => { + const config = loadConfig(); + config.runtimeRole = "hub"; + saveConfig(config); + expect(readClientConnectionState().kind).toBe("disconnected"); + const deps: ApplyProfileDeps = { + findLiveProxyImpl: async () => ({ pid: 4242, port: 10100, hostname: "127.0.0.1", source: "runtime" }), + postApplyImpl: async () => ({ ok: true, path: "/local-daemon" }), + downloadDesktop3pModelsImpl: async () => { throw new Error("must not download for a disconnected hub"); }, + }; + expect(await applyProfile(undefined, "static", deps)).toMatchObject({ ok: true, path: "/local-daemon" }); + expect(loadConfig().claudeCode?.desktopProfile).toBeDefined(); +}); + test("show --json, move, default and export use the same persisted profile", async () => { const log = spyOn(console, "log").mockImplementation(() => {}); const error = spyOn(console, "error").mockImplementation(() => {}); diff --git a/tests/claude-integration/claude-desktop-discovery.test.ts b/tests/claude-integration/claude-desktop-discovery.test.ts new file mode 100644 index 0000000000..70719b3db6 --- /dev/null +++ b/tests/claude-integration/claude-desktop-discovery.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { OcxConfig } from "../../src/types"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { buildDesktopDiscoveryInputs } from "../../src/claude/desktop-discovery-inputs"; +import { buildDesktop3pRegistry, generateDesktop3pModels, resolveDesktop3pAlias } from "../../src/claude/desktop-3p"; +import { parseDesktopProfile } from "../../src/claude/desktop-profile"; +import { desktopVisibleNativeSlugs, type CatalogModel } from "../../src/codex/catalog"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; +import type { CodexModelEntitlementSnapshot } from "../../src/codex/model-entitlements"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const emptyEntitlements = (): CodexModelEntitlementSnapshot => ({ + modelsByAccount: new Map(), clientVersionByAccount: new Map(), + confirmedAccountIds: new Set(), credentialIdentities: new Map(), +}); + +function projectionConfig(mode: "direct" | "pool" = "pool"): OcxConfig { + return { + port: 0, defaultProvider: "test", + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://example.test/v1", codexAccountMode: mode }, + test: { adapter: "openai-chat", baseUrl: "https://example.test/v1", selectedModels: ["model-123", "model-155"] }, + }, + subagentModels: ["test/model-155"], + providerContextCaps: { openai: 272_000 }, + } as OcxConfig; +} + +describe("shared Desktop discovery inputs", () => { + afterEach(() => buildDesktop3pRegistry([], [])); + + test("filters direct grants against main and preserves routed metadata and input arrays", () => { + const snapshot: CodexModelEntitlementSnapshot = { + modelsByAccount: new Map([ + [MAIN_CODEX_ACCOUNT_ID, new Set()], + ["pool-fixture", new Set(["gpt-daybreak-blue-latest"])], + ]), + clientVersionByAccount: new Map(), + confirmedAccountIds: new Set([MAIN_CODEX_ACCOUNT_ID, "pool-fixture"]), + credentialIdentities: new Map(), + }; + const rows: CatalogModel[] = [ + { provider: "test", id: "model-123", contextWindow: 200_000 }, + { provider: "test", id: "not-selected" }, + { provider: "test", id: "model-155", reasoningEfforts: ["low", "high"], contextWindow: 1_000_000, inputModalities: ["text", "image"] }, + ]; + const before = structuredClone(rows); + const candidates = Object.freeze(["gpt-5.6-sol", "gpt-daybreak-blue-latest"]); + const direct = buildDesktopDiscoveryInputs({ + config: projectionConfig("direct"), models: rows, + modelEntitlements: snapshot, desktopNativeCandidates: candidates, + }); + expect(direct.nativeSlugs).toEqual(["gpt-5.6-sol"]); + expect(direct.routedModels.map(row => row.id)).toEqual(["model-155", "model-123"]); + expect(direct.routedModels[0]).toEqual(before[2]); + expect(direct.nativeContextCap.cap).toBe(272_000); + const pooled = buildDesktopDiscoveryInputs({ + config: projectionConfig("pool"), models: rows, + modelEntitlements: snapshot, desktopNativeCandidates: candidates, + }); + expect(pooled.nativeSlugs).toEqual(["gpt-5.6-sol", "gpt-daybreak-blue-latest"]); + expect(rows).toEqual(before); + expect(candidates).toEqual(["gpt-5.6-sol", "gpt-daybreak-blue-latest"]); + }); + + test("respects Desktop native opt-out and disabled routed selections", () => { + const config = projectionConfig(); + config.claudeCode = { desktopNativeModels: false }; + config.disabledModels = ["test/model-155"]; + const result = buildDesktopDiscoveryInputs({ + config, modelEntitlements: emptyEntitlements(), + desktopNativeCandidates: desktopVisibleNativeSlugs(config), + models: [{ provider: "test", id: "model-123" }, { provider: "test", id: "model-155" }], + }); + expect(result.nativeSlugs).toEqual([]); + expect(result.routedModels.map(row => row.id)).toEqual(["model-123"]); + }); + + test("uses featured ordering for the no-profile hash collision winner on either install path", () => { + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const inputs = buildDesktopDiscoveryInputs({ + config: projectionConfig(), modelEntitlements: emptyEntitlements(), desktopNativeCandidates: [], + models: [{ provider: "test", id: "model-123" }, { provider: "test", id: "model-155" }], + }); + buildDesktop3pRegistry(inputs.nativeSlugs, inputs.routedModels, undefined, inputs.nativeContextCap); + expect(resolveDesktop3pAlias("claude-opus-4-8-vdu")).toBe("test/model-155"); + const models = generateDesktop3pModels(inputs.nativeSlugs, inputs.routedModels, undefined, inputs.nativeContextCap); + expect(models.map(model => model.name)).toEqual(["claude-opus-4-8-vdu"]); + expect(resolveDesktop3pAlias("claude-opus-4-8-vdu")).toBe("test/model-155"); + } finally { warning.mockRestore(); } + }); +}); + +describe("Desktop snapshot through authenticated model discovery", () => { + const key = "ocx_data_desktopsnapshotfixture"; + const envKeys = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "OPENCODEX_API_AUTH_TOKEN"] as const; + let previous: Array; + let dir: string; + let codexHome: IsolatedCodexHome; + let upstream: ReturnType; + let server: ReturnType | undefined; + + beforeEach(() => { + previous = envKeys.map(name => process.env[name]); + dir = mkdtempSync(join(tmpdir(), "ocx-desktop-discovery-")); + process.env.OPENCODEX_HOME = dir; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(dir, "desktop"); + delete process.env.OPENCODEX_API_AUTH_TOKEN; + codexHome = installIsolatedCodexHome("ocx-desktop-discovery-codex-"); + upstream = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch: () => Response.json({ data: [{ id: "model-123" }, { id: "model-155" }] }), + }); + }); + + afterEach(async () => { + await server?.stop(true); + server = undefined; + await upstream.stop(true); + buildDesktop3pRegistry([], []); + codexHome.restore(); + envKeys.forEach((name, index) => { + if (previous[index] === undefined) delete process.env[name]; + else process.env[name] = previous[index]; + }); + removeTreeWithRetry(dir); + }); + + function launch(enabled = true): void { + saveConfig({ + port: 0, hostname: "0.0.0.0", defaultProvider: "test", runtimeRole: "hub", + providers: { + test: { adapter: "openai-chat", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, apiKey: "fixture", allowPrivateNetwork: true, models: ["model-123", "model-155"] }, + }, + claudeCode: { + enabled, desktopNativeModels: false, + desktopProfile: parseDesktopProfile({ + version: 1, + assignments: { "test/model-155": { family: "fable", alias: "claude-opus-4-8-20260304" } }, + defaults: { opus: null, fable: "test/model-155", sonnet: null, haiku: null }, + }), + }, + apiKeys: [{ id: "snapshot", name: "Snapshot", key, createdAt: "2026-09-06T00:00:00.000Z" }], + } as OcxConfig); + server = startServer(0); + } + + function request(query: string, headers: Record = { "x-opencodex-api-key": key }): Promise { + return fetch(`http://127.0.0.1:${server!.port}/v1/models${query}`, { headers }); + } + + test("snapshot installs its exact aliases and retains ordinary discovery shapes", async () => { + launch(); + const snapshot = await request("?ids=desktop&format=desktop-config"); + expect(snapshot.status).toBe(200); + expect(snapshot.headers.get("cache-control")).toBe("no-store"); + const body = await snapshot.json() as { version: number; models: Array<{ name: string; anthropicFamilyTier: string }> }; + expect(body.version).toBe(1); + expect(body.models.find(model => model.name === "claude-opus-4-8-20260304")?.anthropicFamilyTier).toBe("fable"); + expect(resolveDesktop3pAlias("claude-opus-4-8-20260304")).toBe("test/model-155"); + const anthropic = await request("?flavor=anthropic&ids=desktop"); + expect(anthropic.status).toBe(200); + const anthropicBody = await anthropic.json() as { data: Array<{ id: string }>; version?: number }; + expect(anthropicBody.version).toBeUndefined(); + expect(anthropicBody.data.some(model => model.id === "claude-opus-4-8-20260304")).toBe(true); + expect(resolveDesktop3pAlias("claude-opus-4-8-20260304")).toBe("test/model-155"); + const cli = await request("?flavor=anthropic&ids=cli"); + expect(cli.status).toBe(200); + expect((await cli.json() as { data: Array<{ id: string }> }).data.some(model => model.id.startsWith("claude-ocx-test--"))).toBe(true); + const openai = await request(""); + expect(openai.status).toBe(200); + const openaiBody = await openai.json() as { object: string; data: unknown[]; version?: number }; + expect(openaiBody.object).toBe("list"); + expect(openaiBody.version).toBeUndefined(); + expect(openaiBody.data.length).toBeGreaterThan(0); + }); + + test("keeps data admission and origin checks ahead of snapshot format parsing", async () => { + launch(); + expect((await request("?format=desktop-config&ids=cli", {})).status).toBe(401); + expect((await request("?format=desktop-config", { "x-opencodex-api-key": key, Origin: "https://untrusted.example.test" })).status).toBe(403); + for (const query of ["?format=desktop-config&ids=cli", "?format=desktop-config&client_version=0.150.0"]) { + expect((await request(query)).status).toBe(400); + } + }); + + test("disabled Claude returns a valid empty snapshot without changing ordinary disabled discovery", async () => { + launch(false); + const snapshot = await request("?format=desktop-config"); + expect(snapshot.status).toBe(200); + expect(snapshot.headers.get("cache-control")).toBe("no-store"); + expect(await snapshot.json()).toEqual({ version: 1, models: [] }); + const ordinary = await request("?flavor=anthropic"); + expect(await ordinary.json()).toEqual({ data: [] }); + }); +}); diff --git a/tests/claude-integration/claude-desktop-remote-hub.test.ts b/tests/claude-integration/claude-desktop-remote-hub.test.ts new file mode 100644 index 0000000000..64c4fac52e --- /dev/null +++ b/tests/claude-integration/claude-desktop-remote-hub.test.ts @@ -0,0 +1,239 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { delimiter, dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import type { OcxConfig } from "../../src/types"; +import type { Desktop3pModelEntry } from "../../src/claude/desktop-3p"; +import { repoPath, fixturePath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const DATA_KEY = "ocx-desktop-process-fixture-key"; +const cliPath = repoPath("src/cli/index.ts"); +const preloadPath = fixturePath("claude-desktop-network-guard.ts"); +const roots: string[] = []; +const children: ReturnType[] = []; +const servers: Array<{ stop(closeActiveConnections?: boolean): void | Promise }> = []; + +function fixture(side: string, allowedOrigins: string[]) { + const root = mkdtempSync(join(tmpdir(), "ocx-desktop-" + side + "-")); + roots.push(root); + const paths = { + root, ocx: join(root, "ocx"), codex: join(root, "codex"), + desktop: join(root, "desktop"), user: join(root, "user"), + denied: join(root, "denied-network.txt"), + }; + for (const path of [paths.ocx, paths.codex, paths.desktop, paths.user]) mkdirSync(path, { recursive: true }); + // A valid, isolated API-key auth file prevents fallback to a real OAuth account. + writeFileSync(join(paths.codex, "auth.json"), JSON.stringify({ OPENAI_API_KEY: "fixture-only-not-a-real-key" }), { mode: 0o600 }); + const env: Record = { + HOME: paths.user, USERPROFILE: paths.user, + OPENCODEX_HOME: paths.ocx, CODEX_HOME: paths.codex, + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: paths.desktop, + CLAUDE_CONFIG_DIR: join(paths.user, ".claude"), + XDG_CONFIG_HOME: join(paths.user, ".config"), XDG_DATA_HOME: join(paths.user, ".local", "share"), + XDG_RUNTIME_DIR: join(root, "runtime"), APPDATA: join(paths.user, "AppData", "Roaming"), + LOCALAPPDATA: join(paths.user, "AppData", "Local"), + PATH: [dirname(process.execPath), ...(process.platform === "win32" + ? [join(process.env.SystemRoot ?? "C:\\Windows", "System32")] + : ["/usr/bin", "/bin", "/usr/sbin", "/sbin"])].join(delimiter), + SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR, + CI: "true", TERM: "dumb", NO_PROXY: "127.0.0.1,localhost", + OCX_TEST_ALLOWED_ORIGINS: JSON.stringify(allowedOrigins), + OCX_TEST_DENIED_REQUESTS: paths.denied, + }; + mkdirSync(env.XDG_RUNTIME_DIR!, { recursive: true }); + return { ...paths, env }; +} +type Fixture = ReturnType; + +function writeConfig(fx: Fixture, config: OcxConfig): void { + writeFileSync(join(fx.ocx, "config.json"), JSON.stringify(config), { mode: 0o600 }); +} +function readConfig(fx: Fixture): OcxConfig { + return JSON.parse(readFileSync(join(fx.ocx, "config.json"), "utf8")) as OcxConfig; +} +function spawnOwned(fx: Fixture, args: string[]) { + const child = Bun.spawn({ + cmd: [process.execPath, "--preload", preloadPath, cliPath, ...args], + cwd: fx.root, env: fx.env, stdin: "ignore", stdout: "pipe", stderr: "pipe", + }); + const owned = { child, stdout: new Response(child.stdout).text(), stderr: new Response(child.stderr).text() }; + children.push(owned); + return owned; +} +async function within(promise: Promise, ms: number, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(label)), ms); }), + ]); + } finally { if (timer !== undefined) clearTimeout(timer); } +} +async function stopOwned(owned: ReturnType): Promise { + if (owned.child.exitCode === null) owned.child.kill("SIGTERM"); + try { await within(owned.child.exited, 8_000, "CLI shutdown deadline"); } + catch { + if (owned.child.exitCode === null) owned.child.kill("SIGKILL"); + await within(owned.child.exited, 5_000, "CLI forced shutdown deadline"); + } + await within(Promise.all([owned.stdout, owned.stderr]), 5_000, "CLI output drain deadline"); +} +async function startHub(fx: Fixture) { + const owned = spawnOwned(fx, ["start"]); + const deadline = performance.now() + 45_000; + while (performance.now() < deadline) { + if (owned.child.exitCode !== null) { + throw new Error("Hub exited before readiness: " + await within(owned.stderr, 5_000, "Exited hub output deadline")); + } + try { + const runtime = JSON.parse(readFileSync(join(fx.ocx, "runtime-port.json"), "utf8")) as { pid: number; port: number }; + if (runtime.pid === owned.child.pid && runtime.port > 0) { + const origin = "http://127.0.0.1:" + runtime.port; + const ready = await fetch(origin + "/readyz", { signal: AbortSignal.timeout(500) }); + await ready.text(); + if (ready.ok) return { owned, origin, port: runtime.port }; + } + } catch { /* listener/runtime record is not ready */ } + await Bun.sleep(20); + } + throw new Error("Hub readiness deadline"); +} + +function mockProvider() { + const inference: Array<{ url: string; model: unknown }> = []; + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/v1/models") return Response.json({ object: "list", data: [] }); + if (url.pathname !== "/v1/chat/completions") return new Response("unexpected fixture path", { status: 404 }); + const body = await req.json() as { model?: unknown }; + inference.push({ url: req.url, model: body.model }); + const chunks = [ + { choices: [{ index: 0, delta: { role: "assistant", content: "fixture reply" } }] }, + { choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 2, completion_tokens: 2 } }, + ]; + return new Response(chunks.map(chunk => "data: " + JSON.stringify(chunk) + "\n\n").join("") + "data: [DONE]\n\n", { + headers: { "content-type": "text/event-stream" }, + }); + }, + }); + servers.push(server); + return { server, inference }; +} +function profile(chosenDay: string, decoyDay: string): NonNullable["desktopProfile"]> { + return { + version: 1, + assignments: { + "chosen/model-target": { family: "opus", alias: "claude-opus-4-8-" + chosenDay }, + "decoy/model-decoy": { family: "sonnet", alias: "claude-opus-4-8-" + decoyDay }, + }, + defaults: { opus: "chosen/model-target", fable: null, sonnet: "decoy/model-decoy", haiku: null }, + }; +} +function deniedTraffic(fx: Fixture): string { + return existsSync(fx.denied) ? readFileSync(fx.denied, "utf8") : ""; +} + +afterEach(async () => { + for (const owned of children.splice(0)) await stopOwned(owned); + for (const server of servers.splice(0)) await server.stop(true); + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +for (const storedProfile of [true, false]) { + test("connected Desktop uses hub IDs across a cold restart (stored profile=" + storedProfile + ")", async () => { + const chosen = mockProvider(); + const decoy = mockProvider(); + const hub = fixture("hub", [chosen.server.url.origin, decoy.server.url.origin]); + hub.env.OPENCODEX_API_AUTH_TOKEN = DATA_KEY; + const provider = (target: ReturnType, model: string) => ({ + adapter: "openai-chat" as const, baseUrl: target.server.url.origin + "/v1", + apiKey: "fixture-provider-key", models: [model], liveModels: false, allowPrivateNetwork: true, + }); + writeConfig(hub, { + port: 0, hostname: "127.0.0.1", runtimeRole: "hub", + defaultProvider: "decoy", codexAutoStart: false, syncResumeHistory: false, + clientIntegrations: { codex: false, grok: false, "claude-desktop": false }, + providers: { chosen: provider(chosen, "model-target"), decoy: provider(decoy, "model-decoy") }, + subagentModels: ["decoy/model-decoy", "chosen/model-target"], + claudeCode: { + enabled: true, nativePassthrough: false, desktopNativeModels: true, + systemEnv: false, injectAgents: false, + ...(storedProfile ? { desktopProfile: profile("20260211", "20260212") } : {}), + }, + } as OcxConfig); + const first = await startHub(hub); + // Retain the allocated endpoint so the client's persisted origin survives restart. + writeConfig(hub, { ...readConfig(hub), port: first.port }); + const client = fixture("client", [first.origin]); + const localProfile = profile("20260911", "20260912"); + writeConfig(client, { + port: 1, hostname: "127.0.0.1", runtimeRole: "client", defaultProvider: "unused", + providers: { unused: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "unused-fixture-key", liveModels: false, models: ["client-only"], allowPrivateNetwork: true } }, + claudeCode: { desktopProfile: localProfile, systemEnv: false, injectAgents: false }, + clientIntegrations: { codex: false, grok: false, "claude-desktop": false }, + client: { + serverUrl: first.origin, managementUrl: first.origin, managementTransport: "direct", + selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "fixture-client", + tokenFingerprint: createHash("sha256").update(DATA_KEY).digest("hex"), + protocolVersion: 1, connectedAt: "2026-01-01T00:00:00.000Z", + }, + } as OcxConfig); + writeFileSync(join(client.ocx, "service-api-token"), DATA_KEY + "\n", { mode: 0o600 }); + const snapshotResponse = await fetch(first.origin + "/v1/models?ids=desktop&format=desktop-config", { + headers: { "x-opencodex-api-key": DATA_KEY }, signal: AbortSignal.timeout(5_000), + }); + expect(snapshotResponse.status).toBe(200); + expect(snapshotResponse.headers.get("cache-control")).toBe("no-store"); + const snapshot = await snapshotResponse.json() as { version: number; models: Desktop3pModelEntry[] }; + expect(snapshot.version).toBe(1); + expect(snapshot.models.some(model => model.labelOverride.includes("(native)"))).toBe(true); + const chosenEntry = snapshot.models.find(model => model.labelOverride.includes("model-target")); + expect(chosenEntry).toBeDefined(); + if (storedProfile) expect(chosenEntry!.name).toBe("claude-opus-4-8-20260211"); + else expect(chosenEntry!.name).toMatch(/^claude-opus-4-8-[a-z][a-z0-9]{2}$/); + + const apply = spawnOwned(client, ["claude", "desktop", "apply", "--static"]); + const appliedCode = await within(apply.child.exited, 30_000, "Remote Desktop apply deadline"); + const appliedOutput = await within(Promise.all([apply.stdout, apply.stderr]), 5_000, "Apply output drain deadline"); + if (appliedCode !== 0) throw new Error("Remote Desktop apply failed: " + appliedOutput[1]); + expect(appliedOutput.join("\n")).not.toContain(DATA_KEY); + const metadata = JSON.parse(readFileSync(join(client.desktop, "_meta.json"), "utf8")) as { appliedId: string }; + const written = JSON.parse(readFileSync(join(client.desktop, metadata.appliedId + ".json"), "utf8")); + expect(written.inferenceGatewayBaseUrl).toBe(first.origin); + expect(written.inferenceGatewayApiKey).toBe(DATA_KEY); + expect(written.inferenceModels).toEqual(snapshot.models); + expect(readConfig(client).claudeCode?.desktopProfile).toEqual(localProfile); + if (!storedProfile) expect(readConfig(hub).claudeCode?.desktopProfile).toBeUndefined(); + + const send = async (origin: string) => { + const response = await fetch(origin + "/v1/messages", { + method: "POST", signal: AbortSignal.timeout(10_000), + headers: { "content-type": "application/json", "x-opencodex-api-key": DATA_KEY, "anthropic-version": "2023-06-01" }, + body: JSON.stringify({ model: chosenEntry!.name, max_tokens: 8, stream: true, messages: [{ role: "user", content: "hello" }] }), + }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("message_stop"); + }; + await send(first.origin); + expect(chosen.inference).toEqual([{ url: chosen.server.url.origin + "/v1/chat/completions", model: "model-target" }]); + expect(decoy.inference).toEqual([]); + + await stopOwned(first.owned); + const restarted = await startHub(hub); + expect(restarted.origin).toBe(first.origin); + // No model discovery call occurs between restart and this saved-ID request. + await send(restarted.origin); + expect(chosen.inference).toEqual([ + { url: chosen.server.url.origin + "/v1/chat/completions", model: "model-target" }, + { url: chosen.server.url.origin + "/v1/chat/completions", model: "model-target" }, + ]); + expect(decoy.inference).toEqual([]); + await stopOwned(restarted.owned); + expect(deniedTraffic(hub)).toBe(""); + expect(deniedTraffic(client)).toBe(""); + }, { timeout: 240_000 }); +} diff --git a/tests/claude-integration/claude-messages-endpoint.test.ts b/tests/claude-integration/claude-messages-endpoint.test.ts index b977278908..877877edef 100644 --- a/tests/claude-integration/claude-messages-endpoint.test.ts +++ b/tests/claude-integration/claude-messages-endpoint.test.ts @@ -5,6 +5,8 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; +import { buildDesktop3pRegistry } from "../../src/claude/desktop-3p"; +import type { DesktopProfile } from "../../src/claude/desktop-profile"; import { createAnthropicAdapter } from "../../src/adapters/anthropic"; import { clearableDeadline } from "../../src/lib/abort"; import { @@ -74,9 +76,11 @@ function mockChatUpstream() { function mockChatUpstreamCapturing() { const captured: Array> = []; + const urls: string[] = []; const server = Bun.serve({ port: 0, async fetch(req) { + urls.push(req.url); const url = new URL(req.url); if (!url.pathname.endsWith("/chat/completions")) { return Response.json({ error: { message: `unexpected path ${url.pathname}` } }, { status: 404 }); @@ -91,7 +95,7 @@ function mockChatUpstreamCapturing() { return new Response(frames.join(""), { headers: { "Content-Type": "text/event-stream" } }); }, }); - return { server, captured }; + return { server, captured, urls }; } function mockConfig(baseUrl: string, claudeCode?: OcxConfig["claudeCode"]): OcxConfig { @@ -1572,3 +1576,123 @@ test("count_tokens is CJK-aware: Korean body counts more tokens than equal-lengt await server.stop(true); } }); + + +const managedDesktopProfile: DesktopProfile = { + version: 1, + assignments: { "selected/model-selected": { family: "opus", alias: "claude-opus-4-8-20260201" } }, + defaults: { opus: "selected/model-selected", fable: null, sonnet: null, haiku: null }, +}; +const desktopRequestHeaders = { + "content-type": "application/json", + "anthropic-version": "2023-06-01", + "anthropic-beta": "oauth-2025-04-20", + authorization: "Bearer sk-ant-oat01-desktop-test", +}; + +for (const fallbacks of [false, true]) { + test(`missing Desktop IDs reject before upstream dispatch (fallbacks=${fallbacks})`, async () => { + const selected = mockChatUpstreamCapturing(); + const fallback = mockChatUpstreamCapturing(); + const native = mockChatUpstreamCapturing(); + const provider = (upstream: ReturnType, models: string[]) => ({ + adapter: "openai-chat" as const, baseUrl: new URL("/v1", upstream.server.url).href, + apiKey: "test-key", allowPrivateNetwork: true, liveModels: false, models, + }); + saveConfig({ + port: 0, defaultProvider: "fallback", + providers: { + selected: provider(selected, ["model-selected"]), + fallback: provider(fallback, ["model-default", "model-dateless", "model-classifier"]), + }, + claudeCode: { + anthropicBaseUrl: native.server.url.origin, + ...(fallbacks ? { + modelMap: { "claude-opus-4-8": "fallback/model-dateless" }, + classifierModel: "fallback/model-classifier", + } : {}), + }, + } as OcxConfig); + buildDesktop3pRegistry([], [{ provider: "selected", id: "model-selected" }], managedDesktopProfile); + const server = startServer(0); + try { + for (const model of [ + "claude-opus-4-8-20260202", "claude-opus-4-8-zzz", "claude-opus-4-zzz", + "claude-opus-4-8-20260202[1m]", + ]) { + for (const path of ["/v1/messages", "/v1/messages/count_tokens"]) { + const response = await fetch(new URL(path, server.url), { + method: "POST", headers: desktopRequestHeaders, signal: AbortSignal.timeout(5_000), + body: JSON.stringify({ model, max_tokens: 8, messages: [{ role: "user", content: "hello" }] }), + }); + expect(response.status).toBe(400); + const body = await response.json() as { type: string; error: { type: string; message: string } }; + expect(body.type).toBe("error"); + expect(body.error.type).toBe("invalid_request_error"); + expect(body.error.message).toContain("Unknown Claude Desktop alias"); + } + } + expect(selected.urls).toEqual([]); + expect(fallback.urls).toEqual([]); + expect(native.urls).toEqual([]); + } finally { + await server.stop(true); + selected.server.stop(true); fallback.server.stop(true); native.server.stop(true); + buildDesktop3pRegistry([], []); + } + }, { timeout: SERVER_BUDGET_MS }); +} + +test("registered Desktop IDs and exact operator overrides reach distinct intended routes", async () => { + const selected = mockChatUpstreamCapturing(); + const explicit = mockChatUpstreamCapturing(); + const fallback = mockChatUpstreamCapturing(); + const provider = (upstream: ReturnType, model: string) => ({ + adapter: "openai-chat" as const, baseUrl: new URL("/v1", upstream.server.url).href, + apiKey: "test-key", allowPrivateNetwork: true, liveModels: false, models: [model], + }); + saveConfig({ + port: 0, defaultProvider: "fallback", + providers: { + selected: provider(selected, "model-selected"), explicit: provider(explicit, "model-explicit"), + fallback: provider(fallback, "model-fallback"), + }, + claudeCode: { + anthropicBaseUrl: fallback.server.url.origin, + modelMap: { + "claude-opus-4-8-20260202": "explicit/model-explicit", + "claude-opus-4-8": "fallback/model-fallback", + }, + classifierModel: "fallback/model-fallback", + }, + } as OcxConfig); + buildDesktop3pRegistry([], [{ provider: "selected", id: "model-selected" }], managedDesktopProfile); + const server = startServer(0); + try { + for (const model of ["claude-opus-4-8-20260201", "claude-opus-4-8-20260201[1m]", "claude-opus-4-8-20260202"]) { + const response = await fetch(new URL("/v1/messages", server.url), { + method: "POST", headers: desktopRequestHeaders, signal: AbortSignal.timeout(5_000), + body: JSON.stringify({ model, stream: true, max_tokens: 8, messages: [{ role: "user", content: "hello" }] }), + }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("message_stop"); + } + expect(selected.captured.map(body => body.model)).toEqual(["model-selected", "model-selected"]); + expect(explicit.captured.map(body => body.model)).toEqual(["model-explicit"]); + expect(selected.urls).toEqual([new URL("/v1/chat/completions", selected.server.url).href, new URL("/v1/chat/completions", selected.server.url).href]); + expect(explicit.urls).toEqual([new URL("/v1/chat/completions", explicit.server.url).href]); + expect(fallback.urls).toEqual([]); + const count = await fetch(new URL("/v1/messages/count_tokens", server.url), { + method: "POST", headers: desktopRequestHeaders, + body: JSON.stringify({ model: "claude-opus-4-8-20260202", messages: [{ role: "user", content: "hello" }] }), + }); + expect(count.status).toBe(200); + expect((await count.json() as { input_tokens: number }).input_tokens).toBeGreaterThan(0); + expect(explicit.urls).toHaveLength(1); + expect(fallback.urls).toEqual([]); + } finally { + await server.stop(true); + selected.server.stop(true); explicit.server.stop(true); fallback.server.stop(true); + buildDesktop3pRegistry([], []); + } +}, { timeout: SERVER_BUDGET_MS }); diff --git a/tests/claude-integration/claude-native-passthrough.test.ts b/tests/claude-integration/claude-native-passthrough.test.ts index c8c798ac9a..5f19cf2bd3 100644 --- a/tests/claude-integration/claude-native-passthrough.test.ts +++ b/tests/claude-integration/claude-native-passthrough.test.ts @@ -5,6 +5,8 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; +import { buildDesktop3pRegistry } from "../../src/claude/desktop-3p"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -493,3 +495,53 @@ test("P5: Files API image source passes through untouched", async () => { upstream.stop(true); } }); + + +test("catalog-published native dates retain identity while unknown Desktop dates reject", async () => { + const published = "claude-opus-4-8-20260402"; + const captured: Captured[] = []; + const upstream = mockAnthropicUpstream(captured); + const config = cfg(upstream.url.origin, { desktopNativeModels: false }); + config.providers.anthropic = { + adapter: "anthropic", baseUrl: upstream.url.origin, apiKey: "test-native-key", + allowPrivateNetwork: true, liveModels: false, models: [published], + }; + saveConfig(config); + buildDesktop3pRegistry([], []); + const server = startServer(0); + try { + // Publish the fixture's genuine identity through the real hub catalog path. + const catalog = await fetch(new URL("/v1/models?ids=desktop", server.url), { + headers: { "anthropic-version": "2023-06-01" }, signal: AbortSignal.timeout(5_000), + }); + expect(catalog.status).toBe(200); + const list = await catalog.json() as { data: Array<{ id: string }> }; + expect(list.data.some(row => row.id === published)).toBe(true); + for (const model of [published, "claude-opus-4-8", "claude-haiku-4-5"]) { + for (const path of ["/v1/messages", "/v1/messages/count_tokens"]) { + const response = await fetch(new URL(path, server.url), { + method: "POST", headers: OAUTH_HEADERS, signal: AbortSignal.timeout(5_000), + body: JSON.stringify({ ...claudeBody(), model }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(captured.at(-1)!.body.model).toBe(model); + expect(captured.at(-1)!.path).toBe(path); + } + } + expect(captured).toHaveLength(6); + for (const path of ["/v1/messages", "/v1/messages/count_tokens"]) { + const response = await fetch(new URL(path, server.url), { + method: "POST", headers: OAUTH_HEADERS, signal: AbortSignal.timeout(5_000), + body: JSON.stringify({ ...claudeBody(), model: "claude-opus-4-8-20260403" }), + }); + expect(response.status).toBe(400); + expect((await response.json() as { error: { message: string } }).error.message).toContain("Unknown Claude Desktop alias"); + } + expect(captured).toHaveLength(6); + } finally { + await server.stop(true); + upstream.stop(true); + buildDesktop3pRegistry([], []); + } +}, { timeout: SERVER_BUDGET_MS }); diff --git a/tests/clients/desktop-3p.test.ts b/tests/clients/desktop-3p.test.ts index 4fae0f4253..224ad733d6 100644 --- a/tests/clients/desktop-3p.test.ts +++ b/tests/clients/desktop-3p.test.ts @@ -10,16 +10,104 @@ import { generateDesktop3pConfig, generateDesktop3pModels, legacyDesktop3pAlias, + isUnresolvedDesktop3pAlias, parseDesktop3pModeArgs, resolveDesktop3pConfigLibraryPath, resolveDesktop3pAlias, writeDesktop3pConfig, + writeRemoteDesktop3pConfig, + type Desktop3pModelEntry, } from "../../src/claude/desktop-3p"; import { moveDesktopRoute, reconcileDesktopProfile, setDesktopFamilyDefault } from "../../src/claude/desktop-profile"; import { resolveInboundModel } from "../../src/claude/inbound"; import { removeTreeWithRetry } from "../helpers/remove-tree"; describe("Claude Desktop 3P models", () => { + test("replaces native exemptions together with the registry on either install path", () => { + const dated = "claude-opus-4-8-20260304"; + try { + buildDesktop3pRegistry([], [{ provider: "anthropic", id: dated }]); + expect(resolveDesktop3pAlias(dated)).toBeNull(); + expect(isUnresolvedDesktop3pAlias(dated)).toBe(false); + generateDesktop3pModels(["gpt-5.6-sol"], []); + expect(isUnresolvedDesktop3pAlias(dated)).toBe(true); + expect(isUnresolvedDesktop3pAlias("claude-opus-4-8-ncb")).toBe(false); + expect(isUnresolvedDesktop3pAlias("claude-opus-4-ncb")).toBe(false); + generateDesktop3pModels([], [{ provider: "anthropic", id: dated }]); + expect(isUnresolvedDesktop3pAlias(dated)).toBe(false); + buildDesktop3pRegistry([], []); + expect(isUnresolvedDesktop3pAlias(dated)).toBe(true); + expect(isUnresolvedDesktop3pAlias("claude-opus-4-8-ncb")).toBe(true); + expect(isUnresolvedDesktop3pAlias("claude-opus-4-ncb")).toBe(true); + for (const id of ["claude-opus-4-8", "claude-haiku-4-5", "claude-opus-4-8-20250201", "claude-ocx-native--claude-fable-5-1"]) { + expect(isUnresolvedDesktop3pAlias(id)).toBe(false); + } + } finally { buildDesktop3pRegistry([], []); } + }); + + test("remote apply preserves exact hub entries and foreign keys without installing aliases", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-desktop-remote-")); + const previous = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = dir; + const models: Desktop3pModelEntry[] = [{ + name: "claude-opus-4-8-20260304", labelOverride: "Hub model", + anthropicFamilyTier: "fable", isFamilyDefault: true, supports1m: true, prefer1m: true, + }]; + try { + const local = writeDesktop3pConfig(4096, ["gpt-5.6-sol"], [], "old-key"); + expect(local.written).toBe(true); + const prior = JSON.parse(readFileSync(local.path, "utf8")); + writeFileSync(local.path, JSON.stringify({ ...prior, foreignSetting: { retained: true } })); + for (const mode of ["static", "hybrid", "discovery"] as const) { + const result = writeRemoteDesktop3pConfig({ baseUrl: "https://hub.example.test", apiKey: "remote-fixture-key", mode, models }); + expect(result.written).toBe(true); + expect(result.path).toBe(local.path); + const written = JSON.parse(readFileSync(result.path, "utf8")); + expect(written.inferenceGatewayBaseUrl).toBe("https://hub.example.test"); + expect(written.inferenceGatewayApiKey).toBe("remote-fixture-key"); + expect(written.modelDiscoveryEnabled).toBe(mode !== "static"); + expect(written.inferenceModels).toEqual(mode === "discovery" ? undefined : models); + expect(written.foreignSetting).toEqual({ retained: true }); + expect(resolveDesktop3pAlias(models[0]!.name)).toBeNull(); + expect(resolveDesktop3pAlias("claude-opus-4-8-ncb")).toBe("native/gpt-5.6-sol"); + } + } finally { + if (previous === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previous; + buildDesktop3pRegistry([], []); + removeTreeWithRetry(dir); + } + }); + + test("local and remote generation failures retain result semantics and existing file bytes", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-desktop-generation-")); + const previous = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = dir; + try { + const initial = writeDesktop3pConfig(4096, [], [{ provider: "test", id: "valid" }]); + expect(initial.written).toBe(true); + const before = readFileSync(initial.path, "utf8"); + const beforeMeta = readFileSync(join(dir, "_meta.json"), "utf8"); + const local = writeDesktop3pConfig(4096, [], [{ provider: "test", id: "x".repeat(90) }]); + const remote = writeRemoteDesktop3pConfig({ + baseUrl: "https://hub.example.test", apiKey: "fixture-key", mode: "static", + models: [{ name: "invalid", labelOverride: "Hub", anthropicFamilyTier: "opus" }], + }); + for (const result of [local, remote]) { + expect(result.written).toBe(false); + expect(result.path).toBe(initial.path); + expect(result.reason).toBeTruthy(); + } + expect(readFileSync(initial.path, "utf8")).toBe(before); + expect(readFileSync(join(dir, "_meta.json"), "utf8")).toBe(beforeMeta); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previous; + buildDesktop3pRegistry([], []); + removeTreeWithRetry(dir); + } + }); + test("resolves the actual cross-platform Claude Desktop config library (#539)", () => { // Claude Desktop appends "-3p" to its userData root (app.asar `GE()`), so the // suffix-less path is one Desktop never reads. Branch-by-branch coverage lives in diff --git a/tests/clients/desktop-profile.test.ts b/tests/clients/desktop-profile.test.ts index f002183850..2f20f4513a 100644 --- a/tests/clients/desktop-profile.test.ts +++ b/tests/clients/desktop-profile.test.ts @@ -7,6 +7,7 @@ import { reconcileDesktopProfile, renderDesktopProfile, setDesktopFamilyDefault, + validDateAlias, type DesktopProfileModel, } from "../../src/claude/desktop-profile"; @@ -17,6 +18,31 @@ const models: DesktopProfileModel[] = [ ]; describe("Claude Desktop profile", () => { + test("recognizes only valid dates in the emitted managed namespace", () => { + expect(validDateAlias("claude-opus-4-8-20260101")).toBe(true); + expect(validDateAlias("claude-opus-4-8-20261231")).toBe(true); + for (const id of ["claude-opus-4-8-20260229", "claude-opus-4-8-20261301", "claude-opus-4-8-20250101", "claude-haiku-4-5-20260101"]) { + expect(validDateAlias(id)).toBe(false); + } + }); + + test("keeps every hidden assignment and reserves its date for newly added routes", () => { + const assignments: ReturnType["assignments"] = {}; + for (let day = 1; day <= 364; day++) { + const date = new Date(Date.UTC(2026, 0, day)).toISOString().slice(0, 10).replaceAll("-", ""); + assignments[`hidden/model-${day}`] = { family: "opus", alias: `claude-opus-4-8-${date}` }; + } + const profile = parseDesktopProfile({ + version: 1, + assignments, + defaults: { opus: "hidden/model-1", fable: null, sonnet: null, haiku: null }, + }); + const next = reconcileDesktopProfile(profile, [{ route: "new/model", label: "New" }]); + for (const [route, assignment] of Object.entries(assignments)) expect(next.assignments[route]).toEqual(assignment); + expect(next.assignments["new/model"]!.alias).toBe("claude-opus-4-8-20261231"); + expect(profile.assignments["new/model"]).toBeUndefined(); + }); + test("reconciles new routes into Opus with stable unique date aliases", () => { const first = reconcileDesktopProfile(undefined, models); const second = reconcileDesktopProfile(first, [...models].reverse()); diff --git a/tests/clients/remote-catalog.test.ts b/tests/clients/remote-catalog.test.ts index b38e0c4587..cfc3c50a70 100644 --- a/tests/clients/remote-catalog.test.ts +++ b/tests/clients/remote-catalog.test.ts @@ -1,12 +1,186 @@ import { describe, expect, test } from "bun:test"; -import { downloadClientCatalog, HubClientError } from "../../src/client/hub-client"; +import { downloadClientCatalog, downloadDesktop3pModels, HubClientError } from "../../src/client/hub-client"; const JSON_HEADERS = { "Content-Type": "application/json", ETag: '"catalog-v1"' }; +const desktopModel = { + name: "claude-opus-4-8-20260101", + labelOverride: "Remote model", + anthropicFamilyTier: "opus" as const, +}; + function response(body: string, headers: HeadersInit = JSON_HEADERS): Response { return new Response(body, { headers }); } +describe("remote Desktop snapshot consumer", () => { + test.each(["https://hub.example.test", "http://127.0.0.1:2345", "http://localhost:2345", "http://[::1]:2345"])( + "uses authenticated opt-in discovery at permitted origin %s", async origin => { + const result = await downloadDesktop3pModels(origin, "ocx_data_test", { + fetchImpl: async (input, init) => { + expect(String(input)).toBe(`${origin}/v1/models?ids=desktop&format=desktop-config`); + expect(init?.method).toBe("GET"); + expect(init?.redirect).toBe("manual"); + const headers = new Headers(init?.headers); + expect(headers.get("anthropic-version")).toBe("2023-06-01"); + expect(headers.get("x-opencodex-api-key")).toBe("ocx_data_test"); + expect(headers.get("accept")).toBe("application/json"); + expect(headers.has("if-none-match")).toBe(false); + return response(JSON.stringify({ version: 1, models: [desktopModel] })); + }, + }); + expect(result).toEqual({ version: 1, models: [desktopModel] }); + }, + ); + + test("refuses insecure transport before constructing credential headers or fetching", async () => { + let calls = 0; + await expect(downloadDesktop3pModels("http://hub.example.test", "invalid\nheader", { + fetchImpl: async () => { calls++; return response("{}"); }, + })).rejects.toMatchObject({ code: "insecure_http_refused" }); + expect(calls).toBe(0); + }); + + test("does not follow redirects or reflect their destination/body", async () => { + let calls = 0; + await expect(downloadDesktop3pModels("https://hub.example.test", "secret-marker", { + fetchImpl: async (_input, init) => { + calls++; + expect(init?.redirect).toBe("manual"); + return new Response("response-marker", { status: 302, headers: { Location: "https://destination-marker.test" } }); + }, + })).rejects.toMatchObject({ code: "redirect_refused", message: "Hub Desktop model snapshot request failed" }); + expect(calls).toBe(1); + }); + + test.each([304, 401, 403, 404, 500])("refuses HTTP %s with a fixed error", async status => { + await expect(downloadDesktop3pModels("https://hub.example.test", "secret-marker", { + fetchImpl: async () => new Response(status === 304 ? null : "remote-body-marker", { status }), + })).rejects.toMatchObject({ code: `desktop_snapshot_http_${status}`, message: "Hub Desktop model snapshot request failed" }); + }); + + test.each([ + ["old catalog", { data: [] }, "desktop_snapshot_unsupported"], + ["future version", { version: 2, models: [] }, "desktop_snapshot_unsupported"], + ["null", null, "desktop_snapshot_invalid"], + ["array envelope", [], "desktop_snapshot_invalid"], + ["missing models", { version: 1 }, "desktop_snapshot_invalid"], + ["object models", { version: 1, models: {} }, "desktop_snapshot_invalid"], + ["null row", { version: 1, models: [null] }, "desktop_snapshot_invalid"], + ["array row", { version: 1, models: [[]] }, "desktop_snapshot_invalid"], + ["missing name", { version: 1, models: [{ labelOverride: "Label", anthropicFamilyTier: "opus" }] }, "desktop_snapshot_invalid"], + ["bad label type", { version: 1, models: [{ ...desktopModel, labelOverride: 1 }] }, "desktop_snapshot_invalid"], + ["bad name", { version: 1, models: [{ ...desktopModel, name: "remote-marker" }] }, "desktop_snapshot_invalid"], + ["duplicate", { version: 1, models: [desktopModel, desktopModel] }, "desktop_snapshot_invalid"], + ["bracket label", { version: 1, models: [{ ...desktopModel, labelOverride: "remote-marker[1m]" }] }, "desktop_snapshot_invalid"], + ["long label", { version: 1, models: [{ ...desktopModel, labelOverride: "x".repeat(81) }] }, "desktop_snapshot_invalid"], + ["bad family", { version: 1, models: [{ ...desktopModel, anthropicFamilyTier: "remote-marker" }] }, "desktop_snapshot_invalid"], + ["bad default", { version: 1, models: [{ ...desktopModel, isFamilyDefault: 1 }] }, "desktop_snapshot_invalid"], + ["false supports1m", { version: 1, models: [{ ...desktopModel, supports1m: false }] }, "desktop_snapshot_invalid"], + ["false prefer1m", { version: 1, models: [{ ...desktopModel, prefer1m: false }] }, "desktop_snapshot_invalid"], + ["null flag", { version: 1, models: [{ ...desktopModel, supports1m: null }] }, "desktop_snapshot_invalid"], + ] as const)("rejects %s without reflecting remote values", async (_label, value, code) => { + let caught: unknown; + try { + await downloadDesktop3pModels("https://hub.example.test", "secret-marker", { + fetchImpl: async () => response(JSON.stringify(value)), + }); + } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(HubClientError); + expect((caught as HubClientError).code).toBe(code); + expect((caught as Error).cause).toBeUndefined(); + expect(String(caught)).not.toContain("remote-marker"); + expect(String(caught)).not.toContain("secret-marker"); + }); + + test("projects only known fields while keeping valid capability flags and an 80-character label", async () => { + const known = { ...desktopModel, labelOverride: "x".repeat(80), isFamilyDefault: false, supports1m: true, prefer1m: true }; + const result = await downloadDesktop3pModels("https://hub.example.test", "ocx_data_test", { + fetchImpl: async () => response(JSON.stringify({ version: 1, models: [{ ...known, apiKey: "remote-marker", endpoint: "http://wrong.test" }], unknown: 1 })), + }); + expect(result).toEqual({ version: 1, models: [known] }); + }); + + test("accepts empty snapshots and 2000 rows, refuses 2001", async () => { + for (const count of [0, 2000, 2001]) { + const models = Array.from({ length: count }, (_, index) => ({ ...desktopModel, name: `claude-test-${index}` })); + const pending = downloadDesktop3pModels("https://hub.example.test", "ocx_data_test", { + fetchImpl: async () => response(JSON.stringify({ version: 1, models })), + }); + if (count <= 2000) expect((await pending).models).toEqual(models); + else await expect(pending).rejects.toMatchObject({ code: "desktop_snapshot_invalid" }); + } + }); + + test("enforces the 1 MiB streamed cap even without or with a forged content-length", async () => { + const prefix = '{"version":1,"models":[]}'; + for (const extra of [0, 1]) { + for (const declared of [undefined, "1"]) { + const body = prefix + " ".repeat(1024 * 1024 - prefix.length + extra); + const pending = downloadDesktop3pModels("https://hub.example.test", "ocx_data_test", { + fetchImpl: async () => new Response(new ReadableStream({ + start(controller) { + const bytes = new TextEncoder().encode(body); + controller.enqueue(bytes.subarray(0, 512 * 1024)); + controller.enqueue(bytes.subarray(512 * 1024)); + controller.close(); + }, + }), { headers: { "Content-Type": "application/json", ...(declared ? { "Content-Length": declared } : {}) } }), + }); + if (extra === 0) expect(await pending).toEqual({ version: 1, models: [] }); + else await expect(pending).rejects.toMatchObject({ code: "body_too_large" }); + } + } + }); + + test("rejects wrong content type, malformed JSON and unsafe error causes", async () => { + for (const [body, type] of [["{remote-marker", "application/json"], ['{"version":1,"models":[]}', "text/html"]]) { + let caught: unknown; + try { + await downloadDesktop3pModels("https://hub.example.test", "secret-marker", { + fetchImpl: async () => response(body!, { "Content-Type": type! }), + }); + } catch (error) { caught = error; } + expect(caught).toMatchObject({ code: "desktop_snapshot_invalid", message: "Hub Desktop model snapshot was invalid" }); + expect((caught as Error).cause).toBeUndefined(); + } + let caught: unknown; + try { + await downloadDesktop3pModels("https://hub.example.test", "secret-marker", { + fetchImpl: async () => { throw new Error("secret-marker remote-marker"); }, + }); + } catch (error) { caught = error; } + expect(caught).toMatchObject({ code: "unreachable", message: "Hub Desktop model snapshot request failed" }); + expect((caught as Error).cause).toBeUndefined(); + }); + + test("normalizes a /v1 URL and accepts JSON-compatible content types", async () => { + expect(await downloadDesktop3pModels("https://hub.example.test/v1/", "ocx_data_test", { + fetchImpl: async input => { + expect(String(input)).toBe("https://hub.example.test/v1/models?ids=desktop&format=desktop-config"); + return response('{"version":1,"models":[]}', { "Content-Type": "application/vnd.opencodex+json; charset=utf-8" }); + }, + })).toEqual({ version: 1, models: [] }); + }); + + test("bounds stalled response headers and streamed bodies without exposing their errors", async () => { + await expect(downloadDesktop3pModels("https://hub.example.test", "ocx_data_test", { + timeoutMs: 25, + fetchImpl: async (_input, init) => new Promise((_resolve, reject) => { + const signal = init!.signal!; + if (signal.aborted) reject(signal.reason); + else signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + })).rejects.toMatchObject({ code: "unreachable" }); + await expect(downloadDesktop3pModels("https://hub.example.test", "ocx_data_test", { + timeoutMs: 25, + fetchImpl: async () => new Response(new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode('{"version":1,"models":[')); }, + }), { headers: JSON_HEADERS }), + })).rejects.toMatchObject({ code: "unreachable" }); + }); +}); + describe("remote catalog adversarial consumer", () => { test("allows a catalog download to exceed five seconds while bytes keep arriving", async () => { const chunks = ['{"models":[', '{"slug":"provider/model"}', ']}']; diff --git a/tests/fixtures/claude-desktop-network-guard.ts b/tests/fixtures/claude-desktop-network-guard.ts new file mode 100644 index 0000000000..25bac6e707 --- /dev/null +++ b/tests/fixtures/claude-desktop-network-guard.ts @@ -0,0 +1,34 @@ +/** Process-fixture guard: unexpected traffic must never reach a real provider. */ +import { appendFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const allowed = new Set(JSON.parse(process.env.OCX_TEST_ALLOWED_ORIGINS ?? "[]")); +const deniedFile = process.env.OCX_TEST_DENIED_REQUESTS; +const nativeFetch = globalThis.fetch; + +function permit(input: Parameters[0]): void { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (allowed.has(url.origin)) return; + // The real CLI can consult its own management endpoint during startup. + if (["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) { + try { + const record = JSON.parse(readFileSync(join(process.env.OPENCODEX_HOME!, "runtime-port.json"), "utf8")); + if (record.pid === process.pid && record.port === Number(url.port)) return; + } catch { /* no owned listener yet */ } + } + if (deniedFile) appendFileSync(deniedFile, url.origin + "\n"); + throw new Error("OCX_TEST_EXTERNAL_REQUEST_BLOCKED"); +} + +globalThis.fetch = Object.assign( + (...args: Parameters) => { + permit(args[0]); + return nativeFetch(...args); + }, + { + preconnect: (...args: Parameters) => { + permit(args[0]); + return nativeFetch.preconnect(...args); + }, + }, +); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 80bf27d4bd..900af319f0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -123,8 +123,10 @@ "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", "claude-desktop-config-path.test.ts": "claude-integration", + "claude-desktop-discovery.test.ts": "claude-integration", "claude-desktop-native-context.test.ts": "claude-integration", "claude-desktop-policy.test.ts": "claude-integration", + "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", From 93c9e8c3d6bc094b8927ce2ebd3479b45e76cf3c Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 06:48:14 +0900 Subject: [PATCH 134/236] docs: record credited A stack integration and verification --- .../030_quota_followup.md | 7 +++++ .../260906_a_runtime_stack/090_outcome.md | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 devlog/_plan/260906_a_final_closeout/030_quota_followup.md create mode 100644 devlog/_plan/260906_a_runtime_stack/090_outcome.md diff --git a/devlog/_plan/260906_a_final_closeout/030_quota_followup.md b/devlog/_plan/260906_a_final_closeout/030_quota_followup.md new file mode 100644 index 0000000000..2312fa234b --- /dev/null +++ b/devlog/_plan/260906_a_final_closeout/030_quota_followup.md @@ -0,0 +1,7 @@ +# Final fixture follow-up and landing + +The final candidate advanced from782e21ed7 to5097e66fa after Windows1 exposed the quota observer fixture's unjoined queue. Only that test file and its record changed; runtime and dependency trees were identical. Independent review passed, remote16tests/typecheck passed, delayed-queue controls reproduced the exact old3failures and kept all16green after repair, and suppressed delivery still failed both event-count assertions. Temporary remote mutations were restored. + +Full509CI33991642514 passed all24actual producers plusci. Finaltop3716 was owner-authorized admin-merged into dev asa2f69c8aa60976345740ae6f3d2301f89297328e. Every pending layer head is an ancestor; originals3581/3671 closed immediately after proof. Earlier3672/3679/3568 remain closed. Live source/carry states, coauthor trailers and3661OPEN were independently re-read by the final state verifier. + +The remaining gate is integrateddevCI33993960826 and docs-only archive publication. No additional runtime change is planned. diff --git a/devlog/_plan/260906_a_runtime_stack/090_outcome.md b/devlog/_plan/260906_a_runtime_stack/090_outcome.md new file mode 100644 index 0000000000..4fbac5fdd0 --- /dev/null +++ b/devlog/_plan/260906_a_runtime_stack/090_outcome.md @@ -0,0 +1,29 @@ +# A runtime and routing outcome + +All five assigned originals are closed and their credited changes are on dev. The final stack entered dev through [#3716](https://github.com/lidge-jun/opencodex/pull/3716), merge [`a2f69c8aa`](https://github.com/lidge-jun/opencodex/commit/a2f69c8aa60976345740ae6f3d2301f89297328e). GitHub automatically recognized the folded review PRs as merged; their actual dev integration is recorded below. + +| Original | Reviewed carry | Dev integration | Exact carried-head full CI | +| --- | --- | --- | --- | +| [#3672](https://github.com/lidge-jun/opencodex/pull/3672) | [#3683](https://github.com/lidge-jun/opencodex/pull/3683) | [#3683](https://github.com/lidge-jun/opencodex/pull/3683) · [`c6d8678f7`](https://github.com/lidge-jun/opencodex/commit/c6d8678f73ce6e1ae9df004ab032af09837b5b45) | [33981578769](https://github.com/lidge-jun/opencodex/actions/runs/33981578769) | +| [#3679](https://github.com/lidge-jun/opencodex/pull/3679) | [#3686](https://github.com/lidge-jun/opencodex/pull/3686) | [#3686](https://github.com/lidge-jun/opencodex/pull/3686) · [`a6d1065cf`](https://github.com/lidge-jun/opencodex/commit/a6d1065cfbadc7d8f9c02e17549908b42d2bfd7a) | [33981581047](https://github.com/lidge-jun/opencodex/actions/runs/33981581047) | +| [#3568](https://github.com/lidge-jun/opencodex/pull/3568) | [#3690](https://github.com/lidge-jun/opencodex/pull/3690) | [#3690](https://github.com/lidge-jun/opencodex/pull/3690) · [`6e15dad6a`](https://github.com/lidge-jun/opencodex/commit/6e15dad6a42682d5dbf3e61c51493385091e37a6) | [33981582675](https://github.com/lidge-jun/opencodex/actions/runs/33981582675) | +| [#3581](https://github.com/lidge-jun/opencodex/pull/3581) | [#3692](https://github.com/lidge-jun/opencodex/pull/3692) | [#3716](https://github.com/lidge-jun/opencodex/pull/3716) · [`a2f69c8aa`](https://github.com/lidge-jun/opencodex/commit/a2f69c8aa60976345740ae6f3d2301f89297328e) | [33991642514](https://github.com/lidge-jun/opencodex/actions/runs/33991642514) | +| [#3671](https://github.com/lidge-jun/opencodex/pull/3671) | [#3694](https://github.com/lidge-jun/opencodex/pull/3694) | [#3716](https://github.com/lidge-jun/opencodex/pull/3716) · [`a2f69c8aa`](https://github.com/lidge-jun/opencodex/commit/a2f69c8aa60976345740ae6f3d2301f89297328e) | [33991642514](https://github.com/lidge-jun/opencodex/actions/runs/33991642514) | + +Original author identities and account-linked Co-authored-by trailers were retained: Hako, Clive Rosfield, voiys and SB Yoon. The carried contributor commits remain ancestors of the final integration. Each source head was checked again before closure; the original WebSocket author rebase had an identical verified patch. + +## Additional verified repairs + +- #3696 stabilized Windows shutdown-spill fixtures with controlled clocks and complete ACL mocks; hosted Windows causal and negative controls passed before integration. +- #3708 kept Unix probe cleanup bounded and fail-closed while allowing the existing observation interval to confirm disappearance after transient EPERM. Replay fixtures now keep one caller credential snapshot across a forced second boundary. +- #3716 budgeted transition-probe startup from the two bounded Windows identity lookups, reported early exits, and joined children before cleanup. Quota fixtures now join their ordered observation/forget queue instead of guessing completion after five milliseconds. + +The final candidate passed all 24 actual cross-platform producers and the aggregate CI check in run33991642514. Remote regression controls covered delayed process startup, direct early exit, delayed quota delivery and deliberately suppressed delivery. Reverting the quota fixture reproduced the exact three historical failures; the repaired fixture passed all sixteen cases under the controlled delay. No local product tests, typechecks or builds ran. + +## Scope and verification record + +[#3661](https://github.com/lidge-jun/opencodex/issues/3661) remains open: this work covers the proven native MESSAGE recovery slice, not its remaining multipart/backend/caller cases. The five source PRs expose no additional closing-issue links. + +Historical failed CI jobs were preserved. One earlier Cursor echo/close timeout has no established cause; the same source subsequently passed all final cross-platform checks. It was not claimed to be fixed by the fixture changes. + +Integrated dev verification is tracked at [run33993960826](https://github.com/lidge-jun/opencodex/actions/runs/33993960826), head `a2f69c8aa60976345740ae6f3d2301f89297328e`. The final landing gate checks completion separately before this unit is archived. From bb9dd254ece0a668c6e659a5c131eae857a468b9 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 06:54:50 +0900 Subject: [PATCH 135/236] fix(gui): use stable identities for client refresh warnings --- gui/src/pages/Models.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 79e9f1a422..a78a57e6a8 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -2146,7 +2146,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; {integrationFailures.length > 0 &&
    {t("models.integrationRefreshWarning")} -