Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/codex/catalog/provider-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 11 additions & 7 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -89,11 +90,12 @@ export interface OAuthAccessSnapshot {
/** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */
kiro?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion" | "authType">;
/**
* 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;
}
Expand Down Expand Up @@ -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"
Expand Down
18 changes: 14 additions & 4 deletions src/web-search/xai-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions tests/codex-integration/catalog-oauth-observation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
OAUTH_PROVIDERS,
} from "../../src/oauth";
import {
gatherRoutedModels,
gatherRoutedModelsForCatalogGather,
type CatalogGatherProviderAuthOutcome,
type CatalogGatherProviderModelOutcome,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
});
});
28 changes: 28 additions & 0 deletions tests/providers/xai/xai-web-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
72 changes: 72 additions & 0 deletions tests/responses/responses-snapshot-repair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).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) {
Expand Down
Loading