From a3f699f623734247762475a4e42872e7c46431c0 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:50:57 +0900 Subject: [PATCH 01/17] docs: harden branch content classification against renames --- devlog/_fin/260904_repo_hygiene_campaign/010_method.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/devlog/_fin/260904_repo_hygiene_campaign/010_method.md b/devlog/_fin/260904_repo_hygiene_campaign/010_method.md index 773b6db6494..de498d0c0fb 100644 --- a/devlog/_fin/260904_repo_hygiene_campaign/010_method.md +++ b/devlog/_fin/260904_repo_hygiene_campaign/010_method.md @@ -7,7 +7,7 @@ A local branch is deletable when at least one holds, and no guard fires. ``` T1 ancestry git merge-base --is-ancestor
origin/dev T2 patch-equiv git cherry origin/dev
-> no '+' lines -T3 content paths = git diff --name-only origin/dev...
+T3 content paths = git diff --no-renames --name-only origin/dev...
git diff --name-only origin/dev
-- -> empty T4 scratch branch name encodes a PR number whose state is MERGED or CLOSED AND the name matches the scratch prefix set @@ -22,6 +22,13 @@ report "unmerged" for work that is fully shipped. T3 asks the only question that is actually load-bearing — is there any difference left in the files this branch claims to change. +Rename detection must be disabled while collecting that path set. Otherwise a +rename contributes only its destination: if `dev` independently contains the +same destination but retains the source, the restricted second diff is empty +even though the complete tip trees differ. `--no-renames` emits both the deleted +source and added destination, so the source-side difference prevents a false +LANDED verdict. + T4 is deliberately narrow. It fires only for throwaway prefixes (`pr*`, `rb-`, `jrb-`, `mtp/`, `big-`, `cf-`, `ocx-`, `wip/`, `backup/`, `candidate`, `cursor-`, `midstream`) created by earlier review and rebase runs, From a4d343c246037595ac1032a540834bb5ce2ef453 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:25:49 +0900 Subject: [PATCH 02/17] docs: date the no-renames correction and align sibling commands --- .../260904_repo_hygiene_campaign/000_plan.md | 8 ++++---- .../260904_repo_hygiene_campaign/010_method.md | 18 +++++++++++------- .../100_pr_verdicts.md | 7 ++++--- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md b/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md index f7f030084a9..338920eed06 100644 --- a/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md +++ b/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md @@ -22,10 +22,10 @@ Every branch was scored on four independent axes rather than by name: 1. `git merge-base --is-ancestor
origin/dev` — plain ancestry. 2. `git cherry origin/dev
` — patch-equivalence, which catches rebases. 3. Content landing — the files the branch touches - (`git diff --name-only origin/dev...
`) are compared two-dot against - `origin/dev` restricted to exactly those paths. Zero remaining difference - means the branch's content is already on `dev` even though a squash merge - destroyed its commit identity. + (`git diff --no-renames --name-only origin/dev...
`) are compared two-dot + against `origin/dev` restricted to exactly those paths. Zero remaining + difference means the branch's content is already on `dev` even though a + squash merge destroyed its commit identity. 4. Exact reference matching against live GitHub state: open-PR head refs, worktree-backing refs, and the PR number a scratch branch was cut for. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/010_method.md b/devlog/_fin/260904_repo_hygiene_campaign/010_method.md index de498d0c0fb..c8e98858bc5 100644 --- a/devlog/_fin/260904_repo_hygiene_campaign/010_method.md +++ b/devlog/_fin/260904_repo_hygiene_campaign/010_method.md @@ -8,7 +8,7 @@ A local branch is deletable when at least one holds, and no guard fires. T1 ancestry git merge-base --is-ancestor
origin/dev T2 patch-equiv git cherry origin/dev
-> no '+' lines T3 content paths = git diff --no-renames --name-only origin/dev...
- git diff --name-only origin/dev
-- -> empty + git diff --no-renames --name-only origin/dev
-- -> empty T4 scratch branch name encodes a PR number whose state is MERGED or CLOSED AND the name matches the scratch prefix set AND the number is a WHOLE numeric token of the branch name @@ -22,12 +22,16 @@ report "unmerged" for work that is fully shipped. T3 asks the only question that is actually load-bearing — is there any difference left in the files this branch claims to change. -Rename detection must be disabled while collecting that path set. Otherwise a -rename contributes only its destination: if `dev` independently contains the -same destination but retains the source, the restricted second diff is empty -even though the complete tip trees differ. `--no-renames` emits both the deleted -source and added destination, so the source-side difference prevents a false -LANDED verdict. +Correction, 2026-09-21: the 71 deletions recorded below ran the listing command +without `--no-renames`. Rename detection must be disabled while collecting that +path set, and any rerun after this date should use the form shown above. +Otherwise a rename contributes only its destination: if `dev` independently +contains the same destination but retains the source, the restricted second +diff is empty even though the complete tip trees differ. `--no-renames` emits +both the deleted source and added destination, so the source-side difference +prevents a false LANDED verdict. No wrongly-LANDED branch has been identified +from the earlier run; this is a preventive correction for the next sweep, not a +measured incident. T4 is deliberately narrow. It fires only for throwaway prefixes (`pr*`, `rb-`, `jrb-`, `mtp/`, `big-`, `cf-`, `ocx-`, `wip/`, `backup/`, diff --git a/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md b/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md index bf8e5d701c0..8990abe5a3e 100644 --- a/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md +++ b/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md @@ -2,9 +2,10 @@ Full classification of the 53 pull requests open when the campaign started. Method: fetch each PR head, take the files it touches -(`git diff --name-only origin/dev...`), then compare those exact paths -two-dot against `origin/dev`. Remaining differences mean the work has not -landed. +(`git diff --no-renames --name-only origin/dev...`), then compare those +exact paths two-dot against `origin/dev`. Remaining differences mean the work +has not landed. (2026-09-21: `--no-renames` added so a rename cannot hide the +deleted source side from the path set; see 010_method.md.) ## Closed From 9128ba311ed849767900a634ed0c46da9677cc3e Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:27:49 +0900 Subject: [PATCH 03/17] test(oauth): exercise configured generic pool validators --- .../account-pool-management-api.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index 1c66163c87c..2bb6f1b832d 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -615,6 +615,17 @@ describe("legacy pool contract goldens (#wp5)", () => { const resp = await handleCodexAuthAPI(req, new URL(req.url), makeCodexConfig()); return resp!.status; }; + const previousHome = process.env.OPENCODEX_HOME; + const testDir = mkdtempSync(join(tmpdir(), "ocx-pool-validator-")); + process.env.OPENCODEX_HOME = testDir; + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authMode: "oauth" }, + }, + } as OcxConfig); const server = startServer(0); try { const oauth = async (payload: Record) => { @@ -623,11 +634,14 @@ describe("legacy pool contract goldens (#wp5)", () => { }); return res.status; }; - for (const strategy of ["weighted", "", 3, null]) { + for (const strategy of ["weighted", "", 3]) { expect(await codex({ strategy })).toBe(400); expect(await oauth({ provider: "anthropic", strategy })).toBe(400); expect(await oauth({ provider: "google-antigravity", strategy })).toBe(400); } + expect(await codex({ strategy: null })).toBe(400); + expect(await oauth({ provider: "anthropic", strategy: null })).toBe(400); + expect(await oauth({ provider: "google-antigravity", strategy: null })).toBe(200); // 0 and 101 sit just outside the shared bound; 1 and 100 are the edges that must pass. for (const stickyLimit of [0, 101, 1.5]) { expect(await codex({ stickyLimit })).toBe(400); @@ -639,6 +653,9 @@ describe("legacy pool contract goldens (#wp5)", () => { } } finally { await server.stop(true); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(testDir); } }); From f8dfca4b553b2e6a81491b646f027b43d8995891 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:50:16 +0900 Subject: [PATCH 04/17] test(oauth): prove the generic null-strategy clear and harden test teardown --- .../account-pool-management-api.test.ts | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index 2bb6f1b832d..1dfb3728239 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -603,11 +603,15 @@ describe("legacy pool contract goldens (#wp5)", () => { } }); - test("a bad strategy and a bad stickyLimit are rejected identically on every kind", async () => { + test("a bad strategy and a bad stickyLimit are rejected identically on every kind, with one null exception", async () => { // One validator, three adapters. The kinds keep their own request and response shapes -- // that is what the goldens above pin -- but the VALUE rules are now a single implementation, // so "quota, round-robin, fill-first" and the 1..100 sticky bound cannot drift apart per // kind. Before this, the generic kind carried a private copy of both. + // ONE deliberate exception: `strategy: null` is not a bad value on the legacy generic + // endpoint -- it clears the saved strategy and answers 200. Codex, Anthropic, and the + // unified /api/pool/settings route all reject the same null with 400. A future reader + // who sees the 200 must not "fix" it back without deciding that contract first. const codex = async (payload: Record) => { const req = new Request("http://localhost/api/codex-auth/pool-strategy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -617,23 +621,30 @@ describe("legacy pool contract goldens (#wp5)", () => { }; const previousHome = process.env.OPENCODEX_HOME; const testDir = mkdtempSync(join(tmpdir(), "ocx-pool-validator-")); - process.env.OPENCODEX_HOME = testDir; - saveConfig({ - port: 0, - hostname: "127.0.0.1", - defaultProvider: "google-antigravity", - providers: { - "google-antigravity": { adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authMode: "oauth" }, - }, - } as OcxConfig); - const server = startServer(0); + let server: ReturnType | undefined; try { + process.env.OPENCODEX_HOME = testDir; + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authMode: "oauth" }, + }, + } as OcxConfig); + server = startServer(0); const oauth = async (payload: Record) => { const res = await fetch(new URL("/api/oauth/accounts/pool", server.url), { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }); return res.status; }; + const oauthJson = async (payload: Record) => { + const res = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), + }); + return { status: res.status, body: await res.json() as { strategy?: unknown } }; + }; for (const strategy of ["weighted", "", 3]) { expect(await codex({ strategy })).toBe(400); expect(await oauth({ provider: "anthropic", strategy })).toBe(400); @@ -641,7 +652,12 @@ describe("legacy pool contract goldens (#wp5)", () => { } expect(await codex({ strategy: null })).toBe(400); expect(await oauth({ provider: "anthropic", strategy: null })).toBe(400); - expect(await oauth({ provider: "google-antigravity", strategy: null })).toBe(200); + // The generic legacy contract: null clears the saved strategy. Prove the clear actually + // happened -- a 200 that left the old strategy in place would be a silent no-op. + expect(await oauth({ provider: "google-antigravity", strategy: "round-robin" })).toBe(200); + const cleared = await oauthJson({ provider: "google-antigravity", strategy: null }); + expect(cleared.status).toBe(200); + expect(cleared.body.strategy ?? null).toBeNull(); // 0 and 101 sit just outside the shared bound; 1 and 100 are the edges that must pass. for (const stickyLimit of [0, 101, 1.5]) { expect(await codex({ stickyLimit })).toBe(400); @@ -652,7 +668,7 @@ describe("legacy pool contract goldens (#wp5)", () => { expect(await codex({ stickyLimit })).toBe(200); } } finally { - await server.stop(true); + await server?.stop(true); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; removeTreeWithRetry(testDir); From 0cc9ab566cd396357a823ffa92f7709889827f04 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:56:15 +0900 Subject: [PATCH 05/17] test(oauth): require the strategy property in the cleared response --- tests/server/account-pool-management-api.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index 1dfb3728239..fa52f8e076f 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -657,7 +657,7 @@ describe("legacy pool contract goldens (#wp5)", () => { expect(await oauth({ provider: "google-antigravity", strategy: "round-robin" })).toBe(200); const cleared = await oauthJson({ provider: "google-antigravity", strategy: null }); expect(cleared.status).toBe(200); - expect(cleared.body.strategy ?? null).toBeNull(); + expect(cleared.body).toHaveProperty("strategy", null); // 0 and 101 sit just outside the shared bound; 1 and 100 are the edges that must pass. for (const stickyLimit of [0, 101, 1.5]) { expect(await codex({ stickyLimit })).toBe(400); From 48a8725d5360fff96a32424dee690afe57220a18 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:21:39 +0900 Subject: [PATCH 06/17] fix(integrations): harden Raycast defaults probe --- src/integrations/raycast-detect.ts | 21 +++++++++++++++++---- tests/clients/raycast-detect.test.ts | 21 ++++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/integrations/raycast-detect.ts b/src/integrations/raycast-detect.ts index 7ae70edf461..1fafd992ff9 100644 --- a/src/integrations/raycast-detect.ts +++ b/src/integrations/raycast-detect.ts @@ -44,10 +44,19 @@ export interface RaycastDetectDeps { */ const RAYCAST_DEFAULTS_DOMAIN = "com.raycast.macos.v1"; const RAYCAST_SUBSCRIPTION_KEY = "subscriptions_active"; +const DEFAULTS_PATH = "/usr/bin/defaults"; +const DEFAULTS_TIMEOUT_MS = 2_000; -export function realRaycastDetectDeps(): RaycastDetectDeps { +interface RealRaycastDetectRuntime { + platform?: string; + spawnSync?: typeof Bun.spawnSync; +} + +export function realRaycastDetectDeps(runtime: RealRaycastDetectRuntime = {}): RaycastDetectDeps { + const platform = runtime.platform ?? process.platform; + const spawnSync = runtime.spawnSync ?? Bun.spawnSync; return { - platform: process.platform, + platform, homedir: homedir(), env: process.env, exists: path => { @@ -59,9 +68,13 @@ export function realRaycastDetectDeps(): RaycastDetectDeps { }, readDefault: (domain, key) => { // `defaults` is macOS-only; elsewhere the plan is simply unknown. - if (process.platform !== "darwin") return null; + if (platform !== "darwin") return null; try { - const result = Bun.spawnSync(["defaults", "read", domain, key], { stdout: "pipe", stderr: "pipe" }); + const result = spawnSync([DEFAULTS_PATH, "read", domain, key], { + stdout: "pipe", + stderr: "pipe", + timeout: DEFAULTS_TIMEOUT_MS, + }); if (result.exitCode !== 0) return null; return result.stdout.toString().trim(); } catch { diff --git a/tests/clients/raycast-detect.test.ts b/tests/clients/raycast-detect.test.ts index 4e4268b29b3..c2865a5fa76 100644 --- a/tests/clients/raycast-detect.test.ts +++ b/tests/clients/raycast-detect.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { detectRaycast, type RaycastDetectDeps } from "../../src/integrations/raycast-detect"; +import { + detectRaycast, + realRaycastDetectDeps, + type RaycastDetectDeps, +} from "../../src/integrations/raycast-detect"; /** * Stubbed deps only. The real detector spawns `defaults` and reads the @@ -29,6 +33,21 @@ function fakeDeps( } describe("detectRaycast", () => { + test("the macOS preference probe uses the system binary with a bounded runtime", () => { + let invocation: { command: string[]; timeout?: number } | undefined; + const spawnSync = ((command: string[], options: { timeout?: number }) => { + invocation = { command, timeout: options.timeout }; + return { exitCode: 0, stdout: Buffer.from("1") }; + }) as typeof Bun.spawnSync; + + const deps = realRaycastDetectDeps({ platform: "darwin", spawnSync }); + expect(deps.readDefault("com.raycast.macos.v1", "subscriptions_active")).toBe("1"); + expect(invocation).toEqual({ + command: ["/usr/bin/defaults", "read", "com.raycast.macos.v1", "subscriptions_active"], + timeout: 2_000, + }); + }); + test("darwin: a Pro subscription, the app bundle and the revealed ai folder", () => { const deps = fakeDeps("darwin", ["/Applications/Raycast.app", "/home/u/.config/raycast/ai"], { defaultValue: "1" }); expect(detectRaycast(deps)).toEqual({ From 3ea46f08188061b6e49c9ec102eebe6cfdf7e014 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:55:02 +0900 Subject: [PATCH 07/17] test(integrations): cover killed defaults probe in Raycast detection --- src/integrations/raycast-detect.ts | 2 ++ tests/clients/raycast-detect.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/src/integrations/raycast-detect.ts b/src/integrations/raycast-detect.ts index 1fafd992ff9..e0ef3191f6d 100644 --- a/src/integrations/raycast-detect.ts +++ b/src/integrations/raycast-detect.ts @@ -75,6 +75,8 @@ export function realRaycastDetectDeps(runtime: RealRaycastDetectRuntime = {}): R stderr: "pipe", timeout: DEFAULTS_TIMEOUT_MS, }); + // A timed-out or signal-killed probe reports exitCode === null; that + // and any non-zero exit mean the preference was not read. if (result.exitCode !== 0) return null; return result.stdout.toString().trim(); } catch { diff --git a/tests/clients/raycast-detect.test.ts b/tests/clients/raycast-detect.test.ts index c2865a5fa76..afdc3dbeb4f 100644 --- a/tests/clients/raycast-detect.test.ts +++ b/tests/clients/raycast-detect.test.ts @@ -74,6 +74,14 @@ describe("detectRaycast", () => { expect(detectRaycast(fakeDeps("darwin", [], { defaultValue: "" })).plan).toBe("unknown"); }); + test("darwin: a timed-out or killed defaults probe is unknown, not a false positive", () => { + // Bun reports exitCode === null when the 2s timeout kills the process. + const spawnSync = (() => ({ exitCode: null, stdout: Buffer.from("") })) as typeof Bun.spawnSync; + const deps = realRaycastDetectDeps({ platform: "darwin", spawnSync }); + expect(deps.readDefault("com.raycast.macos.v1", "subscriptions_active")).toBeNull(); + expect(detectRaycast(deps).plan).toBe("unknown"); + }); + test("win32: LOCALAPPDATA\\Programs\\Raycast is the install path and the plan is unknown", () => { const local = "C:\\Users\\u\\AppData\\Local"; const deps = fakeDeps("win32", [`${local}\\Programs\\Raycast`, "C:\\Users\\u\\.config\\raycast\\ai"], { From 88953124e1bedba63fbc7e3bfecebd606765156a Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:10:19 +0900 Subject: [PATCH 08/17] fix(reauth): stop polling terminal unknown flows --- gui/src/components/use-main-device-reauth.ts | 6 ++++++ gui/tests/main-device-reauth-ownership.test.tsx | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/gui/src/components/use-main-device-reauth.ts b/gui/src/components/use-main-device-reauth.ts index 626e004a5c1..44d18b9645f 100644 --- a/gui/src/components/use-main-device-reauth.ts +++ b/gui/src/components/use-main-device-reauth.ts @@ -181,6 +181,12 @@ export function useMainDeviceReauth(apiBase: string, onCompleted: () => void) { const dto = await res.json().catch(() => ({})) as FlowDto; if (!isCurrent() || flowRef.current !== flowId) return; if (!res.ok) { + if (res.status === 404 && dto.code === "unknown_flow") { + stopPolling(); + flowRef.current = null; + setState({ phase: "failed", code: "request_failed" }); + return; + } // Ownership continues from the Cancel click, including while DELETE // is unresolved. Never offer a replacement POST during that window, // and keep the existing poll cadence so a later terminal status remains observable. diff --git a/gui/tests/main-device-reauth-ownership.test.tsx b/gui/tests/main-device-reauth-ownership.test.tsx index 917cb5f80d1..e90315560b3 100644 --- a/gui/tests/main-device-reauth-ownership.test.tsx +++ b/gui/tests/main-device-reauth-ownership.test.tsx @@ -315,6 +315,17 @@ for (const failure of ["network", "http", "nonterminal"] as const) { }); } +test("unknown flow status stops polling after a failed cancellation", async () => { + await mount(); + await beginFlow("A"); + await invoke(() => hook.cancel()); + await reply(take("DELETE", "A"), { code: "unavailable" }, 503); + await act(async () => { for (const wake of sleepers.splice(0)) wake(); }); + await reply(take("GET", "A"), { code: "unknown_flow" }, 404); + expect(hook.state).toEqual({ phase: "failed", code: "request_failed" }); + expect(sleepers).toHaveLength(0); +}); + test("two successful cancellation replies complete the same flow only once", async () => { await mount(); await beginFlow("A"); From f6504dfde229499665103d004ee1c2984cb1efdb Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:58:22 +0900 Subject: [PATCH 09/17] fix(qoder): preserve offsets in scaffold scanning --- src/adapters/qoder/scaffold-guard.ts | 45 ++++++++++++++------ tests/providers/qoder-scaffold-guard.test.ts | 27 ++++++++++++ 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/adapters/qoder/scaffold-guard.ts b/src/adapters/qoder/scaffold-guard.ts index 8a1b6df6206..abc1a4613ef 100644 --- a/src/adapters/qoder/scaffold-guard.ts +++ b/src/adapters/qoder/scaffold-guard.ts @@ -54,11 +54,31 @@ const MAX_MARKER_LENGTH = Math.max(...ALL_MARKERS.map(marker => marker.length)); * refuse the turn. A stem running to the end of the buffer still counts: more text may be * arriving, and reading it as prose is the one reading that could release the block body. */ -function reminderOpensHere(lowered: string, at: number): boolean { - const after = lowered[at + REMINDER_OPEN.length]; +function reminderOpensHere(text: string, at: number): boolean { + const after = text[at + REMINDER_OPEN.length]; return after === undefined || /[\s/>]/.test(after); } +/** + * Find an ASCII marker without transforming `text`. + * + * Marker offsets must remain offsets into the original string. Unicode lowercasing can expand + * one code unit into several (for example, `İ` becomes `i` plus a combining dot), so an index + * obtained from `text.toLowerCase()` is unsafe to reuse with `text.slice()`. + */ +function indexOfMarker(text: string, marker: string, from = 0): number { + const last = text.length - marker.length; + outer: for (let at = Math.max(0, from); at <= last; at++) { + for (let offset = 0; offset < marker.length; offset++) { + const code = text.charCodeAt(at + offset); + const folded = code >= 65 && code <= 90 ? code + 32 : code; + if (folded !== marker.charCodeAt(offset)) continue outer; + } + return at; + } + return -1; +} + /** * Ceiling on a suppressed block before it is treated as unterminated. * @@ -80,9 +100,10 @@ export interface ScaffoldFilterResult { function heldSuffixLength(text: string): number { const limit = Math.min(MAX_MARKER_LENGTH - 1, text.length); for (let length = limit; length > 0; length--) { - const suffix = text.slice(text.length - length).toLowerCase(); for (const marker of ALL_MARKERS) { - if (marker.length > length && marker.startsWith(suffix)) return length; + if (marker.length > length && indexOfMarker(text, marker.slice(0, length), text.length - length) >= 0) { + return length; + } } } return 0; @@ -114,7 +135,6 @@ export class QoderScaffoldFilter { for (;;) { if (this.mode === "suppress") { const scan = this.suppressedTail + buffer; - const scanned = scan.toLowerCase(); // Unwind nesting rather than ending at the first closer. A reminder containing another // reminder would otherwise hand the outer block's remaining body — the MCP server list // in the reported leak — to the client as the model's answer, with a successful @@ -122,11 +142,11 @@ export class QoderScaffoldFilter { let cursor = 0; let close = -1; for (;;) { - const nextClose = scanned.indexOf(REMINDER_CLOSE, cursor); + const nextClose = indexOfMarker(scan, REMINDER_CLOSE, cursor); if (nextClose < 0) break; - let nextOpen = scanned.indexOf(REMINDER_OPEN, cursor); - while (nextOpen >= 0 && !reminderOpensHere(scanned, nextOpen)) { - nextOpen = scanned.indexOf(REMINDER_OPEN, nextOpen + 1); + let nextOpen = indexOfMarker(scan, REMINDER_OPEN, cursor); + while (nextOpen >= 0 && !reminderOpensHere(scan, nextOpen)) { + nextOpen = indexOfMarker(scan, REMINDER_OPEN, nextOpen + 1); } if (nextOpen >= 0 && nextOpen < nextClose) { this.suppressDepth += 1; @@ -159,11 +179,10 @@ export class QoderScaffoldFilter { let earliest = -1; let found = ""; - const lowered = buffer.toLowerCase(); for (const marker of ALL_MARKERS) { - let at = lowered.indexOf(marker); - while (at >= 0 && marker === REMINDER_OPEN && !reminderOpensHere(lowered, at)) { - at = lowered.indexOf(marker, at + 1); + let at = indexOfMarker(buffer, marker); + while (at >= 0 && marker === REMINDER_OPEN && !reminderOpensHere(buffer, at)) { + at = indexOfMarker(buffer, marker, at + 1); } if (at < 0) continue; // A closer sitting exactly where an opener starts cannot happen, so ties are impossible. diff --git a/tests/providers/qoder-scaffold-guard.test.ts b/tests/providers/qoder-scaffold-guard.test.ts index 65f06d7bc15..02dd8f41212 100644 --- a/tests/providers/qoder-scaffold-guard.test.ts +++ b/tests/providers/qoder-scaffold-guard.test.ts @@ -35,6 +35,22 @@ describe("QoderScaffoldFilter", () => { expect(first.text + filter.flush().text).toBe("Before.After."); }); + test("uses original-string offsets when Unicode lowercasing would expand", () => { + const expandingPrefix = "İ".repeat(64); + const filter = new QoderScaffoldFilter(); + const result = filter.push(`${expandingPrefix}${REMINDER}After.`); + expect(result.fail).toBeNull(); + expect(result.text + filter.flush().text).toBe(`${expandingPrefix}After.`); + expect(result.text).not.toContain("internal-notes"); + }); + + test("uses original-string offsets to find a closer after expanding Unicode", () => { + const filter = new QoderScaffoldFilter(); + const result = filter.push(`${"İ".repeat(64)}After.`); + expect(result.fail).toBeNull(); + expect(result.text + filter.flush().text).toBe("After."); + }); + test("catches a marker split across deltas", () => { const filter = new QoderScaffoldFilter(); // The opening tag arrives in three pieces; a per-delta scan would miss it entirely. @@ -159,6 +175,17 @@ describe("QoderScaffoldFilter", () => { }); describe("guardQoderScaffolding", () => { + test("never emits a reminder after a Unicode case-folding expansion", () => { + const { events, emit } = collect(); + const guarded = guardQoderScaffolding(emit); + const prefix = "İ".repeat(64); + guarded({ type: "text_delta", text: `${prefix}${REMINDER}` }); + guarded({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe(prefix); + expect(textOf(events)).not.toContain("internal-notes"); + expect(events[events.length - 1]!.type).toBe("done"); + }); + test("strips the reminder and still completes the turn", () => { const { events, emit } = collect(); const guarded = guardQoderScaffolding(emit); From c448a497942081990704b0da82f087e142923bd6 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 05:12:00 +0900 Subject: [PATCH 10/17] fix(responses): keep a cyber-policy stop when a 5xx body has malformed UTF-8 consumeComboFailure read 5xx bodies with fatalUtf8, so a single malformed byte rejected the whole read and replaced an otherwise recognizable cyber-policy refusal with "Provider error ". The combo then hopped instead of stopping. readBoundedResponseBody gains reportUtf8Validity: it decodes with replacement characters and reports utf8Valid at EOF (true by construction when fatalUtf8 is also set). consumeComboFailure keeps every existing trust rule for malformed 5xx bodies -- no quota evidence, usage, or ordinary classification -- and only lets the lenient decode through when it identifies a cyber-policy refusal. The quota agreement with shouldRetryCodexPoolAccountQuota is unchanged. Reimplements #5307 with a narrower classification gate. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/lib/bounded-body.ts | 30 ++++++++++++++++++ src/server/responses/core-combo-failure.ts | 31 ++++++++++--------- structure/transports/inventory.md | 8 +++++ .../cyber-policy-error-fidelity.test.ts | 31 +++++++++++++++++++ tests/server/bounded-body.test.ts | 29 +++++++++++++++++ 5 files changed, 114 insertions(+), 15 deletions(-) diff --git a/src/lib/bounded-body.ts b/src/lib/bounded-body.ts index 0b3769eaacf..effb247fb71 100644 --- a/src/lib/bounded-body.ts +++ b/src/lib/bounded-body.ts @@ -15,6 +15,8 @@ export interface BoundedBodyOptions { * Reader cancellation and lock release still run. Defaults to false. */ fatalUtf8?: boolean; + /** Report UTF-8 validity without rejecting malformed bodies. */ + reportUtf8Validity?: boolean; /** * Byte ceiling for retained body data. Defaults to BOUNDED_BODY_MAX_BYTES (64 KiB), * which suits error bodies; callers materializing whole success payloads (e.g. a @@ -44,6 +46,8 @@ export interface BoundedBodyResult { oversized: boolean; /** False means callers should use a status-only fallback, not `text`. */ displaySafe: boolean; + /** Present when reportUtf8Validity was requested and the retained body reached EOF. */ + utf8Valid?: boolean; } export interface BoundedBytesOptions { @@ -238,6 +242,14 @@ function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean, timedOut = fa } } +function decodeUtf8WithValidity(bytes: Uint8Array): { text: string; utf8Valid: boolean } { + try { + return { text: decodeUtf8([bytes], true), utf8Valid: true }; + } catch { + return { text: decodeUtf8([bytes], false), utf8Valid: false }; + } +} + /** * Consume the original response body under strict memory and time bounds. * @@ -326,6 +338,24 @@ export async function readBoundedResponseBody( const { value, done } = outcome as ReadableStreamReadResult; if (done) { + if (options.reportUtf8Validity) { + const bytes = retained.subarray(0, retainedBytes); + // A fatal decode that returned already proved the bytes valid; still + // honour the reporting contract instead of dropping utf8Valid. + const decoded = options.fatalUtf8 === true + ? { text: decodeUtf8([bytes], true), utf8Valid: true } + : decodeUtf8WithValidity(bytes); + return { + text: decoded.text, + truncated: false, + timedOut: false, + totalTimedOut: false, + inactivityTimedOut: false, + oversized: false, + displaySafe: true, + utf8Valid: decoded.utf8Valid, + }; + } return { text: decodeUtf8([retained.subarray(0, retainedBytes)], options.fatalUtf8 === true), truncated: false, diff --git a/src/server/responses/core-combo-failure.ts b/src/server/responses/core-combo-failure.ts index c14c3bb680b..e222c2ccaea 100644 --- a/src/server/responses/core-combo-failure.ts +++ b/src/server/responses/core-combo-failure.ts @@ -45,27 +45,28 @@ export async function consumeComboFailure( // a second body read, so this mirrors its normalization: raw 402/429, or a 5xx whose intact, // display-safe body carries a recognized quota message. let quotaConfirmedByBody = false; + const serverError = response.status >= 500 && response.status < 600; try { - const body = await readBoundedResponseBody(response, { - signal, - // Match shouldRetryCodexPoolAccountQuota before treating a 5xx body as quota evidence. - fatalUtf8: response.status >= 500 && response.status < 600, - }); - usage = usageFromComboFailureText(body.text); - if ( - response.status >= 500 && response.status < 600 - && body.displaySafe && !body.truncated - ) { + const body = await readBoundedResponseBody(response, { signal, reportUtf8Validity: serverError }); + // A 5xx body counts as quota or classification evidence only when it decoded as valid + // UTF-8, matching shouldRetryCodexPoolAccountQuota. A malformed byte keeps the status-only + // fallback, with one exception: a cyber-policy refusal must still stop the combo, so the + // replacement-decoded text may carry that verdict and nothing else. + const utf8Trusted = !serverError || body.utf8Valid === true; + if (utf8Trusted) usage = usageFromComboFailureText(body.text); + if (serverError && utf8Trusted && body.displaySafe && !body.truncated) { const quotaMessage = codexQuotaFailureMessage(body.text); quotaConfirmedByBody = quotaMessage !== undefined && isRateLimitOrQuotaFailureMessage(quotaMessage); } - if (body.displaySafe) { + if (body.displaySafe && !body.truncated) { const normalized = normalizeUpstreamErrorText(body.text, fallback); - classificationText = normalized.safeText; - upstreamCode = normalized.code; - upstreamMessage = normalized.message; - upstreamType = normalized.type; + if (utf8Trusted || isCyberPolicyCode(normalized.code) || isCyberPolicyMessage(normalized.safeText)) { + classificationText = normalized.safeText; + upstreamCode = normalized.code; + upstreamMessage = normalized.message; + upstreamType = normalized.type; + } } } catch (error) { if (signal?.aborted) throw error; diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index bfd34825f7d..3cb8053a5ff 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -98,6 +98,14 @@ promises are observed, and a cancellation that never settles cannot extend the r After an attached read, cleanup removes the abort listener, cancels any inactivity timer, and attempts to release the reader lock. `tests/server/bounded-body.test.ts` covers these paths. +`readBoundedResponseBody` accepts `reportUtf8Validity`: the body decodes with replacement +characters instead of rejecting, and a result that reached EOF carries `utf8Valid`. Combined with +`fatalUtf8`, a returned body is valid by construction and reports `true`. Timeout and oversized +results omit the field. `consumeComboFailure` uses it for 5xx bodies: only a valid body supplies +quota evidence, usage, or classification, and a malformed body keeps the status-only fallback +unless its lenient decode identifies a cyber-policy refusal, which must still stop the combo +(`tests/providers/cyber-policy-error-fidelity.test.ts`). + `src/oauth/orcarouter.ts` applies this reader to a successful `POST /api/v1/auth/keys` response with a 65,536-byte (64 KiB) ceiling. One 30-second signal, combined with caller cancellation, covers both fetching the response headers and consuming the body; no separate body or inactivity diff --git a/tests/providers/cyber-policy-error-fidelity.test.ts b/tests/providers/cyber-policy-error-fidelity.test.ts index 7adc39f3f22..02f5b49d17e 100644 --- a/tests/providers/cyber-policy-error-fidelity.test.ts +++ b/tests/providers/cyber-policy-error-fidelity.test.ts @@ -180,6 +180,37 @@ describe("cyber_policy error fidelity", () => { }); }); + test("malformed UTF-8 does not erase a cyber-policy stop", async () => { + const bytes = new Uint8Array([ + ...new TextEncoder().encode(JSON.stringify(CYBER_ERROR_BODY)), + 0xff, + ]); + const failure = await consumeComboFailure(new Response(bytes, { status: 502 })); + expect(failure.response.status).toBe(400); + expect(failure.upstreamCode).toBe(CYBER_POLICY_ERROR_CODE); + expect(comboFailureDecision(502, failure.classificationText, { + code: failure.upstreamCode, + })).toBe("stop"); + }); + + test("malformed UTF-8 keeps the status-only fallback for a non-cyber 5xx body", async () => { + // Only a cyber-policy verdict may be read from replacement-decoded text. Any other code + // or prose from a malformed body (here a quota code that would widen the cooldown scope) + // must not reach classification, exactly as before the body was decoded leniently. + const bytes = new Uint8Array([ + ...new TextEncoder().encode(JSON.stringify({ + error: { message: "You exceeded your current quota", type: "insufficient_quota", code: "insufficient_quota" }, + })), + 0xff, + ]); + const failure = await consumeComboFailure(new Response(bytes, { status: 502 })); + expect(failure.response.status).toBe(502); + expect(failure.classificationText).toBe("Provider error 502"); + expect(failure.upstreamCode).toBeUndefined(); + expect(failure.resetAt).toBeUndefined(); + expect(failure.usage).toBeUndefined(); + }); + test("drops Codex reset headers as well as Retry-After for a cyber-policy failure", async () => { const upstream = new Response(JSON.stringify(CYBER_ERROR_BODY), { status: 429, diff --git a/tests/server/bounded-body.test.ts b/tests/server/bounded-body.test.ts index 2a142719ae6..80d9cf0b76e 100644 --- a/tests/server/bounded-body.test.ts +++ b/tests/server/bounded-body.test.ts @@ -22,6 +22,35 @@ function responseFromChunks(...chunks: Uint8Array[]): Response { } describe("readBoundedResponseBody", () => { + test("reportUtf8Validity is honoured on the fatal decode path at EOF", async () => { + const valid = await readBoundedResponseBody(responseFromChunks(encoder.encode('{"ok":true}')), { + fatalUtf8: true, + reportUtf8Validity: true, + }); + expect(valid.utf8Valid).toBe(true); + let caught: unknown; + try { + await readBoundedResponseBody(responseFromChunks(new Uint8Array([0xff])), { + fatalUtf8: true, + reportUtf8Validity: true, + }); + } catch (error) { caught = error; } + expect(boundedBodyDecodeFailure(caught)).toBe("invalid_utf8"); + }); + + test("reportUtf8Validity reports a malformed body at EOF without rejecting it", async () => { + const valid = await readBoundedResponseBody(responseFromChunks(encoder.encode("ok")), { + reportUtf8Validity: true, + }); + expect(valid).toMatchObject({ text: "ok", utf8Valid: true, displaySafe: true, truncated: false }); + const malformed = await readBoundedResponseBody(responseFromChunks(new Uint8Array([0x6f, 0xff])), { + reportUtf8Validity: true, + }); + expect(malformed).toMatchObject({ text: "o\uFFFD", utf8Valid: false, displaySafe: true, truncated: false }); + const unrequested = await readBoundedResponseBody(responseFromChunks(encoder.encode("ok"))); + expect(unrequested.utf8Valid).toBeUndefined(); + }); + test("only actual decoder exceptions carry the decode discriminator", async () => { for (const bytes of [new Uint8Array([0xff]), new Uint8Array([0xe2, 0x82])]) { let caught: unknown; From 5c9a16f1a0ec1c3568a5298e097414be7318f67f Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 05:15:26 +0900 Subject: [PATCH 11/17] docs: separate the campaign command from the corrected rerun command The branch and PR classification summaries showed the --no-renames form as though the campaign had used it. State the command that produced the recorded verdicts and the form any rerun must use, matching the correction in 010_method.md. Follow-up to #5461. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- devlog/_fin/260904_repo_hygiene_campaign/000_plan.md | 4 +++- .../260904_repo_hygiene_campaign/100_pr_verdicts.md | 11 ++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md b/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md index 338920eed06..a27c412b9ef 100644 --- a/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md +++ b/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md @@ -17,7 +17,9 @@ every contributor whose work is carried. ## Classification of local branches -Every branch was scored on four independent axes rather than by name: +Every branch was scored on four independent axes rather than by name. Axis 3 is +shown in its corrected form; the campaign itself ran it without `--no-renames` +(see the 2026-09-21 correction in 010_method.md): 1. `git merge-base --is-ancestor
origin/dev` — plain ancestry. 2. `git cherry origin/dev
` — patch-equivalence, which catches rebases. diff --git a/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md b/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md index 8990abe5a3e..3ff033a1c98 100644 --- a/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md +++ b/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md @@ -1,11 +1,12 @@ # 100 — Per-PR verdicts Full classification of the 53 pull requests open when the campaign started. -Method: fetch each PR head, take the files it touches -(`git diff --no-renames --name-only origin/dev...`), then compare those -exact paths two-dot against `origin/dev`. Remaining differences mean the work -has not landed. (2026-09-21: `--no-renames` added so a rename cannot hide the -deleted source side from the path set; see 010_method.md.) +Method: fetch each PR head, take the files it touches, then compare those exact +paths two-dot against `origin/dev`. Remaining differences mean the work has not +landed. The verdicts below were produced with +`git diff --name-only origin/dev...`; any rerun must use +`git diff --no-renames --name-only origin/dev...` so a rename cannot hide +the deleted source side from the path set (2026-09-21; see 010_method.md). ## Closed From 9516f97805bb1fcd5823a35764fd03f65ec3d9aa Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 05:15:27 +0900 Subject: [PATCH 12/17] test(oauth): restore the pool-validator home even when shutdown throws A throwing server.stop skipped the OPENCODEX_HOME restore and temp-dir removal, leaking both into later cases. Run cleanup in an inner finally. Follow-up to #5442. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- tests/server/account-pool-management-api.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index fa52f8e076f..30d32dcb5a7 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -668,10 +668,13 @@ describe("legacy pool contract goldens (#wp5)", () => { expect(await codex({ stickyLimit })).toBe(200); } } finally { - await server?.stop(true); - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - removeTreeWithRetry(testDir); + try { + await server?.stop(true); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(testDir); + } } }); From 873591c694bcccf508911f2f655472a8810052ac Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 05:15:37 +0900 Subject: [PATCH 13/17] test(integrations): model a killed defaults probe with a type-safe result The timeout case cast a result with exitCode null directly to typeof Bun.spawnSync, which strict TypeScript can reject, and its empty stdout could not tell an exit-code check from an empty read. Cast through unknown, cover null and non-zero exits, and return "1" on stdout so ignoring the exit code would visibly report Pro. Follow-up to #5244. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- tests/clients/raycast-detect.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/clients/raycast-detect.test.ts b/tests/clients/raycast-detect.test.ts index afdc3dbeb4f..3d5fc49e6a2 100644 --- a/tests/clients/raycast-detect.test.ts +++ b/tests/clients/raycast-detect.test.ts @@ -38,7 +38,7 @@ describe("detectRaycast", () => { const spawnSync = ((command: string[], options: { timeout?: number }) => { invocation = { command, timeout: options.timeout }; return { exitCode: 0, stdout: Buffer.from("1") }; - }) as typeof Bun.spawnSync; + }) as unknown as typeof Bun.spawnSync; const deps = realRaycastDetectDeps({ platform: "darwin", spawnSync }); expect(deps.readDefault("com.raycast.macos.v1", "subscriptions_active")).toBe("1"); @@ -75,11 +75,14 @@ describe("detectRaycast", () => { }); test("darwin: a timed-out or killed defaults probe is unknown, not a false positive", () => { - // Bun reports exitCode === null when the 2s timeout kills the process. - const spawnSync = (() => ({ exitCode: null, stdout: Buffer.from("") })) as typeof Bun.spawnSync; - const deps = realRaycastDetectDeps({ platform: "darwin", spawnSync }); - expect(deps.readDefault("com.raycast.macos.v1", "subscriptions_active")).toBeNull(); - expect(detectRaycast(deps).plan).toBe("unknown"); + // A probe the 2s timeout kills reports a null or non-zero exit code; neither reads the + // preference. stdout carries "1" so a probe that ignored the exit code would report Pro. + for (const exitCode of [null, 143]) { + const spawnSync = (() => ({ exitCode, exitedDueToTimeout: true, stdout: Buffer.from("1") })) as unknown as typeof Bun.spawnSync; + const deps = realRaycastDetectDeps({ platform: "darwin", spawnSync }); + expect(deps.readDefault("com.raycast.macos.v1", "subscriptions_active")).toBeNull(); + expect(detectRaycast(deps).plan).toBe("unknown"); + } }); test("win32: LOCALAPPDATA\\Programs\\Raycast is the install path and the plan is unknown", () => { From 2c15f146748b7ff43dc7fdfa604a23aaf4d32ee6 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 05:15:57 +0900 Subject: [PATCH 14/17] docs(structure): record the terminal unknown_flow GET in the reauth contract The dashboard contract said a non-2xx GET keeps cancellation ownership and polling, and that no replacement login POST can appear before DELETE settles. A GET 404 unknown_flow now ends the flow the same way the DELETE path does, so qualify both statements as applying to retryable GET errors and state the exception in the overview. Follow-up to #5428. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- structure/gui-and-management-api.md | 2 +- structure/overview.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5be71c0bfbc..0b95f0146af 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -420,7 +420,7 @@ single forms, and the shell pattern is the part worth keeping stable: | Subagents | Featured-roster selection workspace (`gui/src/components/subagents-workspace/`). | | Combos | Rail, detail panel, and an add flow (`gui/src/components/ComboWorkspace.tsx`). | | Add provider | Catalog browser plus form and OAuth panes (`gui/src/components/provider-catalog/`, `gui/src/components/AddProviderModal.tsx`). The catalog browses four tabs — Accounts, Free, Local, Paid — where Local is a catalog-only bucket peeled out of `bucketPresets` after `presetTier` has classified; the workspace `providerTier` stays three-way, so the rail, the free-paid sort and the Free count still treat a local runtime as free. Search sits above the tabs and reaches every tab at once: while a query is live the list renders all four groups with headings and the strip becomes jump chips with counts rather than a tablist, because moving the selected tab would change the row kind under the user (a preset-select button becomes a login row). ArrowDown from the search input focuses the first enabled result action; if none is available, focus stays in the input. The tab strip wraps within narrow modals. Every nonempty note has a full-text button so narrow rows never hide content permanently; the native note dialog closes during teardown and restores focus to its trigger. Provider notes clamp to two lines and open in full in a stacked native `` owned by `AddProviderModal`, which also owns the search text so its `window` Escape handler can unwind popup, then query, then dialog. | -| Codex accounts | Account pool cards, add-account flow, switch and reset modals (`gui/src/components/CodexAccountPool.tsx`, `gui/src/components/AddCodexAccountModal.tsx`), plus the generic account-targeting picker opt-in on `gui/src/pages/codex-set-multiauth.tsx`. Add/delete/login completion is projected to one boolean before presentation; pending catalog work is a warning, not a failed account mutation. The main card's native-main device reauth (#3898) is owned by `gui/src/components/use-main-device-reauth.ts`: the dedicated `/api/codex-auth/main/reauth-device` namespace only — never the pool login route — with flowId-owned polling, an allowlisted verification URL, and no token fields accepted from payloads. The main-device reauth hook retains flow ownership from the Cancel click, while DELETE is unresolved and after retryable failure; polling normally continues. A concurrent GET HTTP error cannot expose a replacement login POST before DELETE settles. If a retryable DELETE failure races with a non-2xx GET while the flow is pending or committing, either response order preserves same-flow Cancel retry, restores the last server-provided device code, verification URL, and phase when needed, and keeps the existing poll cadence so a later terminal result remains observable. Outside same-flow cancellation ownership, a GET HTTP failure still stops polling without starting a second login POST. The cancellation-failure indication survives pending status updates until a trusted terminal result releases ownership. A successful DELETE with a terminal `failed` DTO releases it and uses the same closed failure-code mapping as polling; only `succeeded` notifies login completion. Unrecognized or nonterminal DTO status values remain retryable. A DELETE response with HTTP 404 and code `unknown_flow` releases the expired flow and shows the existing generic failure state so device re-login is available again; it claims neither login success nor confirmed cancellation. Confirmed cancellation also makes device re-login available. Start, polling and cancellation completions verify their controller or flow ownership after asynchronous response reads; replaced flows and unmounted hooks cannot update a newer flow or notify completion. Effect setup restores mounted state after the StrictMode development cleanup cycle. | +| Codex accounts | Account pool cards, add-account flow, switch and reset modals (`gui/src/components/CodexAccountPool.tsx`, `gui/src/components/AddCodexAccountModal.tsx`), plus the generic account-targeting picker opt-in on `gui/src/pages/codex-set-multiauth.tsx`. Add/delete/login completion is projected to one boolean before presentation; pending catalog work is a warning, not a failed account mutation. The main card's native-main device reauth (#3898) is owned by `gui/src/components/use-main-device-reauth.ts`: the dedicated `/api/codex-auth/main/reauth-device` namespace only — never the pool login route — with flowId-owned polling, an allowlisted verification URL, and no token fields accepted from payloads. The main-device reauth hook retains flow ownership from the Cancel click, while DELETE is unresolved and after retryable failure; polling normally continues. A concurrent retryable GET HTTP error (any non-2xx other than 404 `unknown_flow`) cannot expose a replacement login POST before DELETE settles. If a retryable DELETE failure races with such a retryable non-2xx GET while the flow is pending or committing, either response order preserves same-flow Cancel retry, restores the last server-provided device code, verification URL, and phase when needed, and keeps the existing poll cadence so a later terminal result remains observable. A GET with HTTP 404 and code `unknown_flow` is terminal even during cancellation ownership: the flow no longer exists, so polling stops, the flow is released, and the existing generic failure state appears, as on the DELETE path. Outside same-flow cancellation ownership, a GET HTTP failure still stops polling without starting a second login POST. The cancellation-failure indication survives pending status updates until a trusted terminal result releases ownership. A successful DELETE with a terminal `failed` DTO releases it and uses the same closed failure-code mapping as polling; only `succeeded` notifies login completion. Unrecognized or nonterminal DTO status values remain retryable. A DELETE response with HTTP 404 and code `unknown_flow` releases the expired flow and shows the existing generic failure state so device re-login is available again; it claims neither login success nor confirmed cancellation. Confirmed cancellation also makes device re-login available. Start, polling and cancellation completions verify their controller or flow ownership after asynchronous response reads; replaced flows and unmounted hooks cannot update a newer flow or notify completion. Effect setup restores mounted state after the StrictMode development cleanup cycle. | | Dashboard overview | Overview, Providers, and Models tabs at the page level (`gui/src/pages/Dashboard.tsx`), the 30-day token and coverage stats in the overview head (`gui/src/pages/dashboard-overview-head.tsx`), and the effort-cap, injection, maintenance, sidecar, and memory panels below it (`gui/src/pages/dashboard-overview-panels.tsx`). | The native-main reauth poller captures an immutable accepted flow id for queued callbacks. diff --git a/structure/overview.md b/structure/overview.md index e75cf6430ee..d5cd3746564 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -240,7 +240,7 @@ Raw reasoning content and provider-authored summaries remain distinct on the Res Connected-browser pairing and dashboard failure meanings follow the [management UI contract](gui-and-management-api.md#dashboard-surfaces); machine enrollment alone does not authenticate a browser. -Native-main reauthentication keeps its existing polling cadence when a non-2xx status races with retryable cancellation for the same owned flow; the [dashboard flow-ownership contract](gui-and-management-api.md#dashboard-surfaces) defines terminal release and completion notification. +Native-main reauthentication keeps its existing polling cadence when a non-2xx status races with retryable cancellation for the same owned flow; the [dashboard flow-ownership contract](gui-and-management-api.md#dashboard-surfaces) defines terminal release and completion notification. A GET answered with 404 `unknown_flow` is the exception: the flow no longer exists, so polling stops and the generic failure state appears. Cline CLI is a managed file integration: its provider settings and catalog share one recoverable journal operation. The [paired-file contract](clients/integrations.md#cline-paired-files) defines its stop/restart requirement. Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. From b185f244d080064bd5609c68c4c0d78e9cb3203c Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 05:16:08 +0900 Subject: [PATCH 15/17] fix(qoder): keep single-unit Unicode case folding in scaffold scanning Matching markers with ASCII-only folding kept offsets correct but dropped matches the lowercased scan used to make: U+212A KELVIN SIGN lowercases to an ASCII k, so tool markup passed through unsuppressed, whole or split across deltas. Fold each code unit as toLowerCase() does when the result is a single code unit; characters that expand, such as U+0130, still cannot shift offsets. Follow-up to #5366. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/adapters/qoder/scaffold-guard.ts | 23 +++++++++++++++----- tests/providers/qoder-scaffold-guard.test.ts | 19 ++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/adapters/qoder/scaffold-guard.ts b/src/adapters/qoder/scaffold-guard.ts index abc1a4613ef..529a9c0c882 100644 --- a/src/adapters/qoder/scaffold-guard.ts +++ b/src/adapters/qoder/scaffold-guard.ts @@ -60,19 +60,32 @@ function reminderOpensHere(text: string, at: number): boolean { } /** - * Find an ASCII marker without transforming `text`. + * Fold one UTF-16 code unit the way `toLowerCase()` does, when that yields one code unit. + * + * This keeps every match the lowercased scan used to make. U+212A KELVIN SIGN lowercases to an + * ASCII `k`, so `` was treated as tool markup; an ASCII-only fold would release it. + * A character whose lowercase form is longer (such as U+0130) is left as-is. + */ +function foldCodeUnit(code: number): number { + if (code >= 65 && code <= 90) return code + 32; + if (code < 128) return code; + const lowered = String.fromCharCode(code).toLowerCase(); + return lowered.length === 1 ? lowered.charCodeAt(0) : code; +} + +/** + * Find a lowercase ASCII marker without transforming `text`. * * Marker offsets must remain offsets into the original string. Unicode lowercasing can expand * one code unit into several (for example, `İ` becomes `i` plus a combining dot), so an index - * obtained from `text.toLowerCase()` is unsafe to reuse with `text.slice()`. + * obtained from `text.toLowerCase()` is unsafe to reuse with `text.slice()`. Folding one code + * unit at a time keeps the offsets and the matches. */ function indexOfMarker(text: string, marker: string, from = 0): number { const last = text.length - marker.length; outer: for (let at = Math.max(0, from); at <= last; at++) { for (let offset = 0; offset < marker.length; offset++) { - const code = text.charCodeAt(at + offset); - const folded = code >= 65 && code <= 90 ? code + 32 : code; - if (folded !== marker.charCodeAt(offset)) continue outer; + if (foldCodeUnit(text.charCodeAt(at + offset)) !== marker.charCodeAt(offset)) continue outer; } return at; } diff --git a/tests/providers/qoder-scaffold-guard.test.ts b/tests/providers/qoder-scaffold-guard.test.ts index 02dd8f41212..4c7a26d5143 100644 --- a/tests/providers/qoder-scaffold-guard.test.ts +++ b/tests/providers/qoder-scaffold-guard.test.ts @@ -152,6 +152,25 @@ describe("QoderScaffoldFilter", () => { expect(result.fail).toContain(""); }); + test("folds a Kelvin-sign spelling of invoke the way lowercasing did", () => { + // U+212A lowercases to an ASCII k in one code unit, so the old lowercased scan caught it. + const kelvin = "\u212A"; + const filter = new QoderScaffoldFilter(); + const result = filter.push(`Checking.\n\ncd /srv/private && git status\n`); + expect(result.text).toBe("Checking.\n"); + expect(result.text).not.toContain("git status"); + expect(result.fail).not.toBeNull(); + }); + + test("holds a Kelvin-sign invoke prefix split across deltas", () => { + const kelvin = "\u212A"; + const filter = new QoderScaffoldFilter(); + const first = filter.push("Checking.\n\ncd /srv/private && git status`); + expect(first.text + second.text).toBe("Checking.\n"); + expect(second.fail).not.toBeNull(); + }); + test("does not open a block on a word that merely starts with the tag name", () => { // The opener is matched without its ">", so it needs a token boundary of its own. const filter = new QoderScaffoldFilter(); From b76fa9cf6056b7d13f686fadfb952b893c36f581 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 05:26:48 +0900 Subject: [PATCH 16/17] docs(pr-assets): add the reauth unknown_flow GET before/after capture Main-account card rendered with the dev hook and the branch hook against a mocked management API (Cancel DELETE 503, then GET 404 unknown_flow). Synthetic identity only. Follow-up to #5428. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../pr-assets/5428-reauth-unknown-flow-get.jpg | Bin 0 -> 19418 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/pr-assets/5428-reauth-unknown-flow-get.jpg diff --git a/.github/pr-assets/5428-reauth-unknown-flow-get.jpg b/.github/pr-assets/5428-reauth-unknown-flow-get.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c625ed73a2193e6908cc75cfc446c10bb90f5f03 GIT binary patch literal 19418 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 Date: Wed, 23 Sep 2026 07:37:35 +0900 Subject: [PATCH 17/17] test(responses): keep a non-replayable malformed cyber stop free of retry metadata Carries the #5307-related part of cc466ed9c0, which the author added after consolidating #5307 into #5553: a malformed 502 cyber-policy body that was marked non-replayable must keep the marker, carry no Retry-After or quota reset, and still stop the combo. Document the malformed-body contract in the responses structure doc, matching the narrower classification gate this branch implements. Follow-up to #5307 (via #5553). Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- structure/transports/responses.md | 6 ++++++ .../cyber-policy-error-fidelity.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 023bddbe114..ab82bee7a43 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -11,6 +11,12 @@ Plaintext collaboration restoration treats a null namespace as absent, rejects n ## Responses HTTP/SSE +`src/server/responses/core-combo-failure.ts` keeps a cyber-policy stop from bounded +replacement-decoded error text when a 5xx body has malformed UTF-8. Every other use of a +malformed 5xx body (usage, quota and reset evidence, ordinary classification) keeps the +status-only fallback. Rebuilt failures retain the non-replayable marker; cyber-policy failures +carry neither Retry-After nor quota-reset metadata. + `/v1/responses` is the main Codex-facing endpoint. The server parses Responses input, routes to a provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to Responses-compatible streaming output. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). diff --git a/tests/providers/cyber-policy-error-fidelity.test.ts b/tests/providers/cyber-policy-error-fidelity.test.ts index 02f5b49d17e..e1b45aa1833 100644 --- a/tests/providers/cyber-policy-error-fidelity.test.ts +++ b/tests/providers/cyber-policy-error-fidelity.test.ts @@ -18,6 +18,7 @@ import { consumeComboFailure } from "../../src/server/responses/core"; import { handleResponses } from "../../src/server/responses"; import type { AdapterEvent, OcxConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { isNonReplayableResponse, markResponseNonReplayable } from "../../src/lib/upstream-retry"; import { createTestTranslatorBudget, withTestTranslatorBudget } from "../helpers/translator-budget"; const createOpenAIChatAdapter = (...args: Parameters) => @@ -193,6 +194,24 @@ describe("cyber_policy error fidelity", () => { })).toBe("stop"); }); + test("bounded recovery preserves a non-replayable malformed cyber stop", async () => { + const bytes = new Uint8Array([...new TextEncoder().encode(JSON.stringify(CYBER_ERROR_BODY)), 0xff]); + const upstream = new Response(bytes, { + status: 502, + headers: { "retry-after": "120", "x-codex-primary-reset-at": "2000000000" }, + }); + markResponseNonReplayable(upstream); + const failure = await consumeComboFailure(upstream); + expect(failure.response.status).toBe(400); + expect(failure.upstreamCode).toBe(CYBER_POLICY_ERROR_CODE); + expect(failure.nonReplayable).toBe(true); + expect(isNonReplayableResponse(failure.response)).toBe(true); + expect(failure.retryAfter).toBeUndefined(); + expect(failure.resetAt).toBeUndefined(); + expect(failure.response.headers.get("retry-after")).toBeNull(); + expect(comboFailureDecision(502, failure.classificationText, { code: failure.upstreamCode })).toBe("stop"); + }); + test("malformed UTF-8 keeps the status-only fallback for a non-cyber 5xx body", async () => { // Only a cyber-policy verdict may be read from replacement-decoded text. Any other code // or prose from a malformed body (here a quota code that would widen the cooldown scope)