From 08e0d8a3e0b7819b0995f258f373e522c2cfec4f Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 26 Sep 2026 09:34:39 +0000 Subject: [PATCH 1/4] fix(search): bind Devin OAuth to routed provider --- src/oauth/index.ts | 12 +++-- src/server/search.ts | 2 +- src/web-search/devin-executor.ts | 2 +- structure/runtime.md | 2 +- .../server/api-key-scope-alpha-search.test.ts | 50 +++++++++++++++++++ 5 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 84d76a6d520..277550f438e 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -550,9 +550,10 @@ async function resolveAccessSnapshotForAccount( accountId: string, rejectedGeneration?: string, requireUsableAccount = false, + oauthProvider = provider, ): Promise { - const def = OAUTH_PROVIDERS[provider]; - if (!def) throw new UnsupportedOAuthProviderError(provider); + const def = OAUTH_PROVIDERS[oauthProvider]; + if (!def) throw new UnsupportedOAuthProviderError(oauthProvider); // One store read answers both questions. A caller that opts in gets the account REJECTED // when it needs reauthentication, which a bare credential read cannot detect: a revoked // account keeps a readable credential, so resolution would otherwise succeed and the @@ -607,10 +608,13 @@ async function resolveAccessSnapshotForAccount( return refresh; } -export async function getValidAccessTokenSnapshot(provider: string): Promise { +export async function getValidAccessTokenSnapshot( + provider: string, + options: { oauthProvider?: string } = {}, +): Promise { const set = getAccountSet(provider); if (!set) throw new OAuthLoginRequiredError(provider); - return resolveAccessSnapshotForAccount(provider, set.activeAccountId); + return resolveAccessSnapshotForAccount(provider, set.activeAccountId, undefined, false, options.oauthProvider); } /** Providers whose upstream-401 replay path may force a snapshot refresh. */ diff --git a/src/server/search.ts b/src/server/search.ts index 60db8c25ab3..33d52c71528 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -84,7 +84,7 @@ export async function handleSearch( logCtx.routeDecision = route.routeDecision; return handleDevinAlphaSearch( body, - "devin", + route.providerName, config.search?.timeoutMs ?? SEARCH_UPSTREAM_TIMEOUT_MS, req.signal, ); diff --git a/src/web-search/devin-executor.ts b/src/web-search/devin-executor.ts index dde633c89f5..7da0fac1748 100644 --- a/src/web-search/devin-executor.ts +++ b/src/web-search/devin-executor.ts @@ -126,7 +126,7 @@ export async function resolveDevinWebSearchSnapshot( try { const selection = captureOAuthAccountSelection(credentialProvider); if (!selection) return { error: "devin web search auth failed: no signed-in account" }; - const snapshot = await getValidAccessTokenSnapshot(credentialProvider); + const snapshot = await getValidAccessTokenSnapshot(credentialProvider, { oauthProvider: "devin" }); const committed = await commitOAuthAccountSelection(credentialProvider, snapshot.accountId, { expectedSelection: selection, expectedCredentialGeneration: snapshot.generation, diff --git a/structure/runtime.md b/structure/runtime.md index fd99beb93a0..ae74b2f91f8 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -448,7 +448,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. Claude skill-bundle marker parsing follows the [bounded inbound contract](data-planes/inbound-compat.md#claude-skill-marker-path-bound). 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. 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. +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 Devin alpha search likewise resolves OAuth from the routed provider name that admission checked, rather than borrowing the canonical `devin` slot for a custom Devin-adapter row. 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-accounts.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. diff --git a/tests/server/api-key-scope-alpha-search.test.ts b/tests/server/api-key-scope-alpha-search.test.ts index 769d972a130..667204a0365 100644 --- a/tests/server/api-key-scope-alpha-search.test.ts +++ b/tests/server/api-key-scope-alpha-search.test.ts @@ -10,7 +10,9 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; +import { encodeMessage, encodeString } from "../../src/adapters/devin/cloud-direct/wire"; import { saveConfig } from "../../src/config"; +import { saveCredential } from "../../src/oauth/store"; import { MODEL_NOT_ALLOWED_FOR_KEY, UNNAMED_DESTINATION_MODEL } from "../../src/server/admission-model-scope"; import type { DataPlaneAdmission } from "../../src/server/auth-cors"; import type { RequestLogContext } from "../../src/server/request-log"; @@ -199,6 +201,54 @@ test("the relay still runs when the scope names its destination", async () => { expect(upstreamCalls[0]).toContain("/alpha/search"); }); +test("a scoped custom Devin route spends only that provider's OAuth credential", async () => { + const customToken = "team-devin-token"; + await saveCredential("team-devin", { + access: customToken, + refresh: customToken, + expires: Number.MAX_SAFE_INTEGER, + apiBaseUrl: "https://team-devin.example", + }); + await saveCredential("devin", { + access: "canonical-devin-token", + refresh: "canonical-devin-token", + expires: Number.MAX_SAFE_INTEGER, + apiBaseUrl: "https://canonical-devin.example", + }); + let requestBody = Buffer.alloc(0); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + upstreamCalls.push(String(input)); + requestBody = Buffer.from(init?.body as Uint8Array); + const result = Buffer.concat([ + encodeString(3, "https://example.test"), + encodeString(4, "Result"), + ]); + return new Response(encodeMessage(1, result), { status: 200 }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "team-devin", + providers: { + "team-devin": { adapter: "devin", authMode: "oauth", baseUrl: "https://server.codeium.com" }, + }, + apiKeys: keys({ allowedProviders: ["team-devin"] }), + } as OcxConfig; + + const response = await handleSearch( + sidecarRequest(searchBody("team-devin/swe-2")), + config, + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(200); + expect(upstreamCalls).toEqual([ + "https://server.codeium.com/exa.api_server_pb.ApiServerService/GetWebSearchResults", + ]); + expect(requestBody.includes(Buffer.from(customToken))).toBe(true); + expect(requestBody.includes(Buffer.from("canonical-devin-token"))).toBe(false); +}); + test("the sidecar fallback refuses the backend it would have spent", async () => { const response = await handleSearch( sidecarRequest(searchBody(SEARCH_MODEL)), From 87c96d013ad1a90926b2059f4896002f4d97a0f1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:09:41 +0000 Subject: [PATCH 2/4] fix(search): carry the tenant host for custom Devin providers accessSnapshot keyed apiBaseUrl validation on the routed slot name, so a custom provider resolved through the Devin OAuth definition dropped its stored tenant URL and sent the token to the US default. The validated host now follows the resolved OAuth definition instead. Co-Authored-By: Epinephrine --- src/oauth/index.ts | 12 +++--- .../server/api-key-scope-alpha-search.test.ts | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 277550f438e..78521edc125 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -466,7 +466,7 @@ export function publicOAuthAuthenticationErrorMessage(error: unknown): string { return "OAuth authentication failed. Check the OpenCodex account status and retry."; } -function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot { +function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials, oauthProvider = provider): OAuthAccessSnapshot { // Derived, not read back: a stored `authType` is trusted when present, but a credential imported // before the field existed still routes correctly because the client pair implies SSO OIDC. const kiroAuthType = cred.kiro?.authType @@ -484,9 +484,11 @@ 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 accountApiBaseUrl = provider === "github-copilot" + // The host rides on the OAuth definition the snapshot was resolved through, not the routed + // slot name: a custom provider reusing the Devin definition keeps its stored tenant URL. + const accountApiBaseUrl = oauthProvider === "github-copilot" ? validateCopilotApiBaseUrl(cred.apiBaseUrl) - : provider === "devin" || provider === "devin-cli" + : oauthProvider === "devin" || oauthProvider === "devin-cli" ? validateDevinApiBaseUrl(cred.apiBaseUrl) : undefined; return { @@ -562,7 +564,7 @@ async function resolveAccessSnapshotForAccount( if (!row) throw new OAuthLoginRequiredError(provider); if (requireUsableAccount && row.needsReauth) throw new OAuthLoginRequiredError(provider); const cred = row.credential; - const current = accessSnapshot(provider, accountId, cred); + const current = accessSnapshot(provider, accountId, cred, oauthProvider); if (rejectedGeneration !== undefined && current.generation !== rejectedGeneration) return current; if (rejectedGeneration === undefined && cred.expires > Date.now() + REFRESH_SKEW_MS) return current; @@ -596,7 +598,7 @@ async function resolveAccessSnapshotForAccount( if (persisted.access !== accessToken) { throw new Error(`OAuth refresh persisted an unexpected access token for ${provider}`); } - return accessSnapshot(provider, accountId, persisted); + return accessSnapshot(provider, accountId, persisted, oauthProvider); })().catch(error => { if (abort.signal.reason instanceof OAuthTokenRefreshStaleError) throw abort.signal.reason; throw error; diff --git a/tests/server/api-key-scope-alpha-search.test.ts b/tests/server/api-key-scope-alpha-search.test.ts index 667204a0365..4b2ae19d353 100644 --- a/tests/server/api-key-scope-alpha-search.test.ts +++ b/tests/server/api-key-scope-alpha-search.test.ts @@ -249,6 +249,44 @@ test("a scoped custom Devin route spends only that provider's OAuth credential", expect(requestBody.includes(Buffer.from("canonical-devin-token"))).toBe(false); }); +test("a custom Devin route searches the tenant its credential names", async () => { + await saveCredential("team-devin", { + access: "team-devin-token", + refresh: "team-devin-token", + expires: Number.MAX_SAFE_INTEGER, + apiBaseUrl: "https://eu.windsurf.com/_route/api_server", + }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + upstreamCalls.push(String(input)); + expect(String(init?.body)).not.toContain("canonical-devin-token"); + const result = Buffer.concat([ + encodeString(3, "https://example.test"), + encodeString(4, "Result"), + ]); + return new Response(encodeMessage(1, result), { status: 200 }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "team-devin", + providers: { + "team-devin": { adapter: "devin", authMode: "oauth", baseUrl: "https://server.codeium.com" }, + }, + apiKeys: keys({ allowedProviders: ["team-devin"] }), + } as OcxConfig; + + const response = await handleSearch( + sidecarRequest(searchBody("team-devin/swe-2")), + config, + logContext(), + undefined, + SCOPED, + ); + expect(response.status).toBe(200); + expect(upstreamCalls).toEqual([ + "https://eu.windsurf.com/_route/api_server/exa.api_server_pb.ApiServerService/GetWebSearchResults", + ]); +}); + test("the sidecar fallback refuses the backend it would have spent", async () => { const response = await handleSearch( sidecarRequest(searchBody(SEARCH_MODEL)), From 49e60302d7f820264c5a66a884d3783a0cf8679e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:29:25 +0000 Subject: [PATCH 3/4] test(search): assert the searched request carries the custom credential's token Co-Authored-By: Epinephrine --- tests/server/api-key-scope-alpha-search.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/server/api-key-scope-alpha-search.test.ts b/tests/server/api-key-scope-alpha-search.test.ts index 4b2ae19d353..beb4103298b 100644 --- a/tests/server/api-key-scope-alpha-search.test.ts +++ b/tests/server/api-key-scope-alpha-search.test.ts @@ -250,15 +250,23 @@ test("a scoped custom Devin route spends only that provider's OAuth credential", }); test("a custom Devin route searches the tenant its credential names", async () => { + const customToken = "team-devin-token"; await saveCredential("team-devin", { - access: "team-devin-token", - refresh: "team-devin-token", + access: customToken, + refresh: customToken, expires: Number.MAX_SAFE_INTEGER, apiBaseUrl: "https://eu.windsurf.com/_route/api_server", }); + await saveCredential("devin", { + access: "canonical-devin-token", + refresh: "canonical-devin-token", + expires: Number.MAX_SAFE_INTEGER, + apiBaseUrl: "https://canonical-devin.example", + }); + let requestBody = Buffer.alloc(0); globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { upstreamCalls.push(String(input)); - expect(String(init?.body)).not.toContain("canonical-devin-token"); + requestBody = Buffer.from(init?.body as Uint8Array); const result = Buffer.concat([ encodeString(3, "https://example.test"), encodeString(4, "Result"), @@ -285,6 +293,8 @@ test("a custom Devin route searches the tenant its credential names", async () = expect(upstreamCalls).toEqual([ "https://eu.windsurf.com/_route/api_server/exa.api_server_pb.ApiServerService/GetWebSearchResults", ]); + expect(requestBody.includes(Buffer.from(customToken))).toBe(true); + expect(requestBody.includes(Buffer.from("canonical-devin-token"))).toBe(false); }); test("the sidecar fallback refuses the backend it would have spent", async () => { From 1a4c124d4fb042256412e7403251aa5c19807b7f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:03:47 +0000 Subject: [PATCH 4/4] test(search): assert Devin credential in its protobuf field --- .../server/api-key-scope-alpha-search.test.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/server/api-key-scope-alpha-search.test.ts b/tests/server/api-key-scope-alpha-search.test.ts index beb4103298b..0692fc48dff 100644 --- a/tests/server/api-key-scope-alpha-search.test.ts +++ b/tests/server/api-key-scope-alpha-search.test.ts @@ -10,7 +10,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; -import { encodeMessage, encodeString } from "../../src/adapters/devin/cloud-direct/wire"; +import { encodeMessage, encodeString, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; import { saveConfig } from "../../src/config"; import { saveCredential } from "../../src/oauth/store"; import { MODEL_NOT_ALLOWED_FOR_KEY, UNNAMED_DESTINATION_MODEL } from "../../src/server/admission-model-scope"; @@ -67,6 +67,17 @@ afterEach(() => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); }); +/** Decode the request Metadata.api_key field, not an incidental token substring. */ +function metadataApiKey(body: Buffer): string { + const metadata = [...iterFields(body)].filter(field => field.num === 1 && field.wire === 2); + expect(metadata).toHaveLength(1); + if (!Buffer.isBuffer(metadata[0]?.value)) throw new Error("Missing request Metadata"); + const credentials = [...iterFields(metadata[0].value)].filter(field => field.num === 3 && field.wire === 2); + expect(credentials).toHaveLength(1); + if (!Buffer.isBuffer(credentials[0]?.value)) throw new Error("Missing Metadata.api_key"); + return credentials[0].value.toString("utf8"); +} + type Scope = { allowedProviders?: string[]; allowedModels?: string[] }; function keys(scope: Scope): OcxConfig["apiKeys"] { @@ -245,8 +256,8 @@ test("a scoped custom Devin route spends only that provider's OAuth credential", expect(upstreamCalls).toEqual([ "https://server.codeium.com/exa.api_server_pb.ApiServerService/GetWebSearchResults", ]); - expect(requestBody.includes(Buffer.from(customToken))).toBe(true); - expect(requestBody.includes(Buffer.from("canonical-devin-token"))).toBe(false); + expect(metadataApiKey(requestBody)).toBe(customToken); + expect(metadataApiKey(requestBody)).not.toBe("canonical-devin-token"); }); test("a custom Devin route searches the tenant its credential names", async () => { @@ -293,8 +304,8 @@ test("a custom Devin route searches the tenant its credential names", async () = expect(upstreamCalls).toEqual([ "https://eu.windsurf.com/_route/api_server/exa.api_server_pb.ApiServerService/GetWebSearchResults", ]); - expect(requestBody.includes(Buffer.from(customToken))).toBe(true); - expect(requestBody.includes(Buffer.from("canonical-devin-token"))).toBe(false); + expect(metadataApiKey(requestBody)).toBe(customToken); + expect(metadataApiKey(requestBody)).not.toBe("canonical-devin-token"); }); test("the sidecar fallback refuses the backend it would have spent", async () => {