From 71c57ea647fbc376d1207f11d851c09504c9c02d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 25 Aug 2026 10:37:05 +0900 Subject: [PATCH 1/4] release: v2.32.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f73ed2d0e5..063ecfe73e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.32.0", + "version": "2.32.1", "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 ec51e42d745d2645bcb22cb67855fa053ba1778e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 25 Aug 2026 20:25:22 +0900 Subject: [PATCH 2/4] release: v2.33.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 063ecfe73e..6f8499ffbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.32.1", + "version": "2.33.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 aaa9eaf37058965373dc42d1ca344e987950b6b6 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 2 Sep 2026 18:43:29 +0900 Subject: [PATCH 3/4] fix(release): pass the bump job's permissions through the reusable-workflow call (#3262) Both v2.40.0 release dispatches (33615174183 preview, 33615177849 main) died at startup_failure: a workflow_call cannot grant its callee more than the calling job holds, and dev-version-bump.yml's job declares contents+pull- requests write. #3129 wired the call but never dispatched a release, so this is its first live run. The caller job now declares exactly the callee's two permissions; no other job in release.yml gains anything. Co-authored-by: jun (cherry picked from commit 7ce0ba51834740d7b4d5ec4793f6572d84624409) --- .github/workflows/release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 458bb67e0a..261aece1d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,6 +67,14 @@ jobs: bump-dev-version: needs: publish if: ${{ inputs.dry-run != true }} + # A reusable-workflow CALL cannot grant the callee more than the calling job holds, + # and GitHub refuses the whole run at startup when the called workflow's own job + # declares permissions the caller did not pass down ("startup_failure", runs + # 33615174183 / 33615177849 — the first dispatches since #3129 wired this call). + # The callee's job declares exactly these two; nothing else in this file gains them. + permissions: + contents: write + pull-requests: write uses: ./.github/workflows/dev-version-bump.yml with: released-version: v${{ inputs.version }} From 8f7d5763a3ef9a17bc5d78bab202e3fa965a13d6 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Thu, 3 Sep 2026 15:13:58 +0900 Subject: [PATCH 4/4] fix(responses): bound streaming citation spans --- src/responses/citation-markers.ts | 83 +++++++++++++++++++++++++------ tests/citation-markers.test.ts | 16 ++++++ 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/src/responses/citation-markers.ts b/src/responses/citation-markers.ts index 5fe58142cf..0da80b811c 100644 --- a/src/responses/citation-markers.ts +++ b/src/responses/citation-markers.ts @@ -68,6 +68,10 @@ export interface CitationMarkerFilter { flush(): string; } +// Citation references are short opaque identifiers. Bounding malformed spans keeps the +// streaming parser's retained state small while still preserving their text verbatim. +const MAX_STREAMING_MARKER_SPAN_LENGTH = 4_096; + /** * Streaming filter. * @@ -77,25 +81,74 @@ export interface CitationMarkerFilter { * (removed) or the stream ends (verbatim, so nothing the model actually said is lost). */ export function createCitationMarkerFilter(): CitationMarkerFilter { - // Text from an open START that has not been terminated yet. - let held = ""; + // Keep chunks separately so one-character deltas do not repeatedly copy the complete + // unterminated span. They are joined at most once, when emitted or flushed. + let held: string[] = []; + let heldLength = 0; + + const takeHeld = (): string => { + const text = held.join(""); + held = []; + heldLength = 0; + return text; + }; + return { push(delta: string): string { - const combined = held + delta; - held = ""; - const start = combined.lastIndexOf(CITATION_MARKER_START); - if (start === -1) return stripCitationMarkers(combined); - const endAfterStart = combined.indexOf(CITATION_MARKER_END, start + 1); - if (endAfterStart !== -1) return stripCitationMarkers(combined); - // The trailing span is still open: emit everything before it, hold the rest. - held = combined.slice(start); - return stripCitationMarkers(combined.slice(0, start)); + const out: string[] = []; + let index = 0; + + while (index < delta.length) { + if (heldLength === 0) { + const start = delta.indexOf(CITATION_MARKER_START, index); + if (start === -1) { + out.push(delta.slice(index)); + break; + } + out.push(delta.slice(index, start)); + held.push(CITATION_MARKER_START); + heldLength = 1; + index = start + 1; + } + + const end = delta.indexOf(CITATION_MARKER_END, index); + const nextStart = delta.indexOf(CITATION_MARKER_START, index); + if (nextStart !== -1 && (end === -1 || nextStart < end)) { + // As in the whole-string filter, a newer unmatched START makes the older one + // malformed. Release the older text and begin withholding at the newer START. + const between = delta.slice(index, nextStart); + out.push(takeHeld(), between); + held.push(CITATION_MARKER_START); + heldLength = 1; + index = nextStart + 1; + continue; + } + + if (end !== -1) { + // A complete citation span is discarded without ever joining its chunks. + held = []; + heldLength = 0; + index = end + 1; + continue; + } + + const rest = delta.slice(index); + if (heldLength + rest.length <= MAX_STREAMING_MARKER_SPAN_LENGTH) { + if (rest) held.push(rest); + heldLength += rest.length; + break; + } + + // An implausibly large unterminated span is malformed ordinary text. Releasing + // it bounds both retained memory and work per delta; subsequent STARTs can still + // begin valid citation spans. + out.push(takeHeld()); + } + + return out.join(""); }, flush(): string { - const rest = held; - held = ""; - return rest; + return takeHeld(); }, }; } - diff --git a/tests/citation-markers.test.ts b/tests/citation-markers.test.ts index 726585457c..7f74a21488 100644 --- a/tests/citation-markers.test.ts +++ b/tests/citation-markers.test.ts @@ -87,4 +87,20 @@ describe("streaming citation marker filter (#3150)", () => { const filter = createCitationMarkerFilter(); expect(filter.push(`visible now ${S}cite`)).toBe("visible now "); }); + + test("a long unterminated span is released incrementally as malformed text", () => { + const filter = createCitationMarkerFilter(); + let out = filter.push(S); + for (let i = 0; i < 5_000; i += 1) out += filter.push("x"); + + // The bounded parser must release malformed input before close rather than retaining + // and repeatedly scanning an attacker-controlled, ever-growing span. + expect(out.length).toBeGreaterThan(0); + expect(out + filter.flush()).toBe(`${S}${"x".repeat(5_000)}`); + }); + + test("a valid span after an oversized malformed span is still removed", () => { + const malformed = `${S}${"x".repeat(5_000)}`; + expect(drain([malformed, `before${span}after`])).toBe(`${malformed}beforeafter`); + }); });