diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index fe81f356ba4..5565517a6ec 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 de68ef9f7ae..2a020abf99a 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -21,7 +21,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. 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 ec03516b8cc..bec8da8c2a3 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -429,7 +429,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/adapters/openai/openai-chat-dangling-toolcalls.test.ts b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts index 41a61c21023..075e3c33186 100644 --- a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts +++ b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts @@ -12,6 +12,10 @@ const provider: OcxProviderConfig = { baseUrl: "https://example.test/v1", apiKey: "sk-test", authMode: "key", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; this suite is about tool-result repair ordering, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; interface ChatMsg { 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 () => { diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts index dd88fa05788..a718c1c376a 100644 --- a/tests/responses/chat-inline-document-bytes.test.ts +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -28,6 +28,10 @@ const chatProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; the document test asserts the role a turn keeps, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; const anthropicProvider = { adapter: "anthropic",