From e850e2f3e8eafe0cd7aa7b651cb0b0b6a86f1b90 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:15:57 +0900 Subject: [PATCH 1/9] fix(devin): suppress untrusted Connect trailer text Connect-RPC EOS trailers propagated upstream-controlled error.message text into adapter error events, API responses, and diagnostic logs, and that text can reflect the credential carried by the request. Parse trailers with an allowlist for known Connect codes, keep the raw message inside the parser for quota/opaque-denial detection, and throw content-free errors carrying only the allowlisted code and hex trace id. (cherry picked from commit 50eab5aa4e9fd9d5aa3b6b471309ad92140f830b) (cherry picked from commit 6efc75954fe6ea271b4b4bf2453700e1144fbd30) --- src/adapters/devin/cloud-direct/chat.ts | 45 ++++++++++++++++--------- structure/adapters/registry.md | 4 ++- tests/providers/devin-hardening.test.ts | 44 +++++++++++++++++++++++- 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 34547ecc193..6bf3ff38521 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -1078,6 +1078,15 @@ export class CloudChatError extends Error { } const TRACE_ID_RE = /\(trace ID: ([0-9a-f]+)\)/i; +const SAFE_CONNECT_CODES = new Set([ + 'canceled', 'unknown', 'invalid_argument', 'deadline_exceeded', 'not_found', 'already_exists', + 'permission_denied', 'resource_exhausted', 'failed_precondition', 'aborted', 'out_of_range', + 'unimplemented', 'internal', 'unavailable', 'data_loss', 'unauthenticated', +]); + +function safeConnectCode(value: unknown): string | undefined { + return typeof value === 'string' && SAFE_CONNECT_CODES.has(value) ? value : undefined; +} /** * A quota refusal Cognition delivers as `permission_denied`. @@ -1296,7 +1305,7 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator; - let trailerError: { code?: string; message: string; traceId?: string } | null = null; + let trailerError: { code?: string; message: string; opaqueDenial: boolean; traceId?: string } | null = null; let sawEos = false; /** @@ -1441,14 +1450,19 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator { expect(events).toEqual([{ kind: "reasoning_signature", signature: "sig-from-cloud" }]); }); }); + +describe("devin cloud trailer errors", () => { + test("never exposes an upstream message or unrecognized code", async () => { + const credential = "devin-session-token$header.payload.signature"; + const trailer = Buffer.from(JSON.stringify({ + error: { code: credential, message: `reflected request credential: ${credential}` }, + })); + const envelope = Buffer.alloc(5 + trailer.length); + envelope[0] = 0x02; + envelope.writeUInt32BE(trailer.length, 1); + trailer.copy(envelope, 5); + + const originalFetch = globalThis.fetch; + clearCachedCatalog(); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return calls === 1 + ? new Response("catalog unavailable", { status: 503 }) + : new Response(envelope, { status: 200 }); + }) as typeof fetch; + try { + let caught: unknown; + try { + for await (const _event of streamChatEvents({ + apiKey: credential, + modelUid: "swe-2-high", + messages: [{ role: "user", content: "hi" }], + })) { /* no data frames */ } + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toBe("Cognition chat failed (cloud trace ID: n/a)"); + expect((caught as Error).message).not.toContain(credential); + } finally { + globalThis.fetch = originalFetch; + clearCachedCatalog(); + } + }); +}); From 49fd91baacedcc18692f9762da29e0528fef32aa Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:24:14 +0900 Subject: [PATCH 2/9] fix(devin): preserve stated reset delay on content-free trailer errors The trailer suppression left the thrown CloudChatError without the upstream "limit will reset in N" sentence that streamChatEventsWithResetRetry scraped for its cooldown, so a stated-reset 429 failed immediately instead of waiting out the quota window. Parse the delay inside the trailer parser while the raw text still exists and carry it as a typed retryAfterSeconds field; the retry prefers that field and keeps the message scrape for errors whose text still carries a hint. Adds a regression test that drives the real Connect EOS trailer path. (cherry picked from commit 7b7a8b8d3f1e6238a25972cd6275f19372b21c41) (cherry picked from commit b841abdd5b832483c5c4da07fc0b92d44741ca33) --- src/adapters/devin/cloud-direct/chat.ts | 17 +++++- .../devin/cloud-direct/stated-reset-retry.ts | 5 +- .../devin-stated-reset-retry.test.ts | 54 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 6bf3ff38521..9dc5257b53b 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -37,6 +37,7 @@ import { buildMetadata } from './metadata.js'; import { getCachedUserJwt } from './auth.js'; import { getCachedCatalog, ModelNotAvailableError, type CacheEntry } from './catalog.js'; import { anySignal, cancelBodyOnAbort } from '../../../lib/abort.js'; +import { parseRetryAfterFromMessage } from '../../../lib/retry-delay.js'; import { resolveDevinApiBaseUrl } from '../../../oauth/devin/api-base.js'; /** @@ -1071,6 +1072,13 @@ export class CloudChatError extends Error { * a live rate limit was classified 502 and core's failover never rotated. */ public readonly status?: number, + /** + * Provider-stated recovery delay in seconds. The trailer parser extracts + * it while the raw upstream text is still available, because the thrown + * message is content-free: the stated-reset retry reads this typed field + * instead of scraping untrusted trailer text out of error.message. + */ + public readonly retryAfterSeconds?: number, ) { super(message); this.name = 'CloudChatError'; @@ -1305,7 +1313,7 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator; - let trailerError: { code?: string; message: string; opaqueDenial: boolean; traceId?: string } | null = null; + let trailerError: { code?: string; message: string; opaqueDenial: boolean; retryAfterSeconds?: number; traceId?: string } | null = null; let sawEos = false; /** @@ -1461,6 +1469,10 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator { sleep: async () => {}, }))).rejects.toThrow("socket reset"); }); + + test("the stated delay survives the real content-free trailer error", async () => { + // The thrown trailer error no longer carries upstream text, so a fabricated + // CloudChatError cannot prove the contract: this drives the real stream + // through a Connect EOS trailer and expects the typed retryAfterSeconds the + // parser preserved to schedule the replay. + const eosEnvelope = (payload: object) => { + const body = Buffer.from(JSON.stringify(payload)); + const envelope = Buffer.alloc(5 + body.length); + envelope[0] = 0x02; + envelope.writeUInt32BE(body.length, 1); + body.copy(envelope, 5); + return envelope; + }; + const credential = "devin-session-token$header.payload.signature"; + const chatEnvelopes = [ + eosEnvelope({ + error: { + code: "resource_exhausted", + message: `Your limit will reset in 35 seconds. ${credential}`, + }, + }), + eosEnvelope({}), + ]; + let chatCalls = 0; + const originalFetch = globalThis.fetch; + clearCachedCatalog(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + // Everything except the chat POST fails: the catalog pre-flight mints a + // user_jwt first, so gating on GetChatMessage keeps auxiliary RPCs from + // consuming a chat envelope. + if (!url.includes("GetChatMessage")) { + return new Response("catalog unavailable", { status: 503 }); + } + const envelope = chatEnvelopes[Math.min(chatCalls, chatEnvelopes.length - 1)]; + chatCalls += 1; + return new Response(envelope, { status: 200 }); + }) as typeof fetch; + const waits: number[] = []; + try { + const out = await drain(streamChatEventsWithResetRetry( + { apiKey: "k", modelUid: "swe-2", messages: [] } as unknown as CloudChatRequest, + { sleep: async (ms) => { waits.push(ms); } }, + )); + expect(chatCalls).toBe(2); + expect(waits).toEqual([35_000]); + expect(out).toEqual([]); + } finally { + globalThis.fetch = originalFetch; + clearCachedCatalog(); + } + }); }); From 5a75a8e517cfe89a25381c187a7208162057dcd1 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:50:13 +0900 Subject: [PATCH 3/9] fix(devin): carry the stated reset delay in the outward trailer error (cherry picked from commit 4b723ef5d623ee9b24e7f16f989beeeb9edd94db) (cherry picked from commit 82d452c39a157f13d5de92c3931023860f127bd8) --- src/adapters/devin/cloud-direct/chat.ts | 8 ++- tests/providers/devin-hardening.test.ts | 43 ++++++++++++ .../devin-stated-reset-retry.test.ts | 65 ++++++++++++++++--- 3 files changed, 107 insertions(+), 9 deletions(-) diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 9dc5257b53b..047d16d29ee 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -1557,9 +1557,15 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator { clearCachedCatalog(); } }); + + test("carries our own retry-seconds wording without the raw trailer text", async () => { + const credential = "devin-session-token$header.payload.signature"; + const trailer = Buffer.from(JSON.stringify({ + error: { + code: "resource_exhausted", + message: `Your limit will reset in 35 seconds. ${credential}`, + }, + })); + const envelope = Buffer.alloc(5 + trailer.length); + envelope[0] = 0x02; + envelope.writeUInt32BE(trailer.length, 1); + trailer.copy(envelope, 5); + + const originalFetch = globalThis.fetch; + clearCachedCatalog(); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return calls === 1 + ? new Response("catalog unavailable", { status: 503 }) + : new Response(envelope, { status: 200 }); + }) as typeof fetch; + try { + let caught: unknown; + try { + for await (const _event of streamChatEvents({ + apiKey: credential, + modelUid: "swe-2-high", + messages: [{ role: "user", content: "hi" }], + })) { /* no data frames */ } + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toContain("retry after ~35s"); + expect((caught as Error).message).not.toContain(credential); + expect((caught as Error).message).not.toContain("Your limit will reset"); + } finally { + globalThis.fetch = originalFetch; + clearCachedCatalog(); + } + }); }); diff --git a/tests/providers/devin-stated-reset-retry.test.ts b/tests/providers/devin-stated-reset-retry.test.ts index e9af63c3746..0399a463784 100644 --- a/tests/providers/devin-stated-reset-retry.test.ts +++ b/tests/providers/devin-stated-reset-retry.test.ts @@ -28,6 +28,15 @@ async function drain(source: AsyncGenerator): Promise { test("admits and reports every inference POST through one shared budget", async () => { let spendReservations = 0; @@ -254,14 +263,6 @@ describe("streamChatEventsWithResetRetry", () => { // CloudChatError cannot prove the contract: this drives the real stream // through a Connect EOS trailer and expects the typed retryAfterSeconds the // parser preserved to schedule the replay. - const eosEnvelope = (payload: object) => { - const body = Buffer.from(JSON.stringify(payload)); - const envelope = Buffer.alloc(5 + body.length); - envelope[0] = 0x02; - envelope.writeUInt32BE(body.length, 1); - body.copy(envelope, 5); - return envelope; - }; const credential = "devin-session-token$header.payload.signature"; const chatEnvelopes = [ eosEnvelope({ @@ -301,4 +302,52 @@ describe("streamChatEventsWithResetRetry", () => { clearCachedCatalog(); } }); + + test("a stated window beyond the ceiling still reports the wait outward", async () => { + // When the stated delay exceeds the local cap the original trailer error + // propagates. Its message must carry the seconds in our own words so the + // client can tell how long to wait — without the raw trailer text, which + // can reflect the request credential. + const credential = "devin-session-token$header.payload.signature"; + const chatEnvelopes = [ + eosEnvelope({ + error: { + code: "resource_exhausted", + message: `Your limit will reset in 35 seconds. ${credential}`, + }, + }), + ]; + let chatCalls = 0; + const originalFetch = globalThis.fetch; + clearCachedCatalog(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (!url.includes("GetChatMessage")) { + return new Response("catalog unavailable", { status: 503 }); + } + const envelope = chatEnvelopes[Math.min(chatCalls, chatEnvelopes.length - 1)]; + chatCalls += 1; + return new Response(envelope, { status: 200 }); + }) as typeof fetch; + try { + let caught: unknown; + try { + await drain(streamChatEventsWithResetRetry( + { apiKey: "k", modelUid: "swe-2", messages: [] } as unknown as CloudChatRequest, + { sleep: async () => {}, maxWaitMs: 10_000 }, + )); + } catch (error) { + caught = error; + } + expect(chatCalls).toBe(1); + expect(caught).toBeInstanceOf(CloudChatError); + const message = (caught as Error).message; + expect(message).toContain("retry after ~35s"); + expect(message).not.toContain(credential); + expect(message).not.toContain("Your limit will reset"); + } finally { + globalThis.fetch = originalFetch; + clearCachedCatalog(); + } + }); }); From cf10ad99a7982f21085d32067d423fd3b9573dfd Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:55:59 +0900 Subject: [PATCH 4/9] fix(devin): prevent reset waits from holding active-turn capacity (cherry picked from commit df07bc6b32c776f0a24b60ab99adad3604ad8da0) (cherry picked from commit 98c929817012c183cef0a2a5ab8b3815fe7e35c7) --- src/adapters/devin.ts | 8 ++++---- structure/providers-and-adapters.md | 2 +- tests/providers/devin-stated-reset-retry.test.ts | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index fe81f356ba4..d3dfd4d1c08 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -642,10 +642,9 @@ export function createDevinAdapter( const maxOutputTokens = resolveDevinMaxOutputTokens( provider, modelUid, parsed.options.maxOutputTokens, ); - // The reset-retry wrapper waits out a 429 that states its own recovery - // delay ("limit will reset in 35 seconds") and replays the identical - // request — but only while zero events have been yielded, so a - // post-output failure still takes the terminal path untouched. + // An admitted HTTP turn owns globally shared capacity until this call + // emits. Never retain that capacity while waiting out a provider 429; + // surface its parsed Retry-After so the client can retry later instead. for await (const event of streamChatEventsWithResetRetry({ apiKey, apiServerUrl: host, @@ -664,6 +663,7 @@ export function createDevinAdapter( }, signal: incoming.abortSignal, }, { + maxWaitMs: 0, execution: { executor: incoming.providerFetch, sendBudget: incoming.sendBudget, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 132265964a9..e8420fb153c 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -22,7 +22,7 @@ the [bounded ingestion contract](transports/inventory.md#bounded-response-ingest | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog and JWT support RPCs remain outside inference-send accounting. | +| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog and JWT support RPCs remain outside inference-send accounting. Provider-stated 429 reset delays are surfaced to the client rather than slept inside an admitted turn, so they cannot retain shared active-turn capacity. | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling. 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). | | `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. An image's ladder position is pinned to its own identity (content hash + media type), so appending a newer image cannot re-encode older ones and bust Anthropic's prompt prefix cache (#4532). | diff --git a/tests/providers/devin-stated-reset-retry.test.ts b/tests/providers/devin-stated-reset-retry.test.ts index 0399a463784..53356089d6f 100644 --- a/tests/providers/devin-stated-reset-retry.test.ts +++ b/tests/providers/devin-stated-reset-retry.test.ts @@ -196,6 +196,20 @@ describe("streamChatEventsWithResetRetry", () => { expect(calls).toBe(1); }); + test("a zero wait allowance surfaces a stated reset without sleeping", async () => { + let calls = 0; + const stream = () => { + calls += 1; + return exhausting("Your limit will reset in 21 minutes")(); + }; + await expect(drain(streamChatEventsWithResetRetry(REQ, { + stream, + sleep: async () => { throw new Error("sleep must not run"); }, + maxWaitMs: 0, + }))).rejects.toThrow("21 minutes"); + expect(calls).toBe(1); + }); + test("a 21-minute stated window replays under the default ceiling", async () => { // Observed upstream windows reach ~21 minutes; the default ceiling is 30. const waits: number[] = []; From 8cd9687dc587ab9f157fe9a13ee3ccb310ed2735 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:39:19 +0900 Subject: [PATCH 5/9] fix(devin): pin the adapter's zero reset wait and correct the surface comment (cherry picked from commit 0e737367387e4e821f9dbc56ecb0a9c5157388f1) (cherry picked from commit a2936ca97ae32cce42886a1dee4030543aba4637) --- src/adapters/devin.ts | 6 +- .../devin-adapter-reset-wait.test.ts | 69 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 tests/providers/devin-adapter-reset-wait.test.ts diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index d3dfd4d1c08..7620f398213 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -643,8 +643,10 @@ export function createDevinAdapter( provider, modelUid, parsed.options.maxOutputTokens, ); // An admitted HTTP turn owns globally shared capacity until this call - // emits. Never retain that capacity while waiting out a provider 429; - // surface its parsed Retry-After so the client can retry later instead. + // emits. Never retain that capacity while waiting out a provider 429: + // the stated reset is surfaced as the original error message (the + // seconds figure stays inside that text for clients that parse it), + // and the shared slot is freed immediately. for await (const event of streamChatEventsWithResetRetry({ apiKey, apiServerUrl: host, diff --git a/tests/providers/devin-adapter-reset-wait.test.ts b/tests/providers/devin-adapter-reset-wait.test.ts new file mode 100644 index 00000000000..6b64b5ccb89 --- /dev/null +++ b/tests/providers/devin-adapter-reset-wait.test.ts @@ -0,0 +1,69 @@ +/** + * The Devin adapter pins maxWaitMs to 0 when it calls the stated-reset retry + * helper, so an admitted HTTP turn never holds shared capacity while sleeping + * out a provider 429. The helper-level tests cannot see this: they pass their + * own maxWaitMs. This file drives the real adapter and captures the option the + * helper actually receives. + */ +import { describe, expect, mock, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let captured: { maxWaitMs?: number } | undefined; +mock.module("../../src/adapters/devin/cloud-direct/stated-reset-retry", () => ({ + streamChatEventsWithResetRetry: (_req: unknown, opts: { maxWaitMs?: number }) => { + captured = opts; + return (async function* () {})(); + }, + STATED_RESET_MAX_REPLAYS: 3, + STATED_RESET_MAX_WAIT_MS: 1_800_000, +})); + +const { createDevinAdapter } = await import("../../src/adapters/devin"); +const { parseCatalogBuffer, setCachedCatalogForTests } = await import("../../src/adapters/devin/cloud-direct/catalog"); +const { encodeMessage, encodeString, encodeVarintField } = await import("../../src/adapters/devin/cloud-direct/wire"); +const { createTranslatorBudget } = await import("../../src/lib/translator-budget"); + +const apiKey = "ocx-devin-reset-wait-fixture"; +const host = "https://server.codeium.com"; +let home = ""; +const previousHome = process.env.OPENCODEX_HOME; + +function seed(): void { + const buffer = Buffer.concat([encodeMessage(1, Buffer.concat([ + encodeString(1, "swe-2-high"), + encodeString(22, "swe-2-high"), + encodeVarintField(18, 262_000), + encodeVarintField(4, 0), + ]))]); + setCachedCatalogForTests(parseCatalogBuffer(buffer, apiKey, host)); +} + +describe("devin adapter stated-reset wait", () => { + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-devin-reset-wait-")); + process.env.OPENCODEX_HOME = home; + captured = undefined; + seed(); + }); + afterEach(() => { + setCachedCatalogForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + }); + + test("the adapter passes maxWaitMs 0 to the stated-reset helper", async () => { + const adapter = createDevinAdapter({ adapter: "devin", apiKey, baseUrl: host }); + const events: unknown[] = []; + await adapter.runTurn!({ + modelId: "swe-2-high", + stream: true, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + options: {}, + }, { headers: new Headers(), translatorBudget: createTranslatorBudget() }, + event => { events.push(event); }); + expect(captured).toBeDefined(); + expect(captured!.maxWaitMs).toBe(0); + }); +}); From edcca142a676e54823c5d7ee9c10dee86476581e Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:35:18 +0900 Subject: [PATCH 6/9] test(devin): prove the zero reset wait through the adapter's own sends The module-mock spy leaked into sibling test files in the same bun test process, so devin-stated-reset-retry and devin-stated-reset-hardening saw a stubbed helper. Drive the real runTurn with a stubbed fetch returning a Connect EOS-trailer refusal instead: the asserted signals are the surfaced error text and the physical GetChatMessage count, which fail fast under the 5s abort signal if the adapter's maxWaitMs: 0 pin is removed or weakened. Register the file in the test layout map and fixture. (cherry picked from commit 1abc0358445139ba114061964ea5ce0e0e56c5a0) (cherry picked from commit 2c4bf00ac6749506062d792c45c99fbb60542689) --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + .../devin-adapter-reset-wait.test.ts | 114 +++++++++++++----- 3 files changed, 85 insertions(+), 31 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index bc0a5653ad3..e66874074e8 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -735,6 +735,7 @@ "desktop-start-at-login-default.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", + "devin-adapter-reset-wait.test.ts": "providers", "devin-cli-authmode-migration.test.ts": "providers", "devin-effort-ladder.test.ts": "providers", "devin-live-models.test.ts": "providers", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 81214295a00..7c0a97e5feb 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -566,6 +566,7 @@ "desktop-start-at-login-default.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", + "devin-adapter-reset-wait.test.ts": "providers", "devin-effort-ladder.test.ts": "providers", "devin-hardening.test.ts": "providers", "devin-image-passthrough.test.ts": "providers", diff --git a/tests/providers/devin-adapter-reset-wait.test.ts b/tests/providers/devin-adapter-reset-wait.test.ts index 6b64b5ccb89..70446cc5e3b 100644 --- a/tests/providers/devin-adapter-reset-wait.test.ts +++ b/tests/providers/devin-adapter-reset-wait.test.ts @@ -2,33 +2,33 @@ * The Devin adapter pins maxWaitMs to 0 when it calls the stated-reset retry * helper, so an admitted HTTP turn never holds shared capacity while sleeping * out a provider 429. The helper-level tests cannot see this: they pass their - * own maxWaitMs. This file drives the real adapter and captures the option the - * helper actually receives. + * own maxWaitMs. This file drives the real adapter end to end and asserts the + * refusal surfaces without a replay — a mock.module spy would also work, but a + * module mock registered at file scope leaks into every sibling test file Bun + * loads into the same process. + * + * Removing the adapter's maxWaitMs: 0 fails both tests fast: the helper would + * sleep out the stated window, the 5s abort signal cancels that sleep, and the + * turn ends in the 499 client-closed error instead of the provider's refusal. */ -import { describe, expect, mock, test, beforeEach, afterEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; - -let captured: { maxWaitMs?: number } | undefined; -mock.module("../../src/adapters/devin/cloud-direct/stated-reset-retry", () => ({ - streamChatEventsWithResetRetry: (_req: unknown, opts: { maxWaitMs?: number }) => { - captured = opts; - return (async function* () {})(); - }, - STATED_RESET_MAX_REPLAYS: 3, - STATED_RESET_MAX_WAIT_MS: 1_800_000, -})); - -const { createDevinAdapter } = await import("../../src/adapters/devin"); -const { parseCatalogBuffer, setCachedCatalogForTests } = await import("../../src/adapters/devin/cloud-direct/catalog"); -const { encodeMessage, encodeString, encodeVarintField } = await import("../../src/adapters/devin/cloud-direct/wire"); -const { createTranslatorBudget } = await import("../../src/lib/translator-budget"); +import { createDevinAdapter } from "../../src/adapters/devin"; +import { parseCatalogBuffer, setCachedCatalogForTests } from "../../src/adapters/devin/cloud-direct/catalog"; +import { encodeMessage, encodeString, encodeVarintField } from "../../src/adapters/devin/cloud-direct/wire"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import type { AdapterEvent } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; const apiKey = "ocx-devin-reset-wait-fixture"; const host = "https://server.codeium.com"; +const CHAT_URL = `${host}/exa.api_server_pb.ApiServerService/GetChatMessage`; let home = ""; const previousHome = process.env.OPENCODEX_HOME; +const previousFetch = globalThis.fetch; +let chatPosts = 0; function seed(): void { const buffer = Buffer.concat([encodeMessage(1, Buffer.concat([ @@ -40,30 +40,82 @@ function seed(): void { setCachedCatalogForTests(parseCatalogBuffer(buffer, apiKey, host)); } +// A Connect-RPC stream whose only frame is an end-stream trailer carrying the +// provider's stated-reset refusal: flags 0x02, big-endian length, JSON body. +function refusalResponse(message: string): Response { + const trailer = Buffer.from(JSON.stringify({ + error: { code: "resource_exhausted", message }, + }), "utf8"); + const frame = Buffer.alloc(5 + trailer.length); + frame[0] = 0x02; + frame.writeUInt32BE(trailer.length, 1); + trailer.copy(frame, 5); + return new Response(frame, { + status: 200, + headers: { "Content-Type": "application/connect+proto" }, + }); +} + +function stubTransport(message: string): void { + chatPosts = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + // The seeded catalog cache means GetChatMessage is the only RPC this turn + // performs; count it anyway so a replay is visible as a second send. + if (String(input).startsWith(CHAT_URL)) chatPosts += 1; + return refusalResponse(message); + }) as typeof fetch; +} + +async function runOneTurn(): Promise { + const adapter = createDevinAdapter({ adapter: "devin", apiKey, baseUrl: host }); + const events: AdapterEvent[] = []; + await adapter.runTurn!({ + modelId: "swe-2-high", + stream: true, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + options: {}, + }, { + headers: new Headers(), + translatorBudget: createTranslatorBudget(), + abortSignal: AbortSignal.timeout(5_000), + }, event => { events.push(event); }); + return events; +} + describe("devin adapter stated-reset wait", () => { beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-devin-reset-wait-")); process.env.OPENCODEX_HOME = home; - captured = undefined; seed(); }); afterEach(() => { + globalThis.fetch = previousFetch; setCachedCatalogForTests(null); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); }); - test("the adapter passes maxWaitMs 0 to the stated-reset helper", async () => { - const adapter = createDevinAdapter({ adapter: "devin", apiKey, baseUrl: host }); - const events: unknown[] = []; - await adapter.runTurn!({ - modelId: "swe-2-high", - stream: true, - context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, - options: {}, - }, { headers: new Headers(), translatorBudget: createTranslatorBudget() }, - event => { events.push(event); }); - expect(captured).toBeDefined(); - expect(captured!.maxWaitMs).toBe(0); + test("a stated reset beyond the allowance surfaces instead of holding the turn", async () => { + stubTransport("Your limit will reset in 21 minutes"); + + const events = await runOneTurn(); + + // The provider's refusal is preserved verbatim — including the stated + // reset text — instead of the turn sleeping inside the shared slot. + const error = events.find((event): event is Extract => event.type === "error"); + expect(error?.message).toContain("reset in 21 minutes"); + expect(events.some(event => event.type === "done")).toBe(false); + expect(chatPosts).toBe(1); + }); + + test("even a one-second stated reset is not waited out", async () => { + stubTransport("Your limit will reset in 1 second"); + + const events = await runOneTurn(); + + const error = events.find((event): event is Extract => event.type === "error"); + expect(error?.message).toContain("reset in 1 second"); + expect(chatPosts).toBe(1); }); }); From 6a2caa7e84eb13da8ffc9f118280ac31d13e9155 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 21 Sep 2026 09:24:34 +0900 Subject: [PATCH 7/9] fix(devin): bind alias tenant host to credential key (cherry picked from commit d225fc2b6bb507943a8b494b0c3ced0f97def0f6) (cherry picked from commit 9b54aebb8ce1dbad8a49000e6cc7f002ab71d3ae) --- src/adapters/devin.ts | 2 +- src/oauth/devin.ts | 11 ++++++++--- structure/providers-and-adapters.md | 2 +- structure/runtime.md | 2 +- tests/providers/devin-adapter.test.ts | 21 ++++++++++++++++++--- tests/providers/devin-login.test.ts | 4 ++-- 6 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 7620f398213..45032d623a3 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -612,7 +612,7 @@ export function createDevinAdapter( // The signed-in account's tenant decides the host, not the static registry // entry: an EU or FedStart account that used provider.baseUrl would send // every RPC to the US server it is not provisioned on. - const host = resolveDevinApiServer(provider.baseUrl, credentialProviderId); + const host = resolveDevinApiServer(provider.baseUrl, credentialProviderId, apiKey); // One catalog read per turn serves model-UID resolution, the input // ceiling, and the chat pre-flight inside streamChatEvents. Failures are // not cached, so a second read would only pay another fetch timeout on diff --git a/src/oauth/devin.ts b/src/oauth/devin.ts index 47bac42f9d3..57ee66d71c6 100644 --- a/src/oauth/devin.ts +++ b/src/oauth/devin.ts @@ -51,7 +51,7 @@ function devinAliasCredentialSlots(providerId: string): string[] { * resort; both are re-validated because neither is trusted more than the * network value. */ -export function resolveDevinApiServer(configuredBaseUrl?: string, providerId = "devin"): string { +export function resolveDevinApiServer(configuredBaseUrl?: string, providerId = "devin", apiKey?: string): string { // Provider-scoped, keyed by the configured provider id verbatim and consulted // FIRST. `devin-cli` is a deprecated alias for `devin`, but an unmigrated // config row still owns its old credential slot until the startup migration @@ -72,7 +72,10 @@ export function resolveDevinApiServer(configuredBaseUrl?: string, providerId = " // directions closes that window: "devin" finds the not-yet-rekeyed // "devin-cli" credential, and a lingering "devin-cli" row finds a credential // already rekeyed to "devin". Every candidate passes the same allowlist — an - // alias slot is not trusted more than the literal one. + // alias slot is not trusted more than the literal one. The candidate must + // also own the key this request will transmit: provider-configured and + // forwarded keys are resolved outside the credential store, and borrowing a + // host from a different key would cross an account or regional boundary. // Only when this id owns no credential at all. A present credential whose // apiBaseUrl is missing or off-allowlist is a different situation: the rekey // refuses an occupied destination slot, so both ids can hold credentials that @@ -84,7 +87,9 @@ export function resolveDevinApiServer(configuredBaseUrl?: string, providerId = " // closed. if (literalCredential === null || literalCredential === undefined) { for (const slot of devinAliasCredentialSlots(providerId)) { - const host = validateDevinApiBaseUrl(getCredential(slot)?.apiBaseUrl); + const credential = getCredential(slot); + if (!apiKey || credential?.access !== apiKey) continue; + const host = validateDevinApiBaseUrl(credential.apiBaseUrl); if (host !== undefined) return host; } } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index e8420fb153c..75e666a7324 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -22,7 +22,7 @@ the [bounded ingestion contract](transports/inventory.md#bounded-response-ingest | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog and JWT support RPCs remain outside inference-send accounting. Provider-stated 429 reset delays are surfaced to the client rather than slept inside an admitted turn, so they cannot retain shared active-turn capacity. | +| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog and JWT support RPCs remain outside inference-send accounting. Provider-stated 429 reset delays are surfaced to the client rather than slept inside an admitted turn, so they cannot retain shared active-turn capacity. Tenant-host fallback across the deprecated provider alias is admitted only when the alias credential owns the transmitted key. | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling. 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). | | `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. An image's ladder position is pinned to its own identity (content hash + media type), so appending a newer image cannot re-encode older ones and bust Anthropic's prompt prefix cache (#4532). | diff --git a/structure/runtime.md b/structure/runtime.md index 8fdffda6775..376db9fb817 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -446,7 +446,7 @@ following a final symlink, so an exchange during a mutation cannot redirect the Config JSON preserves the boolean; only literal true activates the role-changing transform. The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. -Devin CLI credential path composition in `src/oauth/devin/cli-import.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. The `src/providers/devin-provider-merge-migration.ts` startup migration treats the legacy provider row and its OAuth slot as one account-bound unit: an occupied destination or a refused config projection leaves both unchanged, and both backups complete before either file changes. +Devin CLI credential path composition in `src/oauth/devin/cli-import.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. The `src/providers/devin-provider-merge-migration.ts` startup migration treats the legacy provider row and its OAuth slot as one account-bound unit: an occupied destination or a refused config projection leaves both unchanged, and both backups complete before either file changes. During a detached rekey window, the adapter may use the alias slot's tenant host only when that slot owns the exact key being transmitted, so separately configured or forwarded credentials cannot borrow another account's destination. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index 7407252db2a..5417bcd906c 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -500,10 +500,10 @@ describe("devin adapter api-server host resolution (#4503)", () => { // Drive one real runTurn. The stubbed 500 ends the turn in an upstream error // only after every outbound URL has been recorded. - async function runOneTurn(apiKey: string): Promise { + async function runOneTurn(apiKey: string, providerId = "devin"): Promise { const adapter = createDevinAdapter( { adapter: "devin", baseUrl: CONFIGURED_BASE_URL, apiKey }, - { providerId: "devin" }, + { providerId }, ); const parsed: OcxParsedRequest = { modelId: "swe-2-high", @@ -534,7 +534,7 @@ describe("devin adapter api-server host resolution (#4503)", () => { apiBaseUrl: EU_TENANT_HOST, }); - const events = await runOneTurn("ocx-test-alias-slot-key"); + const events = await runOneTurn("devin-cli-session"); expectDispatchedTo(EU_TENANT_HOST); expect(seenUrls.some((url) => url.startsWith(DEVIN_DEFAULT_API_SERVER))).toBe(false); @@ -544,6 +544,21 @@ describe("devin adapter api-server host resolution (#4503)", () => { expect(events.some((event) => event.type === "error")).toBe(true); }); + test("an independently configured key does not borrow an alias credential's tenant host", async () => { + await saveCredential("devin", { + access: "different-account-session", + refresh: "different-account-session", + expires: Number.MAX_SAFE_INTEGER, + source: "local-cli", + apiBaseUrl: EU_TENANT_HOST, + }); + + await runOneTurn("configured-provider-key", "devin-cli"); + + expectDispatchedTo(CONFIGURED_BASE_URL); + expect(seenUrls.some((url) => url.startsWith(EU_TENANT_HOST))).toBe(false); + }); + test("a usable literal devin slot still wins over the aliased devin-cli slot", async () => { await saveCredential("devin", { access: "devin-session", diff --git a/tests/providers/devin-login.test.ts b/tests/providers/devin-login.test.ts index 0b4b938728b..727218ea3e1 100644 --- a/tests/providers/devin-login.test.ts +++ b/tests/providers/devin-login.test.ts @@ -259,14 +259,14 @@ describe("devin tenant selection is provider-scoped", () => { // tenant host sitting on "devin-cli"; otherwise the key is sent to the US // default and Cognition answers permission_denied. await seedSlot("devin-cli", EU_HOST); - expect(resolveDevinApiServer(undefined, "devin")).toBe(EU_HOST); + expect(resolveDevinApiServer(undefined, "devin", KEY)).toBe(EU_HOST); }); test("the signed-in alias tenant wins over a configured baseUrl", async () => { // RegisterUser recorded the tenant on the credential. A leftover US // baseUrl on the rewritten config row must not override that account. await seedSlot("devin-cli", EU_HOST); - expect(resolveDevinApiServer(US_HOST, "devin")).toBe(EU_HOST); + expect(resolveDevinApiServer(US_HOST, "devin", KEY)).toBe(EU_HOST); }); test("the literal slot wins when both alias ids hold a tenant", async () => { From 8ab96cc5cb980ab01a3047d30db3b3b876f1c6af Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:44:33 +0900 Subject: [PATCH 8/9] test(devin): verify sanitized reset refusals across bound tenants Reconcile #5455 zero-wait coverage with #5143 content-free Connect diagnostics: retain 429 and generated retry timing while proving reflected credentials do not escape and only one inference send occurs. Exercise a matching deprecated alias credential through tenant dispatch and the same refusal path. Preserve the zero wait pin, host allowlist, credential binding, and typed reset field. Document the safe typed retry delay and opt-in helper replay contract. (cherry picked from commit 6f2901b117b6d8a9a7eae503e795b74749b6e7e0) --- src/adapters/devin.ts | 6 +-- structure/adapters/registry.md | 8 ++-- structure/transports/responses.md | 4 +- .../devin-adapter-reset-wait.test.ts | 41 +++++++++++++++---- 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 45032d623a3..2fb7f567c09 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -644,9 +644,9 @@ export function createDevinAdapter( ); // An admitted HTTP turn owns globally shared capacity until this call // emits. Never retain that capacity while waiting out a provider 429: - // the stated reset is surfaced as the original error message (the - // seconds figure stays inside that text for clients that parse it), - // and the shared slot is freed immediately. + // preserve the typed reset delay in generated diagnostic wording, + // never the raw trailer text that may reflect a credential. The + // refusal returns immediately so the caller can release its slot. for await (const event of streamChatEventsWithResetRetry({ apiKey, apiServerUrl: host, diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 1854543141c..ffa5b86d95b 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -40,8 +40,8 @@ Some adapters share another adapter's routed-tool semantics while retaining inde does not. `devin-cli` survives only as a deprecated alias — `ocx login devin-cli` routes to `devin`, and a startup merge migration rewrites any saved row still keyed under the old provider id, so the registry carries one Devin provider, not two. - Its `GetChatMessage` inference POSTs, including the two bounded pre-output stated-reset - replays, pass through the request's provider executor and shared physical-send budget. + Its `GetChatMessage` inference POSTs pass through the provider executor and shared send budget. + The adapter allows no reset wait; the shared helper retains bounded replays for opted-in callers. Catalog and JWT RPCs remain adapter support traffic rather than inference sends. `AdapterFactoryContext.providerId` still tells the shared adapter which configured row it is serving: the Cognition tenant is recorded on the credential, not in the registry, so the @@ -93,8 +93,8 @@ Some adapters share another adapter's routed-tool semantics while retaining inde 128000 fallback. Smaller operator hints cap live evidence and never enlarge it; with no evidence the adapter hint is omitted and the encoder still serializes its own 128000 fallback for field #3. Connect trailer diagnostics expose only an - allowlisted error code and hexadecimal trace id; upstream message text stays inside - the parser because it can reflect the request credential. Investigation and limits: + allowlisted error code, hexadecimal trace id and typed `retryAfterSeconds`, optionally rendered as + generated `retry after ~Ns` wording; raw text stays internal because it can reflect credentials. Investigation and limits: `devlog/_plan/260917_devin_input_ceiling/000_review.md`. The registry records those relationships with `contractParent`. A parent relationship does **not** mean the registry recursively constructs a parent adapter and injects it into the child. Azure and MiMo keep owning their existing internal composition. This avoids making production constructors depend on test/conformance needs and keeps this authority refactor behavior-neutral. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index a82e8f8cb08..1efd99ee638 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -1176,8 +1176,8 @@ from (#4709). `run-turn-execution.ts` passes the same physical-send and recovery-withheld observers used by the request-building adapter path. Devin builds one `createAdapterPhysicalSend` for the whole -`GetChatMessage` invocation, so its initial POST and at most two same-target replays report ordinals -1, 2, and 3. The outer runTurn attempt already records ordinal 1, and the shared observer therefore +`GetChatMessage` invocation. Its helper supports at most two pre-output replays, but the adapter sets +zero wait allowance, so positive reset delays surface immediately. The outer runTurn records ordinal 1; the shared observer adds only ordinals above 1 to `sendCount`; the execution budget still reserves every ordinal. A replay reserves only after its server-stated wait. If admission is refused, no inference I/O occurs, `retry-send-budget` is recorded, and the preceding provider 429 remains the returned error. diff --git a/tests/providers/devin-adapter-reset-wait.test.ts b/tests/providers/devin-adapter-reset-wait.test.ts index 70446cc5e3b..c4bf3a50815 100644 --- a/tests/providers/devin-adapter-reset-wait.test.ts +++ b/tests/providers/devin-adapter-reset-wait.test.ts @@ -19,6 +19,7 @@ import { createDevinAdapter } from "../../src/adapters/devin"; import { parseCatalogBuffer, setCachedCatalogForTests } from "../../src/adapters/devin/cloud-direct/catalog"; import { encodeMessage, encodeString, encodeVarintField } from "../../src/adapters/devin/cloud-direct/wire"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { saveCredential } from "../../src/oauth/store"; import type { AdapterEvent } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -29,15 +30,16 @@ let home = ""; const previousHome = process.env.OPENCODEX_HOME; const previousFetch = globalThis.fetch; let chatPosts = 0; +let seenUrls: string[] = []; -function seed(): void { +function seed(tenantHost = host): void { const buffer = Buffer.concat([encodeMessage(1, Buffer.concat([ encodeString(1, "swe-2-high"), encodeString(22, "swe-2-high"), encodeVarintField(18, 262_000), encodeVarintField(4, 0), ]))]); - setCachedCatalogForTests(parseCatalogBuffer(buffer, apiKey, host)); + setCachedCatalogForTests(parseCatalogBuffer(buffer, apiKey, tenantHost)); } // A Connect-RPC stream whose only frame is an end-stream trailer carrying the @@ -58,10 +60,12 @@ function refusalResponse(message: string): Response { function stubTransport(message: string): void { chatPosts = 0; + seenUrls = []; globalThis.fetch = (async (input: RequestInfo | URL) => { // The seeded catalog cache means GetChatMessage is the only RPC this turn // performs; count it anyway so a replay is visible as a second send. - if (String(input).startsWith(CHAT_URL)) chatPosts += 1; + seenUrls.push(String(input)); + if (String(input).endsWith(new URL(CHAT_URL).pathname)) chatPosts += 1; return refusalResponse(message); }) as typeof fetch; } @@ -97,14 +101,16 @@ describe("devin adapter stated-reset wait", () => { }); test("a stated reset beyond the allowance surfaces instead of holding the turn", async () => { - stubTransport("Your limit will reset in 21 minutes"); + stubTransport(`Your limit will reset in 21 minutes; credential=${apiKey}`); const events = await runOneTurn(); - // The provider's refusal is preserved verbatim — including the stated - // reset text — instead of the turn sleeping inside the shared slot. + // Preserve reset timing without forwarding the provider's credential-reflecting text. const error = events.find((event): event is Extract => event.type === "error"); - expect(error?.message).toContain("reset in 21 minutes"); + expect(error).toMatchObject({ status: 429, errorType: "rate_limit_error", code: "resource_exhausted" }); + expect(error?.message).toContain("retry after ~1260s"); + expect(JSON.stringify(events)).not.toContain(apiKey); + expect(error?.message).not.toContain("Your limit"); expect(events.some(event => event.type === "done")).toBe(false); expect(chatPosts).toBe(1); }); @@ -115,7 +121,26 @@ describe("devin adapter stated-reset wait", () => { const events = await runOneTurn(); const error = events.find((event): event is Extract => event.type === "error"); - expect(error?.message).toContain("reset in 1 second"); + expect(error?.message).toContain("retry after ~1s"); + expect(chatPosts).toBe(1); + }); + + test("an alias-bound tenant refusal keeps one send and content-free reset timing", async () => { + const tenantHost = "https://server.eu.windsurf.com"; + await saveCredential("devin-cli", { + access: apiKey, refresh: apiKey, expires: Number.MAX_SAFE_INTEGER, + source: "local-cli", apiBaseUrl: tenantHost, + }); + seed(tenantHost); + stubTransport(`Your limit will reset in 1 second; credential=${apiKey}`); + + const events = await runOneTurn(); + + expect(seenUrls).toEqual([`${tenantHost}${new URL(CHAT_URL).pathname}`]); + const error = events.find((event): event is Extract => event.type === "error"); + expect(error).toMatchObject({ status: 429, code: "resource_exhausted" }); + expect(error?.message).toContain("retry after ~1s"); + expect(JSON.stringify(events)).not.toContain(apiKey); expect(chatPosts).toBe(1); }); }); From e24ef95389f7dc4cc07b4faaf9a95423ba6e20bc Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 02:53:23 +0900 Subject: [PATCH 9/9] fix(devin): select tenant host from the account that owns the transmitted key The consolidated fix bound alias-slot tenant hosts to the transmitted key but still returned the active literal slot's host for any key. Devin keeps several accounts per provider id and the request path injects the admitted account's token, which need not be the active one, while a configured key, a forwarded bearer, or a test token is not stored at all. Each of those could inherit another account's EU or FedStart tenant. resolveDevinApiServer now looks up the stored credential whose access token is exactly the transmitted key, in the configured provider id first and then its deprecated alias, active account first. Only that owner's validated host is used; an owner with an unusable host, an unmatched key, or no key falls back to the validated configured base URL and then the US default. The lookup uses getCredential and listAccounts, which store mocks elsewhere in the suite leave real. Tests pass the owning key where a stored tenant is expected, so the invalid alias-host case now reaches the host validator. New cases cover a mismatched literal slot, an alias owner beside an occupied literal slot, a key neither slot owns, a missing key, a non-active owning account, and a configured key at the adapter boundary. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/oauth/devin.ts | 83 ++++++++++++++------------- structure/providers-and-adapters.md | 2 +- structure/runtime.md | 2 +- tests/providers/devin-adapter.test.ts | 17 +++++- tests/providers/devin-login.test.ts | 70 ++++++++++++++++++++-- 5 files changed, 125 insertions(+), 49 deletions(-) diff --git a/src/oauth/devin.ts b/src/oauth/devin.ts index 57ee66d71c6..75d9c68ce0f 100644 --- a/src/oauth/devin.ts +++ b/src/oauth/devin.ts @@ -19,7 +19,7 @@ import { DEFAULT_REGION, type WindsurfRegion } from "./devin/types"; import { registerUser } from "./devin/register-user"; import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiBaseUrl, validateDevinApiBaseUrl } from "./devin/api-base"; import { readDevinCliCredentialOutcome } from "./devin/cli-import"; -import { getCredential } from "./store"; +import { getCredential, listAccounts } from "./store"; import { DEPRECATED_OAUTH_PROVIDER_ALIASES } from "./index"; export { DEVIN_DEFAULT_API_SERVER } from "./devin/api-base"; @@ -50,51 +50,52 @@ function devinAliasCredentialSlots(providerId: string): string[] { * configured provider baseUrl is the fallback, and the US default is the last * resort; both are re-validated because neither is trusted more than the * network value. + * + * A stored tenant host is used only for the account that owns `apiKey`, the key + * this request will transmit. Devin keeps several accounts per provider id and + * the request path injects the admitted account's token, which need not be the + * active one, while a provider-configured key, a forwarded bearer, or a test + * token is not stored at all. Reading the active slot's host for any of those + * would send one account's key to another account's EU or FedStart tenant. */ export function resolveDevinApiServer(configuredBaseUrl?: string, providerId = "devin", apiKey?: string): string { - // Provider-scoped, keyed by the configured provider id verbatim and consulted - // FIRST. `devin-cli` is a deprecated alias for `devin`, but an unmigrated - // config row still owns its old credential slot until the startup migration - // rekeys the row and the slot together — normalizing the id here would read - // the wrong slot for that window. An EU or FedStart tenant is recorded on the - // credential rather than in the registry, so a fixed "devin" slot would send - // the key to the wrong host either way. - const literalCredential = getCredential(providerId); - const literal = validateDevinApiBaseUrl(literalCredential?.apiBaseUrl); - if (literal !== undefined) return literal; + const owner = apiKey ? findDevinCredentialOwner(providerId, apiKey) : undefined; + if (owner !== undefined) { + // The owning account decides. An owner whose recorded host is missing or + // off-allowlist falls through to the configured base URL rather than + // borrowing another slot's tenant: the same key stored twice is the rekey + // window, and a second slot's host is not more trustworthy than this one. + const host = validateDevinApiBaseUrl(owner.apiBaseUrl); + if (host !== undefined) return host; + } + return validateDevinApiBaseUrl(configuredBaseUrl) ?? DEVIN_DEFAULT_API_SERVER; +} - // The startup merge saves providers["devin"] synchronously but fires the - // credential rekey detached — runDevinProviderMergeStartupMigration cannot - // await inside the synchronous startServer window — so the row can already - // say "devin" while the credential still sits in the "devin-cli" slot, and it - // stays that way for the whole process when the rekey fails or refuses on an - // occupied destination slot. Reading the alias-linked slots in both - // directions closes that window: "devin" finds the not-yet-rekeyed - // "devin-cli" credential, and a lingering "devin-cli" row finds a credential - // already rekeyed to "devin". Every candidate passes the same allowlist — an - // alias slot is not trusted more than the literal one. The candidate must - // also own the key this request will transmit: provider-configured and - // forwarded keys are resolved outside the credential store, and borrowing a - // host from a different key would cross an account or regional boundary. - // Only when this id owns no credential at all. A present credential whose - // apiBaseUrl is missing or off-allowlist is a different situation: the rekey - // refuses an occupied destination slot, so both ids can hold credentials that - // belong to two different accounts. Borrowing a tenant across that pair would - // send this account's key to the other account's EU or FedStart host, which - // is the exact misdirection the provider-scoped lookup exists to prevent. An - // unusable host on a credential that does exist falls through to the - // configured base URL and then the default, as it did before this window was - // closed. - if (literalCredential === null || literalCredential === undefined) { - for (const slot of devinAliasCredentialSlots(providerId)) { - const credential = getCredential(slot); - if (!apiKey || credential?.access !== apiKey) continue; - const host = validateDevinApiBaseUrl(credential.apiBaseUrl); - if (host !== undefined) return host; +/** + * The stored credential whose access token is exactly `apiKey`. + * + * The configured provider id is searched first and verbatim: `devin-cli` is a + * deprecated alias for `devin`, but an unmigrated config row still owns its old + * slot until the startup migration rekeys the row and the slot together. The + * startup merge saves providers["devin"] synchronously but fires the credential + * rekey detached (runDevinProviderMergeStartupMigration cannot await inside the + * synchronous startServer window), so the alias-linked slots are searched next, + * in both directions. Within a slot the active account is read first, then the + * rest, because the admitted account can be any of them. + * + * A key refreshed between token resolution and this lookup would match nothing + * and use the configured host for that turn. Devin keys are long-lived + * RegisterUser API keys, so this window is not a routine refresh race. + */ +function findDevinCredentialOwner(providerId: string, apiKey: string): OAuthCredentials | undefined { + for (const slot of [providerId, ...devinAliasCredentialSlots(providerId)]) { + const active = getCredential(slot); + if (active?.access === apiKey) return active; + for (const account of listAccounts(slot)) { + if (account.credential.access === apiKey) return account.credential; } } - - return validateDevinApiBaseUrl(configuredBaseUrl) ?? DEVIN_DEFAULT_API_SERVER; + return undefined; } function decodeJwtPayload(token: string): Record | undefined { diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 75e666a7324..ab1cb260f69 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -22,7 +22,7 @@ the [bounded ingestion contract](transports/inventory.md#bounded-response-ingest | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog and JWT support RPCs remain outside inference-send accounting. Provider-stated 429 reset delays are surfaced to the client rather than slept inside an admitted turn, so they cannot retain shared active-turn capacity. Tenant-host fallback across the deprecated provider alias is admitted only when the alias credential owns the transmitted key. | +| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog and JWT support RPCs remain outside inference-send accounting. Provider-stated 429 reset delays are surfaced to the client rather than slept inside an admitted turn, so they cannot retain shared active-turn capacity. 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. | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling. 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). | | `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. An image's ladder position is pinned to its own identity (content hash + media type), so appending a newer image cannot re-encode older ones and bust Anthropic's prompt prefix cache (#4532). | diff --git a/structure/runtime.md b/structure/runtime.md index 376db9fb817..28a3bcc6808 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -446,7 +446,7 @@ following a final symlink, so an exchange during a mutation cannot redirect the Config JSON preserves the boolean; only literal true activates the role-changing transform. The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. -Devin CLI credential path composition in `src/oauth/devin/cli-import.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. The `src/providers/devin-provider-merge-migration.ts` startup migration treats the legacy provider row and its OAuth slot as one account-bound unit: an occupied destination or a refused config projection leaves both unchanged, and both backups complete before either file changes. During a detached rekey window, the adapter may use the alias slot's tenant host only when that slot owns the exact key being transmitted, so separately configured or forwarded credentials cannot borrow another account's destination. +Devin CLI credential path composition in `src/oauth/devin/cli-import.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. The `src/providers/devin-provider-merge-migration.ts` startup migration treats the legacy provider row and its OAuth slot as one account-bound unit: an occupied destination or a refused config projection leaves both unchanged, and both backups complete before either file changes. The adapter takes a tenant host only from the stored account that owns the exact key being transmitted, in the literal slot or, during a detached rekey window, the alias slot, so separately configured or forwarded credentials and non-owning accounts cannot lend another account's destination. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index 5417bcd906c..46554e60839 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -575,11 +575,26 @@ describe("devin adapter api-server host resolution (#4503)", () => { apiBaseUrl: EU_TENANT_HOST, }); - await runOneTurn("ocx-test-literal-slot-key"); + await runOneTurn("devin-session"); expectDispatchedTo(FEDSTART_TENANT_HOST); }); + test("a configured key does not borrow the literal devin slot's tenant host", async () => { + await saveCredential("devin", { + access: "devin-session", + refresh: "devin-session", + expires: Number.MAX_SAFE_INTEGER, + source: "oauth", + apiBaseUrl: FEDSTART_TENANT_HOST, + }); + + await runOneTurn("configured-provider-key"); + + expectDispatchedTo(CONFIGURED_BASE_URL); + expect(seenUrls.some((url) => url.startsWith(FEDSTART_TENANT_HOST))).toBe(false); + }); + test("with neither credential slot populated the configured baseUrl still applies", async () => { await runOneTurn("ocx-test-no-credential-key"); diff --git a/tests/providers/devin-login.test.ts b/tests/providers/devin-login.test.ts index 727218ea3e1..92d8b9e49c3 100644 --- a/tests/providers/devin-login.test.ts +++ b/tests/providers/devin-login.test.ts @@ -275,8 +275,8 @@ describe("devin tenant selection is provider-scoped", () => { // to the other account's host. await seedSlot("devin", EU_HOST); await seedSlot("devin-cli", FEDSTART_HOST); - expect(resolveDevinApiServer(undefined, "devin")).toBe(EU_HOST); - expect(resolveDevinApiServer(undefined, "devin-cli")).toBe(FEDSTART_HOST); + expect(resolveDevinApiServer(undefined, "devin", KEY)).toBe(EU_HOST); + expect(resolveDevinApiServer(undefined, "devin-cli", KEY)).toBe(FEDSTART_HOST); }); test("a credential that exists but has no usable tenant does not borrow the alias tenant", async () => { @@ -287,8 +287,8 @@ describe("devin tenant selection is provider-scoped", () => { // account's FedStart tenant. await seedSlot("devin", "https://api.githubcopilot.com"); await seedSlot("devin-cli", FEDSTART_HOST); - expect(resolveDevinApiServer(undefined, "devin")).toBe(US_HOST); - expect(resolveDevinApiServer(EU_HOST, "devin")).toBe(EU_HOST); + expect(resolveDevinApiServer(undefined, "devin", KEY)).toBe(US_HOST); + expect(resolveDevinApiServer(EU_HOST, "devin", KEY)).toBe(EU_HOST); }); test("an alias slot with a non-Devin apiBaseUrl is not trusted", async () => { @@ -296,9 +296,69 @@ describe("devin tenant selection is provider-scoped", () => { // the host that survives persist and still fails validateDevinApiBaseUrl. // Without that check on the alias candidate, the merge window would send // a Devin key to GitHub. + // The request key must match the seeded credential, or the lookup stops + // before the host reaches the validator and this case proves nothing. await seedSlot("devin-cli", "https://api.githubcopilot.com"); - expect(resolveDevinApiServer(EU_HOST, "devin")).toBe(EU_HOST); + expect(resolveDevinApiServer(EU_HOST, "devin", KEY)).toBe(EU_HOST); + expect(resolveDevinApiServer(undefined, "devin", KEY)).toBe(US_HOST); + }); + + test("a literal tenant is not lent to a key that credential does not own", async () => { + // A provider-configured key or a forwarded bearer is resolved outside the + // credential store. Taking the active slot's host for it would send that + // key to another account's EU tenant. + const STAGING_HOST = "https://server-staging.codeium.com"; + await seedSlot("devin", EU_HOST); + expect(resolveDevinApiServer(STAGING_HOST, "devin", "configured-provider-key")).toBe(STAGING_HOST); + expect(resolveDevinApiServer(undefined, "devin", "configured-provider-key")).toBe(US_HOST); + expect(resolveDevinApiServer(undefined, "devin", KEY)).toBe(EU_HOST); + }); + + test("an alias credential that owns the key is used when the literal slot owns another", async () => { + // Both slots are occupied because the rekey refused an occupied + // destination. Ownership, not slot order, decides which tenant the + // transmitted key belongs to. + const ALIAS_KEY = "devin-session-token$alias-account.payload.sig"; + await seedSlot("devin", EU_HOST); + await saveCredential("devin-cli", { + access: ALIAS_KEY, refresh: ALIAS_KEY, expires: Number.MAX_SAFE_INTEGER, + source: "local-cli", apiBaseUrl: FEDSTART_HOST, + }); + expect(resolveDevinApiServer(undefined, "devin", ALIAS_KEY)).toBe(FEDSTART_HOST); + expect(resolveDevinApiServer(undefined, "devin", KEY)).toBe(EU_HOST); + }); + + test("a key neither occupied slot owns gets neither stored tenant", async () => { + const STAGING_HOST = "https://server-staging.codeium.com"; + await seedSlot("devin", EU_HOST); + await seedSlot("devin-cli", FEDSTART_HOST); + expect(resolveDevinApiServer(STAGING_HOST, "devin", "unowned-key")).toBe(STAGING_HOST); + expect(resolveDevinApiServer(STAGING_HOST, "devin-cli", "unowned-key")).toBe(STAGING_HOST); + }); + + test("without a transmitted key no stored tenant is trusted", async () => { + await seedSlot("devin", EU_HOST); + await seedSlot("devin-cli", FEDSTART_HOST); expect(resolveDevinApiServer(undefined, "devin")).toBe(US_HOST); + expect(resolveDevinApiServer(undefined, "devin", "")).toBe(US_HOST); + expect(resolveDevinApiServer("https://server-staging.codeium.com", "devin")).toBe("https://server-staging.codeium.com"); + }); + + test("a non-active account's key keeps that account's own tenant", async () => { + // Several Devin accounts can share one provider id and the request path can + // admit any of them, so the owner is found by key, not by active selection. + const EU_KEY = "devin-session-token$eu-account.payload.sig"; + const FEDSTART_KEY = "devin-session-token$fedstart-account.payload.sig"; + await saveCredential("devin", { + access: EU_KEY, refresh: EU_KEY, expires: Number.MAX_SAFE_INTEGER, + source: "oauth", accountId: "eu-account", apiBaseUrl: EU_HOST, + }); + await saveCredential("devin", { + access: FEDSTART_KEY, refresh: FEDSTART_KEY, expires: Number.MAX_SAFE_INTEGER, + source: "oauth", accountId: "fedstart-account", apiBaseUrl: FEDSTART_HOST, + }); + expect(resolveDevinApiServer(undefined, "devin", EU_KEY)).toBe(EU_HOST); + expect(resolveDevinApiServer(undefined, "devin", FEDSTART_KEY)).toBe(FEDSTART_HOST); }); });