From 7f78c4f6c8567e550a40ed7d87101cea9c6892bb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 01:59:12 +0000 Subject: [PATCH 1/3] fix(qoder): bound CN PNG raster dimensions --- docs-site/src/content/docs/guides/providers.md | 2 +- src/adapters/qoder/image-input.ts | 7 +++++-- structure/providers/chat-compat.md | 2 +- tests/providers/qoder-cn-image-validation.test.ts | 11 +++++++++++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 4959c8a019b..ff8e5d34635 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1446,7 +1446,7 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa - **Token usage:** Qoder CLI reports zero token counts unless its usage switch is on, so each preset turns it on in its scoped CLI child environment: `QODER_EXPOSE_TOKEN_USAGE=true` for Global and `QODERCN_EXPOSE_TOKEN_USAGE=true` for CN, which reads only its own prefix. - **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server. - **Output guard:** If the CLI echoes OpenCodex tool-call/result replay envelopes into assistant text, the turn fails with `vendor_scaffold_detected` (502) instead of returning the envelope as a successful answer, and neither reasoning nor tool calls following the envelope are forwarded. Reasoning starts a fresh replay-text block, so earlier prose or code fences cannot hide an envelope in later text. If the CLI's turn already ended with its own error, that error is reported instead, still without the envelope. The proxy does not retry that failure automatically. Ordinary prose and closed fenced examples follow the shared envelope recognizer; an unverified overflowing fenced example is refused. Assistant text preserves Unicode characters split across CLI frames; unpaired surrogates are replaced with U+FFFD. -- **Image Input:** Global continues forwarding supported images through the shared projection without a per-model vision allowlist. CN retains its vision-sidecar defaults. Its raw-image route admits only one canonical base64 PNG of at most 1 MiB decoded in a single user turn with `DeepSeek-Flash` or `Qwen3.8-Flash`. Use `qoderclicn 1.1.70`: [issue #7516](https://github.com/lidge-jun/opencodex/issues/7516) reports Windows 11 x64 live success for those models on both `--attachment` and inline `--input-format stream-json --output-format stream-json` stdin; OpenCodex uses only stdin. The adapter validates base64 length/padding, decoded PNG signature and basic IHDR fields before CLI discovery; it does not decompress pixels or validate every PNG chunk/CRC. The 1 MiB ceiling is an OpenCodex resource policy, not a verified vendor maximum: the reported live evidence covers a 96×96 PNG, whose RGBA raster is only 36 KiB. Larger inputs require separate verification. Other CLI versions have not been verified, and the adapter does not probe the installed version. CN refuses raw URL images, other image formats/models, multiple images, tool-result images and image-bearing history/multi-turn replay before starting the CLI with `unsupported_input_modality`; use the vision sidecar for those inputs. Injected CLI tests cover serialization and cancellation without claiming live upstream support. Qoder's supported projection keeps image blocks in conversation order and marks unsupported references as `[image omitted: unsupported reference]`. Global stateless replay can still associate historical images unreliably with their original text; CN does not enable that unverified replay surface. +- **Image Input:** Global continues forwarding supported images through the shared projection without a per-model vision allowlist. CN retains its vision-sidecar defaults. Its raw-image route admits only one canonical base64 PNG of at most 1 MiB decoded, 8192 pixels per edge, and 16 million total pixels in a single user turn with `DeepSeek-Flash` or `Qwen3.8-Flash`. Use `qoderclicn 1.1.70`: [issue #7516](https://github.com/lidge-jun/opencodex/issues/7516) reports Windows 11 x64 live success for those models on both `--attachment` and inline `--input-format stream-json --output-format stream-json` stdin; OpenCodex uses only stdin. The adapter validates base64 length/padding, decoded PNG signature and basic IHDR fields before CLI discovery; it does not decompress pixels or validate every PNG chunk/CRC. These ceilings are OpenCodex resource policy, not verified vendor maxima: the reported live evidence covers a 96×96 PNG, whose RGBA raster is only 36 KiB. Larger inputs require separate verification. Other CLI versions have not been verified, and the adapter does not probe the installed version. CN refuses raw URL images, other image formats/models, multiple images, tool-result images and image-bearing history/multi-turn replay before starting the CLI with `unsupported_input_modality`; use the vision sidecar for those inputs. Injected CLI tests cover serialization and cancellation without claiming live upstream support. Qoder's supported projection keeps image blocks in conversation order and marks unsupported references as `[image omitted: unsupported reference]`. Global stateless replay can still associate historical images unreliably with their original text; CN does not enable that unverified replay surface. - **PAT recovery:** For both Qoder presets, a multi-key API-key pool can switch to another PAT on a supported authentication rejection or account-level quota exhaustion, but only before response content or an event that makes replay unsafe. Recovery requires Qoder-specific classification and respects credential-hop and send budgets; an HTTP 401/429 or quota message alone is not permission to replay. Single-key configurations do not rotate. Sharing the CLI runner does not enable this API-key recovery for CodeBuddy or self-authenticated Claude CLI. - **Quota:** No public quota API is used, so totals and reset times are unavailable. Codes 110 (account daily limit) and 118 (personal Credits exhaustion) report `insufficient_quota` and permit consideration of PAT recovery. Other supported quota codes also report `insufficient_quota` but do not authorize it. Numeric error codes take precedence: an unrecognized numeric code does not fall back to quota or authentication text. Without a numeric code, only the supported account-daily message beginning “You've reached your daily usage limit for Chat”, with no team/model/shared qualifier, grants quota recovery; generic credit-limit text does not. Only quota failures eligible for PAT recovery enter key cooldown handling. When such a failure has no reset time, the failed key uses the existing default cooldown; nonrecoverable quota classifications do not create a cooldown through this path, and no next-day reset is inferred. - **Operators:** Qoder Global is operated by BRIGHT ZENITH PRIVATE LIMITED under the [product service terms](https://qoder.com/product-service); Qoder CN by 通义云启(杭州)信息技术有限公司 with Alibaba Cloud. Verify `ocx provider test qoder` (or `qoder-cn`) after configuring. diff --git a/src/adapters/qoder/image-input.ts b/src/adapters/qoder/image-input.ts index ea67a032e28..2e9a72008bc 100644 --- a/src/adapters/qoder/image-input.ts +++ b/src/adapters/qoder/image-input.ts @@ -4,6 +4,8 @@ import { buildConversationInput } from "../coding-agent/protocol"; // Local resource policy: #7516 verifies a 96x96 PNG, not a vendor size maximum. // 1 MiB leaves ample room over its 36 KiB RGBA raster while bounding stdin copies. const MAX_CN_PNG_BYTES = 1024 * 1024; +const MAX_CN_PNG_EDGE = 8192; +const MAX_CN_PNG_PIXELS = 16_000_000; const PNG_DATA_PREFIX = "data:image/png;base64,"; const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); const PNG_DEPTHS: Readonly> = { @@ -25,8 +27,9 @@ function boundedPngDataUrl(imageUrl: string): boolean { || bytes.readUInt32BE(8) !== 13 || bytes.readUInt32BE(12) !== 0x49484452) return false; const width = bytes.readUInt32BE(16); const height = bytes.readUInt32BE(20); - // Minimal IHDR validation only; this does not decompress or validate all chunks. - return width > 0 && width <= 0x7fffffff && height > 0 && height <= 0x7fffffff + // Bound declared raster work before the child can decode compressed pixels. + return width > 0 && width <= MAX_CN_PNG_EDGE && height > 0 && height <= MAX_CN_PNG_EDGE + && width * height <= MAX_CN_PNG_PIXELS && (PNG_DEPTHS[bytes[25]!] ?? []).includes(bytes[24]!) && bytes[26] === 0 && bytes[27] === 0 && (bytes[28] === 0 || bytes[28] === 1); } diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index a675d58bba0..77be92573ff 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -580,7 +580,7 @@ transport or URL fetch is added, and no client filename, payload, URL or metadat charges supported audio by decoded size at the conservative 256-bytes-per-token proxy, not an upstream token formula. The shared coding-agent projection in `src/adapters/coding-agent/` carries supported tool-result images as real image blocks in conversation order, history before current. Current tool-result prose retains `[image attached below]` and `[image omitted: unsupported reference]`. Qoder alone opts into omission markers for single-user input and historical user/tool-result prose through `src/adapters/qoder/image-input.ts`; CodeBuddy retains its previous bytes (historical `[image]` and single-user unsupported-reference omission), and Claude keeps its stable replay builder. Vendor tool execution stays disabled. -CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` container followed by a named bare or namespaced invoke control line in either output channel; it preserves preceding answer text, never promotes vendor prose into execution authority, and leaves discussed or quoted literals and code examples untouched. Qoder Global continues forwarding supported images without a per-model vision allowlist. CN retains its sidecar classification. Its raw-image route admits only one canonical base64 PNG of at most 1 MiB decoded in one non-developer user message, on `DeepSeek-Flash` or `Qwen3.8-Flash`; developer images, URL references, other media types/models, multiple images, tool-result images and image-bearing multi-turn replay return a fixed non-retryable 400 `unsupported_input_modality` before CLI discovery/spawn. Before CLI discovery, admission bounds encoded length, validates base64 length/padding and round-trip decoding, then checks the PNG signature and basic IHDR fields (13-byte first chunk, positive PNG-range dimensions, legal depth/colour pair, compression/filter/interlace). It does not decompress pixels or validate all chunks/CRCs. Invalid or oversized images receive the same fixed error event; text-only CN turns remain unrestricted by this gate. +CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` container followed by a named bare or namespaced invoke control line in either output channel; it preserves preceding answer text, never promotes vendor prose into execution authority, and leaves discussed or quoted literals and code examples untouched. Qoder Global continues forwarding supported images without a per-model vision allowlist. CN retains its sidecar classification. Its raw-image route admits only one canonical base64 PNG of at most 1 MiB decoded in one non-developer user message, on `DeepSeek-Flash` or `Qwen3.8-Flash`; developer images, URL references, other media types/models, multiple images, tool-result images and image-bearing multi-turn replay return a fixed non-retryable 400 `unsupported_input_modality` before CLI discovery/spawn. Before CLI discovery, admission bounds encoded length, validates base64 length/padding and round-trip decoding, then checks the PNG signature and basic IHDR fields (13-byte first chunk, positive dimensions no larger than 8192 per edge or 16 million pixels, legal depth/colour pair, compression/filter/interlace). It does not decompress pixels or validate all chunks/CRCs. Invalid or oversized images receive the same fixed error event; text-only CN turns remain unrestricted by this gate. Issue #7516 reports live verification on Windows 11 x64 with `qoderclicn 1.1.70` for those two models and a single PNG, through both `--attachment` and inline `--input-format stream-json --output-format stream-json` stdin. OpenCodex uses only stdin; attachment flags are not added. The 1 MiB decoded ceiling is local resource policy, not a vendor maximum: the reported 96×96 PNG has a 36 KiB RGBA raster; larger upstream inputs remain unverified. This is reported evidence, not a fresh live test or a guarantee for other CLI versions; operators must use the verified version (no runtime version probe is added). Injected CLI tests prove serialization, omission markers, refusals and cancellation, not upstream image understanding. Global still replays historical images with `--no-session-persistence`, whose text/image association remains unverified across turns. The scaffold/replay output guards ([ADR-7517](../clients/qoder.md#qoder-assistant-text-replay-guard)) and selector ownership ([ADR-7512](../clients/qoder.md#qoder-settings-ownership)) remain independent. Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. diff --git a/tests/providers/qoder-cn-image-validation.test.ts b/tests/providers/qoder-cn-image-validation.test.ts index 57d12a9d126..a13aa58e8eb 100644 --- a/tests/providers/qoder-cn-image-validation.test.ts +++ b/tests/providers/qoder-cn-image-validation.test.ts @@ -20,6 +20,13 @@ function changedHeader(offset: number, value: number): Buffer { return bytes; } +function changedDimensions(width: number, height: number): Buffer { + const bytes = Buffer.from(png); + bytes.writeUInt32BE(width, 16); + bytes.writeUInt32BE(height, 20); + return bytes; +} + // Insert a valid ancillary tEXt chunk before IEND to exercise exact decoded sizes. function sizedPng(size: number): Buffer { const chunk = Buffer.alloc(size - png.length, 0x20); @@ -57,6 +64,9 @@ describe("Qoder CN decoded PNG admission", () => { ["zero height", dataUrl(changedHeader(23, 0))], ["width beyond PNG range", dataUrl(changedHeader(16, 0x80))], ["height beyond PNG range", dataUrl(changedHeader(20, 0x80))], + ["width above edge ceiling", dataUrl(changedDimensions(8193, 1))], + ["height above edge ceiling", dataUrl(changedDimensions(1, 8193))], + ["pixel count above ceiling", dataUrl(changedDimensions(4001, 4000))], ["invalid bit depth", dataUrl(changedHeader(24, 3))], ["invalid colour type", dataUrl(changedHeader(25, 1))], ["invalid compression", dataUrl(changedHeader(26, 1))], @@ -85,5 +95,6 @@ describe("Qoder CN decoded PNG admission", () => { expect(qoderCnImageInputSupported(request(dataUrl(png)))).toBe(true); expect(qoderCnImageInputSupported(request(dataUrl(sizedPng(ceiling))))).toBe(true); expect(qoderCnImageInputSupported(request(dataUrl(sizedPng(ceiling - 1))))).toBe(true); + expect(qoderCnImageInputSupported(request(dataUrl(changedDimensions(8000, 2000))))).toBe(true); }); }); From 33769e1cc7c87e7e022528cd12cfbbe082d93250 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 05:36:32 +0000 Subject: [PATCH 2/3] test(qoder): encode complete PNG raster boundary fixtures --- .../qoder-cn-image-validation.test.ts | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/tests/providers/qoder-cn-image-validation.test.ts b/tests/providers/qoder-cn-image-validation.test.ts index a13aa58e8eb..aacc5364000 100644 --- a/tests/providers/qoder-cn-image-validation.test.ts +++ b/tests/providers/qoder-cn-image-validation.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test"; +import { deflateSync } from "node:zlib"; import { createQoderAdapter } from "../../src/adapters/qoder/adapter"; import { qoderCnImageInputSupported } from "../../src/adapters/qoder/image-input"; import { clearQoderBinaryCache } from "../../src/adapters/qoder/profiles"; @@ -27,19 +28,38 @@ function changedDimensions(width: number, height: number): Buffer { return bytes; } -// Insert a valid ancillary tEXt chunk before IEND to exercise exact decoded sizes. -function sizedPng(size: number): Buffer { - const chunk = Buffer.alloc(size - png.length, 0x20); - chunk.writeUInt32BE(chunk.length - 12, 0); - chunk.write("tEXt", 4, "ascii"); - chunk.write("Note\0", 8, "ascii"); +function pngChunk(type: string, data: Buffer): Buffer { + const chunk = Buffer.alloc(data.length + 12); + chunk.writeUInt32BE(data.length, 0); + chunk.write(type, 4, "ascii"); + data.copy(chunk, 8); let crc = 0xffffffff; for (const byte of chunk.subarray(4, -4)) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); } chunk.writeUInt32BE((crc ^ 0xffffffff) >>> 0, chunk.length - 4); - return Buffer.concat([png.subarray(0, -12), chunk, png.subarray(-12)]); + return chunk; +} + +// Insert a valid ancillary tEXt chunk before IEND to exercise exact decoded sizes. +function sizedPng(size: number): Buffer { + const text = Buffer.alloc(size - png.length - 12, 0x20); + text.write("Note\0", 0, "ascii"); + return Buffer.concat([png.subarray(0, -12), pngChunk("tEXt", text), png.subarray(-12)]); +} + +function rasterPng(width: number, height: number): Buffer { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; // 8-bit grayscale; no interlace. + // Every scanline includes filter 0 followed by width black pixels. + const raster = Buffer.alloc((width + 1) * height); + return Buffer.concat([ + png.subarray(0, 8), pngChunk("IHDR", header), + pngChunk("IDAT", deflateSync(raster)), pngChunk("IEND", Buffer.alloc(0)), + ]); } beforeEach(() => clearQoderBinaryCache()); @@ -95,6 +115,15 @@ describe("Qoder CN decoded PNG admission", () => { expect(qoderCnImageInputSupported(request(dataUrl(png)))).toBe(true); expect(qoderCnImageInputSupported(request(dataUrl(sizedPng(ceiling))))).toBe(true); expect(qoderCnImageInputSupported(request(dataUrl(sizedPng(ceiling - 1))))).toBe(true); - expect(qoderCnImageInputSupported(request(dataUrl(changedDimensions(8000, 2000))))).toBe(true); + }); + + test.each([ + ["pixel ceiling", 8000, 2000], + ["width ceiling", 8192, 1], + ["height ceiling", 1, 8192], + ] as const)("admits a complete PNG at the %s raster boundary", (_name, width, height) => { + const bytes = rasterPng(width, height); + expect(bytes.byteLength).toBeLessThanOrEqual(ceiling); + expect(qoderCnImageInputSupported(request(dataUrl(bytes)))).toBe(true); }); }); From 33dad092c460fb514619f7f965b55f443e0dec5f Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 31 Oct 2026 05:12:33 +0900 Subject: [PATCH 3/3] fix(qoder): explain PNG size and raster limits on rejection --- src/adapters/qoder/adapter.ts | 2 +- tests/providers/qoder-cn-image-validation.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts index 3da6e0407cd..7bf29ddbe39 100644 --- a/src/adapters/qoder/adapter.ts +++ b/src/adapters/qoder/adapter.ts @@ -280,7 +280,7 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt emit({ type: "error", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false, - message: "Qoder CN raw-image routing is verified only with qoderclicn 1.1.70, DeepSeek-Flash or Qwen3.8-Flash, and one base64 PNG in a single user turn on stream-json stdin. URL, multiple-image, tool-result and history replay surfaces remain unverified; use the vision sidecar for those inputs.", + message: "Qoder CN raw-image routing is verified only with qoderclicn 1.1.70, DeepSeek-Flash or Qwen3.8-Flash, and one base64 PNG in a single user turn on stream-json stdin. The PNG must be at most 1 MiB decoded, with each edge at most 8192 pixels and at most 16,000,000 total pixels. URL, multiple-image, tool-result and history replay surfaces remain unverified; use the vision sidecar for those inputs.", }); return; } diff --git a/tests/providers/qoder-cn-image-validation.test.ts b/tests/providers/qoder-cn-image-validation.test.ts index aacc5364000..edf3291aeca 100644 --- a/tests/providers/qoder-cn-image-validation.test.ts +++ b/tests/providers/qoder-cn-image-validation.test.ts @@ -107,7 +107,7 @@ describe("Qoder CN decoded PNG admission", () => { expect(spawns).toBe(0); expect(events).toEqual([{ type: "error", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false, - message: "Qoder CN raw-image routing is verified only with qoderclicn 1.1.70, DeepSeek-Flash or Qwen3.8-Flash, and one base64 PNG in a single user turn on stream-json stdin. URL, multiple-image, tool-result and history replay surfaces remain unverified; use the vision sidecar for those inputs.", + message: "Qoder CN raw-image routing is verified only with qoderclicn 1.1.70, DeepSeek-Flash or Qwen3.8-Flash, and one base64 PNG in a single user turn on stream-json stdin. The PNG must be at most 1 MiB decoded, with each edge at most 8192 pixels and at most 16,000,000 total pixels. URL, multiple-image, tool-result and history replay surfaces remain unverified; use the vision sidecar for those inputs.", }]); });