diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 3ff7f9abe5b..cfc801cef4f 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -216,11 +216,15 @@ export async function fetchProviderModelsWithAuth( return observed(configured, "authoritative"); } const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing" - ? prov.authMode === "oauth" && effectiveGoogleMode(name, prov) === "cloud-code-assist" + ? prov.authMode === "oauth" && ( + effectiveGoogleMode(name, prov) === "cloud-code-assist" + || prov.adapter === "devin" + ) ? await getValidAccessTokenSnapshot(name) .then(snapshot => ({ apiKey: snapshot.accessToken, observed: false, + ...(snapshot.apiBaseUrl ? { oauthApiBaseUrl: snapshot.apiBaseUrl } : {}), ...(snapshot.projectId ? { oauthProjectId: snapshot.projectId } : {}), })) .catch(() => ({ apiKey: undefined, observed: false })) @@ -309,7 +313,12 @@ export async function fetchProviderModelsWithAuth( "degraded", ); } - const liveResult = await fetchDevinUsableModels({ apiKey, baseUrl: prov.baseUrl }); + // The OAuth snapshot owns both values: never combine one account's durable + // key with the registry's default host or another account's tenant host. + const liveResult = await fetchDevinUsableModels({ + apiKey, + baseUrl: auth.oauthApiBaseUrl ?? prov.baseUrl, + }); if (liveResult.ok) { // Live catalog is the source of truth — use the discovered base models // directly, not a filtered subset of the static seed. diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 5d8e00ef472..8d533d02818 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -38,6 +38,7 @@ import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chat import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; import { loginDevin, refreshDevinToken } from "./devin"; +import { validateDevinApiBaseUrl } from "./devin/api-base"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { loginCommandCode, refreshCommandCodeToken } from "./command-code"; import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse"; @@ -89,11 +90,12 @@ export interface OAuthAccessSnapshot { /** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */ kiro?: Pick; /** - * Allowlisted GitHub Copilot API origin belonging to THIS account. + * Allowlisted API origin belonging to THIS account. * - * Copilot pins its bearer to an account-scoped regional host. Initial routing, 401 refresh, and - * account failover must resolve transport from this same snapshot; rereading the active account - * can pair account A's token with account B's origin during a concurrent switch (#2568d). + * Copilot and Devin pin credentials to account-scoped regional or tenant hosts. Initial routing, + * discovery, refresh, and account failover must resolve transport from this same snapshot; + * rereading the active account can pair account A's token with account B's origin during a + * concurrent switch (#2568d). */ apiBaseUrl?: string; } @@ -480,16 +482,18 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti // Validated here, not at the call site: an unvalidated origin from a legacy or crafted // credential must never travel with a bearer, and dropping it makes the transport fall back to // the canonical host rather than to whatever the previous account was using. - const copilotApiBaseUrl = provider === "github-copilot" + const accountApiBaseUrl = provider === "github-copilot" ? validateCopilotApiBaseUrl(cred.apiBaseUrl) - : undefined; + : provider === "devin" || provider === "devin-cli" + ? validateDevinApiBaseUrl(cred.apiBaseUrl) + : undefined; return { provider, accountId, generation: credentialGeneration(cred), accessToken: cred.access, ...(cred.projectId ? { projectId: cred.projectId } : {}), - ...(copilotApiBaseUrl ? { apiBaseUrl: copilotApiBaseUrl } : {}), + ...(accountApiBaseUrl ? { apiBaseUrl: accountApiBaseUrl } : {}), // Stored account metadata remains authoritative. Metadata-less legacy/environment credentials // may use explicit environment routing, but never borrow the currently signed-in local CLI account. ...(provider === "kiro" diff --git a/src/web-search/xai-executor.ts b/src/web-search/xai-executor.ts index 29524a1af49..8a6a8b28020 100644 --- a/src/web-search/xai-executor.ts +++ b/src/web-search/xai-executor.ts @@ -14,6 +14,7 @@ import type { OcxProviderConfig } from "../types"; import { getValidAccessToken, publicOAuthAuthenticationErrorMessage } from "../oauth"; import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry"; import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort"; +import { readBoundedResponseBytes } from "../lib/bounded-body"; import { sidecarEnter } from "../lib/sidecar-tracker"; import { redactSecretString } from "../lib/redact"; import { MAX_SIDECAR_RESPONSE_BYTES, type WebSearchSource } from "./parse"; @@ -114,10 +115,19 @@ export async function runXaiWebSearch( ); const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); if (!res.ok) { - const t = await res.text().catch(() => ""); - detachBodyGuard(); - const entitlement = res.status === 401 || res.status === 403 ? " (Grok OAuth entitlement — re-run ocx login xai?)" : ""; - return { text: "", sources: [], error: `xai sidecar HTTP ${res.status}${entitlement}: ${redactSecretString(t.slice(0, 200))}` }; + try { + const bounded = await readBoundedResponseBytes(res, { + maxBytes: MAX_SIDECAR_RESPONSE_BYTES, + signal: linkedSignal.signal, + }); + const detail = bounded.oversized + ? "response body exceeded byte bound" + : redactSecretString(new TextDecoder().decode(bounded.bytes).slice(0, 200)); + const entitlement = res.status === 401 || res.status === 403 ? " (Grok OAuth entitlement — re-run ocx login xai?)" : ""; + return { text: "", sources: [], error: `xai sidecar HTTP ${res.status}${entitlement}: ${detail}` }; + } finally { + detachBodyGuard(); + } } try { return await parseXaiResponsesSSE(res); diff --git a/structure/catalog.md b/structure/catalog.md index 7b035ee99fc..333448751ae 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -166,6 +166,9 @@ Provider live-model lists are cached with a configured TTL (`src/codex/model-cac deleting, or editing a provider's shape clears that per-provider cache; a disabled-only change deliberately does not, because a disabled provider is already excluded from the catalog gather instead. Codex's own `models_cache.json` is a different cache, invalidated by catalog refresh. +Account-scoped discovery transports remain bound to the credential snapshot that supplied the +token. In particular, Devin discovery uses the allowlisted tenant API base URL from that same +snapshot rather than pairing a durable account key with the provider registry's default host. Entitlement-specific rosters (Qoder, Devin, Cursor) additionally bind their cache entry to an irreversible credential fingerprint: a credential switch observes neither the fresh nor the stale roster recorded under the previous credential, and a failed discovery's cooldown neither supplies diff --git a/tests/codex-integration/catalog-oauth-observation.test.ts b/tests/codex-integration/catalog-oauth-observation.test.ts index b8bba6fcd5a..8a5eee0601d 100644 --- a/tests/codex-integration/catalog-oauth-observation.test.ts +++ b/tests/codex-integration/catalog-oauth-observation.test.ts @@ -16,6 +16,7 @@ import { OAUTH_PROVIDERS, } from "../../src/oauth"; import { + gatherRoutedModels, gatherRoutedModelsForCatalogGather, type CatalogGatherProviderAuthOutcome, type CatalogGatherProviderModelOutcome, @@ -56,6 +57,23 @@ function authStoreBytes(expires: number): Buffer { }) + "\n"); } +function devinAuthStoreBytes(apiBaseUrl: string): Buffer { + return Buffer.from(JSON.stringify({ + devin: { + activeAccountId: "active", + accounts: [{ + id: "active", + credential: { + access: "fixture-devin-key", + refresh: "fixture-devin-key", + expires: Number.MAX_SAFE_INTEGER, + apiBaseUrl, + }, + }], + }, + }) + "\n"); +} + function snapshotFile(path: string): FileSnapshot { const stat = statSync(path, { bigint: true }); return { @@ -230,4 +248,38 @@ describe("catalog gather OAuth observation", () => { expectFileUnchanged(authPath, before); expect(readdirSync(opencodexHome).sort()).toEqual(listingBefore); }); + + test("Devin discovery keeps the durable key bound to its observed tenant host", async () => { + const tenantBaseUrl = "https://eu.windsurf.com/_route/api_server"; + const observedBuffer = devinAuthStoreBytes(tenantBaseUrl); + const observation = observeActiveOAuthAccessToken("devin", observedBuffer); + expect(observation.kind).toBe("available"); + if (observation.kind !== "available") throw new Error("expected available Devin credential"); + expect(observation.snapshot.apiBaseUrl).toBe(tenantBaseUrl); + + writeFileSync(join(opencodexHome, "auth.json"), observedBuffer, { mode: 0o600 }); + const originalFetch = globalThis.fetch; + const urls: string[] = []; + globalThis.fetch = (async (input) => { + urls.push(String(input)); + return new Response("upstream unavailable", { status: 503 }); + }) as typeof fetch; + try { + const provider = structuredClone(OAUTH_PROVIDERS["devin"]!.providerConfig); + await gatherRoutedModelsForCatalogGather( + { providers: { devin: provider } }, + { authStoreBuffer: observedBuffer }, + ); + clearModelCache(); + await gatherRoutedModels({ providers: { devin: provider } }); + } finally { + globalThis.fetch = originalFetch; + } + + // Both observe-only catalog materialization and ordinary refreshing discovery + // must retain the account's destination alongside its token. + expect(urls.length).toBe(2); + expect(urls.every(url => url.startsWith(`${tenantBaseUrl}/`))).toBe(true); + expect(urls.every(url => !url.startsWith("https://server.codeium.com/"))).toBe(true); + }); }); diff --git a/tests/providers/xai/xai-web-search.test.ts b/tests/providers/xai/xai-web-search.test.ts index b755481a936..f9ef4309fbb 100644 --- a/tests/providers/xai/xai-web-search.test.ts +++ b/tests/providers/xai/xai-web-search.test.ts @@ -157,6 +157,34 @@ describe("credential pinning + loop fail-closed (review blockers)", () => { globalThis.fetch = realFetch; } }); + + test("non-OK response bodies are byte-bounded and canceled upstream", async () => { + let producedBytes = 0; + let canceled = false; + const chunk = new Uint8Array(1024).fill(0x61); + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull(controller) { + producedBytes += chunk.byteLength; + controller.enqueue(chunk); + }, + cancel() { + canceled = true; + }, + }), { status: 500 })) as typeof fetch; + try { + const { runXaiWebSearch } = await import("../../../src/web-search/xai-executor"); + const out = await runXaiWebSearch("q", "xai", xaiProvider, { model: "grok-4.6", reasoning: "low", timeoutMs: 5000, describeImages: false }); + + expect(out.error).toContain("response body exceeded byte bound"); + // The stream implementation may prefetch a small number of chunks, but it must + // stop near the cap rather than consume an arbitrarily large upstream body. + expect(producedBytes).toBeLessThanOrEqual(MAX_SIDECAR_RESPONSE_BYTES + (4 * chunk.byteLength)); + expect(canceled).toBe(true); + } finally { + globalThis.fetch = realFetch; + } + }); }); import { runWithWebSearch, type WebSearchLoopDeps } from "../../../src/web-search/loop"; diff --git a/tests/responses/responses-snapshot-repair.test.ts b/tests/responses/responses-snapshot-repair.test.ts index 7596134b5ad..616f125ee18 100644 --- a/tests/responses/responses-snapshot-repair.test.ts +++ b/tests/responses/responses-snapshot-repair.test.ts @@ -254,6 +254,78 @@ describe("createGrokResponsesSparseTerminalBlockRewrite", () => { expect(terminal.output).toEqual([call]); }); + test("Grok compatibility does not reconstruct client calls excluded by tool_choice", () => { + const message = { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "answer", annotations: [] }], + }; + const calls = [ + { type: "function_call", id: "fc_1", status: "completed", call_id: "call_1", name: "shell", arguments: "{}" }, + { type: "custom_tool_call", id: "ctc_1", status: "completed", call_id: "call_1", name: "shell", input: "{}" }, + ]; + const choices = [ + "none", + { type: "function", name: "search" }, + { type: "allowed_tools", tools: [{ type: "function", name: "search" }] }, + ]; + + for (const choice of choices) { + for (const call of calls) { + const rewrite = createGrokResponsesSparseTerminalBlockRewrite( + createTestTranslatorBudget(), + { tool_choice: choice }, + ); + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item: message })); + rewrite(dataBlock({ type: "response.output_item.done", output_index: 1, item: call })); + const terminalBlock = dataBlock({ + type: "response.completed", + response: { id: "resp_1", status: "completed", output: [] }, + }); + const out = rewrite(terminalBlock); + expect(out).toHaveLength(1); + const terminal = eventsOf(out)[0]!; + expect(terminal.type).toBe("response.incomplete"); + expect(terminal.response.output).toEqual([message]); + expect(terminal.response.incomplete_details).toMatchObject({ + reason: "forbidden_tool_call", + }); + } + } + }); + + test("Grok compatibility reconstructs only an exactly selected client tool identity", () => { + const call = { + type: "custom_tool_call", + id: "ctc_1", + status: "completed", + call_id: "call_1", + name: "shell", + namespace: "workspace", + input: "{}", + }; + const rewrite = createGrokResponsesSparseTerminalBlockRewrite( + createTestTranslatorBudget(), + { tool_choice: { type: "allowed_tools", tools: [{ type: "custom", name: "shell", namespace: "workspace" }] } }, + ); + rewrite(dataBlock({ type: "response.output_item.done", output_index: 0, item: call })); + const message = { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "answer", annotations: [] }], + }; + rewrite(dataBlock({ type: "response.output_item.done", output_index: 1, item: message })); + const out = rewrite(dataBlock({ + type: "response.completed", + response: { id: "resp_1", status: "completed", output: [] }, + })); + expect((eventsOf(out)[0]!.response as Record).output).toEqual([call, message]); + }); + test("Grok compatibility rejects missing, empty, or whitespace function_call call_id", () => { const callIds = [undefined, "", " "] as const; for (const callId of callIds) {