diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ac1a90a8c17..89d8434a4e3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1219,8 +1219,10 @@ "responses-custom-tool-repair-dispatch.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", "responses-custom-tool-stream-consistency.test.ts": "responses", + "responses-freeform-wrapper-keys.test.ts": "responses", "responses-fetch-helpers-boundary.test.ts": "responses", "responses-hosted-tool-declaration.test.ts": "responses", + "responses-hosted-tool-min-spread.test.ts": "responses", "responses-field-backfill.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", "responses-forward-incomplete-quota.test.ts": "responses", diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 2f96545c790..5832b2be8b7 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -15,6 +15,7 @@ import { getCachedCatalog, type CacheEntry } from "./devin/cloud-direct/catalog" import { collapseDevinModelUid } from "./devin/live-models"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiServer } from "../oauth/devin"; +import { isProviderIssuedThinkingSignature } from "../responses/reasoning-envelope"; import { SendBudgetExhaustedError } from "../lib/upstream-retry"; /** @@ -329,23 +330,26 @@ function assistantText(message: OcxAssistantMessage): string { * clients of the same service write #11 thinking with #12 signature and #18 * signature_type on the assistant prompt. * - * The signature attests the thinking it was produced with, so a block without - * one contributes its text and nothing else rather than borrowing a neighbour's. + * Field #12 attests the exact text at #11, and the wire has room for one pair. + * Every block that carries text is replayed, so the chain stays intact; the + * signature rides along only when the text being replayed IS the text it + * attests, which is exactly the single-block case. Several independently signed + * blocks send an unsigned prompt rather than pairing one block's attestation + * with another block's words. A signature-only block attests encrypted thinking + * that is not being replayed at all, so it is not one of these blocks and + * cannot contribute the pair. */ function assistantThinking( message: OcxAssistantMessage, ): { thinking?: string; signature?: string } { const blocks = message.content.filter( (part): part is Extract => part.type === "thinking", - ); + ).filter(part => Boolean(part.thinking)); if (blocks.length === 0) return {}; - const thinking = blocks.map(b => b.thinking).filter(Boolean).join("\n"); - // Only one signature can ride the prompt, so take the last block that has - // one: that is the block the turn actually ended on. - const signature = blocks.filter(b => b.signature).at(-1)?.signature; + const signature = blocks.length === 1 ? blocks[0]!.signature : undefined; return { - ...(thinking ? { thinking } : {}), - ...(signature ? { signature } : {}), + thinking: blocks.map(part => part.thinking).join("\n"), + ...(isProviderIssuedThinkingSignature(signature) ? { signature } : {}), }; } diff --git a/src/adapters/openai-responses/image-gen.ts b/src/adapters/openai-responses/image-gen.ts index 4b29c66ad77..4ceef1ca899 100644 --- a/src/adapters/openai-responses/image-gen.ts +++ b/src/adapters/openai-responses/image-gen.ts @@ -101,14 +101,17 @@ export function preferConfiguredHostedTools( } let input = body.input; - const strippedAdditionalToolsIndices = new Set(); + // Indices arrive in increasing order, so the first stripped container is already + // the minimum — tracking it directly avoids spreading an attacker-sized Set into + // Math.min's argument list. + let firstStrippedAdditionalToolsIndex: number | undefined; if (Array.isArray(body.input)) { let nestedChanged = false; const mappedInput = body.input.map((item, index) => { if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; const nestedTools = stripGroup(item.tools); if (nestedTools === item.tools) return item; - strippedAdditionalToolsIndices.add(index); + firstStrippedAdditionalToolsIndex ??= index; nestedChanged = true; return { ...item, tools: nestedTools }; }); @@ -127,17 +130,16 @@ export function preferConfiguredHostedTools( || (Array.isArray(input) && input.some(item => isPlainObject(item) && item.type === "additional_tools" && hasHostedImageGenTool(item.tools))); - if ((strippedTopLevelImageGenTool || strippedAdditionalToolsIndices.size > 0) && !hasHostedImageGenDeclaration) { + if ((strippedTopLevelImageGenTool || firstStrippedAdditionalToolsIndex !== undefined) && !hasHostedImageGenDeclaration) { if (strippedTopLevelImageGenTool && Array.isArray(tools)) { tools = [...tools, { type: HOSTED_IMAGE_GENERATION_TOOL }]; - } else if (strippedAdditionalToolsIndices.size > 0 && Array.isArray(input)) { + } else if (firstStrippedAdditionalToolsIndex !== undefined && Array.isArray(input)) { // Restore into the FIRST stripped container only. Tool declarations are // request-scoped, not container-scoped — the containers are separate carriers for // one tool set, so a single hosted declaration covers the request. An earlier // revision restored into every stripped container and put `image_generation` on // the wire twice; review caught it. - const firstStripped = Math.min(...strippedAdditionalToolsIndices); - input = input.map((item, index) => index === firstStripped + input = input.map((item, index) => index === firstStrippedAdditionalToolsIndex && isPlainObject(item) && Array.isArray(item.tools) ? { ...item, tools: [...item.tools, { type: HOSTED_IMAGE_GENERATION_TOOL }] } diff --git a/src/grok/reset-coupons.ts b/src/grok/reset-coupons.ts index b35afd286a6..90e9229a9c2 100644 --- a/src/grok/reset-coupons.ts +++ b/src/grok/reset-coupons.ts @@ -52,27 +52,48 @@ export function encodeVarint(value: number | bigint): Uint8Array { return new Uint8Array(bytes); } +const MAX_PROTOBUF_VARINT_BYTES = 10; + /** * Decodes a protobuf varint from bytes at offset. */ export function decodeVarint(bytes: Uint8Array, offset: number): { value: number; bytesRead: number } { + if (!Number.isInteger(offset) || offset < 0 || offset >= bytes.length) { + throw new Error("Invalid protobuf varint offset"); + } + let result = 0; - let shift = 0; let count = 0; while (offset + count < bytes.length) { + // Protobuf caps a varint at the ten bytes a 64-bit value needs. The safe-integer guard + // below cannot stand in for this bound, because a continuation byte carrying no payload + // bits contributes a part of zero, which IS a safe integer: without an explicit length + // limit an arbitrarily long run of 0x80 followed by 0x00 decoded as a valid zero, and an + // overlong zero length can normalize a malformed body into an empty coupon list. + if (count >= MAX_PROTOBUF_VARINT_BYTES) { + throw new Error("Overlong protobuf varint"); + } const b = bytes[offset + count]; - count++; - result |= (b & 0x7f) << shift; - if ((b & 0x80) === 0) break; - shift += 7; - if (shift > 35) { - // For timestamps seconds, JS safe integers suffice. - break; + const part = (b & 0x7f) * (2 ** (7 * count)); + if (!Number.isSafeInteger(part) || result > Number.MAX_SAFE_INTEGER - part) { + throw new Error("Protobuf varint exceeds JavaScript safe integer range"); } + result += part; + count++; + if ((b & 0x80) === 0) return { value: result, bytesRead: count }; } - return { value: result, bytesRead: count }; + throw new Error("Truncated protobuf varint"); +} + +function decodeLength(bytes: Uint8Array, offset: number): { start: number; end: number } { + const { value: length, bytesRead } = decodeVarint(bytes, offset); + const start = offset + bytesRead; + if (!Number.isSafeInteger(length) || length < 0 || length > bytes.length - start) { + throw new Error("Invalid protobuf length-delimited field"); + } + return { start, end: start + length }; } /** @@ -109,8 +130,8 @@ function decodeTimestamp(bytes: Uint8Array): number { offset += bytesRead; if (fieldNum === 1) seconds = value; } else if (wireType === 2) { - const { value: len, bytesRead } = decodeVarint(bytes, offset); - offset += bytesRead + len; + const { end } = decodeLength(bytes, offset); + offset = end; } else { break; } @@ -135,10 +156,9 @@ function decodeConsumerResetToken(bytes: Uint8Array): GrokResetCoupon | null { const wireType = tag & 0x7; if (wireType === 2) { - const { value: len, bytesRead: lenRead } = decodeVarint(bytes, offset); - offset += lenRead; - const sub = bytes.subarray(offset, offset + len); - offset += len; + const { start, end } = decodeLength(bytes, offset); + const sub = bytes.subarray(start, end); + offset = end; if (fieldNum === 10) { tokenId = new TextDecoder("utf-8").decode(sub); @@ -178,10 +198,9 @@ export function decodeGetRemainingResetsResponse(payload: Uint8Array): GrokReset const wireType = tag & 0x7; if (wireType === 2) { - const { value: len, bytesRead: lenRead } = decodeVarint(payload, offset); - offset += lenRead; - const sub = payload.subarray(offset, offset + len); - offset += len; + const { start, end } = decodeLength(payload, offset); + const sub = payload.subarray(start, end); + offset = end; if (fieldNum === 10) { const token = decodeConsumerResetToken(sub); diff --git a/src/responses/apply-patch-envelope.ts b/src/responses/apply-patch-envelope.ts index 7d3d885a9a0..fdca20259bc 100644 --- a/src/responses/apply-patch-envelope.ts +++ b/src/responses/apply-patch-envelope.ts @@ -34,18 +34,6 @@ function stripMarkdownCodeFence(text: string, toolName: string): string { return match ? match[1] : text; } -/** - * The single-field wrappers `unwrapFreeformToolInput` accepts for one tool name, besides the - * canonical `input`. - * - * Exported so the streaming side can hold a buffer that is still turning into one of these. - * A second list of key names beside this one is how the streamed bytes and the completed item - * come to disagree, which is the defect it exists to prevent (#5047). - */ -export function freeformFallbackKeys(toolName: string): readonly string[] { - return FREEFORM_FALLBACK_KEYS[toolName] ?? []; -} - /** Unwrap the `{input:string}` function-call wrapper used for freeform tools. */ export function unwrapFreeformToolInput(argumentsText: unknown, toolName = ""): string { if (typeof argumentsText !== "string") return ""; diff --git a/src/responses/freeform-wrapper-scan.ts b/src/responses/freeform-wrapper-scan.ts new file mode 100644 index 00000000000..5ba91987e49 --- /dev/null +++ b/src/responses/freeform-wrapper-scan.ts @@ -0,0 +1,279 @@ +// Bounded classification of a PARTIAL freeform tool-call wrapper. +// +// `unwrapFreeformToolInput` decides the completed input with `JSON.parse`, which does not care +// what order an object's properties arrive in or how their names are spelled. The streaming side +// has to reach the same answer from a prefix, and it used to do that by comparing the buffer +// against the literal `{"input":"`. Spellings `JSON.parse` calls identical therefore matched no +// wrapper at all, streamed as raw JSON, and then completed as the unwrapped body: +// `{"metadata":1,"input":"cmd"}` and `{"\u0069nput":"cmd"}` both previewed the whole object and +// completed as `cmd` (#5151). #5047 and #5129 closed the compact and whitespace spellings of the +// same disagreement; reordering and escaping are the two the literal matcher could never see, +// because RFC 8259 objects are unordered and their names are strings with escapes. +// +// So the prefix is scanned as JSON instead of matched as text. The scan answers one question — +// which wrapper, if any, the completed text will unwrap to — and it answers it three ways: +// +// `input` the canonical key is present with a string value, whatever preceded it and however +// its name was spelled. Its value can be decoded progressively, because +// `unwrapFreeformToolInput` gives an own `input` precedence over every other property. +// `raw` no wrapper can apply, because the text is not an object or because it is one that +// `JSON.parse` will reject. Completion returns the buffer, so streaming it agrees. +// `hold` undecided. A key that has not arrived yet can still change the answer, so nothing +// is published until the object parses and completion's own rule decides. +// +// The bound matters as much as the classification. A scan that walks the whole buffer on every +// delta is quadratic in the argument size, so classification gives up after +// `MAX_FREEFORM_WRAPPER_SCAN_CHARS` and holds. Giving up costs preview, never agreement: a held +// buffer is still resolved by the authoritative parse at completion. + +/** The insignificant whitespace `JSON.parse` accepts between tokens. */ +export const JSON_WHITESPACE = new Set([" ", "\t", "\n", "\r"]); + +/** The two-character escapes JSON defines, and nothing else. */ +export const JSON_ESCAPES = new Map([ + ['"', '"'], ["\\", "\\"], ["/", "/"], + ["b", "\b"], ["f", "\f"], ["n", "\n"], ["r", "\r"], ["t", "\t"], +]); + +/** + * How far a classification scan walks before it gives up and holds. + * + * A wrapper's own structure — its property names and the small scalar siblings a model puts + * beside the body — lives at the front of the object. A buffer still undecided after this much + * scanning is one whose preceding values are large, and holding it is what the routed + * restoration path already does for every unrecognized object. + */ +export const MAX_FREEFORM_WRAPPER_SCAN_CHARS = 4096; + +const CANONICAL_KEY = "input"; + +/** The buffer ends inside a token: what follows can still change the answer. */ +const HOLD = -1; +/** `JSON.parse` will reject this text no matter what is appended to it. */ +const NEVER = -2; + +export type FreeformWrapperScan = + | { kind: "hold"; parse: boolean } + | { kind: "raw" } + | { kind: "input"; valueStart: number }; + +/** Index of the first non-whitespace character at or after `from`, or HOLD past the end. */ +function skipWhitespace(text: string, from: number): number { + let i = from; + while (i < text.length && JSON_WHITESPACE.has(text[i]!)) i++; + return i < text.length ? i : HOLD; +} + +/** + * Index just past the complete JSON string opening at `from`, or HOLD / NEVER. + * + * This reports a BOUNDARY where `decodeJsonStringPrefix` reports a decoded PREFIX: one has to + * fail on a truncated string and the other has to return what it decoded so far. They answer + * different questions about the same bytes and share one escape table so they cannot disagree + * about which escapes exist. + */ +function scanString(text: string, from: number): number { + let i = from + 1; + while (i < text.length) { + const c = text[i]!; + if (c === '"') return i + 1; + if (c === "\\") { + const n = text[i + 1]; + if (n === undefined) return HOLD; + if (n === "u") { + const hex = text.slice(i + 2, i + 6); + if (hex.length < 4) return HOLD; + if (!/^[0-9a-fA-F]{4}$/.test(hex)) return NEVER; + i += 6; + continue; + } + if (!JSON_ESCAPES.has(n)) return NEVER; + i += 2; + continue; + } + // A literal control character is not legal inside a JSON string. + if (c.charCodeAt(0) <= 0x1f) return NEVER; + i++; + } + return HOLD; +} + +const JSON_NUMBER_CHARS = /[-+0-9.eE]/; +const JSON_NUMBER = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$/; + +/** Index just past one complete scalar at `from`, or HOLD / NEVER. */ +function scanScalar(text: string, from: number): number { + const c = text[from]!; + if (c === '"') return scanString(text, from); + if (c === "t" || c === "f" || c === "n") { + const literal = c === "t" ? "true" : c === "f" ? "false" : "null"; + if (text.startsWith(literal, from)) return from + literal.length; + return literal.startsWith(text.slice(from)) ? HOLD : NEVER; + } + let end = from; + while (end < text.length && JSON_NUMBER_CHARS.test(text[end]!)) end++; + if (end === from) return NEVER; + // A number running to the end of the buffer can still grow another digit or exponent. + if (end >= text.length) return HOLD; + return JSON_NUMBER.test(text.slice(from, end)) ? end : NEVER; +} + +/** Index where the value of the member whose name opens at `from` begins, or HOLD / NEVER. */ +function scanMemberKey(text: string, from: number): number { + if (text[from] !== '"') return NEVER; + const nameEnd = scanString(text, from); + if (nameEnd < 0) return nameEnd; + const colon = skipWhitespace(text, nameEnd); + if (colon === HOLD) return HOLD; + if (text[colon] !== ":") return NEVER; + return skipWhitespace(text, colon + 1); +} + +/** + * Index just past one complete JSON value at `from`, or HOLD / NEVER. + * + * Containers are walked with an explicit stack rather than recursion: the value being skipped + * is provider-controlled, and a deeply nested one must not be able to exhaust the call stack. + */ +function scanValue(text: string, from: number): number { + const closers: string[] = []; + let i = from; + for (;;) { + const c = text[i]!; + if (c === "{" || c === "[") { + const closer = c === "{" ? "}" : "]"; + closers.push(closer); + const first = skipWhitespace(text, i + 1); + if (first === HOLD) return HOLD; + if (text[first] === closer) { + closers.pop(); + i = first + 1; + } else if (closer === "}") { + const value = scanMemberKey(text, first); + if (value < 0) return value; + i = value; + continue; + } else { + i = first; + continue; + } + } else { + const end = scanScalar(text, i); + if (end < 0) return end; + i = end; + } + + // One value is complete: close whatever it finished and find the next value, if any. + for (;;) { + if (closers.length === 0) return i; + const at = skipWhitespace(text, i); + if (at === HOLD) return HOLD; + const closer = closers[closers.length - 1]!; + if (text[at] === closer) { + closers.pop(); + i = at + 1; + continue; + } + if (text[at] !== ",") return NEVER; + const next = skipWhitespace(text, at + 1); + if (next === HOLD) return HOLD; + if (closer === "}") { + const value = scanMemberKey(text, next); + if (value < 0) return value; + i = value; + } else { + i = next; + } + break; + } + } +} + +/** + * Which wrapper the completed text will unwrap to, as far as this prefix can say. + * + * Fallback keys are deliberately not recognized here. They only unwrap when exactly one of them + * carries a string, and a second one can still arrive, so no prefix decides them — which makes + * them indistinguishable from any other undecided object and lets one HOLD cover both. + */ +export function scanFreeformWrapper(text: string): FreeformWrapperScan { + // One clamp rather than a budget threaded through every helper. Every helper already holds + // when it runs off the end of what it can see, so a buffer whose classification needs more + // than this holds for exactly the right reason, and no scan can cost more than this many + // characters however large the arguments grow. Indices into the clamp are indices into the + // full text, because the clamp is a prefix of it. + const bounded = text.length > MAX_FREEFORM_WRAPPER_SCAN_CHARS + ? text.slice(0, MAX_FREEFORM_WRAPPER_SCAN_CHARS) + : text; + // `parse` is true only where this scan actually SAW the object close. Every other hold ran + // out of buffer or out of budget, and in both cases asking `JSON.parse` is work with no + // possible payoff: the first is provably incomplete, and the second would re-read a growing + // buffer on every delta that happens to end in a brace — repeated braces inside a long + // unterminated string are enough to make that quadratic. Holding a budget-exhausted prefix + // costs nothing that matters, because the value it would release arrives in the same instant + // as the authoritative completion that follows it. + const hold = (): FreeformWrapperScan => ({ kind: "hold", parse: false }); + const open = skipWhitespace(bounded, 0); + if (open === HOLD) return hold(); + // Not an object, so no wrapper rule reaches it: arrays, scalars and ordinary bodies all + // complete as themselves. + if (bounded[open] !== "{") return { kind: "raw" }; + + let i = open + 1; + for (;;) { + const at = skipWhitespace(bounded, i); + if (at === HOLD) return hold(); + if (bounded[at] === "}") return afterTopLevelClose(bounded, at + 1); + if (bounded[at] !== '"') return { kind: "raw" }; + + const nameEnd = scanString(bounded, at); + if (nameEnd === HOLD) return hold(); + if (nameEnd === NEVER) return { kind: "raw" }; + let name: string; + try { + // The authoritative decoder for the name, so `{"\u0069nput":...}` resolves to the same + // key completion sees. Decoding it by hand here is the second implementation that would + // drift from `JSON.parse`, which is how this defect existed in the first place. + name = JSON.parse(bounded.slice(at, nameEnd)) as string; + } catch { + return { kind: "raw" }; + } + + const colon = skipWhitespace(bounded, nameEnd); + if (colon === HOLD) return hold(); + if (bounded[colon] !== ":") return { kind: "raw" }; + const valueAt = skipWhitespace(bounded, colon + 1); + if (valueAt === HOLD) return hold(); + + if (name === CANONICAL_KEY) { + // An own `input` wins over everything else in the object, so this is decidable now. A + // non-string value is decidable too, in the other direction: completion hands back the + // argument text unchanged rather than unwrapping a value it cannot use. + return bounded[valueAt] === '"' + ? { kind: "input", valueStart: valueAt + 1 } + : { kind: "raw" }; + } + + const valueEnd = scanValue(bounded, valueAt); + if (valueEnd === HOLD) return hold(); + if (valueEnd === NEVER) return { kind: "raw" }; + + const next = skipWhitespace(bounded, valueEnd); + if (next === HOLD) return hold(); + if (bounded[next] === ",") { + i = next + 1; + continue; + } + if (bounded[next] === "}") return afterTopLevelClose(bounded, next + 1); + return { kind: "raw" }; + } +} + +/** + * The object closed without a canonical key. Only whitespace may follow one that parses, so + * anything else makes the text raw; otherwise the completed parse decides between a fallback + * wrapper and no wrapper at all. + */ +function afterTopLevelClose(text: string, from: number): FreeformWrapperScan { + return skipWhitespace(text, from) === HOLD ? { kind: "hold", parse: true } : { kind: "raw" }; +} diff --git a/src/responses/progressive-freeform-input.ts b/src/responses/progressive-freeform-input.ts index eb080598b92..8ac1af79013 100644 --- a/src/responses/progressive-freeform-input.ts +++ b/src/responses/progressive-freeform-input.ts @@ -1,36 +1,5 @@ -import { freeformFallbackKeys, unwrapFreeformToolInput } from "./apply-patch-envelope"; - -const JSON_WHITESPACE = new Set([" ", "\t", "\n", "\r"]); - -type WrapperOpening = - | { state: "none" } - | { state: "prefix" } - | { state: "open"; valueStart: number }; - -/** - * Where the string value of `{"":"` begins, tolerating the insignificant whitespace - * `JSON.parse` accepts. - * - * The earlier form of this compared the buffer against the compact literal `{"key":"`, so a - * wrapper written with spaces or newlines matched no prefix at all, streamed as raw JSON - * deltas and then completed as the unwrapped body. That is the same delta/completion - * disagreement #5047 closed for compact wrappers, reached through a different spelling: - * `unwrapFreeformToolInput` reads the completed text with `JSON.parse`, which does not care - * how the object is laid out, so neither can the streaming side. - */ -function wrapperOpening(args: string, key: string): WrapperOpening { - let index = 0; - for (const token of ["{", `"${key}"`, ":", '"']) { - while (index < args.length && JSON_WHITESPACE.has(args[index]!)) index++; - if (index >= args.length) return { state: "prefix" }; - for (const expected of token) { - if (index >= args.length) return { state: "prefix" }; - if (args[index] !== expected) return { state: "none" }; - index++; - } - } - return { state: "open", valueStart: index }; -} +import { unwrapFreeformToolInput } from "./apply-patch-envelope"; +import { JSON_ESCAPES, scanFreeformWrapper } from "./freeform-wrapper-scan"; /** * Whether a body could still grow into one complete outer Markdown fence. @@ -48,11 +17,6 @@ function mayBecomeFencedBody(text: string, toolName: string): boolean { return head.startsWith("```") || "```".startsWith(head); } -/** The two-character escapes JSON defines, and nothing else. */ -const JSON_ESCAPES = new Map([ - ['"', '"'], ["\\", "\\"], ["/", "/"], - ["b", "\b"], ["f", "\f"], ["n", "\n"], ["r", "\r"], ["t", "\t"], -]); const LOW_SURROGATE_ESCAPE = /^\\u[dD][c-fC-F][0-9a-fA-F]{2}$/; /** @@ -128,31 +92,39 @@ function decodeJsonStringPrefix(body: string): string | null { * damage is what is available without giving up progressive streaming, and the args are * unusable in that case whichever representation wins. * - * A fallback key is not. It only unwraps when it is the SINGLE string field, and a second - * key can still arrive — so a value emitted early would have to be taken back. That is the - * rewind this holds instead: stream nothing until the object closes, then publish the one + * A fallback key is not decidable. It only unwraps when it is the SINGLE string field, and a + * second key can still arrive — so a value emitted early would have to be taken back. That is + * the rewind this holds instead: stream nothing until the object closes, then publish the one * repaired body. The routed passthrough in `responses-custom-tool-repair.ts` already holds * any object prefix for the same reason (#5047). + * + * An object that has not reached a canonical key YET is in exactly that position, and used to + * be treated as raw because it did not match the literal `{"input":"`. It holds now: `input` + * can still arrive after other properties, or wearing an escaped spelling, and completion would + * then unwrap a body whose wrapper syntax had already been published as deltas (#5151). The + * cost of holding is preview on an object body that turns out not to be a wrapper; the cost of + * not holding was publishing bytes the completed item removes. */ export function progressiveFreeformInput(args: string, toolName: string): string | null { - const openings = ["input", ...freeformFallbackKeys(toolName)] - .map(key => ({ key, opening: wrapperOpening(args, key) })); - const canonical = openings[0]!.opening; - if (canonical.state === "open") { - const decoded = decodeJsonStringPrefix(args.slice(canonical.valueStart)); + const scan = scanFreeformWrapper(args); + if (scan.kind === "input") { + const decoded = decodeJsonStringPrefix(args.slice(scan.valueStart)); if (decoded === null) return null; return mayBecomeFencedBody(decoded, toolName) ? null : decoded; } - if (openings.some(entry => entry.opening.state === "open")) { - // Committed to a fallback wrapper. Undecidable until the object is complete. - try { - JSON.parse(args); - } catch { - return null; - } - return unwrapFreeformToolInput(args, toolName); + if (scan.kind === "raw") return mayBecomeFencedBody(args, toolName) ? null : args; + + // Undecided: some wrapper may still apply, and only the completed object says which one. + // The parse runs exactly where the scan SAW the object close, so it reads a buffer the scan + // already walked and its cost is bounded by the same clamp. A hold from either limit stays + // held: an incomplete object has nothing to parse, and re-reading a budget-exhausted buffer + // on every delta is quadratic work for a delta that would arrive in the same instant as the + // authoritative completion behind it. + if (!scan.parse) return null; + try { + JSON.parse(args); + } catch { + return null; } - // Still an ambiguous prefix of some wrapper: which wrapper, if any, is not known yet. - if (openings.some(entry => entry.opening.state === "prefix")) return null; - return mayBecomeFencedBody(args, toolName) ? null : args; + return unwrapFreeformToolInput(args, toolName); } diff --git a/src/responses/reasoning-envelope.ts b/src/responses/reasoning-envelope.ts index 9b97dd060ab..60612864fdd 100644 --- a/src/responses/reasoning-envelope.ts +++ b/src/responses/reasoning-envelope.ts @@ -63,6 +63,36 @@ export function encodeReasoningEnvelope(envelope: ReasoningEnvelope, budget?: Tr } } +/** + * Whether a thinking part's `signature` is an attestation some provider issued, rather than + * the serialized reasoning item the parser parks in the same field. + * + * `src/responses/parser.ts` stores `JSON.stringify(reasoningItem)` on an UNSIGNED thinking + * part so the opaque item survives a same-provider round trip. Every adapter that forwards a + * signature upstream is forwarding an opaque attestation, and a provider asked to verify our + * own parser state cannot recognize it. The predicate lives beside the envelope code that owns + * this field's representation rather than in one adapter, because a private second copy is how + * the side that writes the field and a side that reads it come to disagree about what it holds. + * + * This is a deny-list for exactly that one shape, not a guess at what an attestation looks + * like. `src/adapters/anthropic.ts` applies a stricter allow-list on top of it, because an + * Anthropic signature has a known base64 spelling; that shape is a fact about Anthropic's wire + * and is not assumed of any other provider's token here. + */ +export function isProviderIssuedThinkingSignature( + signature: string | undefined, +): signature is string { + if (typeof signature !== "string" || signature.length === 0) return false; + if (!signature.startsWith("{")) return true; + try { + const parsed: unknown = JSON.parse(signature); + return !parsed || typeof parsed !== "object" || Array.isArray(parsed) + || (parsed as { type?: unknown }).type !== "reasoning"; + } catch { + return true; + } +} + /** Decode an ocxr1 envelope; returns null for native (OpenAI-encrypted) blobs or garbage. */ export function decodeReasoningEnvelope(encryptedContent: string, budget?: TranslatorBudget): ReasoningEnvelope | null { if (!encryptedContent.startsWith(OCX_REASONING_PREFIX)) return null; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index ff8218a30e1..1d1e0a7871e 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -52,6 +52,19 @@ Some adapters share another adapter's routed-tool semantics while retaining inde adapter accepts them on return. One tool's canonical identity can be another tool's advertised local name, and resolving that name to either owner would dispatch the call to a tool the caller may not have named, so it is treated as ambiguous and fails before dispatch too. + Assistant reasoning replay likewise follows the Cognition wire shape. One history prompt carries + a single thinking/signature pair, so every block with text is replayed at #11 and #12 is attached + only when the text being replayed is the text that signature attests — the single-block case. + Several independently signed blocks send the joined chain unsigned rather than pairing one + block's attestation with another block's words, and rather than dropping reasoning the turn + produced to keep a pair. A signature-only block carries encrypted thinking that is not replayed, + so it contributes neither the text nor the signature. The signature is replayed only when the + source envelope actually carried one: the serialized reasoning item the Responses parser parks + on unsigned thinking parts is provider state, not an attestation, and + `isProviderIssuedThinkingSignature` in `src/responses/reasoning-envelope.ts` denies that one + shape beside the code that writes it. It is a deny-list rather than a guess at what an opaque + token looks like; the stricter base64 allow-list in `src/adapters/anthropic.ts` is a fact about + Anthropic's wire and is not assumed of Cognition's. There is no second Devin transport. An Agent Client Protocol adapter that spawned a local `devin acp` child once existed under the `devin-cli` adapter id and was removed: the CLI's diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 942fa3e680f..0ae510c28a6 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -27,7 +27,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Transport | Owner | Invariant worth knowing | | --- | --- | --- | | Azure OpenAI Responses | `src/adapters/azure.ts` | Deployment-shaped URLs on top of the Responses contract. | -| Responses custom-tool preview | `src/bridge/sse.ts`, `src/server/responses-custom-tool-repair.ts`, `src/responses/progressive-freeform-input.ts` | Direct adapter events and routed function restoration share one progressive wrapper decoder. Fence-shaped `exec`/`apply_patch` prefixes stay held until authoritative completion normalization, while each caller retains its own patch-envelope and byte-budget policy. | +| Responses custom-tool preview | `src/bridge/sse.ts`, `src/server/responses-custom-tool-repair.ts`, `src/responses/progressive-freeform-input.ts`, `src/responses/freeform-wrapper-scan.ts` | Direct adapter events and routed function restoration share one progressive wrapper decoder over one bounded JSON classification of the prefix, so property order and escaped key spellings preview as the wrapper completion unwraps them. Fence-shaped `exec`/`apply_patch` prefixes stay held until authoritative completion normalization, while each caller retains its own patch-envelope and byte-budget policy. | | Meta Muse Responses tool names | `src/responses/muse-tool-name-alias.ts`, `src/adapters/openai-responses.ts` | `api.meta.ai` only: function names over 64 characters or containing characters outside `[a-zA-Z0-9_-]` become collision-safe wire aliases and are restored before the client sees them. | | Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts`, `src/adapters/google-wire-shape.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. The Google-family wrapper reuses shared abort/deadline helpers, upstream error normalization, and policy-aware wire-body repair: strict initial schema loss sends nothing, while strict repair withholding returns the original 400 without a changed send. The final compiler produces the [content-free tool-schema loss contract](../providers/google.md#google-tool-schema-loss-reporting). `google-wire-shape.ts` remains diagnostic-only. | | Mimo Free | `src/adapters/mimo-free.ts` | Client identity and JWT handling are transport-local; the per-install client id lives in the opencodex state root. | diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 57206ad0f2d..d39ef8660ee 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -127,13 +127,42 @@ therefore remain untouched. Foreign freeform grammars never receive that compati Progressive preview for those wrappers is decoded by `src/responses/progressive-freeform-input.ts` in both the adapter-event bridge and routed -function-call restoration. A prefix that can still become a complete outer fence stays held so -completion never removes bytes already published in a delta; ordinary raw input remains -progressive, fallback fields wait for a complete parse, and JSON escapes emit only complete -decoded units. Routed restoration additionally keeps its existing hold for an unrecognized JSON -object and its separate code-mode patch-envelope hold. Duplicate `input` keys and wrappers that -become invalid only after a valid prefix was emitted remain bounded exceptions: completion is -authoritative because preserving progressive canonical input leaves no rewind mechanism. +function-call restoration, over the classification in `src/responses/freeform-wrapper-scan.ts`. +A prefix that can still become a complete outer fence stays held so completion never removes +bytes already published in a delta; a body that is not shaped like a wrapper object remains +progressive, and JSON escapes emit only complete decoded units. + +Which wrapper applies is decided by scanning the prefix as JSON rather than matching it against +a literal opening. `JSON.parse` decides the completed input, and it cares about neither property +order nor how a name is spelled, so a canonical key arriving after other properties or written +with an escape is the same wrapper and has to preview as one (#5151). The buffering policy that +follows is: an own `input` with a string value streams progressively, because completion gives +it precedence over everything else in the object whatever its position; an `input` with a +non-string value, a text that is not an object, and an object `JSON.parse` can no longer accept +all publish their own bytes, because that is what completion returns for them; every other +object HOLDS until it parses, because a key that has not arrived yet can still change the +answer. Fallback fields fall out of that last rule rather than being recognized separately: +they only unwrap as the single string field, so no prefix decides them. Classification is +bounded to `MAX_FREEFORM_WRAPPER_SCAN_CHARS`, which keeps the work per delta from growing with +the arguments. Past the bound nothing is previewed at all: the authoritative parse still +unwraps the wrapper at completion, so the bound costs preview and never agreement. The parse +that releases a held object therefore runs only where the scan SAW the object close, which is +what keeps a buffer whose deltas happen to end on a brace from being re-read on every one of +them. + +What that policy costs is worth stating plainly, because it is a real narrowing. A body that IS +a parseable JSON object but not a wrapper — `{"code":1}` or `{"code":"a","script":"b"}` — now +reaches the direct bridge in one delta when the object closes, where it previously streamed as +it arrived. That is not a tuning choice: `input` can still arrive after any property, so any +prefix published before the object closes is a prefix that completion may unwrap away. Routed +restoration has held exactly these bodies since #5047 and this is the two paths agreeing, not a +new restriction invented for one of them. Bodies that are not objects, which is what an `exec` +program or an `apply_patch` envelope actually looks like, are unaffected and still stream. + +Routed restoration additionally keeps its existing hold for an unrecognized JSON object and its +separate code-mode patch-envelope hold. Duplicate `input` keys and wrappers that become invalid +only after a valid prefix was emitted remain bounded exceptions: completion is authoritative +because preserving progressive canonical input leaves no rewind mechanism. Codex-private tool fields are removed at the same boundary from one table (`CANONICAL_ONLY_TOOL_FIELDS`) rather than one bespoke pass each: `external_web_access` on either diff --git a/tests/adapters/bridge.test.ts b/tests/adapters/bridge.test.ts index 9b51a25c5a1..3737538a442 100644 --- a/tests/adapters/bridge.test.ts +++ b/tests/adapters/bridge.test.ts @@ -1786,8 +1786,10 @@ describe("fallback freeform wrappers stream one stable representation (#5047)", expect(inputView(await streamExec([wrapper]))) .toEqual({ concatenated: wrapper, done: wrapper, itemInput: wrapper }); - // A non-string value is not a wrapper either, and it never matched `{"code":"`, so it was - // never held: this pins that ordinary bodies keep streaming immediately. + // A non-string value is not a wrapper either. Since #5151 an object-shaped body is held + // until it parses, because a canonical `input` can still arrive after any property, so + // what this pins is the bytes rather than when they leave: the object reaches the client + // byte-exact and unrepaired. Bodies that are not objects still stream as they arrive. const numeric = JSON.stringify({ code: 1 }); expect(inputView(await streamExec([numeric]))) .toEqual({ concatenated: numeric, done: numeric, itemInput: numeric }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d677aea796a..a82ef6514a2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1046,9 +1046,11 @@ "responses-custom-tool-repair-dispatch.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", "responses-custom-tool-stream-consistency.test.ts": "responses", + "responses-freeform-wrapper-keys.test.ts": "responses", "responses-default-namespace-emit-normalize.test.ts": "responses", "responses-fetch-helpers-boundary.test.ts": "responses", "responses-hosted-tool-declaration.test.ts": "responses", + "responses-hosted-tool-min-spread.test.ts": "responses", "responses-field-backfill.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", "responses-forward-incomplete-quota.test.ts": "responses", diff --git a/tests/providers/devin-hardening.test.ts b/tests/providers/devin-hardening.test.ts index 319b1330614..0e9323b5be3 100644 --- a/tests/providers/devin-hardening.test.ts +++ b/tests/providers/devin-hardening.test.ts @@ -12,6 +12,8 @@ import { connectTrailerHttpStatus } from "../../src/adapters/devin/cloud-direct/ import { devinErrorClassification, mergeDevinUsage } from "../../src/adapters/devin"; import { iterFields } from "../../src/adapters/devin/cloud-direct/wire"; import { buildMetadata, normalizeDevinSessionToken } from "../../src/adapters/devin/cloud-direct/metadata"; +import { parseRequest } from "../../src/responses/parser"; +import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; /** Tag -> field for one encoded proto message. */ function iterFieldMap(buf: Buffer): Record { @@ -496,6 +498,128 @@ describe("devin reasoning replay", () => { expect(history.find(m => m.role === "assistant")?.thinking).toBe("only thought"); }); + test("independently signed blocks replay every chain and go unsigned rather than mispaired", () => { + // The wire carries one thinking/signature pair. Sending every block's text under the last + // block's signature attests words that signature never covered; sending only the last + // block's text to keep the pair throws away reasoning the turn actually produced. #11 takes + // the whole chain and #12 is omitted, because no single attestation covers the joined text. + const history = mapOcxMessagesToDevin(parsedWith([ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "first thought", signature: "sig-first" }, + { type: "thinking", thinking: "final thought", signature: "sig-final" }, + ], + }, + ])); + expect(history[0]?.thinking).toBe("first thought\nfinal thought"); + expect(history[0]?.signature).toBeUndefined(); + + // One signed block is the ordinary shape and still pairs: the text replayed IS the text + // the signature attests, so dropping #12 here would lose a valid attestation for nothing. + const single = mapOcxMessagesToDevin(parsedWith([ + { role: "assistant", content: [{ type: "thinking", thinking: "only thought", signature: "sig-only" }] }, + ])); + expect(single[0]?.thinking).toBe("only thought"); + expect(single[0]?.signature).toBe("sig-only"); + + // A signed block followed by an unsigned one is the same ambiguity in the other direction. + const unsignedLast = mapOcxMessagesToDevin(parsedWith([ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "signed thought", signature: "sig-signed" }, + { type: "thinking", thinking: "unsigned thought" }, + ], + }, + ])); + expect(unsignedLast[0]?.thinking).toBe("signed thought\nunsigned thought"); + expect(unsignedLast[0]?.signature).toBeUndefined(); + }); + + test("a signature-only tail block cannot steal the pair or drop the turn", () => { + // Encrypted-only reasoning parts (thinking: "" + signature) are real: the + // Responses parser emits them for opaque blobs. Picking one as the replay + // unit used to send a signature with no thinking and drop a reasoning-only + // turn outright. Fed through the real parser: direct part injection used to + // bypass the shapes replay actually carries (including the unsigned-item + // signature dump covered below). + const reasoningOnly = mapOcxMessagesToDevin(parseRequest({ + model: "swe-2", + input: [ + { type: "reasoning", id: "rs_signed", summary: [], encrypted_content: encodeReasoningEnvelope({ txt: "signed thought", sig: "sig-signed" }) }, + { type: "reasoning", id: "rs_orphan", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: "sig-orphan" }) }, + ], + })); + expect(reasoningOnly[0]?.thinking).toBe("signed thought"); + expect(reasoningOnly[0]?.signature).toBe("sig-signed"); + + const trailingText = mapOcxMessagesToDevin(parseRequest({ + model: "swe-2", + input: [ + { type: "reasoning", id: "rs_signed", summary: [], encrypted_content: encodeReasoningEnvelope({ txt: "signed thought", sig: "sig-signed" }) }, + { type: "reasoning", id: "rs_orphan", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: "sig-orphan" }) }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, + ], + })); + const assistant = trailingText.find(m => m.role === "assistant"); + expect(assistant?.thinking).toBe("signed thought"); + expect(assistant?.signature).toBe("sig-signed"); + expect(assistant?.content).toBe("answer"); + }); + + test("an unsigned reasoning part's serialized item is never sent as the signature", () => { + // The parser stores JSON.stringify(reasoningItem) on an unsigned thinking + // part so the opaque item survives a same-provider round trip. Cognition's + // #12 expects the service's own issued token, so the dump must be dropped + // at the field boundary rather than relayed as an attestation. + const parsed = parseRequest({ + model: "swe-2", + input: [ + { type: "reasoning", id: "rs_unsigned", summary: [{ type: "summary_text", text: "unsigned thought" }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, + ], + }); + const thinkingPart = parsed.context.messages + .find(m => m.role === "assistant")?.content + .find(p => p.type === "thinking") as { signature?: string } | undefined; + const dumped = JSON.parse(thinkingPart?.signature ?? "null") as { type?: string } | null; + expect(dumped?.type).toBe("reasoning"); + + const history = mapOcxMessagesToDevin(parsed); + const assistant = history.find(m => m.role === "assistant"); + expect(assistant?.thinking).toBe("unsigned thought"); + expect(assistant?.signature).toBeUndefined(); + + const unsignedReq = buildGetChatMessageRequestForTests({ + apiKey: "devin-session-token$x", + modelUid: "swe-2", + messages: history, + cascadeId: "c", + } as never); + const unsignedPrompts = fieldsOf(unsignedReq)[3] ?? []; + const unsignedPrompt = unsignedPrompts.map(fieldsOf).find(p => p[11]); + expect(unsignedPrompt?.[11]?.[0]?.toString("utf8")).toBe("unsigned thought"); + expect(unsignedPrompt?.[12]).toBeUndefined(); + }); + + test("an opaque signature the service issued still rides #12 whatever its spelling", () => { + // The counter-case to the one above: #12 is opaque, so the field boundary denies exactly + // one known shape — the parser's own serialized reasoning item — and nothing else. An + // allow-list written around Anthropic's base64 spelling would silently drop both of these, + // which is why this adapter does not borrow one. + for (const signature of [ + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0aG91Z2h0In0.c2lnbmF0dXJl", + '{"type":"attestation","issuer":"cognition","v":1}', + "sig with spaces and + slashes/", + ]) { + const history = mapOcxMessagesToDevin(parsedWith([ + { role: "assistant", content: [{ type: "thinking", thinking: "thought", signature }] }, + ])); + expect(history.find(m => m.role === "assistant")?.signature).toBe(signature); + } + }); + test("the encoded prompt carries thinking at #11 and its signature at #12", () => { const req = buildGetChatMessageRequestForTests({ apiKey: "devin-session-token$x", diff --git a/tests/providers/xai/grok-reset-coupons.test.ts b/tests/providers/xai/grok-reset-coupons.test.ts index 62143568405..e61d5e62e88 100644 --- a/tests/providers/xai/grok-reset-coupons.test.ts +++ b/tests/providers/xai/grok-reset-coupons.test.ts @@ -11,6 +11,7 @@ import { import { getGrokRemainingResets, decodeGetRemainingResetsResponse, + decodeVarint, encodeRedeemResetRequest, encodeVarint, GROK_GET_REMAINING_RESETS_ENDPOINT, @@ -135,6 +136,78 @@ describe("grok reset coupons", () => { expect(tokens[0].validityEnd).toBe(new Date(1728788400 * 1000).toISOString()); }); + it("rejects oversized and truncated protobuf lengths", () => { + const oversizedLength = new Uint8Array([0x52, 0x80, 0x80, 0x80, 0x80, 0x08]); + expect(() => decodeGetRemainingResetsResponse(oversizedLength)).toThrow( + "Invalid protobuf length-delimited field", + ); + + expect(() => decodeVarint(new Uint8Array([0x80]), 0)).toThrow("Truncated protobuf varint"); + }); + + it("rejects protobuf varints beyond the JavaScript safe integer range", () => { + const maxSafe = decodeVarint(encodeVarint(BigInt(Number.MAX_SAFE_INTEGER)), 0); + expect(maxSafe.value).toBe(Number.MAX_SAFE_INTEGER); + + const unsafeVarint = encodeVarint(BigInt(Number.MAX_SAFE_INTEGER) + 1n); + expect(() => decodeVarint(unsafeVarint, 0)).toThrow( + "Protobuf varint exceeds JavaScript safe integer range", + ); + + const unsafeLength = new Uint8Array(1 + unsafeVarint.length); + unsafeLength[0] = 0x52; // field 10, wire type 2 + unsafeLength.set(unsafeVarint, 1); + expect(() => decodeGetRemainingResetsResponse(unsafeLength)).toThrow( + "Protobuf varint exceeds JavaScript safe integer range", + ); + }); + + it("rejects an overlong varint whose continuation bytes carry no payload", () => { + // The safe-integer guard cannot bound the length on its own: a continuation byte with no + // payload bits contributes a part of zero, which is a safe integer, so twenty 0x80 bytes + // followed by 0x00 decoded as a valid zero. An overlong zero length is what turns a + // malformed body into an empty coupon list reported as success. + const overlongZero = new Uint8Array([...new Array(20).fill(0x80), 0x00]); + expect(() => decodeVarint(overlongZero, 0)).toThrow("Overlong protobuf varint"); + + // The bound is a protocol limit, not a value limit: a ten-byte varint carrying real + // payload still fails for its value, which is the guard above it. + const tenBytePayload = new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]); + expect(() => decodeVarint(tenBytePayload, 0)).toThrow( + "Protobuf varint exceeds JavaScript safe integer range", + ); + + // A legal single-byte zero is unaffected. + expect(decodeVarint(new Uint8Array([0x00]), 0)).toEqual({ value: 0, bytesRead: 1 }); + }); + + it("fails the read rather than returning coupons decoded from a malformed response", async () => { + // Field 10, wire type 2, declaring 127 bytes when none follow. This is the shape the + // bounds check exists for, asserted where the callers actually consume it: both routes in + // src/server/management/grok-coupon-routes.ts wrap getGrokRemainingResets in try/catch and + // answer 502, so the read must throw rather than hand them a tokenId recovered from a + // short subarray. + const malformed = new Uint8Array([0x52, 0x7f]); + const mockFetch: typeof globalThis.fetch = async () => { + const data = encodeGrpcWebEnvelope(malformed); + const trailer = new TextEncoder().encode("grpc-status:0\r\n"); + const trailerEnvelope = new Uint8Array(5 + trailer.length); + trailerEnvelope[0] = 0x80; + new DataView(trailerEnvelope.buffer).setUint32(1, trailer.length, false); + trailerEnvelope.set(trailer, 5); + const body = new Uint8Array(data.length + trailerEnvelope.length); + body.set(data, 0); + body.set(trailerEnvelope, data.length); + return new Response(body, { + status: 200, + headers: { "content-type": "application/grpc-web+proto" }, + }); + }; + + await expect(getGrokRemainingResets({ accessToken: "mock-access-token-12345", fetchFn: mockFetch })) + .rejects.toThrow("Invalid protobuf length-delimited field"); + }); + it("asserts auth headers and tokenAuth compatibility header on request", async () => { let capturedHeaders: Headers | undefined; let capturedBody: Uint8Array | undefined; diff --git a/tests/responses/responses-freeform-wrapper-keys.test.ts b/tests/responses/responses-freeform-wrapper-keys.test.ts new file mode 100644 index 00000000000..8416692c0d8 --- /dev/null +++ b/tests/responses/responses-freeform-wrapper-keys.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test"; +import { unwrapFreeformToolInput } from "../../src/responses/apply-patch-envelope"; +import { MAX_FREEFORM_WRAPPER_SCAN_CHARS } from "../../src/responses/freeform-wrapper-scan"; +import { progressiveFreeformInput } from "../../src/responses/progressive-freeform-input"; + +/** + * #5151. The progressive decoder matched the buffer against the literal wrapper opening + * {"input":" so two spellings JSON.parse treats as the same wrapper matched nothing at all: + * a canonical key arriving after another property, and one written with a JSON escape. Both + * streamed the raw object as deltas and then completed as the unwrapped body, which is the + * delta/completion disagreement #5047 closed for compact wrappers and #5129 for whitespace. + * + * These cases live in their own file rather than in the two transport suites that exercise the + * same decoder: it is shared by the direct bridge and the routed restoration, so asserting it + * once here keeps one contract instead of duplicating it into two capped files. + */ + +/** + * The delta each prefix publishes, using the rule BOTH callers apply: hold on null, and emit + * only the suffix of a value that still starts with what was already emitted. Nothing may + * rewind, so the concatenation is exactly what a client received. + * + * A value that does NOT extend what was emitted is recorded rather than quietly dropped. Both + * callers drop it, which is what makes the transports safe, but a test that only mirrored that + * would pass while the decoder proposed a retraction on some intermediate prefix. The proposal + * is the defect; the callers refusing it is the backstop. + */ +function stream(body: string, toolName: string): { deltas: string[]; retractions: string[] } { + const deltas: string[] = []; + const retractions: string[] = []; + let emitted = ""; + for (let end = 1; end <= body.length; end++) { + const full = progressiveFreeformInput(body.slice(0, end), toolName); + if (full === null) continue; + if (!full.startsWith(emitted)) { + retractions.push(full); + continue; + } + if (full.length === emitted.length) continue; + deltas.push(full.slice(emitted.length)); + emitted = full; + } + return { deltas, retractions }; +} + +const emissions = (body: string, toolName: string) => stream(body, toolName).deltas; +const published = (body: string, toolName: string) => emissions(body, toolName).join(""); + +describe("freeform wrapper keys the literal matcher could not see", () => { + // `progressive` records whether the value can be previewed before the object closes. It is + // the liveness half of the contract and it is asserted, because holding everything until + // completion would satisfy the agreement half on its own while quietly ending progressive + // streaming for the wrappers this change exists to preview. + const REORDERED_AND_ESCAPED: Array<[label: string, body: string, progressive: boolean]> = [ + ["a scalar property before the canonical key", '{"metadata":1,"input":"cmd"}', true], + ["an escaped canonical key", '{"\\u0069nput":"cmd"}', true], + ["a nested preceding value", '{"a":[1,{"b":null},true],"input":"cmd"}', true], + ["both spellings at once", '{"note":"x","\\u0069nput":"cmd"}', true], + ["a preceding value holding a brace inside a string", '{"note":"}","input":"cmd"}', true], + ["whitespace around a reordered key", '{ "metadata" : 1 , "input" : "cmd" }', true], + // A fallback key only unwraps as the SINGLE string field, so a second field can still + // arrive and no prefix decides it. It is the one case here that must NOT preview. + ["an escaped fallback key", '{"\\u0063ode":"cmd"}', false], + ]; + + test("a reordered or escaped wrapper agrees with completion at every split", () => { + for (const [label, body, progressive] of REORDERED_AND_ESCAPED) { + const { deltas, retractions } = stream(body, "exec"); + // The authoritative answer, read the way the completed item is read. + expect({ label, completed: unwrapFreeformToolInput(body, "exec") }) + .toEqual({ label, completed: "cmd" }); + // Characterwise, which covers every split boundary at once. + expect({ label, streamed: deltas.join("") }).toEqual({ label, streamed: "cmd" }); + // And as one chunk, which is how a non-streaming upstream delivers it. + expect({ label, whole: progressiveFreeformInput(body, "exec") }) + .toEqual({ label, whole: "cmd" }); + // No prefix may even PROPOSE a value that does not extend what was already published. + expect({ label, retractions }).toEqual({ label, retractions: [] }); + // A decidable wrapper previews as it arrives; an undecidable one publishes once. + expect({ label, multiple: deltas.length > 1 }).toEqual({ label, multiple: progressive }); + + // The defect was not a wrong total but wrapper syntax reaching the client and then being + // removed at completion, so assert the published bytes rather than only their sum. + for (const delta of deltas) { + expect({ label, delta, leaked: delta.includes("{") || delta.includes('"') }) + .toEqual({ label, delta, leaked: false }); + } + } + }); + + test("bodies that are not wrappers keep their original bytes", () => { + // Two string fallback fields: completion declines to guess. A non-string canonical value is + // not a wrapper either. Both must reach the client byte-exact, not as a repaired body. + for (const body of ['{"code":"a","script":"b"}', '{"code":1}', '{"input":1}', "{}"]) { + expect({ body, completed: unwrapFreeformToolInput(body, "exec") }) + .toEqual({ body, completed: body }); + const { deltas, retractions } = stream(body, "exec"); + expect({ body, streamed: deltas.join("") }).toEqual({ body, streamed: body }); + expect({ body, retractions }).toEqual({ body, retractions: [] }); + } + + // Tool-name negative: code is a fallback key only for the tools that own the grammar, so + // the same object is an ordinary body for any other freeform tool. + expect(unwrapFreeformToolInput('{"code":"a"}', "")).toBe('{"code":"a"}'); + expect(published('{"code":"a"}', "")).toBe('{"code":"a"}'); + + // Text that opens like an object but is not JSON is decidable immediately and must not be + // held: ordinary code-mode JavaScript reaches this function. + const program = "{ let x = 1; return x; }"; + expect(published(program, "exec")).toBe(program); + expect(emissions(program, "exec").length).toBeGreaterThan(1); + }); + + test("canonical input and plain bodies stay progressive", () => { + // Holding an undecided object must not cost the progressive streaming these paths provide. + const canonical = '{"input":"line one\\nline two"}'; + expect(emissions(canonical, "exec").length).toBeGreaterThan(1); + expect(published(canonical, "exec")).toBe("line one\nline two"); + expect(emissions("text(1)", "exec").length).toBeGreaterThan(1); + expect(published("text(1)", "exec")).toBe("text(1)"); + }); + + test("a preceding value larger than the scan budget holds rather than guessing", () => { + // Classification is bounded so the work per delta cannot grow with the arguments. Past the + // bound nothing is published at all, and the wrapper is still unwrapped by the + // authoritative completion: the budget costs preview, never agreement. + const pad = "x".repeat(MAX_FREEFORM_WRAPPER_SCAN_CHARS + 1); + const body = '{"pad":"' + pad + '","input":"cmd"}'; + expect(unwrapFreeformToolInput(body, "exec")).toBe("cmd"); + expect(stream(body, "exec")).toEqual({ deltas: [], retractions: [] }); + + // Braces inside that oversized value must not tempt a release either. Reading the whole + // buffer on every delta whose last character happens to be a brace is the quadratic cost + // the bound exists to prevent, and a prefix ending in one is not a complete object. + const braces = "}".repeat(MAX_FREEFORM_WRAPPER_SCAN_CHARS + 1); + const braced = '{"pad":"' + braces + '","input":"cmd"}'; + expect(unwrapFreeformToolInput(braced, "exec")).toBe("cmd"); + expect(stream(braced, "exec")).toEqual({ deltas: [], retractions: [] }); + + // The same object under the bound still resolves, so the bound is what changed the answer + // and not the shape: this is the identical body with a value the scan can walk. + const small = '{"pad":"}}}}","input":"cmd"}'; + expect(published(small, "exec")).toBe("cmd"); + }); + + test("a fenced body inside a reordered wrapper still publishes nothing", () => { + // The fence hold is decided on the DECODED value, so reaching the canonical key through a + // reordered object must not let fence bytes out that completion strips. + const fence = "\u0060\u0060\u0060"; + const body = JSON.stringify({ metadata: 1, input: fence + "js\nconst x = 1;\n" + fence }); + expect(unwrapFreeformToolInput(body, "exec")).toBe("const x = 1;"); + expect(published(body, "exec")).toBe(""); + }); + + test("the documented preview limits are unchanged by reordering", () => { + // A duplicate key: JSON.parse keeps the last one after the first was already streamed. + // Closing this means holding every canonical wrapper until it parses, which is the + // progressive streaming the first case above requires. + const duplicate = '{"metadata":1,"input":"first","input":"second"}'; + expect(published(duplicate, "exec")).toBe("first"); + expect(unwrapFreeformToolInput(duplicate, "exec")).toBe("second"); + + // A wrapper that turns invalid after the preview committed: the preview stops at the last + // decodable character and completion keeps the raw text. No rewind, nothing invented. + const lateInvalid = '{"metadata":1,"input":"safe\\q"}'; + expect(published(lateInvalid, "exec")).toBe("safe"); + expect(unwrapFreeformToolInput(lateInvalid, "exec")).toBe(lateInvalid); + }); +}); diff --git a/tests/responses/responses-hosted-tool-min-spread.test.ts b/tests/responses/responses-hosted-tool-min-spread.test.ts new file mode 100644 index 00000000000..316a8539898 --- /dev/null +++ b/tests/responses/responses-hosted-tool-min-spread.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; +import { preferConfiguredHostedTools } from "../../src/adapters/openai-responses/image-gen"; +import type { OcxProviderConfig } from "../../src/types"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +/** + * #5132. The pre-fix additional_tools restoration collected every stripped container index + * in a Set and spread it into Math.min. A request carrying enough additional_tools containers + * exceeds the argument-count limit and throws RangeError, so an attacker-sized request becomes + * a denial of service. Indices arrive in order, so the tracked first index is the minimum. + * + * This case lives in its own file rather than in + * tests/responses/openai-responses-passthrough.test.ts: that file is exactly at its + * file-size ratchet cap (4,809 lines in tests/fixtures/file-size-baseline.json), and the + * cap only ever moves downward, so appending there would fail the ratchet for every later + * pull request. + */ + +const createResponsesPassthroughAdapter = ( + ...args: Parameters +) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +describe("OpenAI Responses hosted-tool name conflicts", () => { + const keyedProvider = { + adapter: "openai-responses", + baseUrl: "https://api.openai.example/v1", + authMode: "key" as const, + apiKey: "sk-test", + }; + const meta = { headers: new Headers({ authorization: "Bearer token" }) }; + + test("additional_tools restoration does not spread stripped indices into Math.min", () => { + // The pre-fix code collected every stripped container index in a Set and spread it + // into Math.min. A request carrying enough additional_tools containers exceeds the + // argument-count limit and throws RangeError — an attacker-sized request becomes a + // denial of service. Indices arrive in order, so the tracked first index is the min. + const adapter = createResponsesPassthroughAdapter({ + ...keyedProvider, + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + }); + const input = Array.from({ length: 3 }, () => ({ + type: "additional_tools", + tools: [{ type: "namespace", name: "image_gen", tools: [] }], + })); + const originalMin = Math.min; + Math.min = (...values: number[]) => { + if (values.length > 2) throw new RangeError("too many arguments"); + return originalMin(...values); + }; + + try { + expect(() => adapter.buildRequest({ + modelId: "provider-image-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "provider-image-model", input }, + }, meta)).not.toThrow(); + } finally { + Math.min = originalMin; + } + }); + + test("the hosted declaration is restored into the first stripped container only", () => { + // The restoration path claims two things: the declaration lands in the FIRST stripped + // container, and it lands exactly once. The Math.min case above proves neither, because + // every container in it is stripped — index 0 and "first stripped" are the same number + // there, so a regression that replaced the scalar with a literal 0 would still pass it. + // Here the first container is not a carrier and the second carries nothing to strip, so + // the first stripped index is 2 and the two claims become separable. + const provider = { + modelPreferHostedTools: { "provider-image-model": ["image_generation"] }, + } as unknown as OcxProviderConfig; + const execTool = { type: "function", name: "exec_command", parameters: {} }; + const imageGenTool = { type: "function", name: "image_gen.imagegen", parameters: {} }; + const next = preferConfiguredHostedTools({ + model: "provider-image-model", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + { type: "additional_tools", tools: [execTool] }, + { type: "additional_tools", tools: [imageGenTool, execTool] }, + { type: "additional_tools", tools: [imageGenTool] }, + ], + }, provider, "provider-image-model") as { + input: Array<{ type: string; tools?: Array<{ type: string; name?: string }> }>; + }; + + expect(next.input[1]?.tools).toEqual([execTool]); + expect(next.input[2]?.tools).toEqual([execTool, { type: "image_generation" }]); + expect(next.input[3]?.tools).toEqual([]); + const hosted = next.input + .flatMap(item => item.tools ?? []) + .filter(tool => tool.type === "image_generation"); + expect(hosted).toHaveLength(1); + }); +});