From 451a389c80c4bd2d30a975dd9a5761b789627e7d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 12 Nov 2026 11:48:28 +0900 Subject: [PATCH 1/4] devin: opt-in Cognition blocklist rewrite on history data surfaces The blocklist sanitizer only touched instruction surfaces (tool descriptions, the #2 system prompt); data fields stayed byte-exact, so one quoted trigger phrase anywhere in a long thread history made every later request fail with upstream permission_denied. New OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES=1 opt-in extends the same rewrites to message text (#3), tool-call arguments (#6.3), image captions (#10.3) and replayed thinking (#11). Identifiers (tool name, tool_call_id) and opaque signature blobs stay byte-exact. Off by default: rewriting literal history changes what the model sees, so refusing stays the default failure. The permission_denied hint now names the opt-in. Tests pin both modes: data literal by default, rewritten when enabled. --- src/adapters/devin/cloud-direct/chat.ts | 59 ++++++++++++++++++++----- tests/providers/devin-adapter.test.ts | 53 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 12 deletions(-) diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 05a02c58bfb..6c319b91720 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -223,8 +223,11 @@ function encodeChatMessagePrompt( signature?: string; signatureType?: string; isError?: boolean; + sanitizeDataSurfaces?: boolean; }, ): Buffer { + const data = (s: string): string => + opts?.sanitizeDataSurfaces ? sanitizeTextForCognition(s) : s; const textParts = content.filter((p): p is { type: 'text'; text: string } => p.type === 'text'); const imageParts = content.filter((p): p is { type: 'image'; mimeType: string; base64Data: string; caption?: string } => p.type === 'image'); const joined = textParts.map((p) => p.text).join('\n'); @@ -232,7 +235,7 @@ function encodeChatMessagePrompt( // #1 message_id. The verified turn-1 capture stamps one on every prompt. encodeString(1, crypto.randomUUID()), encodeVarintField(2, source), - encodeString(3, joined), + encodeString(3, data(joined)), ]; // Tool-result message: attach the id of the call this result answers. // Without it, the model can't pair multi-tool conversations. @@ -245,18 +248,23 @@ function encodeChatMessagePrompt( // Assistant message with tool_calls: encode each as a ChatToolCall. if (opts?.toolCalls && opts.toolCalls.length > 0) { for (const tc of opts.toolCalls) { - parts.push(encodeMessage(6, encodeChatToolCall(tc))); + // #2 name + #1 id are identifiers — only the #3 arguments_json is a + // data surface a user can quote blocklisted text into. + parts.push(encodeMessage(6, encodeChatToolCall({ ...tc, arguments: data(tc.arguments) }))); } } for (const img of imageParts) { - parts.push(encodeMessage(10, encodeImageData(img))); + parts.push(encodeMessage(10, encodeImageData({ + ...img, + caption: img.caption === undefined ? undefined : data(img.caption), + }))); } // Reasoning replay. This adapter used to assert that Cognition has no // reasoning-replay field and drop the assistant's own thinking, so a // reasoning model restarted its chain on every turn of a tool loop. Two // independent clients of the same service write it here: #11 thinking, // #12 signature, #18 signature_type on the assistant prompt. - if (opts?.thinking) parts.push(encodeString(11, opts.thinking)); + if (opts?.thinking) parts.push(encodeString(11, data(opts.thinking))); if (opts?.signature) parts.push(encodeString(12, opts.signature)); if (opts?.signatureType) parts.push(encodeString(18, opts.signatureType)); return Buffer.concat(parts); @@ -551,6 +559,15 @@ interface BuildArgs { requestId: bigint; triggerId: string; tools?: ToolDef[]; + /** + * Opt-in: also run the Cognition blocklist rewrite over *data* surfaces — + * prompt text (#3), tool-call arguments (#6.3), image captions (#10.3) and + * replayed thinking (#11). Off by default: a rewrite inside literal content + * silently changes what the model sees, so callers who prefer a refusal over + * altered history keep the default. Enabled by the + * OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES env var at the call site. + */ + sanitizeDataSurfaces?: boolean; /** Default 5 = CHAT_MESSAGE_REQUEST_TYPE_CASCADE (matches captured LS body). */ requestType?: number; completionOpts?: { @@ -632,15 +649,23 @@ const MAX_TOOL_DESC_LEN = 6998; * for "whether to allow", verified live to clear the filter while preserving * the instruction's meaning. * - * Scope note: the sanitizer only ever touches *instruction surfaces* — - * tool descriptions and the #2 system prompt, where a meaning-preserving - * reword loses nothing. It deliberately does NOT touch data fields: - * message text (#3), replayed thinking (#11), and tool-call arguments - * (#6.3) carry literal content (patches, exact needles, quoted file bytes) + * Scope note: by default the sanitizer only ever touches *instruction + * surfaces* — tool descriptions and the #2 system prompt, where a + * meaning-preserving reword loses nothing. Data fields — message text (#3), + * replayed thinking (#11), image captions (#10.3), and tool-call arguments + * (#6.3) — carry literal content (patches, exact needles, quoted file bytes) * where a rewrite would silently change what the model did or sees. If a * blocklisted phrase reaches the cloud inside one of those, the request is - * refused and the caller sees the upstream `permission_denied` — which is - * the correct failure, better than corrupting the data. + * refused and the caller sees the upstream `permission_denied`. + * + * That refusal is the correct failure for short histories, but a long thread + * keeps resending its earlier turns, so one quoted trigger phrase anywhere + * in the history (e.g. a tool description pasted into the transcript, or a + * mid-conversation system block Codex injected) permanently refuses every + * subsequent request. Operators who would rather have the rewrites applied + * to data surfaces than lose the thread can set + * `OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES=1` — it is opt-in precisely + * because rewriting literal history changes what the model is told. */ const COGNITION_BLOCKLIST_REWRITES: ReadonlyArray<[RegExp, string]> = [ [/\bTakes a task_id parameter identifying the task\b/g, "Accepts a task_id parameter identifying the task"], @@ -742,6 +767,7 @@ function buildGetChatMessageRequest(args: BuildArgs): Buffer { signature: m.role === 'assistant' ? m.signature : undefined, signatureType: m.role === 'assistant' ? m.signature_type : undefined, isError: m.role === 'tool' ? m.is_error : undefined, + sanitizeDataSurfaces: args.sanitizeDataSurfaces, }, ), ), @@ -1232,6 +1258,11 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator { expect(toolOnly.includes(Buffer.from("whether to allow the action", "utf8"))).toBe(true); }); + test("sanitizeDataSurfaces extends the rewrite to history data fields", () => { + const trigger = "asking the user if they want to allow the action in `justification` parameter"; + const rewrittenFragment = "asking the user whether to allow the action in the `justification` parameter"; + const signature = "sig--opaque"; + const req = buildGetChatMessageRequestForTests({ + apiKey: "k", + modelUid: "swe-2", + cascadeId: "c", + sessionId: "s", + requestId: 1n, + triggerId: "t", + sanitizeDataSurfaces: true, + messages: [ + { role: "user", content: `here is a quote: ${trigger}` }, + { + role: "assistant", + content: "ok", + thinking: `the prompt says: ${trigger}`, + signature, + signature_type: "opaque", + tool_calls: [{ id: "c1", name: "apply_patch", arguments: `{"patch":"${trigger}"}` }], + }, + { role: "tool", tool_call_id: "c1", content: `file bytes: ${trigger}` }, + { role: "user", content: "hello" }, + ], + tools: [{ name: "codex_escalation", description: trigger, parameters: { type: "object" } }], + }); + // Every data surface carrying the phrase is rewritten on the wire. + expect(req.includes(Buffer.from(trigger, "utf8"))).toBe(false); + expect(req.includes(Buffer.from(`here is a quote: ${rewrittenFragment}`, "utf8"))).toBe(true); + expect(req.includes(Buffer.from(`the prompt says: ${rewrittenFragment}`, "utf8"))).toBe(true); + expect(req.includes(Buffer.from(`{"patch":"${rewrittenFragment}"}`, "utf8"))).toBe(true); + expect(req.includes(Buffer.from(`file bytes: ${rewrittenFragment}`, "utf8"))).toBe(true); + // Identifiers and opaque replay blobs stay byte-exact: a tool_call name + // or signature is not free text and a rewrite would corrupt it. + expect(req.includes(Buffer.from("apply_patch", "utf8"))).toBe(true); + expect(req.includes(Buffer.from(signature, "utf8"))).toBe(true); + + // A clean message carries no surprise rewrites — only the trigger phrase + // itself is touched, everything else stays literal. + const clean = buildGetChatMessageRequestForTests({ + apiKey: "k", + modelUid: "swe-2", + cascadeId: "c", + sessionId: "s", + requestId: 1n, + triggerId: "t", + sanitizeDataSurfaces: true, + messages: [{ role: "user", content: "run sed -n '5,10p' file.ts verbatim" }], + }); + expect(clean.includes(Buffer.from("run sed -n '5,10p' file.ts verbatim", "utf8"))).toBe(true); + }); + test("the catalog parser reads the per-account context window", () => { // ClientModelConfig #18 is the max input tokens, and it is the only // first-party context-window figure Cognition exposes: the Devin CLI and From b534b8849bd3fcb1abc69dc39a1ed81365af29e9 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 12 Nov 2026 13:41:13 +0900 Subject: [PATCH 2/4] devin: add Codex CLI boilerplate phrases to Cognition blocklist rewrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more live-isolated triggers in the Codex CLI developer boilerplate: the identity sentence "Codex refers to the open-source agentic coding interface" and the "Don not output ANSI escape codes directly — the CLI renderer applies them" bullet. Both are rewritten meaning-preservingly like the existing entries. Also adds the dump/bisect probe scripts used to isolate the triggers. --- scripts/bisect-denied.ts | 71 +++++++++++++++++++++++++ scripts/probe-sanitize-live.ts | 65 ++++++++++++++++++++++ src/adapters/devin/cloud-direct/chat.ts | 16 ++++++ tests/providers/devin-adapter.test.ts | 29 ++++++++++ 4 files changed, 181 insertions(+) create mode 100644 scripts/bisect-denied.ts create mode 100644 scripts/probe-sanitize-live.ts diff --git a/scripts/bisect-denied.ts b/scripts/bisect-denied.ts new file mode 100644 index 00000000000..6fd6c86a794 --- /dev/null +++ b/scripts/bisect-denied.ts @@ -0,0 +1,71 @@ +// Bisect a dumped denied request to find the blocklist trigger. +// Usage: bun scripts/bisect-denied.ts [args] +// modes: full | sys | msgs A B | sysonly +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { streamChatEvents, CloudChatError } from "../src/adapters/devin/cloud-direct/index"; +import { resolveDevinApiBaseUrl } from "../src/oauth/devin/api-base"; + +const dump = JSON.parse(readFileSync(process.argv[2], "utf8")); +const mode = process.argv[3] ?? "full"; +const A = parseInt(process.argv[4] ?? "0", 10); +const B = parseInt(process.argv[5] ?? String(dump.messages.length), 10); + +const auth = JSON.parse(readFileSync(join(homedir(), ".opencodex", "auth.json"), "utf8")); +const acct = auth.devin.accounts.find((a: any) => a?.id === auth.devin.activeAccountId) ?? auth.devin.accounts[0]; +const apiKey: string = acct?.credential?.access; +const apiServerUrl: string | undefined = acct?.credential?.apiBaseUrl; + +process.env.OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES = "1"; // production-identical + +function toMsgs(list: any[]) { + return list.map((m: any) => ({ + role: m.role === "tool" ? "user" : m.role, + content: m.role === "tool" ? "\n" + m.text + "\n" : m.text, + })); +} + +async function probe(label: string, msgs: any[], tools: any[], sys: string) { + try { + let n = 0; + for await (const ev of streamChatEvents({ + apiKey, apiServerUrl, modelUid: "swe-2-max", + messages: [{ role: "system", content: sys }, ...msgs], + tools, + } as any)) { if (ev.kind === "text") n += ev.text.length; } + console.log(label + " => OK textLen=" + n); + return true; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const denied = /permission_denied/.test(msg); + console.log(label + " => " + (denied ? "DENIED" : "ERR " + msg.slice(0, 140))); + return !denied; + } +} + +const tools = dump.tools.map((t: any) => ({ name: t.name, description: t.description })); + +if (mode === "full") { + await probe("full", toMsgs(dump.messages), tools, dump.systemPrompt); +} else if (mode === "sys") { + await probe("sys+tools+hi", [{ role: "user", content: "hi" }], tools, dump.systemPrompt); +} else if (mode === "sysonly") { + await probe("sys-only+hi", [{ role: "user", content: "hi" }], [], dump.systemPrompt); +} else if (mode === "toolsonly") { + await probe("tools-only+hi", [{ role: "user", content: "hi" }], tools, ""); +} else if (mode === "msgs") { + await probe("msgs[" + A + ".." + B + "]", toMsgs(dump.messages.slice(A, B)), [], ""); +} else if (mode === "lit") { + // Read candidate literal strings from file (one per line) given in argv[4]. + const cand = readFileSync(process.argv[4], "utf8").split("\n").filter((s: string) => s.trim().length > 0); + for (const c of cand) { + await probe(JSON.stringify(c.slice(0, 80)), [{ role: "user", content: "hi" }], [], c); + } +} else if (mode === "syspart") { + await probe("sys[" + A + ".." + B + "]", [{ role: "user", content: "hi" }], [], dump.systemPrompt.slice(A, B)); +} else if (mode === "full-sys-half") { + const h = Math.floor(dump.systemPrompt.length / 2); + await probe("sys-first-half", [{ role: "user", content: "hi" }], [], dump.systemPrompt.slice(0, h)); + await probe("sys-second-half", [{ role: "user", content: "hi" }], [], dump.systemPrompt.slice(h)); +} diff --git a/scripts/probe-sanitize-live.ts b/scripts/probe-sanitize-live.ts new file mode 100644 index 00000000000..21b5cb71c3f --- /dev/null +++ b/scripts/probe-sanitize-live.ts @@ -0,0 +1,65 @@ +// Live probe: does a blocklisted phrase inside a *data* surface (message text) +// trigger Cognition permission_denied, and does the opt-in sanitizer clear it? +// Usage: +// bun scripts/probe-sanitize-live.ts -> sanitize OFF (expect denial) +// OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES=1 bun scripts/probe-sanitize-live.ts +// -> sanitize ON (expect success) +// The api key is read from ~/.opencodex/auth.json; nothing is written to disk. + +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { streamChatEvents, CloudChatError } from "../src/adapters/devin/cloud-direct/index"; +import { getCachedCatalog } from "../src/adapters/devin/cloud-direct/catalog"; +import { resolveDevinApiBaseUrl } from "../src/oauth/devin/api-base"; + +const auth = JSON.parse(readFileSync(join(homedir(), ".opencodex", "auth.json"), "utf8")); +const devin = auth.devin; +const acct = (devin.accounts as any[]).find(a => a?.id === devin.activeAccountId) ?? devin.accounts[0]; +const apiKey: string = acct?.credential?.access; +if (!apiKey) { + console.error("no devin api key in auth.json"); + process.exit(2); +} +const apiServerUrl: string | undefined = acct?.credential?.apiBaseUrl; + +if (process.argv.includes("--catalog")) { + const host = resolveDevinApiBaseUrl(apiServerUrl); + const cat = await getCachedCatalog(apiKey, host).catch(e => { console.log('catalog err', String(e)); return null; }); + if (cat) for (const [uid, e] of cat.byUid) console.log(`${uid} disabled=${e.disabled} label=${e.label}`); + process.exit(0); +} + +// The exact Codex exec_command description Cognition refuses — here it sits in +// a user message (data surface), not a tool description (instruction surface). +const TRIGGER = "Runs a command in a PTY, returning output or a session ID for ongoing interaction"; +const messages = [ + { role: "user" as const, content: `The tool doc says: "${TRIGGER}". Remember it verbatim.` }, + { role: "assistant" as const, content: "Understood." }, + { role: "user" as const, content: "Reply with exactly: PONG" }, +]; + +const sanitize = process.env.OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES === "1"; +console.log(`sanitize=${sanitize} account=${acct?.id ?? 'first'} model=swe-2`); + +try { + let text = ""; + let events = 0; + for await (const ev of streamChatEvents({ + apiKey, + apiServerUrl, + modelUid: "swe-2-max", + messages, + })) { + events++; + if (ev.kind === "text") text += ev.text; + } + console.log(`RESULT=OK events=${events} text=${JSON.stringify(text.slice(0, 120))}`); +} catch (e) { + if (e instanceof CloudChatError) { + console.log(`RESULT=CLOUD_ERROR code=${e.code} msg=${e.message.slice(0, 160)}`); + } else { + console.log(`RESULT=ERROR ${String(e).slice(0, 200)}`); + } + process.exit(1); +} diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 6c319b91720..e91b486fdc9 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -649,6 +649,14 @@ const MAX_TOOL_DESC_LEN = 6998; * for "whether to allow", verified live to clear the filter while preserving * the instruction's meaning. * + * Entries five and six are more Codex CLI boilerplate, isolated by live + * bisection on a denied thread: "Codex refers to the open-source agentic + * coding interface" (the CLI's identity sentence — once Cognition listed it, + * every request from a Codex CLI client was refused) and "Don't output ANSI + * escape codes directly — the CLI renderer applies them" (a formatting + * bullet). Matching is flexible on case, whitespace, the open-source hyphen, + * and the Don't apostrophe; each rewrite swaps one required word. + * * Scope note: by default the sanitizer only ever touches *instruction * surfaces* — tool descriptions and the #2 system prompt, where a * meaning-preserving reword loses nothing. Data fields — message text (#3), @@ -681,6 +689,14 @@ const COGNITION_BLOCKLIST_REWRITES: ReadonlyArray<[RegExp, string]> = [ /\basking\s+the\s+user\s+if\s+they\s+want\s+to\s+allow\s+the\s+action\s+in\s+`?justification`?\s+parameter\b/gi, "asking the user whether to allow the action in the `justification` parameter", ], + [ + /\bCodex\s+refers\s+to\s+the\s+open[-\s]?source\s+agentic\s+coding\s+interface\b/gi, + "Codex refers to the open-source agentic coding tool", + ], + [ + /\bDon[\u0060'’]?t\s+output\s+ANSI\s+escape\s+codes\s+directly\s*[—-]\s*the\s+CLI\s+renderer\s+applies\s+them\b/gi, + "Don’t emit ANSI escape codes directly — the CLI renderer applies them", + ], ]; function sanitizeTextForCognition(text: string): string { diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index fdf3505f072..8b18cc1fd54 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -381,6 +381,35 @@ describe("devin adapter", () => { expect(toolOnly.includes(Buffer.from("whether to allow the action", "utf8"))).toBe(true); }); + test("rewrites the Codex CLI identity phrase and the ANSI renderer clause", () => { + const identity = "Codex refers to the open-source agentic coding tool"; + expect(sanitizeTextForCognitionForTests( + "Within this context, Codex refers to the open-source agentic coding tool (not the old Codex language model built by OpenAI).", + )).toBe( + "Within this context, Codex refers to the open-source agentic coding tool (not the old Codex language model built by OpenAI).", + ); + expect(sanitizeTextForCognitionForTests(identity.toLowerCase())) + .toContain("agentic coding tool"); + expect(sanitizeTextForCognitionForTests(identity.replace("open-source", "open source"))) + .toContain("agentic coding tool"); + const identityNearMiss = "Codex refers to the open-source agentic coding"; + expect(sanitizeTextForCognitionForTests(identityNearMiss)).toBe(identityNearMiss); + const unrelated = "refers to the open-source agentic coding tool"; + expect(sanitizeTextForCognitionForTests(unrelated)).toBe(unrelated); + + const ansi = "Don’t output ANSI escape codes directly — the CLI renderer applies them"; + expect(sanitizeTextForCognitionForTests("- " + ansi + ".")) + .toBe("- Don’t emit ANSI escape codes directly — the CLI renderer applies them."); + expect(sanitizeTextForCognitionForTests(ansi.toUpperCase())) + .toContain("emit ANSI escape codes"); + expect(sanitizeTextForCognitionForTests(ansi.replace("’", "\u0060"))) + .toContain("emit ANSI escape codes"); + const ansiPrefix = "Don’t output ANSI escape codes directly"; + expect(sanitizeTextForCognitionForTests(ansiPrefix)).toBe(ansiPrefix); + const ansiTail = "ANSI escape codes directly — the CLI renderer applies them"; + expect(sanitizeTextForCognitionForTests(ansiTail)).toBe(ansiTail); + }); + test("sanitizeDataSurfaces extends the rewrite to history data fields", () => { const trigger = "asking the user if they want to allow the action in `justification` parameter"; const rewrittenFragment = "asking the user whether to allow the action in the `justification` parameter"; From b3590db48d2bed893163fd93e4a959e78d3770b0 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 12 Nov 2026 17:08:40 +0900 Subject: [PATCH 3/4] fix(devin): drop stale signatures after thinking sanitization Keep reasoning signatures only when the serialized thinking is unchanged. Preserve signature-only replay and default-off sanitization, and add wire, authentication and continuity regressions with matching adapter docs. --- .../src/content/docs/reference/adapters.md | 12 +- src/adapters/devin/cloud-direct/chat.ts | 12 +- structure/providers-and-adapters.md | 2 +- tests/providers/devin-adapter.test.ts | 149 +++++++++++++++++- 4 files changed, 163 insertions(+), 12 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index b7d038c1854..6cd2ae5497f 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -844,7 +844,17 @@ configuration that names the old id is rewritten at startup. - Tool definitions are encoded in the request and tool-call events are decoded from the response stream. Cognition enforces a per-tool-description length limit (6,998 chars) and an exact-phrase blocklist; the adapter sanitizes known triggers and truncates over-long descriptions before - encoding. + encoding. Known trigger rewrites also apply to the leading system prompt (request field #2). +- `OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES` is off by default and enables only for the exact value + `"1"`; an unset variable or any other value leaves history data outside the sanitizer. When + enabled, it also rewrites joined message text (#3), tool-call `arguments_json` (#6.3), image + captions (#10.3), and replayed thinking (#11). Tool names, tool-call IDs, and image bytes remain + untouched. This changes literal history received by the model, including quoted file content + and tool arguments. If a rewrite changes thinking, that prompt sends the rewritten #11 text + without its original #12 signature or #18 signature type. Unchanged thinking preserves both + signature fields byte-for-byte, even when another data field changes; signature-only replay + also remains intact. Rewritten thinking therefore uses unsigned replay, which can weaken + signed reasoning continuity for that turn. This option does not guarantee upstream acceptance. - Devin/Cognition API keys have no refresh endpoint. If Cognition rejects a stored key with 401, OpenCodex marks that account for reauthentication and can use another signed-in account for the turn. Run `ocx login devin` again for a revoked browser-login key. A CLI-imported account diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index e91b486fdc9..94bd3109cd7 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -264,9 +264,15 @@ function encodeChatMessagePrompt( // reasoning model restarted its chain on every turn of a tool loop. Two // independent clients of the same service write it here: #11 thinking, // #12 signature, #18 signature_type on the assistant prompt. - if (opts?.thinking) parts.push(encodeString(11, data(opts.thinking))); - if (opts?.signature) parts.push(encodeString(12, opts.signature)); - if (opts?.signatureType) parts.push(encodeString(18, opts.signatureType)); + const thinking = opts?.thinking === undefined ? undefined : data(opts.thinking); + if (thinking) parts.push(encodeString(11, thinking)); + // A signature can only attest the original thinking. Rewritten text uses + // unsigned replay; unchanged text (including signature-only turns) keeps + // the original signature and type without altering the stored history. + if (thinking === opts?.thinking) { + if (opts?.signature) parts.push(encodeString(12, opts.signature)); + if (opts?.signatureType) parts.push(encodeString(18, opts.signatureType)); + } return Buffer.concat(parts); } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 423de96bbd2..2d594468b14 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -212,7 +212,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/azure.ts` | Azure OpenAI bridge; optional `minMaxOutputTokens` raises only smaller explicit positive integer output budgets. Unset preserves caller caps. Serialization and pre-admission spend estimates share `effectiveAzureOutputBudget`; see the [spend contract](transports/responses-spend.md#durable-spend-reservations). | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | | `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's authoritative item end or successful response terminal because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived, marking unfinished calls incomplete. Text and reasoning wait behind an open call; invisible heartbeats report buffered progress. Item aliases share one execution identity and conflicting reuse fails closed. Missing tool names fail the turn, and no output follows a terminal error. Per-item reasoning and done-only text fallback suppress completed replays. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Arguments, full snapshots, dedupe identities, queued events, text and opaque usage are budgeted and released on failure or cancellation; typed and untyped native error envelopes scrub every diagnostic string, including code/type, with the captured credential scrubber and discard diagnostic metadata before delegation. Untyped error envelopes terminate the OpenAI relay. Argument-done frames confirm bytes only; item status or successful response completion authorizes executable closure. Authoritative custom snapshots and every later delta agree byte-for-byte regardless of emission order; extensions or contradictions close outstanding calls incomplete and fail the turn. Function arguments compare ordered lexical JSON tokens for complete snapshots and replay fingerprints: whitespace outside strings is ignored, strings compare decoded Unicode units, number literals remain exact, and object-key order (including duplicate members) stays significant; compatible whitespace-adjusted prefixes append only a validated suffix and preserve delivered bytes. `src/adapters/zed-tool-arguments.ts` owns lexical reconciliation and domain-separated replay fingerprints. `src/adapters/zed-json-tokens.ts` scans JSON iteratively with a 65,536-container nesting limit, reporting a clear limit failure above it. Late fragments scan only new source units against retained authoritative tokens, including calls held behind another call; `tests/providers/zed-argument-consistency.test.ts` covers late, buffered and JSON/SSE cases. Failed terminals close outstanding calls incomplete and replay queued text/reasoning before the original error. Diagnostic usage retains only finite nonnegative numeric token counters through `src/adapters/zed-diagnostic-usage.ts`, including nested cache/reasoning counters; opaque extensions are discarded. `tests/providers/zed-failure-relay.test.ts` covers JSON/SSE failure projection and completion conflicts. `src/adapters/zed-additional-tools.ts` owns tool promotion, while `src/adapters/openai-responses/usage.ts` shares usage projection with Responses compaction. `tests/providers/zed-openai-contract.test.ts` covers the relay and client-boundary regressions. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | -| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays expressed as type-only `anyOf` branches in tool parameters. Shared constraints remain on the surrounding node, so nested schemas have one wire representation while outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions retain their conjunctive meaning. A null-only type keeps its null constraint even when an outer enum/const excludes null, preserving an unsatisfiable schema without producing an empty anyOf. Schema resource identifiers, anchors, definition maps, and existing anyOf branches remain on their original node. The type constraint is appended to existing allOf entries without shifting their indices, so JSON Pointer targets and reference scopes remain unchanged. Claude uids (`claude-*`, `MODEL_CLAUDE_*`) whose tool parameters have a root `oneOf`, `anyOf` or `allOf` get the same root flattening as Claude Chat gateways (`flattenClaudeRootToolParameters` in `src/adapters/openai-chat/tool-schema.ts`), because Cognition refuses a root composition for Claude with `invalid_argument`; every other Claude schema is sent unchanged. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | +| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays expressed as type-only `anyOf` branches in tool parameters. Shared constraints remain on the surrounding node, so nested schemas have one wire representation while outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions retain their conjunctive meaning. A null-only type keeps its null constraint even when an outer enum/const excludes null, preserving an unsatisfiable schema without producing an empty anyOf. Schema resource identifiers, anchors, definition maps, and existing anyOf branches remain on their original node. The type constraint is appended to existing allOf entries without shifting their indices, so JSON Pointer targets and reference scopes remain unchanged. Claude uids (`claude-*`, `MODEL_CLAUDE_*`) whose tool parameters have a root `oneOf`, `anyOf` or `allOf` get the same root flattening as Claude Chat gateways (`flattenClaudeRootToolParameters` in `src/adapters/openai-chat/tool-schema.ts`), because Cognition refuses a root composition for Claude with `invalid_argument`; every other Claude schema is sent unchanged. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer always rewrites known triggers on instruction surfaces — tool descriptions and the #2 system prompt. Data sanitization is off by default and enables only when `OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES` is exactly `"1"`: it also rewrites joined message text (#3), tool-call `arguments_json` (#6.3), image captions (#10.3), and replayed thinking (#11). Other values leave those data fields outside the sanitizer; a trigger there can still surface as upstream `permission_denied`. Tool names, tool-call IDs, and image bytes remain untouched. If sanitized thinking differs from the original, the prompt replays the rewritten #11 text unsigned and omits both its original #12 signature and #18 signature type; otherwise those signature fields remain byte-exact, including signature-only replay and changes confined to other data fields. The opt-in changes literal history and can weaken signed reasoning continuity for rewritten turns; it does not guarantee upstream acceptance. | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. Its registry entry is `deprecated`: existing saved providers keep the adapter and their stored configuration, while new-provider discovery (presets, the init menu, the featured list, and key logins) excludes it and `provider add` warns before creating one. | | `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. On OAuth rows the native fetch path normalizes only a 400 whose JSON is `success: false`, `error.code: "BAD_REQUEST"`, and message begins `You have insufficient credits to make this request.` to 429, adding `x-opencodex-refusal: insufficient-credits` and preserving the upstream status in `x-opencodex-upstream-status: 400`; ordinary 400s, other statuses, malformed or oversized bodies, and malformed UTF-8 stay untouched. Key and forward rows keep the 400, because a canonical key row has a same-key 429 retry ladder (`rateLimitRetryPolicyFor`) that would replay a credential with no credits left. A genuine upstream 429 keeps its own `Retry-After` and a 402 stays a 402 even when its body repeats the credit wording; `tests/providers/command-code-credit-failover.test.ts` pins that boundary and `tests/providers/command-code-credit-rotation.test.ts` drives the refusal through dispatch to the sibling account. On every auth mode a 403 whose JSON is `success: false`, `error.code: "FORBIDDEN"`, and message begins `MODEL_NOT_IN_PLAN` (then `:` or the end) becomes a 400 `invalid_request_error` with code `model_not_in_plan`, fixed copy naming the model, `x-opencodex-refusal: model-not-in-plan` and `x-opencodex-upstream-status: 403` (#7684): Codex reconnects on any 403 through its retry budget but ends the turn on a 400. The upstream detail is never quoted, so its free text cannot reach logs or steer the quota and reasoning-effort classifiers, and the effort repair skips the refusal. `isModelNotInPlanRefusal` in `src/lib/errors.ts` trusts the code only on a response object the adapter marked in memory, since an upstream can forge both the code and the diagnostic header; `formatErrorResponse` keeps it as the wire code, only the verifying boundaries (`formatUpstreamFailureResponse` in `src/bridge/errors.ts`, called by `adapter-dispatch.ts`, and `consumeComboFailure`) mark the envelope in memory (carried across the `src/server/responses/core-lifetime.ts` wrappers), `consumeComboFailure` drops an unmarked echo in any spelling failover folds into that code, and `comboFailureDecision` in `src/combos/failover.ts` hops past the refusal only with that caller-supplied provenance (combo or policy fallback; never a bare, passthrough or streamed code) while `comboFailureCooldownScope` cools only that target. The web-search and image sidecar loops keep a verified refusal's fixed copy without a wire-model name, code and mark in their own error envelope (`verifiedPlanRefusalMessage`), and `src/server/responses/sidecar-execution.ts` carries the mark across its stream wrappers; a later iteration's refusal inside an already committed 200 stream ends as `invalid_prompt` (`MODEL_NOT_IN_PLAN_STREAM_CODE`) with `retryable: false` and the same copy, because Codex reconnects on any other `response.failed` code. No account, key or provider cooldown is recorded. `tests/providers/command-code-plan-denial.test.ts` covers lookalikes, malformed bodies, abort, the Responses, Chat and Messages inbound paths, combo and policy hops, and an unmarked echo. | diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index 8b18cc1fd54..0f336c2777a 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -3,10 +3,13 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createDevinAdapter, mapDevinToolCallStartForTests, mapOcxMessagesToDevin, mapOcxToolsToDevin, resolveWireModelUidForTests } from "../../src/adapters/devin"; -import { buildGetChatMessageRequestForTests, sanitizeTextForCognitionForTests, sanitizeToolDescriptionForCognitionForTests } from "../../src/adapters/devin/cloud-direct/chat"; +import { buildGetChatMessageRequestForTests, sanitizeTextForCognitionForTests, sanitizeToolDescriptionForCognitionForTests, streamChatEvents, type ChatHistoryItem } from "../../src/adapters/devin/cloud-direct/chat"; import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_STATIC_MODELS, collapseDevinModelUid } from "../../src/adapters/devin/live-models"; import { parseCatalogBuffer } from "../../src/adapters/devin/cloud-direct/catalog"; -import { encodeMessage, encodeString, encodeVarintField, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; +import { encodeMessage, encodeString, encodeVarintField, frameConnectStream, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; +import { encodeDevinSignature } from "../../src/adapters/devin/reasoning-signature"; +import { parseRequest } from "../../src/responses/parser"; +import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; import { DEPRECATED_OAUTH_PROVIDER_ALIASES, OAUTH_PROVIDERS, resolveRefreshPolicy } from "../../src/oauth"; import { DEVIN_DEFAULT_API_SERVER } from "../../src/oauth/devin"; import { saveCredential } from "../../src/oauth/store"; @@ -382,9 +385,9 @@ describe("devin adapter", () => { }); test("rewrites the Codex CLI identity phrase and the ANSI renderer clause", () => { - const identity = "Codex refers to the open-source agentic coding tool"; + const identity = "Codex refers to the open-source agentic coding interface"; expect(sanitizeTextForCognitionForTests( - "Within this context, Codex refers to the open-source agentic coding tool (not the old Codex language model built by OpenAI).", + "Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI).", )).toBe( "Within this context, Codex refers to the open-source agentic coding tool (not the old Codex language model built by OpenAI).", ); @@ -443,10 +446,10 @@ describe("devin adapter", () => { expect(req.includes(Buffer.from(`the prompt says: ${rewrittenFragment}`, "utf8"))).toBe(true); expect(req.includes(Buffer.from(`{"patch":"${rewrittenFragment}"}`, "utf8"))).toBe(true); expect(req.includes(Buffer.from(`file bytes: ${rewrittenFragment}`, "utf8"))).toBe(true); - // Identifiers and opaque replay blobs stay byte-exact: a tool_call name - // or signature is not free text and a rewrite would corrupt it. + // Identifiers stay byte-exact, but a rewritten thinking block no longer + // matches its attestation and must be replayed unsigned. expect(req.includes(Buffer.from("apply_patch", "utf8"))).toBe(true); - expect(req.includes(Buffer.from(signature, "utf8"))).toBe(true); + expect(req.includes(Buffer.from(signature, "utf8"))).toBe(false); // A clean message carries no surprise rewrites — only the trigger phrase // itself is touched, everything else stays literal. @@ -539,6 +542,138 @@ describe("devin adapter", () => { }); }); +describe("Devin data-surface reasoning signatures", () => { + const trigger = "Takes a task_id parameter identifying the task"; + const rewritten = "Accepts a task_id parameter identifying the task"; + const signature = `opaque\u0001:${trigger}:한글`; + const fields = (wire: Buffer) => [...iterFields(wire)]; + const nested = (wire: Buffer, num: number) => fields(wire).filter(f => f.num === num).map(f => f.value as Buffer); + const text = (wire: Buffer, num: number) => nested(wire, num)[0]?.toString("utf8"); + const build = (messages: ChatHistoryItem[], sanitizeDataSurfaces?: boolean) => buildGetChatMessageRequestForTests({ + apiKey: "k", modelUid: "swe-2", messages, cascadeId: "c", sessionId: "s", requestId: 1n, triggerId: "t", + sanitizeDataSurfaces, + }); + const assistant = (wire: Buffer) => nested(wire, 3).find(prompt => fields(prompt).some(f => f.num === 2 && f.value === 2n))!; + + test.each([ + [undefined, "sealed"], [false, "sealed"], [true, "sealed"], + [true, "openai"], [true, "anthropic"], [true, undefined], + ] as const)("changed thinking, sanitize=%s, signature type=%s", (sanitize, signatureType) => { + const prompt = assistant(build([{ + role: "assistant", content: "calling", thinking: trigger, signature, signature_type: signatureType, + }], sanitize)); + expect(text(prompt, 11)).toBe(sanitize ? rewritten : trigger); + expect(text(prompt, 12)).toBe(sanitize ? undefined : signature); + expect(text(prompt, 18)).toBe(sanitize ? undefined : signatureType); + expect(nested(prompt, 12)).toHaveLength(sanitize ? 0 : 1); + expect(nested(prompt, 18)).toHaveLength(sanitize || signatureType === undefined ? 0 : 1); + }); + + test.each([undefined, "", "reasoning without a trigger"])("preserves unchanged thinking and signature-only replay (%s)", thinking => { + const id = `call:${trigger}`; + const name = `tool:${trigger}`; + const wire = build([ + { + role: "assistant", thinking, signature, signature_type: "sealed", + content: [{ type: "text", text: trigger }, { type: "image", mimeType: "image/png", base64Data: "AAEC", caption: trigger }], + tool_calls: [{ id, name, arguments: JSON.stringify({ quote: trigger }) }], + }, + { role: "tool", content: trigger, tool_call_id: id }, + ], true); + const prompt = assistant(wire); + expect(text(prompt, 3)).toBe(rewritten); + expect(text(prompt, 11)).toBe(thinking || undefined); + expect(text(prompt, 12)).toBe(signature); + expect(text(prompt, 18)).toBe("sealed"); + const call = nested(prompt, 6)[0]!; + expect(text(call, 1)).toBe(id); + expect(text(call, 2)).toBe(name); + expect(text(call, 3)).toBe(JSON.stringify({ quote: rewritten })); + const image = nested(prompt, 10)[0]!; + expect(text(image, 1)).toBe("AAEC"); + expect(text(image, 2)).toBe("image/png"); + expect(text(image, 3)).toBe(rewritten); + const tool = nested(wire, 3)[1]!; + expect(text(tool, 7)).toBe(id); + expect(text(tool, 3)).toBe(rewritten); + }); + + test("rewrites unsigned thinking without introducing a signature or orphan signature type", () => { + for (const signatureType of [undefined, "sealed"]) { + const prompt = assistant(build([{ + role: "assistant", content: "calling", thinking: trigger, signature_type: signatureType, + }], true)); + expect(text(prompt, 11)).toBe(rewritten); + expect(nested(prompt, 12)).toHaveLength(0); + expect(nested(prompt, 18)).toHaveLength(0); + } + }); + + test("only env value 1 rewrites replay, preserving authentication, continuity and stored history", async () => { + const beforeSanitize = process.env.OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES; + const beforeJwt = process.env.OPENCODEX_DEVIN_SEND_USER_JWT; + // Exercise the Responses parser and late signature trailer, then encode the + // same history with the opt-in both on and off. Nothing stored is rewritten. + const messages = mapOcxMessagesToDevin(parseRequest({ + model: "devin/swe-2", + input: [ + { role: "user", content: trigger }, + { type: "reasoning", id: "rs_text", summary: [{ type: "summary_text", text: trigger }] }, + { type: "reasoning", id: "rs_sig", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: encodeDevinSignature(signature, "sealed") }) }, + { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "done" }, + ], + })); + const original = structuredClone(messages); + const apiKey = "devin-session-token$sanitize-regression"; + try { + delete process.env.OPENCODEX_DEVIN_SEND_USER_JWT; + for (const setting of [undefined, "", "0", "true", "01", " 1 ", "1", undefined]) { + if (setting === undefined) delete process.env.OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES; + else process.env.OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES = setting; + const requests: Buffer[] = []; + const output: string[] = []; + for await (const event of streamChatEvents({ + apiKey, modelUid: "swe-2", messages, cascadeId: "cascade-sanitize", trajectoryId: "trajectory-sanitize", catalog: null, + executor: (async (url, init) => { + expect(String(url)).toBe("https://server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage"); + expect(new Headers(init?.headers).get("Authorization")).toBe(`Basic ${apiKey}-${apiKey}`); + const framed = Buffer.from(await (init!.body as Blob).arrayBuffer()); + expect(framed[0]).toBe(0); + expect(framed.readUInt32BE(1)).toBe(framed.length - 5); + requests.push(framed.subarray(5)); + return new Response(Buffer.concat([ + frameConnectStream(encodeString(3, "ok"), false), Buffer.from([2, 0, 0, 0, 2, 123, 125]), + ])); + }) as typeof fetch, + })) if (event.kind === "text") output.push(event.text); + expect(output).toEqual(["ok"]); + expect(requests).toHaveLength(1); + const wire = requests[0]!; + const metadata = nested(wire, 1)[0]!; + expect(text(metadata, 3)).toBe(apiKey); + expect(nested(metadata, 21)).toHaveLength(0); + expect(text(wire, 16)).toBe("cascade-sanitize"); + expect(text(nested(wire, 15)[0]!, 1)).toBe("trajectory-sanitize"); + const prompt = assistant(wire); + const enabled = setting === "1"; + expect(text(nested(wire, 3)[0]!, 3)).toBe(enabled ? rewritten : trigger); + expect(text(prompt, 11)).toBe(enabled ? rewritten : trigger); + expect(text(prompt, 12)).toBe(enabled ? undefined : signature); + expect(text(prompt, 18)).toBe(enabled ? undefined : "sealed"); + expect(text(nested(prompt, 6)[0]!, 1)).toBe("call_1"); + expect(text(nested(wire, 3)[2]!, 7)).toBe("call_1"); + expect(messages).toEqual(original); + } + } finally { + if (beforeSanitize === undefined) delete process.env.OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES; + else process.env.OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES = beforeSanitize; + if (beforeJwt === undefined) delete process.env.OPENCODEX_DEVIN_SEND_USER_JWT; + else process.env.OPENCODEX_DEVIN_SEND_USER_JWT = beforeJwt; + } + }); +}); + describe("SWE-2 wire effort selection", () => { // Every case here passes a null catalog: this is the degraded path. With a // catalog, family metadata decides (devin-family-resolution.test.ts). From 33884d887cc1ebecb3049e8ec87330149df045bb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 12 Nov 2026 17:18:04 +0900 Subject: [PATCH 4/4] chore(devin): keep live credential probes out of the runtime PR --- scripts/bisect-denied.ts | 71 ---------------------------------- scripts/probe-sanitize-live.ts | 65 ------------------------------- 2 files changed, 136 deletions(-) delete mode 100644 scripts/bisect-denied.ts delete mode 100644 scripts/probe-sanitize-live.ts diff --git a/scripts/bisect-denied.ts b/scripts/bisect-denied.ts deleted file mode 100644 index 6fd6c86a794..00000000000 --- a/scripts/bisect-denied.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Bisect a dumped denied request to find the blocklist trigger. -// Usage: bun scripts/bisect-denied.ts [args] -// modes: full | sys | msgs A B | sysonly -import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { streamChatEvents, CloudChatError } from "../src/adapters/devin/cloud-direct/index"; -import { resolveDevinApiBaseUrl } from "../src/oauth/devin/api-base"; - -const dump = JSON.parse(readFileSync(process.argv[2], "utf8")); -const mode = process.argv[3] ?? "full"; -const A = parseInt(process.argv[4] ?? "0", 10); -const B = parseInt(process.argv[5] ?? String(dump.messages.length), 10); - -const auth = JSON.parse(readFileSync(join(homedir(), ".opencodex", "auth.json"), "utf8")); -const acct = auth.devin.accounts.find((a: any) => a?.id === auth.devin.activeAccountId) ?? auth.devin.accounts[0]; -const apiKey: string = acct?.credential?.access; -const apiServerUrl: string | undefined = acct?.credential?.apiBaseUrl; - -process.env.OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES = "1"; // production-identical - -function toMsgs(list: any[]) { - return list.map((m: any) => ({ - role: m.role === "tool" ? "user" : m.role, - content: m.role === "tool" ? "\n" + m.text + "\n" : m.text, - })); -} - -async function probe(label: string, msgs: any[], tools: any[], sys: string) { - try { - let n = 0; - for await (const ev of streamChatEvents({ - apiKey, apiServerUrl, modelUid: "swe-2-max", - messages: [{ role: "system", content: sys }, ...msgs], - tools, - } as any)) { if (ev.kind === "text") n += ev.text.length; } - console.log(label + " => OK textLen=" + n); - return true; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const denied = /permission_denied/.test(msg); - console.log(label + " => " + (denied ? "DENIED" : "ERR " + msg.slice(0, 140))); - return !denied; - } -} - -const tools = dump.tools.map((t: any) => ({ name: t.name, description: t.description })); - -if (mode === "full") { - await probe("full", toMsgs(dump.messages), tools, dump.systemPrompt); -} else if (mode === "sys") { - await probe("sys+tools+hi", [{ role: "user", content: "hi" }], tools, dump.systemPrompt); -} else if (mode === "sysonly") { - await probe("sys-only+hi", [{ role: "user", content: "hi" }], [], dump.systemPrompt); -} else if (mode === "toolsonly") { - await probe("tools-only+hi", [{ role: "user", content: "hi" }], tools, ""); -} else if (mode === "msgs") { - await probe("msgs[" + A + ".." + B + "]", toMsgs(dump.messages.slice(A, B)), [], ""); -} else if (mode === "lit") { - // Read candidate literal strings from file (one per line) given in argv[4]. - const cand = readFileSync(process.argv[4], "utf8").split("\n").filter((s: string) => s.trim().length > 0); - for (const c of cand) { - await probe(JSON.stringify(c.slice(0, 80)), [{ role: "user", content: "hi" }], [], c); - } -} else if (mode === "syspart") { - await probe("sys[" + A + ".." + B + "]", [{ role: "user", content: "hi" }], [], dump.systemPrompt.slice(A, B)); -} else if (mode === "full-sys-half") { - const h = Math.floor(dump.systemPrompt.length / 2); - await probe("sys-first-half", [{ role: "user", content: "hi" }], [], dump.systemPrompt.slice(0, h)); - await probe("sys-second-half", [{ role: "user", content: "hi" }], [], dump.systemPrompt.slice(h)); -} diff --git a/scripts/probe-sanitize-live.ts b/scripts/probe-sanitize-live.ts deleted file mode 100644 index 21b5cb71c3f..00000000000 --- a/scripts/probe-sanitize-live.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Live probe: does a blocklisted phrase inside a *data* surface (message text) -// trigger Cognition permission_denied, and does the opt-in sanitizer clear it? -// Usage: -// bun scripts/probe-sanitize-live.ts -> sanitize OFF (expect denial) -// OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES=1 bun scripts/probe-sanitize-live.ts -// -> sanitize ON (expect success) -// The api key is read from ~/.opencodex/auth.json; nothing is written to disk. - -import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { streamChatEvents, CloudChatError } from "../src/adapters/devin/cloud-direct/index"; -import { getCachedCatalog } from "../src/adapters/devin/cloud-direct/catalog"; -import { resolveDevinApiBaseUrl } from "../src/oauth/devin/api-base"; - -const auth = JSON.parse(readFileSync(join(homedir(), ".opencodex", "auth.json"), "utf8")); -const devin = auth.devin; -const acct = (devin.accounts as any[]).find(a => a?.id === devin.activeAccountId) ?? devin.accounts[0]; -const apiKey: string = acct?.credential?.access; -if (!apiKey) { - console.error("no devin api key in auth.json"); - process.exit(2); -} -const apiServerUrl: string | undefined = acct?.credential?.apiBaseUrl; - -if (process.argv.includes("--catalog")) { - const host = resolveDevinApiBaseUrl(apiServerUrl); - const cat = await getCachedCatalog(apiKey, host).catch(e => { console.log('catalog err', String(e)); return null; }); - if (cat) for (const [uid, e] of cat.byUid) console.log(`${uid} disabled=${e.disabled} label=${e.label}`); - process.exit(0); -} - -// The exact Codex exec_command description Cognition refuses — here it sits in -// a user message (data surface), not a tool description (instruction surface). -const TRIGGER = "Runs a command in a PTY, returning output or a session ID for ongoing interaction"; -const messages = [ - { role: "user" as const, content: `The tool doc says: "${TRIGGER}". Remember it verbatim.` }, - { role: "assistant" as const, content: "Understood." }, - { role: "user" as const, content: "Reply with exactly: PONG" }, -]; - -const sanitize = process.env.OPENCODEX_DEVIN_SANITIZE_DATA_SURFACES === "1"; -console.log(`sanitize=${sanitize} account=${acct?.id ?? 'first'} model=swe-2`); - -try { - let text = ""; - let events = 0; - for await (const ev of streamChatEvents({ - apiKey, - apiServerUrl, - modelUid: "swe-2-max", - messages, - })) { - events++; - if (ev.kind === "text") text += ev.text; - } - console.log(`RESULT=OK events=${events} text=${JSON.stringify(text.slice(0, 120))}`); -} catch (e) { - if (e instanceof CloudChatError) { - console.log(`RESULT=CLOUD_ERROR code=${e.code} msg=${e.message.slice(0, 160)}`); - } else { - console.log(`RESULT=ERROR ${String(e).slice(0, 200)}`); - } - process.exit(1); -}