diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 5a43f759fe2..ebd12465265 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1450,7 +1450,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/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/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 c9c33cb8278..d9d648da1c4 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -581,7 +581,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/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index 3ab33edae87..1595da1f9b4 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -25,6 +25,8 @@ import { firstPartyDesired } from "../../src/claude/first-party-settings"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; +import { setLocalCaWindowsAclRunnerForTests } from "../../src/claude/intercept/local-ca-windows"; +import { resolveCurrentWindowsPrincipal } from "../../src/lib/windows-user-principal"; let root = ""; let lifecycleLock: ReturnType; @@ -72,10 +74,23 @@ beforeEach(() => { process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; process.env.CLAUDE_CONFIG_DIR = claudeDir; writeFileSync(join(root, "config.json"), JSON.stringify(config())); + // The local-CA ACL inspection spawns PowerShell under the shared 30 s principal-lookup budget, + // which a loaded hosted Windows runner can exhaust (local_ca_path_unsafe). This file tests + // first-party apply; claude-intercept-local-ca.test.ts runs the native inspection. Report the + // private ACL for the current SID, which production resolves (and caches) before this runner. + if (process.platform === "win32") { + setLocalCaWindowsAclRunnerForTests((_path, timeoutMs) => { + const sid = resolveCurrentWindowsPrincipal(timeoutMs).replace(/^\*/, ""); + return { success: true, exitCode: 0, timedOut: false, stdout: JSON.stringify({ + owner: sid, protected: true, rules: [{ sid, type: 0, rights: 2032127 }], + }) }; + }); + } }); afterEach(async () => { - await flushConfigDirHardeningAndReaps(root); + try { await flushConfigDirHardeningAndReaps(root); } + finally { setLocalCaWindowsAclRunnerForTests(null); } lifecycleLock.restore(); for (const key of ENV_KEYS) { if (previous[key] === undefined) delete process.env[key]; diff --git a/tests/providers/qoder-cn-image-validation.test.ts b/tests/providers/qoder-cn-image-validation.test.ts index 57d12a9d126..edf3291aeca 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"; @@ -20,19 +21,45 @@ function changedHeader(offset: number, value: 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 changedDimensions(width: number, height: number): Buffer { + const bytes = Buffer.from(png); + bytes.writeUInt32BE(width, 16); + bytes.writeUInt32BE(height, 20); + return bytes; +} + +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()); @@ -57,6 +84,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))], @@ -77,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.", }]); }); @@ -86,4 +116,14 @@ describe("Qoder CN decoded PNG admission", () => { expect(qoderCnImageInputSupported(request(dataUrl(sizedPng(ceiling))))).toBe(true); expect(qoderCnImageInputSupported(request(dataUrl(sizedPng(ceiling - 1))))).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); + }); });