From 18edf830651cdbd41caf411a02931bde648c4ab4 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Tue, 8 Sep 2026 10:01:49 +0100 Subject: [PATCH 01/33] Guard OAuth refresh commits with the stored generation A refresh now pins the generation it read and commits only while the stored record still holds it, so the slower of two concurrent refreshes writes nothing. This keeps a rotated refresh token from being replaced by the one it superseded, and stops the losing exchange's rejection from tombstoning credentials the winner just renewed. Backings therefore no longer need cross-writer exclusion for OAuth, only for AWS preserve mutations, which merge fields into the current record. Documents that split on StoreBackendStorage and in the credential store memory bank. --- memory-bank/aiCredentialStore.md | 10 +++-- packages/ai-credentials/src/acquisition.ts | 2 +- .../src/store-backend/StoreBackend.ts | 39 ++++++++++++++----- .../ai-provider-bridge/src/local-providers.ts | 7 ++-- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/memory-bank/aiCredentialStore.md b/memory-bank/aiCredentialStore.md index a18bf8f..d62d6f1 100644 --- a/memory-bank/aiCredentialStore.md +++ b/memory-bank/aiCredentialStore.md @@ -86,10 +86,12 @@ compare-and-commit OAuth transactions. Values in `/store` remain fully generic. The backend consumes storage through the `StoreBackendStorage` interface defined in `/store-backend` (`get`/`set`/`withLock`/`watch`); `SingleFileStore` satisfies it structurally, and non-file backings (e.g. VS Code SecretStorage) can be injected. -`withLock`'s lock scope is the backing's contract: OAuth compare-and-write and AWS -preserve mutations are read-modify-write transactions that require exclusion against -every writer of the same keys, so weaker backings (in-process mutex) are only safe -for whole-record API-key replace/clear configurations. +`withLock`'s lock scope is the backing's contract. AWS preserve mutations merge fields +into the current record, so they need exclusion against every writer of the same keys. +OAuth does not: each record carries a `generation`, and a commit lands only while the +stored record still holds the one the operation read. So a backing with in-process +exclusion alone (VS Code SecretStorage) stays safe for OAuth and for whole-record +API-key replace/clear, which is last-writer-wins by design. ```ts import { createDefaultStore, getDefaultStorePath } from "ai-credentials/store"; diff --git a/packages/ai-credentials/src/acquisition.ts b/packages/ai-credentials/src/acquisition.ts index 74273ef..8a749cb 100644 --- a/packages/ai-credentials/src/acquisition.ts +++ b/packages/ai-credentials/src/acquisition.ts @@ -429,7 +429,7 @@ export class AcquisitionEngine { } /** - * Refresh under the cross-process store lock. Only a definitive server + * Refresh under the backing store's transaction boundary. Only a definitive server * rejection (see {@link TERMINAL_REFRESH_CODES}) tombstones the stored * tokens; every other failure keeps them so a later attempt can retry. * The transaction yields the access token to shape; shaping happens diff --git a/packages/ai-credentials/src/store-backend/StoreBackend.ts b/packages/ai-credentials/src/store-backend/StoreBackend.ts index 546b876..6ad1e55 100644 --- a/packages/ai-credentials/src/store-backend/StoreBackend.ts +++ b/packages/ai-credentials/src/store-backend/StoreBackend.ts @@ -32,14 +32,15 @@ import { * structurally, but any backing with atomic per-key writes can serve it — * e.g. VS Code `SecretStorage` in an extension host. * - * Lock-scope contract: `withLock` must serialize its critical section - * against **every** writer of the same keys. The backend's OAuth acquisition - * (generation compare-and-write) and AWS `preserve` mutations are - * read-modify-write transactions that are only correct under that exclusion. - * A backing that cannot provide it — e.g. an in-process mutex over a - * per-window secret store — is only safe for configurations limited to - * whole-record writes: API-key `replace`/`clear`, with no - * `oauthConfigForProvider` and no AWS mutations. + * How much exclusion `withLock` gives is up to the backing: `SingleFileStore` + * locks across processes, VS Code `SecretStorage` only within one window. + * + * OAuth tolerates the weaker case. Every record carries a `generation`, and a + * refresh commits only while the stored record still holds the one it read, so + * the slower of two concurrent refreshes writes nothing. + * + * AWS `preserve` mutations have no such marker and do need a backing that + * excludes every writer of the same keys. */ export interface StoreBackendStorage { get(key: string): Promise; @@ -596,11 +597,14 @@ export function createStoreBackend(options: CreateStoreBackendOptions): MutableB return normalized.tokens ?? null; } + const refreshGenerations = new Map(); + async function persistRefreshedTokens(providerId: string, tokens: TokenData): Promise { const key = keyFor(providerId); if (!key) return; const current = normalize(providerId, await readRecord(providerId)); if (!current?.source) return; + if (current.generation !== refreshGenerations.get(providerId)) return; if (current.source.type !== "oauth-device" && current.source.type !== "oauth-u2m") return; await store.set(key, authenticatedOAuthRecord(current.source, tokens, generationFactory())); } @@ -610,9 +614,26 @@ export function createStoreBackend(options: CreateStoreBackendOptions): MutableB if (!key) return; const current = normalize(providerId, await readRecord(providerId)); if (!current?.source) return; + if (current.generation !== refreshGenerations.get(providerId)) return; await store.set(key, terminalOAuthRecord(current.source, generationFactory(), error)); } + /** Note the record's current generation, then run the refresh against it. */ + async function withRefreshTransaction( + providerId: string, + operation: () => Promise, + ): Promise { + return store.withLock(async () => { + const current = normalize(providerId, await readRecord(providerId)); + refreshGenerations.set(providerId, current?.generation); + try { + return await operation(); + } finally { + refreshGenerations.delete(providerId); + } + }); + } + const acquisition: AcquisitionBackendHooks | undefined = oauthConfigForProvider ? { configForProvider: resolveGrant, @@ -622,7 +643,7 @@ export function createStoreBackend(options: CreateStoreBackendOptions): MutableB finishAuthentication, persistRefreshedTokens, persistRefreshError, - withRefreshTransaction: (_providerId, operation) => store.withLock(operation), + withRefreshTransaction, shapeToken: asyncShapeToken, notifyReady(providerId) { notifyReady?.(providerId); diff --git a/packages/ai-provider-bridge/src/local-providers.ts b/packages/ai-provider-bridge/src/local-providers.ts index d338654..893cff9 100644 --- a/packages/ai-provider-bridge/src/local-providers.ts +++ b/packages/ai-provider-bridge/src/local-providers.ts @@ -12,9 +12,10 @@ * Local providers do NOT go through PROVIDER_MAP or MAPPED_PROVIDER_IDS — * those are strictly for VS Code auth-based providers. * - * Endpoints are stored in `~/.positai/settings.json` under a `providers` key - * and cached in-memory for synchronous reads. A file watcher keeps the cache - * in sync with external edits. + * Endpoints live under a `providers` key in whichever settings file the caller's + * injected I/O reads — `~/.posit/assistant/settings.json` on Node platforms and + * in Positron — and are cached in-memory for synchronous reads. The injected + * watcher keeps that cache in sync with external edits. */ import { normalizeBaseUrlForProvider } from "ai-config"; From 97aa07e36f9b7a2878d91186858bbd3c3296d4fc Mon Sep 17 00:00:00 2001 From: Brice Stacey Date: Wed, 9 Sep 2026 12:29:36 -0400 Subject: [PATCH 02/33] Log device-code poll termination The poll loop had no visibility into how a device-code attempt ended: a denied or expired attempt failed silently with nothing in the logs to distinguish it from a hung poll. Log the terminal error code whenever a current, non-aborted attempt reaches its terminal state. --- packages/ai-credentials/src/acquisition.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ai-credentials/src/acquisition.ts b/packages/ai-credentials/src/acquisition.ts index 8a749cb..6a69607 100644 --- a/packages/ai-credentials/src/acquisition.ts +++ b/packages/ai-credentials/src/acquisition.ts @@ -394,6 +394,9 @@ export class AcquisitionEngine { } } catch (error) { if (this.isCurrent(attempt) && !attempt.controller.signal.aborted) { + this.logger?.info( + `[ai-credentials] device poll for ${attempt.providerId} ended: ${errorCode(error)}`, + ); await this.hooks.finishAuthentication( attempt.providerId, attempt.generation, From 14bf3e92c5ed3ef081b91797795cccd005fbc866 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Mon, 14 Sep 2026 13:25:14 -0400 Subject: [PATCH 03/33] Accept GOOGLE_API_KEY as an alias for the Gemini key --- .../__tests__/EnvCredentialResolver.test.ts | 13 +++++++++++++ .../__tests__/providerEnvironmentCapture.test.ts | 9 +++++++++ .../src/store-backend/envCredentialResolver.ts | 12 ++++++------ .../src/store-backend/providerEnvMappings.ts | 3 +++ .../src/store-backend/providerEnvRegistry.ts | 1 + 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts b/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts index 5aa9234..963cb66 100644 --- a/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts +++ b/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts @@ -48,6 +48,19 @@ describe("resolveCredentialsFromEnv", () => { expect(resolveCredentialsFromEnv("anthropic", {})).toBeNull(); }); + it("falls back to GOOGLE_API_KEY for gemini", () => { + expect(resolveCredentialsFromEnv("gemini", { GOOGLE_API_KEY: "g-1" })).toEqual({ + type: "apikey", + apiKey: "g-1", + }); + }); + + it("prefers GEMINI_API_KEY over GOOGLE_API_KEY", () => { + expect( + resolveCredentialsFromEnv("gemini", { GEMINI_API_KEY: "gem", GOOGLE_API_KEY: "goog" }), + ).toEqual({ type: "apikey", apiKey: "gem" }); + }); + it("returns null for an unknown provider", () => { expect(resolveCredentialsFromEnv("nonexistent-provider", { SOME_KEY: "value" })).toBeNull(); }); diff --git a/packages/ai-credentials/src/store-backend/__tests__/providerEnvironmentCapture.test.ts b/packages/ai-credentials/src/store-backend/__tests__/providerEnvironmentCapture.test.ts index 6023742..44f8a46 100644 --- a/packages/ai-credentials/src/store-backend/__tests__/providerEnvironmentCapture.test.ts +++ b/packages/ai-credentials/src/store-backend/__tests__/providerEnvironmentCapture.test.ts @@ -57,6 +57,15 @@ describe("captureProviderEnvironment", () => { expect(captured.environment.AZURE_CLIENT_ID).toBe("client"); }); + it("captures and scrubs api key aliases", () => { + const captured = captureProviderEnvironment(["gemini"], { + GEMINI_API_KEY: "a", + GOOGLE_API_KEY: "b", + }); + expect(captured.declaredNames).toEqual(["GEMINI_API_KEY", "GOOGLE_API_KEY"]); + expect(captured.scrubbedNames).toEqual(["GEMINI_API_KEY", "GOOGLE_API_KEY"]); + }); + it("ignores custom and unknown provider ids without guessing their client kind", () => { expect( captureProviderEnvironment(["custom:corp", "unknown"], { OPENAI_API_KEY: "secret" }), diff --git a/packages/ai-credentials/src/store-backend/envCredentialResolver.ts b/packages/ai-credentials/src/store-backend/envCredentialResolver.ts index 8b9f6ac..04df9fe 100644 --- a/packages/ai-credentials/src/store-backend/envCredentialResolver.ts +++ b/packages/ai-credentials/src/store-backend/envCredentialResolver.ts @@ -62,12 +62,12 @@ function resolveFromMapping( ): ProviderCredentials | null { // API key providers if (mapping.apiKey) { - const apiKey = envVars[mapping.apiKey.name]; - if (apiKey) { - return { - type: "apikey", - apiKey, - }; + const names = [mapping.apiKey, ...(mapping.apiKeyAliases ?? [])]; + for (const descriptor of names) { + const apiKey = envVars[descriptor.name]; + if (apiKey) { + return { type: "apikey", apiKey }; + } } return null; } diff --git a/packages/ai-credentials/src/store-backend/providerEnvMappings.ts b/packages/ai-credentials/src/store-backend/providerEnvMappings.ts index 6e56325..aca8807 100644 --- a/packages/ai-credentials/src/store-backend/providerEnvMappings.ts +++ b/packages/ai-credentials/src/store-backend/providerEnvMappings.ts @@ -59,6 +59,8 @@ export interface SdkCredentialEnvironment { export interface ProviderEnvMapping { apiKey?: EnvironmentFieldDescriptor; + /** Older names for the same key, tried in order after `apiKey`. */ + apiKeyAliases?: readonly EnvironmentFieldDescriptor[]; oauthM2m?: { authType: EnvironmentFieldDescriptor; host: EnvironmentFieldDescriptor; @@ -131,6 +133,7 @@ function providerEnvironmentDescriptors(providerId: string): EnvironmentFieldDes if (!mapping) return []; return [ ...(mapping.apiKey ? [mapping.apiKey] : []), + ...(mapping.apiKeyAliases ?? []), ...(mapping.oauthM2m ? Object.values(mapping.oauthM2m) : []), ...(mapping.aws ? Object.values(mapping.aws) : []), ...(mapping.sdkCredentialEnvironment ? Object.values(mapping.sdkCredentialEnvironment) : []), diff --git a/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts b/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts index 5af3551..8fe7255 100644 --- a/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts +++ b/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts @@ -52,6 +52,7 @@ export const PROVIDER_ENV_MAPPINGS: Record = { }, gemini: { apiKey: scrubbed("GEMINI_API_KEY"), + apiKeyAliases: [scrubbed("GOOGLE_API_KEY")], }, openrouter: { apiKey: scrubbed("OPENROUTER_API_KEY"), From 1bca1aa9418f859702e1431826a1a7c94beee592 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Mon, 14 Sep 2026 13:28:43 -0400 Subject: [PATCH 04/33] Add a hostDefaults layer to the catalog loader --- .../src/__tests__/load-config.test.ts | 31 +++++++++++++++++++ .../src/__tests__/watch-catalog.test.ts | 27 ++++++++++++++++ packages/ai-config/src/node/load-catalog.ts | 7 +++++ packages/ai-config/src/node/types.ts | 14 ++++++++- packages/ai-config/src/node/watch-catalog.ts | 10 ++++++ 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/ai-config/src/__tests__/load-config.test.ts b/packages/ai-config/src/__tests__/load-config.test.ts index ee4830d..badbda0 100644 --- a/packages/ai-config/src/__tests__/load-config.test.ts +++ b/packages/ai-config/src/__tests__/load-config.test.ts @@ -951,4 +951,35 @@ describe("loadResolvedProviderCatalog", () => { expect((await loadProviderCatalogReport(opts)).issues).toEqual([]); }); }); + + describe("hostDefaults", () => { + it("folds hostDefaults below the user file", async () => { + await fixture.writeTypedConfig({ + providers: { positai: { positaiLogin: { host: "login.example.test" } } }, + }); + const catalog = await loadResolvedProviderCatalog({ + configPath, + envVars: {}, + hostDefaults: { providers: { positai: { positaiLogin: { clientId: "positron" } } } }, + }); + const positai = findProvider(catalog, "positai"); + expect(positai?.connection.positaiLogin).toEqual({ + host: "login.example.test", + clientId: "positron", + scope: "prism", + }); + }); + + it("lets the user file override a hostDefaults value", async () => { + await fixture.writeTypedConfig({ + providers: { positai: { positaiLogin: { clientId: "mine" } } }, + }); + const catalog = await loadResolvedProviderCatalog({ + configPath, + envVars: {}, + hostDefaults: { providers: { positai: { positaiLogin: { clientId: "positron" } } } }, + }); + expect(findProvider(catalog, "positai")?.connection.positaiLogin?.clientId).toBe("mine"); + }); + }); }); diff --git a/packages/ai-config/src/__tests__/watch-catalog.test.ts b/packages/ai-config/src/__tests__/watch-catalog.test.ts index ba71f1f..b895e0a 100644 --- a/packages/ai-config/src/__tests__/watch-catalog.test.ts +++ b/packages/ai-config/src/__tests__/watch-catalog.test.ts @@ -391,4 +391,31 @@ describe("watchResolvedProviderCatalog", () => { expect(probe.changes).toHaveLength(3); expect(mockLogger.warn).toHaveBeenCalledTimes(warningsAfterAdd + 1); }); + + it("keeps hostDefaults through a user-file rebuild", async () => { + await fixture.writeTypedConfigAtomic({ + providers: { positai: { positaiLogin: { host: "login.example.test" } } }, + }); + const probe = createChangeProbe(); + const watcher = watchResolvedProviderCatalog(probe.handler, { + configPath, + logger: mockLogger, + envVars: {}, + hostDefaults: { providers: { positai: { positaiLogin: { clientId: "positron" } } } }, + }); + await awaitReady(watcher); + + const changed = probe.next((change) => change.connectionChanged, "connection change"); + await fixture.writeTypedConfigAtomic({ + providers: { positai: { positaiLogin: { host: "login.other.test" } } }, + }); + const change = await changed; + watcher.dispose(); + + expect(change.catalog.find((p) => p.id === "positai")?.connection.positaiLogin).toEqual({ + host: "login.other.test", + clientId: "positron", + scope: "prism", + }); + }); }); diff --git a/packages/ai-config/src/node/load-catalog.ts b/packages/ai-config/src/node/load-catalog.ts index ee59901..f56cc95 100644 --- a/packages/ai-config/src/node/load-catalog.ts +++ b/packages/ai-config/src/node/load-catalog.ts @@ -32,6 +32,13 @@ export async function loadProviderCatalogReport( const legacyProviders = createLegacyPositronSourceProviders(opts, env); reports.push(...(await Promise.all(legacyProviders.map((provider) => provider.read())))); + if (opts.hostDefaults) { + reports.push({ + source: { kind: "default", label: "host defaults", config: opts.hostDefaults }, + issues: [], + }); + } + const loaded = reports.flatMap((report) => (report.source ? [report.source] : [])); const sources = opts.transformSource ? loaded.map(opts.transformSource) : loaded; const resolver = resolveProviderCatalogReport({ diff --git a/packages/ai-config/src/node/types.ts b/packages/ai-config/src/node/types.ts index a2babe6..d9bdcd8 100644 --- a/packages/ai-config/src/node/types.ts +++ b/packages/ai-config/src/node/types.ts @@ -10,7 +10,12 @@ import type { SourcedConfigIssue } from "../config-issue.js"; import type { Disposable as ConfigDisposable } from "../config-source.js"; import type { LegacySettingsReader } from "../legacy-positron-settings/translate.js"; import type { ProviderConfigSource } from "../resolve-catalog.js"; -import type { LoggerLike, ResolvedProvider, ResolvedProviderId } from "../types.js"; +import type { + LoggerLike, + ProvidersConfigFragment, + ResolvedProvider, + ResolvedProviderId, +} from "../types.js"; // Re-export the pure logger type so node consumers can import it from here. export type { LoggerLike } from "../types.js"; @@ -99,6 +104,13 @@ export interface LoadCatalogOptions { * Load-path only; watch paths never apply it. */ readonly transformSource?: (source: ProviderConfigSource) => ProviderConfigSource; + + /** + * Config the host supplies beneath the user's file: ranked `default`, after + * the POSIT_AI_PROVIDERS_DEFAULT fragment. For host-specific values such as + * the OAuth client id a host is registered under. + */ + readonly hostDefaults?: ProvidersConfigFragment; } /** diff --git a/packages/ai-config/src/node/watch-catalog.ts b/packages/ai-config/src/node/watch-catalog.ts index af9b4aa..8498278 100644 --- a/packages/ai-config/src/node/watch-catalog.ts +++ b/packages/ai-config/src/node/watch-catalog.ts @@ -70,6 +70,16 @@ export function watchResolvedProviderCatalog( ...createLegacyPositronSourceProviders(opts, env), ]; + if (opts.hostDefaults) { + const hostDefaults = opts.hostDefaults; + sourceProviders.push({ + read: async () => ({ + source: { kind: "default", label: "host defaults", config: hostDefaults }, + issues: [], + }), + }); + } + let debounceTimer: ReturnType | undefined; let disposed = false; let previousCatalog: readonly ResolvedProvider[] | undefined; From cc374e3ee14636c9839454bad4ccbd872f8f26f5 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Fri, 18 Sep 2026 13:23:36 -0400 Subject: [PATCH 05/33] Add normalizeFoundryBaseUrl to ai-config --- .../ai-config/src/__tests__/base-url.test.ts | 23 +++++++++++++++++++ packages/ai-config/src/base-url.ts | 18 +++++++++++++++ packages/ai-config/src/index.ts | 1 + 3 files changed, 42 insertions(+) diff --git a/packages/ai-config/src/__tests__/base-url.test.ts b/packages/ai-config/src/__tests__/base-url.test.ts index 5a8b904..71995c8 100644 --- a/packages/ai-config/src/__tests__/base-url.test.ts +++ b/packages/ai-config/src/__tests__/base-url.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { normalizeBaseUrlForProvider, + normalizeFoundryBaseUrl, normalizeOpenRouterBaseUrl, OPENROUTER_DEFAULT_BASE_URL, } from "../base-url.js"; @@ -37,6 +38,28 @@ describe("normalizeBaseUrlForProvider", () => { }); }); +describe("normalizeFoundryBaseUrl", () => { + it.each([ + [ + "https://r.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-02-01", + "https://r.openai.azure.com/openai/v1", + ], + [ + "https://r.openai.azure.com/openai/deployments/gpt-4o", + "https://r.openai.azure.com/openai/v1", + ], + ["https://r.openai.azure.com/openai/v1", "https://r.openai.azure.com/openai/v1"], + ["https://r.openai.azure.com/openai/v1/", "https://r.openai.azure.com/openai/v1"], + ["https://r.openai.azure.com/", "https://r.openai.azure.com/openai/v1"], + ["https://r.openai.azure.com", "https://r.openai.azure.com/openai/v1"], + ["https://r.openai.azure.com?api-version=1", "https://r.openai.azure.com/openai/v1"], + ["", ""], + [" ", ""], + ])("normalizes %s", (input, expected) => { + expect(normalizeFoundryBaseUrl(input)).toBe(expected); + }); +}); + describe("normalizeOpenRouterBaseUrl", () => { it("defaults to the canonical API root", () => { expect(normalizeOpenRouterBaseUrl()).toBe(OPENROUTER_DEFAULT_BASE_URL); diff --git a/packages/ai-config/src/base-url.ts b/packages/ai-config/src/base-url.ts index dc7f005..6ea27b0 100644 --- a/packages/ai-config/src/base-url.ts +++ b/packages/ai-config/src/base-url.ts @@ -145,3 +145,21 @@ export function normalizeBaseUrlForProvider(providerId: BuiltinProviderId, url: } return url; } + +/** + * Normalize a Microsoft Foundry endpoint to its `/openai/v1` base URL: strips + * the query string, trailing slashes and any `/openai/deployments/...` suffix + * users paste from the portal. Empty input stays empty. + */ +export function normalizeFoundryBaseUrl(rawUrl: string): string { + let url = rawUrl.trim(); + if (!url) return ""; + const queryIndex = url.indexOf("?"); + if (queryIndex !== -1) url = url.substring(0, queryIndex); + url = url.replace(/\/+$/, ""); + if (!url) return ""; + const deploymentIndex = url.indexOf("/openai/deployments/"); + if (deploymentIndex !== -1) url = url.substring(0, deploymentIndex); + if (!url.endsWith("/openai/v1")) url += "/openai/v1"; + return url; +} diff --git a/packages/ai-config/src/index.ts b/packages/ai-config/src/index.ts index 843daa8..127167d 100644 --- a/packages/ai-config/src/index.ts +++ b/packages/ai-config/src/index.ts @@ -179,6 +179,7 @@ export { LMSTUDIO_API_VERSION, LMSTUDIO_HOST, normalizeBaseUrlForProvider, + normalizeFoundryBaseUrl, normalizeOpenRouterBaseUrl, OPENCODE_DEFAULT_PRODUCT, OPENCODE_GO_BASE_URL, From d44b49be309be5fbdb27383edc4ca367f4225334 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Fri, 18 Sep 2026 13:24:51 -0400 Subject: [PATCH 06/33] Resolve Google Vertex tokens from inline service-account env vars before ADC --- packages/ai-provider-bridge/src/providers.ts | 1 + .../__tests__/google-vertex-provider.test.ts | 60 +++++++++++++++++++ .../src/providers/google-vertex-provider.ts | 59 +++++++++++++++--- 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/packages/ai-provider-bridge/src/providers.ts b/packages/ai-provider-bridge/src/providers.ts index 90d8883..4a5c48b 100644 --- a/packages/ai-provider-bridge/src/providers.ts +++ b/packages/ai-provider-bridge/src/providers.ts @@ -42,6 +42,7 @@ export { registerCustomGeminiProvider, registerGeminiProvider } from "./provider export { registerCustomGoogleVertexProvider, registerGoogleVertexProvider, + resolveGoogleVertexAccessToken, } from "./providers/google-vertex-provider"; export type { GoogleVertexProviderCallbacks } from "./providers/google-vertex-provider"; export { diff --git a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts index d94b395..7e0d0bc 100644 --- a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts +++ b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts @@ -24,6 +24,7 @@ import type { Logger } from "../../types"; import { registerCustomGoogleVertexProvider, registerGoogleVertexProvider, + resolveGoogleVertexAccessToken, } from "../google-vertex-provider"; import { ProviderRegistry } from "../ProviderRegistry"; @@ -174,6 +175,65 @@ describe("registerGoogleVertexProvider", () => { }); }); +describe("resolveGoogleVertexAccessToken", () => { + const inlineEnv = { + GOOGLE_CLIENT_EMAIL: "svc@example.iam.gserviceaccount.com", + GOOGLE_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----", + }; + + beforeEach(() => { + vi.clearAllMocks(); + authMocks.getClient.mockResolvedValue({ getAccessToken: authMocks.getAccessToken }); + authMocks.googleAuth.mockImplementation(() => ({ getClient: authMocks.getClient })); + }); + + it("mints from inline service-account env vars before ADC", async () => { + authMocks.getAccessToken.mockResolvedValueOnce({ token: "inline-token" }); + await expect(resolveGoogleVertexAccessToken(inlineEnv)).resolves.toBe("inline-token"); + expect(authMocks.googleAuth).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: expect.objectContaining({ + client_email: inlineEnv.GOOGLE_CLIENT_EMAIL, + private_key: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + }), + }), + ); + }); + + it("includes GOOGLE_PRIVATE_KEY_ID when set", async () => { + authMocks.getAccessToken.mockResolvedValueOnce({ token: "inline-token" }); + await resolveGoogleVertexAccessToken({ ...inlineEnv, GOOGLE_PRIVATE_KEY_ID: "kid-1" }); + expect(authMocks.googleAuth).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: expect.objectContaining({ private_key_id: "kid-1" }), + }), + ); + }); + + it("surfaces an inline failure instead of falling through to ADC", async () => { + authMocks.getAccessToken.mockRejectedValueOnce(new Error("bad key")); + await expect(resolveGoogleVertexAccessToken(inlineEnv)).rejects.toThrow( + "Inline service-account credentials failed: bad key", + ); + expect(authMocks.googleAuth).toHaveBeenCalledTimes(1); + }); + + it("falls back to ADC when the inline vars are absent", async () => { + authMocks.getAccessToken.mockResolvedValueOnce({ token: "adc-token" }); + await expect(resolveGoogleVertexAccessToken({})).resolves.toBe("adc-token"); + expect(authMocks.googleAuth).toHaveBeenCalledWith( + expect.not.objectContaining({ credentials: expect.anything() }), + ); + }); + + it("throws when ADC yields no token", async () => { + authMocks.getAccessToken.mockResolvedValueOnce({ token: null }); + await expect(resolveGoogleVertexAccessToken({})).rejects.toThrow( + "Failed to obtain access token from Application Default Credentials", + ); + }); +}); + describe("GoogleVertexClient location heuristic", () => { it("routes recognized Anthropic model IDs to global via model-ID heuristic", () => { // Baseline: recognized model IDs already go to global diff --git a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts index d98e78e..50aa818 100644 --- a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts +++ b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts @@ -58,30 +58,71 @@ function isAuthError(error: unknown): boolean { // Cache TTL for models (1 hour) in milliseconds const MODEL_CACHE_TTL = 60 * 60 * 1000; +const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; + +async function tokenFrom(auth: GoogleAuth): Promise { + const client = await auth.getClient(); + const { token } = await client.getAccessToken(); + return token ?? undefined; +} + /** - * Resolve an access token for the Vertex AI REST API. - * Uses a broker-provided token (e.g. from Positron auth ext) when available; - * otherwise falls back to Application Default Credentials. + * Resolve a cloud-platform access token: inline service-account env vars + * (`GOOGLE_CLIENT_EMAIL` + `GOOGLE_PRIVATE_KEY`, optional + * `GOOGLE_PRIVATE_KEY_ID`) first, Application Default Credentials otherwise. + * An inline failure is surfaced rather than masked by an ADC "no credentials" + * error, because setting both vars signals explicit intent. */ -async function getAccessToken( - brokered?: string, +export async function resolveGoogleVertexAccessToken( credentialEnvironment?: Readonly>, ): Promise { - if (brokered) return brokered; + const env = credentialEnvironment ?? process.env; + const clientEmail = env.GOOGLE_CLIENT_EMAIL; + const privateKey = env.GOOGLE_PRIVATE_KEY; + if (clientEmail && privateKey) { + const auth = new GoogleAuth({ + credentials: { + client_email: clientEmail, + // google-auth-library needs literal newlines; pasted keys carry escaped `\n`. + private_key: privateKey.replace(/\\n/g, "\n"), + ...(env.GOOGLE_PRIVATE_KEY_ID && { private_key_id: env.GOOGLE_PRIVATE_KEY_ID }), + }, + scopes: [CLOUD_PLATFORM_SCOPE], + }); + try { + const token = await tokenFrom(auth); + if (token) return token; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Inline service-account credentials failed: ${message}`); + } + } const auth = new GoogleAuth({ - scopes: ["https://www.googleapis.com/auth/cloud-platform"], + scopes: [CLOUD_PLATFORM_SCOPE], keyFilename: credentialEnvironment ? readSdkCredentialEnvironment(credentialEnvironment).googleApplicationCredentials : undefined, }); - const client = await auth.getClient(); - const { token } = await client.getAccessToken(); + const token = await tokenFrom(auth); if (!token) { throw new Error("Failed to obtain access token from Application Default Credentials"); } return token; } +/** + * Resolve an access token for the Vertex AI REST API. + * Uses a broker-provided token (e.g. from Positron auth ext) when available; + * otherwise falls back to Application Default Credentials. + */ +async function getAccessToken( + brokered?: string, + credentialEnvironment?: Readonly>, +): Promise { + if (brokered) return brokered; + return resolveGoogleVertexAccessToken(credentialEnvironment); +} + /** * Fetch models from a single Vertex AI publisher endpoint. */ From 5aec5bab708ec9eefa1ec2d6b1d3ce0caf672016 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Fri, 18 Sep 2026 13:28:54 -0400 Subject: [PATCH 07/33] Register custom entries through registerAllProviders and share the credential helpers --- .../types/__tests__/auth-descriptors.test.ts | 51 ++++++++- .../__tests__/credential-shaping.test.ts | 34 +++++- .../src/types/auth-descriptors.ts | 29 +++++ .../src/types/credential-shaping.ts | 35 ++++++ packages/ai-credentials/src/types/index.ts | 8 +- .../src/__tests__/aws-credentials.test.ts | 18 +++ .../__tests__/register-all-providers.test.ts | 27 +++++ .../ai-provider-bridge/src/aws-credentials.ts | 10 +- .../src/credential-shaping.ts | 9 +- packages/ai-provider-bridge/src/providers.ts | 4 + .../src/register-all-providers.ts | 107 ++++++++++++++++-- 11 files changed, 316 insertions(+), 16 deletions(-) diff --git a/packages/ai-credentials/src/types/__tests__/auth-descriptors.test.ts b/packages/ai-credentials/src/types/__tests__/auth-descriptors.test.ts index b01d912..be3328a 100644 --- a/packages/ai-credentials/src/types/__tests__/auth-descriptors.test.ts +++ b/packages/ai-credentials/src/types/__tests__/auth-descriptors.test.ts @@ -4,7 +4,11 @@ import { describe, expect, it } from "vitest"; -import { CUSTOM_CLIENT_KIND_AUTH_MAP, resolveCustomAuthMapping } from "../auth-descriptors.js"; +import { + CUSTOM_CLIENT_KIND_AUTH_MAP, + customProviderAuthMapping, + resolveCustomAuthMapping, +} from "../auth-descriptors.js"; describe("resolveCustomAuthMapping", () => { it("returns the kind-level mapping when the entry authors nothing", () => { @@ -45,3 +49,48 @@ describe("resolveCustomAuthMapping", () => { ); }); }); + +describe("customProviderAuthMapping", () => { + it("maps an openai-compatible entry to apikey with the entry name as scope", () => { + expect( + customProviderAuthMapping("my-gateway", "openai-compatible", "custom-providers"), + ).toEqual({ + authProviderId: "custom-providers", + scopes: ["my-gateway"], + credentialType: "apikey", + }); + }); + + it("maps an aws entry to aws-credentials", () => { + expect(customProviderAuthMapping("my-bedrock", "aws", "custom-providers")).toEqual({ + authProviderId: "custom-providers", + scopes: ["my-bedrock"], + credentialType: "aws-credentials", + }); + }); + + it("maps a google-vertex entry to google-cloud", () => { + expect(customProviderAuthMapping("my-vertex", "google-vertex", "custom-providers")).toEqual({ + authProviderId: "custom-providers", + scopes: ["my-vertex"], + credentialType: "google-cloud", + }); + }); + + it("carries structuredBaseUrl for a snowflake entry", () => { + expect(customProviderAuthMapping("my-snow", "snowflake", "custom-providers")).toEqual({ + authProviderId: "custom-providers", + scopes: ["my-snow"], + credentialType: "apikey", + structuredBaseUrl: "snowflake", + }); + }); + + it("returns undefined for a local kind", () => { + expect(customProviderAuthMapping("my-ollama", "ollama", "custom-providers")).toBeUndefined(); + }); + + it("returns undefined for an unknown kind", () => { + expect(customProviderAuthMapping("my-thing", "positai", "custom-providers")).toBeUndefined(); + }); +}); diff --git a/packages/ai-credentials/src/types/__tests__/credential-shaping.test.ts b/packages/ai-credentials/src/types/__tests__/credential-shaping.test.ts index 38c714e..3ced2fa 100644 --- a/packages/ai-credentials/src/types/__tests__/credential-shaping.test.ts +++ b/packages/ai-credentials/src/types/__tests__/credential-shaping.test.ts @@ -15,7 +15,11 @@ import { describe, expect, it } from "vitest"; -import { type CredentialConfig, shapeCredentials } from "../credential-shaping.js"; +import { + type CredentialConfig, + serializeSessionToken, + shapeCredentials, +} from "../credential-shaping.js"; const SNOWFLAKE = { authProviderId: "snowflake-cortex", credentialType: "apikey" } as const; const AWS = { authProviderId: "bedrock", credentialType: "aws-credentials" } as const; @@ -297,3 +301,31 @@ describe("shapeCredentials — providers.custom entries", () => { }); }); }); + +describe("serializeSessionToken", () => { + it("round-trips through shapeCredentials", () => { + const google = { type: "google-cloud" as const, project: "p", location: "l", accessToken: "t" }; + expect( + shapeCredentials( + "google-vertex", + { authProviderId: "google-vertex", credentialType: "google-cloud" }, + serializeSessionToken(google), + config(), + ), + ).toEqual(google); + const aws = { + type: "aws-credentials" as const, + accessKeyId: "AK", + secretAccessKey: "SK", + sessionToken: "ST", + }; + expect( + shapeCredentials( + "bedrock", + { authProviderId: "bedrock", credentialType: "aws-credentials" }, + serializeSessionToken(aws), + config({ getAws: () => ({ region: "eu-west-1" }) }), + ), + ).toEqual({ ...aws, region: "eu-west-1" }); + }); +}); diff --git a/packages/ai-credentials/src/types/auth-descriptors.ts b/packages/ai-credentials/src/types/auth-descriptors.ts index ce00277..394ba77 100644 --- a/packages/ai-credentials/src/types/auth-descriptors.ts +++ b/packages/ai-credentials/src/types/auth-descriptors.ts @@ -23,6 +23,8 @@ * (Notebooks) can resolve custom-provider credentials without @assistant/*. */ +import type { AuthProviderMapping } from "./credential-shaping.js"; + /** * Auth metadata derived from a custom provider's `clientKind`. */ @@ -133,3 +135,30 @@ export function resolveCustomAuthMapping( export const SUPPORTED_CUSTOM_CLIENT_KINDS: ReadonlySet = new Set( SUPPORTED_CUSTOM_CLIENT_KIND_VALUES, ); + +/** + * Auth mapping for a `providers.custom` entry: read through the host's + * aggregate auth provider with the entry name as the scope, shaped by the + * kind's auth method. `undefined` for kinds with no session (local endpoints) + * or no descriptor. + */ +export function customProviderAuthMapping( + entryId: string, + clientKind: string, + aggregateAuthProviderId: string, +): AuthProviderMapping | undefined { + const authMethodId = CUSTOM_CLIENT_KIND_AUTH_MAP.get(clientKind)?.authMethodId; + if ( + authMethodId !== "apikey" && + authMethodId !== "aws-credentials" && + authMethodId !== "google-cloud" + ) { + return undefined; + } + return { + authProviderId: aggregateAuthProviderId, + scopes: [entryId], + credentialType: authMethodId, + ...(clientKind === "snowflake" ? { structuredBaseUrl: "snowflake" as const } : {}), + }; +} diff --git a/packages/ai-credentials/src/types/credential-shaping.ts b/packages/ai-credentials/src/types/credential-shaping.ts index fb55f0d..52366c2 100644 --- a/packages/ai-credentials/src/types/credential-shaping.ts +++ b/packages/ai-credentials/src/types/credential-shaping.ts @@ -242,6 +242,41 @@ export function shapeCredentials( } } +/** What an auth provider puts in a session token: exactly the fields `shapeCredentials` reads back. */ +export type SessionTokenEnvelope = + | { + readonly type: "google-cloud"; + readonly project: string; + readonly location: string; + readonly accessToken?: string; + } + | { + readonly type: "aws-credentials"; + readonly accessKeyId: string; + readonly secretAccessKey: string; + readonly sessionToken?: string; + }; + +/** + * Serialize a session token for a `google-cloud` or `aws-credentials` + * mapping. Region and profile are not part of the envelope; the reader takes + * them from its own catalog. + */ +export function serializeSessionToken(envelope: SessionTokenEnvelope): string { + if (envelope.type === "google-cloud") { + const { project, location, accessToken } = envelope; + return JSON.stringify( + accessToken ? { project, location, token: accessToken } : { project, location }, + ); + } + const { accessKeyId, secretAccessKey, sessionToken } = envelope; + return JSON.stringify( + sessionToken + ? { accessKeyId, secretAccessKey, sessionToken } + : { accessKeyId, secretAccessKey }, + ); +} + /** Narrowed local alias so the optional-accessToken spread above stays typed. */ type GoogleCloudCredentialsResult = Extract; diff --git a/packages/ai-credentials/src/types/index.ts b/packages/ai-credentials/src/types/index.ts index fa4d492..d6720cd 100644 --- a/packages/ai-credentials/src/types/index.ts +++ b/packages/ai-credentials/src/types/index.ts @@ -22,11 +22,16 @@ export type { ProviderCredentials, } from "./credentials.js"; -export { CONFIG_KEY_OVERRIDES, shapeCredentials } from "./credential-shaping.js"; +export { + CONFIG_KEY_OVERRIDES, + serializeSessionToken, + shapeCredentials, +} from "./credential-shaping.js"; export type { AuthProviderMapping, CredentialConfig, CredentialConfigTarget, + SessionTokenEnvelope, StructuredBaseUrlSource, } from "./credential-shaping.js"; @@ -49,6 +54,7 @@ export { storageKeyFor } from "./storage-key.js"; export { CUSTOM_CLIENT_KIND_AUTH_DESCRIPTORS, CUSTOM_CLIENT_KIND_AUTH_MAP, + customProviderAuthMapping, resolveCustomAuthMapping, SUPPORTED_CUSTOM_CLIENT_KIND_VALUES, SUPPORTED_CUSTOM_CLIENT_KINDS, diff --git a/packages/ai-provider-bridge/src/__tests__/aws-credentials.test.ts b/packages/ai-provider-bridge/src/__tests__/aws-credentials.test.ts index b8aa17e..b0757c3 100644 --- a/packages/ai-provider-bridge/src/__tests__/aws-credentials.test.ts +++ b/packages/ai-provider-bridge/src/__tests__/aws-credentials.test.ts @@ -57,4 +57,22 @@ describe("createAwsCredentialProvider", () => { sessionToken: "chain-token", }); }); + + it("targets the configured region when AWS_WEB_IDENTITY_TOKEN_FILE is set", () => { + createAwsCredentialProvider( + { region: "us-east-2", profile: "analytics" }, + { AWS_WEB_IDENTITY_TOKEN_FILE: "/var/run/secrets/token" }, + ); + + expect(fromNodeProviderChain).toHaveBeenCalledWith({ + profile: "analytics", + clientConfig: { region: "us-east-2" }, + }); + }); + + it("omits region when AWS_WEB_IDENTITY_TOKEN_FILE is unset, so an SSO profile's own region applies", () => { + createAwsCredentialProvider({ region: "us-east-2", profile: "analytics" }, {}); + + expect(fromNodeProviderChain).toHaveBeenCalledWith({ profile: "analytics" }); + }); }); diff --git a/packages/ai-provider-bridge/src/__tests__/register-all-providers.test.ts b/packages/ai-provider-bridge/src/__tests__/register-all-providers.test.ts index 5e25c7a..b1fc272 100644 --- a/packages/ai-provider-bridge/src/__tests__/register-all-providers.test.ts +++ b/packages/ai-provider-bridge/src/__tests__/register-all-providers.test.ts @@ -74,4 +74,31 @@ describe("registerAllProviders", () => { expect(fetchMock).toHaveBeenCalledWith("https://second.example.com/models", expect.any(Object)); }); + + it("registers a per-id model fetcher and a kind-keyed client factory for each custom entry", () => { + const registry = new ProviderRegistry(logger()); + registerAllProviders(registry, logger(), { + positAiBaseUrl: "https://api.posit.cloud", + allowedProviders: [], + customProviders: [{ id: "my-gateway" as never, clientKind: "openai-compatible" }], + }); + expect( + registry.getClientForProviderOrKind( + "my-gateway", + { type: "apikey", apiKey: "k", baseUrl: "https://gw.example/v1" }, + "openai-compatible", + ), + ).not.toBeNull(); + expect(registry.getClientForProvider("anthropic", { type: "apikey", apiKey: "k" })).toBeNull(); + }); + + it("rejects a custom entry whose kind has no registrar", () => { + const registry = new ProviderRegistry(logger()); + expect(() => + registerAllProviders(registry, logger(), { + positAiBaseUrl: "https://api.posit.cloud", + customProviders: [{ id: "odd" as never, clientKind: "positai" as never }], + }), + ).toThrow("Unsupported custom provider kind: positai"); + }); }); diff --git a/packages/ai-provider-bridge/src/aws-credentials.ts b/packages/ai-provider-bridge/src/aws-credentials.ts index 1d19c46..f3f1665 100644 --- a/packages/ai-provider-bridge/src/aws-credentials.ts +++ b/packages/ai-provider-bridge/src/aws-credentials.ts @@ -32,9 +32,14 @@ export function hasManualAwsKeys( /** * Build the single credential-provider seam shared by every Bedrock route. * Both manual keys and the standard Node chain return the same provider shape. + * + * When `AWS_WEB_IDENTITY_TOKEN_FILE` is set, the chain's STS token exchange is + * pointed at the configured region via `clientConfig`; otherwise the region is + * omitted so an SSO profile's own `sso_region` applies. */ export function createAwsCredentialProvider( credentials: AwsCredentialSource, + env: Readonly> = process.env, ): () => Promise { if (hasManualAwsKeys(credentials)) { return async () => ({ @@ -44,5 +49,8 @@ export function createAwsCredentialProvider( }); } - return fromNodeProviderChain({ profile: credentials.profile }); + return fromNodeProviderChain({ + profile: credentials.profile, + ...(env.AWS_WEB_IDENTITY_TOKEN_FILE ? { clientConfig: { region: credentials.region } } : {}), + }); } diff --git a/packages/ai-provider-bridge/src/credential-shaping.ts b/packages/ai-provider-bridge/src/credential-shaping.ts index 1f54ca6..44e3743 100644 --- a/packages/ai-provider-bridge/src/credential-shaping.ts +++ b/packages/ai-provider-bridge/src/credential-shaping.ts @@ -10,10 +10,17 @@ * continue to work. */ -export { CONFIG_KEY_OVERRIDES, shapeCredentials } from "ai-credentials/types"; +export { + CONFIG_KEY_OVERRIDES, + CUSTOM_CLIENT_KIND_AUTH_MAP, + customProviderAuthMapping, + serializeSessionToken, + shapeCredentials, +} from "ai-credentials/types"; export type { AuthProviderMapping, CredentialConfig, CredentialConfigTarget, + SessionTokenEnvelope, StructuredBaseUrlSource, } from "ai-credentials/types"; diff --git a/packages/ai-provider-bridge/src/providers.ts b/packages/ai-provider-bridge/src/providers.ts index 4a5c48b..5971cc2 100644 --- a/packages/ai-provider-bridge/src/providers.ts +++ b/packages/ai-provider-bridge/src/providers.ts @@ -81,6 +81,10 @@ export type { SnowflakeProviderCallbacks } from "./providers/snowflake-cortex-pr export { registerAllProviders } from "./register-all-providers"; export type { ProviderRegistrationConfig } from "./register-all-providers"; +// AWS credential provider +export { createAwsCredentialProvider, hasManualAwsKeys } from "./aws-credentials"; +export type { AwsCredentialSource, ResolvedAwsCredentials } from "./aws-credentials"; + // Bedrock SSO utilities export { isAwsSsoProfileConfigured, parseAwsConfig } from "./providers/bedrock-sso"; diff --git a/packages/ai-provider-bridge/src/register-all-providers.ts b/packages/ai-provider-bridge/src/register-all-providers.ts index 2fd877c..cf8832f 100644 --- a/packages/ai-provider-bridge/src/register-all-providers.ts +++ b/packages/ai-provider-bridge/src/register-all-providers.ts @@ -10,9 +10,15 @@ * lifecycle and passes it in. */ -import { registerAnthropicProvider } from "./providers/anthropic-provider"; +import type { ResolvedProviderId, SupportedCustomClientKind } from "ai-config"; + +import { + registerAnthropicProvider, + registerCustomAnthropicProvider, +} from "./providers/anthropic-provider"; import { registerBedrockProvider, + registerCustomBedrockProvider, type BedrockProviderCallbacks, } from "./providers/bedrock-provider"; import { @@ -21,24 +27,47 @@ import { } from "./providers/connect-provider"; import { registerCopilotProvider } from "./providers/copilot-provider"; import { registerDatabricksProvider } from "./providers/databricks-provider"; -import { registerDeepSeekProvider } from "./providers/deepseek-provider"; -import { registerFoundryProvider } from "./providers/foundry-provider"; -import { registerGeminiProvider } from "./providers/gemini-provider"; import { + registerCustomDeepSeekProvider, + registerDeepSeekProvider, +} from "./providers/deepseek-provider"; +import { + registerCustomFoundryProvider, + registerFoundryProvider, +} from "./providers/foundry-provider"; +import { registerCustomGeminiProvider, registerGeminiProvider } from "./providers/gemini-provider"; +import { + registerCustomGoogleVertexProvider, registerGoogleVertexProvider, type GoogleVertexProviderCallbacks, } from "./providers/google-vertex-provider"; -import { registerLitellmProvider } from "./providers/litellm-provider"; -import { registerLMStudioProvider } from "./providers/lmstudio-provider"; -import { registerOllamaProvider } from "./providers/ollama-provider"; -import { registerOpenAICompatibleProvider } from "./providers/openai-compatible-provider"; -import { registerOpenAIProvider } from "./providers/openai-provider"; +import { + registerCustomLitellmProvider, + registerLitellmProvider, +} from "./providers/litellm-provider"; +import { + registerCustomLMStudioProvider, + registerLMStudioProvider, +} from "./providers/lmstudio-provider"; +import { registerCustomOllamaProvider, registerOllamaProvider } from "./providers/ollama-provider"; +import { + registerCustomOpenAICompatibleProvider, + registerOpenAICompatibleProvider, +} from "./providers/openai-compatible-provider"; +import { registerCustomOpenAIProvider, registerOpenAIProvider } from "./providers/openai-provider"; import { registerOpencodeProvider } from "./providers/opencode-provider"; -import { registerOpenRouterProvider } from "./providers/openrouter-provider"; -import { registerPortkeyProvider } from "./providers/portkey-provider"; +import { + registerCustomOpenRouterProvider, + registerOpenRouterProvider, +} from "./providers/openrouter-provider"; +import { + registerCustomPortkeyProvider, + registerPortkeyProvider, +} from "./providers/portkey-provider"; import { registerPositAiProvider } from "./providers/positai-provider"; import type { ProviderRegistry } from "./providers/ProviderRegistry"; import { + registerCustomSnowflakeProvider, registerSnowflakeCortexProvider, type SnowflakeProviderCallbacks, } from "./providers/snowflake-cortex-provider"; @@ -62,6 +91,8 @@ export interface ProviderRegistrationConfig { connectCallbacks?: ConnectProviderCallbacks; /** Host-captured environment for SDK credential constructors after ambient scrubbing. */ credentialEnvironment?: Readonly>; + /** `providers.custom` entries to register after the built-ins; independent of `allowedProviders`. */ + customProviders?: ReadonlyArray<{ readonly id: ResolvedProviderId; readonly clientKind: string }>; } /** @@ -114,8 +145,49 @@ const PROVIDER_REGISTRARS = { opencode: registerOpencodeProvider, } satisfies Record; +type CustomProviderRegistrar = ( + registry: ProviderRegistry, + providerId: ResolvedProviderId, + logger: Logger, + config: ProviderRegistrationConfig, +) => void; + +/** One registrar per supported custom kind; each reads its callbacks from the same config the built-ins use. */ +const CUSTOM_PROVIDER_REGISTRARS = { + "openai-compatible": (registry, id, logger) => + registerCustomOpenAICompatibleProvider(registry, id, logger), + anthropic: (registry, id, logger) => registerCustomAnthropicProvider(registry, id, logger), + openai: (registry, id, logger) => registerCustomOpenAIProvider(registry, id, logger), + gemini: (registry, id, logger) => registerCustomGeminiProvider(registry, id, logger), + aws: (registry, id, logger, config) => + registerCustomBedrockProvider(registry, id, logger, config.bedrockCallbacks), + snowflake: (registry, id, logger, config) => + registerCustomSnowflakeProvider(registry, id, logger, config.snowflakeCallbacks), + "google-vertex": (registry, id, logger, config) => + registerCustomGoogleVertexProvider( + registry, + id, + logger, + config.googleVertexCallbacks, + config.credentialEnvironment, + ), + ollama: (registry, id, logger) => registerCustomOllamaProvider(registry, id, logger), + lmstudio: (registry, id, logger) => registerCustomLMStudioProvider(registry, id, logger), + deepseek: (registry, id, logger) => registerCustomDeepSeekProvider(registry, id, logger), + openrouter: (registry, id, logger) => registerCustomOpenRouterProvider(registry, id, logger), + "ms-foundry": (registry, id, logger, config) => + registerCustomFoundryProvider(registry, id, logger, config.credentialEnvironment), + litellm: (registry, id, logger) => registerCustomLitellmProvider(registry, id, logger), + portkey: (registry, id, logger) => registerCustomPortkeyProvider(registry, id, logger), +} satisfies Record; + /** * Register every provider with the given registry, honoring `config.allowedProviders`. + * + * `config.customProviders` entries register after the built-ins, are not + * filtered by `allowedProviders`, and are looked up through + * `ProviderRegistry.getClientForProviderOrKind` because their client + * factories are keyed by kind. */ export function registerAllProviders( registry: ProviderRegistry, @@ -128,4 +200,17 @@ export function registerAllProviders( PROVIDER_REGISTRARS[id](registry, logger, config); } } + + for (const { id, clientKind } of config.customProviders ?? []) { + const registrar = ( + CUSTOM_PROVIDER_REGISTRARS as Partial> + )[clientKind]; + if (!registrar) { + throw new Error(`Unsupported custom provider kind: ${clientKind}`); + } + registrar(registry, id, logger, config); + logger.debug( + `[registerAllProviders] Registered ${clientKind} support for custom provider "${id}"`, + ); + } } From 19ce1ea5d45a21db560d27d1a0a0f396261084d4 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Fri, 18 Sep 2026 13:29:37 -0400 Subject: [PATCH 08/33] Ignore DATABRICKS_TOKEN when Workbench manages the Databricks profile --- .../__tests__/EnvCredentialResolver.test.ts | 22 +++++++++++++++++++ .../store-backend/envCredentialResolver.ts | 8 +++++++ 2 files changed, 30 insertions(+) diff --git a/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts b/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts index 963cb66..1701284 100644 --- a/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts +++ b/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts @@ -128,6 +128,28 @@ describe("resolveCredentialsFromEnv", () => { ] as const)("returns null for %s non-secret environment config", (providerId, env) => { expect(resolveCredentialsFromEnv(providerId, env)).toBeNull(); }); + + describe("Workbench-managed Databricks profile", () => { + it("ignores DATABRICKS_TOKEN when DATABRICKS_CONFIG_FILE is Workbench-managed", () => { + const env = { + DATABRICKS_TOKEN: "shell-pat", + DATABRICKS_CONFIG_FILE: "/home/user/.posit-workbench/databricks/cfg", + }; + expect(resolveCredentialsFromEnv("databricks", env)).toBeNull(); + expect(hasEnvCredentials("databricks", env)).toBe(false); + }); + + it("reads DATABRICKS_TOKEN when the config file is not Workbench-managed", () => { + const env = { + DATABRICKS_TOKEN: "shell-pat", + DATABRICKS_CONFIG_FILE: "/home/user/.databrickscfg", + }; + expect(resolveCredentialsFromEnv("databricks", env)).toEqual({ + type: "apikey", + apiKey: "shell-pat", + }); + }); + }); }); describe("hasEnvCredentials", () => { diff --git a/packages/ai-credentials/src/store-backend/envCredentialResolver.ts b/packages/ai-credentials/src/store-backend/envCredentialResolver.ts index 04df9fe..4132b5d 100644 --- a/packages/ai-credentials/src/store-backend/envCredentialResolver.ts +++ b/packages/ai-credentials/src/store-backend/envCredentialResolver.ts @@ -35,6 +35,14 @@ export function resolveCredentialsFromEnv( providerId: string, envVars: Readonly> = process.env, ): ProviderCredentials | null { + // A Workbench-provisioned Databricks profile outranks DATABRICKS_TOKEN; the admin credential must not be overridable from the shell. + if ( + providerId === "databricks" && + (envVars.DATABRICKS_CONFIG_FILE ?? "").includes("posit-workbench") + ) { + return null; + } + const mapping = PROVIDER_ENV_MAPPINGS[providerId]; if (!mapping) return null; From 560e5ac49027a9729066bc781ef20abd09f48ba7 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Fri, 18 Sep 2026 16:49:42 -0400 Subject: [PATCH 09/33] Strip operation paths after /openai/v1 in normalizeFoundryBaseUrl --- packages/ai-config/src/__tests__/base-url.test.ts | 9 +++++++++ packages/ai-config/src/base-url.ts | 14 +++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/ai-config/src/__tests__/base-url.test.ts b/packages/ai-config/src/__tests__/base-url.test.ts index 71995c8..fc1e2d8 100644 --- a/packages/ai-config/src/__tests__/base-url.test.ts +++ b/packages/ai-config/src/__tests__/base-url.test.ts @@ -50,6 +50,15 @@ describe("normalizeFoundryBaseUrl", () => { ], ["https://r.openai.azure.com/openai/v1", "https://r.openai.azure.com/openai/v1"], ["https://r.openai.azure.com/openai/v1/", "https://r.openai.azure.com/openai/v1"], + [ + "https://r.openai.azure.com/openai/v1/chat/completions", + "https://r.openai.azure.com/openai/v1", + ], + [ + "https://r.openai.azure.com/openai/v1/responses?api-version=preview", + "https://r.openai.azure.com/openai/v1", + ], + ["https://r.openai.azure.com/openai/v10", "https://r.openai.azure.com/openai/v10/openai/v1"], ["https://r.openai.azure.com/", "https://r.openai.azure.com/openai/v1"], ["https://r.openai.azure.com", "https://r.openai.azure.com/openai/v1"], ["https://r.openai.azure.com?api-version=1", "https://r.openai.azure.com/openai/v1"], diff --git a/packages/ai-config/src/base-url.ts b/packages/ai-config/src/base-url.ts index 6ea27b0..b7570c2 100644 --- a/packages/ai-config/src/base-url.ts +++ b/packages/ai-config/src/base-url.ts @@ -146,10 +146,13 @@ export function normalizeBaseUrlForProvider(providerId: BuiltinProviderId, url: return url; } +const FOUNDRY_V1_PATH = "/openai/v1"; + /** * Normalize a Microsoft Foundry endpoint to its `/openai/v1` base URL: strips - * the query string, trailing slashes and any `/openai/deployments/...` suffix - * users paste from the portal. Empty input stays empty. + * the query string, trailing slashes, any `/openai/deployments/...` suffix and + * any operation path after `/openai/v1` that users paste from the portal. + * Empty input stays empty. */ export function normalizeFoundryBaseUrl(rawUrl: string): string { let url = rawUrl.trim(); @@ -160,6 +163,11 @@ export function normalizeFoundryBaseUrl(rawUrl: string): string { if (!url) return ""; const deploymentIndex = url.indexOf("/openai/deployments/"); if (deploymentIndex !== -1) url = url.substring(0, deploymentIndex); - if (!url.endsWith("/openai/v1")) url += "/openai/v1"; + const v1Index = url.indexOf(FOUNDRY_V1_PATH); + if (v1Index !== -1) { + const afterV1 = url.charAt(v1Index + FOUNDRY_V1_PATH.length); + if (afterV1 === "" || afterV1 === "/") url = url.substring(0, v1Index + FOUNDRY_V1_PATH.length); + } + if (!url.endsWith(FOUNDRY_V1_PATH)) url += FOUNDRY_V1_PATH; return url; } From d809d4aa846c445cc7a027ea46716742985101d9 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Fri, 18 Sep 2026 16:55:16 -0400 Subject: [PATCH 10/33] Declare the Vertex inline service-account variables in the provider environment registry --- ...viderEnvMappings-external.contract.test.ts | 3 +++ .../providerEnvironmentCapture.test.ts | 13 +++++++++++ .../src/store-backend/providerEnvMappings.ts | 6 +++++ .../src/store-backend/providerEnvRegistry.ts | 8 +++++-- .../__tests__/google-vertex-provider.test.ts | 22 +++++++++++++++++++ .../src/providers/google-vertex-provider.ts | 10 +++++---- 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/ai-credentials/src/store-backend/__tests__/providerEnvMappings-external.contract.test.ts b/packages/ai-credentials/src/store-backend/__tests__/providerEnvMappings-external.contract.test.ts index cc9eaa4..308d6cd 100644 --- a/packages/ai-credentials/src/store-backend/__tests__/providerEnvMappings-external.contract.test.ts +++ b/packages/ai-credentials/src/store-backend/__tests__/providerEnvMappings-external.contract.test.ts @@ -54,6 +54,9 @@ describe("external provider-env-registry variant", () => { }); expect(readSdkCredentialEnvironment(env)).toEqual({ googleApplicationCredentials: undefined, + googleClientEmail: undefined, + googlePrivateKey: undefined, + googlePrivateKeyId: undefined, azureTenantId: undefined, azureClientId: undefined, azureClientSecret: undefined, diff --git a/packages/ai-credentials/src/store-backend/__tests__/providerEnvironmentCapture.test.ts b/packages/ai-credentials/src/store-backend/__tests__/providerEnvironmentCapture.test.ts index 44f8a46..9f4ad86 100644 --- a/packages/ai-credentials/src/store-backend/__tests__/providerEnvironmentCapture.test.ts +++ b/packages/ai-credentials/src/store-backend/__tests__/providerEnvironmentCapture.test.ts @@ -96,6 +96,9 @@ describe("captureProviderEnvironment", () => { it("captures every field a complete lazy SDK credential path needs", () => { const env = { GOOGLE_APPLICATION_CREDENTIALS: "/creds/adc.json", + GOOGLE_CLIENT_EMAIL: "svc@example.iam.gserviceaccount.com", + GOOGLE_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----", + GOOGLE_PRIVATE_KEY_ID: "kid-1", AZURE_TENANT_ID: "tenant", AZURE_CLIENT_ID: "client", AZURE_CLIENT_SECRET: "secret", @@ -105,9 +108,16 @@ describe("captureProviderEnvironment", () => { const captured = captureProviderEnvironment(["google-vertex", "ms-foundry"], env); expect(captured.environment).toMatchObject(env); + // The private key is scrubbed from the host; the account identifiers stay ambient. + expect(captured.scrubbedNames).toContain("GOOGLE_PRIVATE_KEY"); + expect(captured.scrubbedNames).not.toContain("GOOGLE_CLIENT_EMAIL"); + expect(captured.scrubbedNames).not.toContain("GOOGLE_PRIVATE_KEY_ID"); // The reader reconstructs the full typed struct from the capture. expect(readSdkCredentialEnvironment(captured.environment)).toEqual({ googleApplicationCredentials: "/creds/adc.json", + googleClientEmail: "svc@example.iam.gserviceaccount.com", + googlePrivateKey: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----", + googlePrivateKeyId: "kid-1", azureTenantId: "tenant", azureClientId: "client", azureClientSecret: "secret", @@ -141,6 +151,9 @@ describe("readSdkCredentialEnvironment", () => { it("returns undefined fields when the environment lacks them", () => { expect(readSdkCredentialEnvironment({})).toEqual({ googleApplicationCredentials: undefined, + googleClientEmail: undefined, + googlePrivateKey: undefined, + googlePrivateKeyId: undefined, azureTenantId: undefined, azureClientId: undefined, azureClientSecret: undefined, diff --git a/packages/ai-credentials/src/store-backend/providerEnvMappings.ts b/packages/ai-credentials/src/store-backend/providerEnvMappings.ts index aca8807..cae9796 100644 --- a/packages/ai-credentials/src/store-backend/providerEnvMappings.ts +++ b/packages/ai-credentials/src/store-backend/providerEnvMappings.ts @@ -50,6 +50,9 @@ export interface EnvironmentFieldDescriptor { */ export interface SdkCredentialEnvironment { readonly googleApplicationCredentials?: string; + readonly googleClientEmail?: string; + readonly googlePrivateKey?: string; + readonly googlePrivateKeyId?: string; readonly azureTenantId?: string; readonly azureClientId?: string; readonly azureClientSecret?: string; @@ -155,6 +158,9 @@ export function readSdkCredentialEnvironment( const descriptors = sdkCredentialDescriptors(); return { googleApplicationCredentials: readField(env, descriptors.googleApplicationCredentials), + googleClientEmail: readField(env, descriptors.googleClientEmail), + googlePrivateKey: readField(env, descriptors.googlePrivateKey), + googlePrivateKeyId: readField(env, descriptors.googlePrivateKeyId), azureTenantId: readField(env, descriptors.azureTenantId), azureClientId: readField(env, descriptors.azureClientId), azureClientSecret: readField(env, descriptors.azureClientSecret), diff --git a/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts b/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts index 8fe7255..df6bb98 100644 --- a/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts +++ b/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts @@ -110,11 +110,15 @@ export const PROVIDER_ENV_MAPPINGS: Record = { opencode: { apiKey: scrubbed("OPENCODE_API_KEY"), }, - // Google Application Default Credentials file, read directly by the - // google-auth-library SDK on the lazy Vertex credential path. + // Google Application Default Credentials file and the inline service-account + // fields, read by the google-auth-library SDK on the lazy Vertex credential + // path. The email and key id identify the account the way the Azure ids do. "google-vertex": { sdkCredentialEnvironment: { googleApplicationCredentials: scrubbed("GOOGLE_APPLICATION_CREDENTIALS"), + googleClientEmail: ambient("GOOGLE_CLIENT_EMAIL"), + googlePrivateKey: scrubbed("GOOGLE_PRIVATE_KEY"), + googlePrivateKeyId: ambient("GOOGLE_PRIVATE_KEY_ID"), }, }, }; diff --git a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts index 7e0d0bc..10427c6 100644 --- a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts +++ b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts @@ -3,6 +3,7 @@ *--------------------------------------------------------------------------------------------*/ import { mintCustomProviderId } from "ai-config"; +import { captureProviderEnvironment } from "ai-credentials/store-backend"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const authMocks = vi.hoisted(() => ({ @@ -200,6 +201,27 @@ describe("resolveGoogleVertexAccessToken", () => { ); }); + it("mints from the captured provider environment a scrubbing host hands over", async () => { + authMocks.getAccessToken.mockResolvedValueOnce({ token: "inline-token" }); + const captured = captureProviderEnvironment(["google-vertex"], { + ...inlineEnv, + GOOGLE_PRIVATE_KEY_ID: "kid-1", + UNRELATED: "x", + }); + expect(captured.scrubbedNames).toContain("GOOGLE_PRIVATE_KEY"); + await expect(resolveGoogleVertexAccessToken(captured.environment)).resolves.toBe( + "inline-token", + ); + expect(authMocks.googleAuth).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: expect.objectContaining({ + client_email: inlineEnv.GOOGLE_CLIENT_EMAIL, + private_key_id: "kid-1", + }), + }), + ); + }); + it("includes GOOGLE_PRIVATE_KEY_ID when set", async () => { authMocks.getAccessToken.mockResolvedValueOnce({ token: "inline-token" }); await resolveGoogleVertexAccessToken({ ...inlineEnv, GOOGLE_PRIVATE_KEY_ID: "kid-1" }); diff --git a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts index 50aa818..18467ef 100644 --- a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts +++ b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts @@ -76,16 +76,18 @@ async function tokenFrom(auth: GoogleAuth): Promise { export async function resolveGoogleVertexAccessToken( credentialEnvironment?: Readonly>, ): Promise { - const env = credentialEnvironment ?? process.env; - const clientEmail = env.GOOGLE_CLIENT_EMAIL; - const privateKey = env.GOOGLE_PRIVATE_KEY; + const sdkEnvironment = readSdkCredentialEnvironment(credentialEnvironment ?? process.env); + const clientEmail = sdkEnvironment.googleClientEmail; + const privateKey = sdkEnvironment.googlePrivateKey; if (clientEmail && privateKey) { const auth = new GoogleAuth({ credentials: { client_email: clientEmail, // google-auth-library needs literal newlines; pasted keys carry escaped `\n`. private_key: privateKey.replace(/\\n/g, "\n"), - ...(env.GOOGLE_PRIVATE_KEY_ID && { private_key_id: env.GOOGLE_PRIVATE_KEY_ID }), + ...(sdkEnvironment.googlePrivateKeyId && { + private_key_id: sdkEnvironment.googlePrivateKeyId, + }), }, scopes: [CLOUD_PLATFORM_SCOPE], }); From baf99a30e1677688186e9e0084ebfb0753778d86 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Mon, 21 Sep 2026 11:13:27 -0400 Subject: [PATCH 11/33] Classify rejected inline Vertex service-account credentials as an auth error --- .../__tests__/google-vertex-provider.test.ts | 40 +++++++++++++++++-- .../src/providers/google-vertex-provider.ts | 29 +++++++++++--- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts index 10427c6..2b9c3d0 100644 --- a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts +++ b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts @@ -90,6 +90,39 @@ describe("registerGoogleVertexProvider", () => { ); }); + it("reports rejected inline service-account credentials as an auth error, not a network error", async () => { + authMocks.getAccessToken.mockRejectedValueOnce(new Error("invalid_rapt")); + const onProviderStatusChange = vi.fn().mockResolvedValue(undefined); + const registry = new ProviderRegistry(mockLogger); + registerGoogleVertexProvider( + registry, + mockLogger, + { onProviderStatusChange }, + { + GOOGLE_CLIENT_EMAIL: "svc@example.iam.gserviceaccount.com", + GOOGLE_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----", + }, + ); + + const models = await registry.getModelsForProvider("google-vertex", { + type: "google-cloud", + project: "my-project", + location: "us-central1", + }); + + expect(models).toEqual([]); + expect(onProviderStatusChange).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: "google-vertex", + status: "auth_error", + error: expect.objectContaining({ + code: "inline_service_account_rejected", + message: expect.stringContaining("GOOGLE_CLIENT_EMAIL and GOOGLE_PRIVATE_KEY"), + }), + }), + ); + }); + it("uses a captured ADC path after the ambient environment is scrubbed", async () => { const parentEnvironment: Record = { GOOGLE_APPLICATION_CREDENTIALS: "/secrets/service-account.json", @@ -234,9 +267,10 @@ describe("resolveGoogleVertexAccessToken", () => { it("surfaces an inline failure instead of falling through to ADC", async () => { authMocks.getAccessToken.mockRejectedValueOnce(new Error("bad key")); - await expect(resolveGoogleVertexAccessToken(inlineEnv)).rejects.toThrow( - "Inline service-account credentials failed: bad key", - ); + await expect(resolveGoogleVertexAccessToken(inlineEnv)).rejects.toMatchObject({ + name: "InlineServiceAccountError", + message: "Inline service-account credentials failed: bad key", + }); expect(authMocks.googleAuth).toHaveBeenCalledTimes(1); }); diff --git a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts index 18467ef..2216135 100644 --- a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts +++ b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts @@ -32,12 +32,23 @@ export interface GoogleVertexProviderCallbacks { }) => Promise; } +const INLINE_SERVICE_ACCOUNT_ERROR = "InlineServiceAccountError"; + +/** The inline service-account variables were set but Google rejected them; ADC is deliberately not tried. */ +class InlineServiceAccountError extends Error { + constructor(cause: string) { + super(`Inline service-account credentials failed: ${cause}`); + this.name = INLINE_SERVICE_ACCOUNT_ERROR; + } +} + /** * Check whether an error from google-auth-library or the Vertex API indicates * expired / missing ADC credentials (similar to Bedrock's `isAuthError`). */ function isAuthError(error: unknown): boolean { if (!(error instanceof Error)) return false; + if (error.name === INLINE_SERVICE_ACCOUNT_ERROR) return true; const msg = error.message; // google-auth-library: refresh token revoked or expired if (msg.includes("invalid_grant") || msg.includes("Token has been expired or revoked")) { @@ -96,7 +107,7 @@ export async function resolveGoogleVertexAccessToken( if (token) return token; } catch (err) { const message = err instanceof Error ? err.message : String(err); - throw new Error(`Inline service-account credentials failed: ${message}`); + throw new InlineServiceAccountError(message); } } const auth = new GoogleAuth({ @@ -387,9 +398,13 @@ function createGoogleVertexModelFetcher( if (isAuthError(error)) { const isBrokeredAuth = Boolean(credentials.accessToken); - const authMessage = isBrokeredAuth - ? "Google Cloud authentication expired or is unavailable. Reconnect Google Cloud auth in Positron, then click Reload model list." - : "Google Cloud credentials expired or missing. Run 'gcloud auth application-default login' to refresh, then click Reload model list."; + const isInlineAuth = + error instanceof Error && error.name === INLINE_SERVICE_ACCOUNT_ERROR; + const authMessage = isInlineAuth + ? "Google Cloud rejected the service-account credentials in GOOGLE_CLIENT_EMAIL and GOOGLE_PRIVATE_KEY. Fix them, or unset them to use Application Default Credentials, then click Reload model list." + : isBrokeredAuth + ? "Google Cloud authentication expired or is unavailable. Reconnect Google Cloud auth in Positron, then click Reload model list." + : "Google Cloud credentials expired or missing. Run 'gcloud auth application-default login' to refresh, then click Reload model list."; logger.error(`[GoogleVertex] ${authMessage} Error: ${errorMsg}`); await callbacks?.onProviderStatusChange?.({ @@ -397,7 +412,11 @@ function createGoogleVertexModelFetcher( authMethodId: "google-cloud", status: "auth_error", error: { - code: isBrokeredAuth ? "google_cloud_auth_expired" : "adc_expired", + code: isInlineAuth + ? "inline_service_account_rejected" + : isBrokeredAuth + ? "google_cloud_auth_expired" + : "adc_expired", message: authMessage, action: { label: "Reload model list", From 753d3f2910b7dbc34c815d9e5da356e6746fc1e6 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Mon, 21 Sep 2026 11:16:13 -0400 Subject: [PATCH 12/33] Fail inline Vertex service-account auth when no token is returned --- .../src/providers/google-vertex-provider.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts index 2216135..3d902fc 100644 --- a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts +++ b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts @@ -102,13 +102,15 @@ export async function resolveGoogleVertexAccessToken( }, scopes: [CLOUD_PLATFORM_SCOPE], }); + let token: string | undefined; try { - const token = await tokenFrom(auth); - if (token) return token; + token = await tokenFrom(auth); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new InlineServiceAccountError(message); } + if (!token) throw new InlineServiceAccountError("no access token was returned"); + return token; } const auth = new GoogleAuth({ scopes: [CLOUD_PLATFORM_SCOPE], From 18b9594567209424ee2ee1eaef0e2567889ea082 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 29 Sep 2026 11:45:13 -0400 Subject: [PATCH 13/33] Describe the OAuth generation check as narrowing, not closing, the cross-window race --- memory-bank/aiCredentialStore.md | 10 ++++++---- .../ai-credentials/src/store-backend/StoreBackend.ts | 12 +++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/memory-bank/aiCredentialStore.md b/memory-bank/aiCredentialStore.md index d62d6f1..f3c8bd3 100644 --- a/memory-bank/aiCredentialStore.md +++ b/memory-bank/aiCredentialStore.md @@ -88,10 +88,12 @@ The backend consumes storage through the `StoreBackendStorage` interface defined structurally, and non-file backings (e.g. VS Code SecretStorage) can be injected. `withLock`'s lock scope is the backing's contract. AWS preserve mutations merge fields into the current record, so they need exclusion against every writer of the same keys. -OAuth does not: each record carries a `generation`, and a commit lands only while the -stored record still holds the one the operation read. So a backing with in-process -exclusion alone (VS Code SecretStorage) stays safe for OAuth and for whole-record -API-key replace/clear, which is last-writer-wins by design. +OAuth records carry a `generation`, and a commit lands only while the stored record +still holds the one the operation read. Under a cross-process lock (`SingleFileStore`) +that makes concurrent OAuth commits safe. Under in-process exclusion alone (VS Code +SecretStorage) it only narrows the race: the generation check and the write are +separate steps, so two windows can both pass the check before either writes. Whole-record +API-key replace/clear is last-writer-wins by design under either backing. ```ts import { createDefaultStore, getDefaultStorePath } from "ai-credentials/store"; diff --git a/packages/ai-credentials/src/store-backend/StoreBackend.ts b/packages/ai-credentials/src/store-backend/StoreBackend.ts index 6ad1e55..5892c88 100644 --- a/packages/ai-credentials/src/store-backend/StoreBackend.ts +++ b/packages/ai-credentials/src/store-backend/StoreBackend.ts @@ -35,12 +35,14 @@ import { * How much exclusion `withLock` gives is up to the backing: `SingleFileStore` * locks across processes, VS Code `SecretStorage` only within one window. * - * OAuth tolerates the weaker case. Every record carries a `generation`, and a - * refresh commits only while the stored record still holds the one it read, so - * the slower of two concurrent refreshes writes nothing. + * Every OAuth record carries a `generation`, and a refresh commits only while the + * stored record still holds the one it read. Under a cross-process lock that makes + * concurrent refreshes safe. Under a per-window lock it only narrows the race: the + * generation check and the write are separate steps, so two windows can both pass + * the check before either writes. * - * AWS `preserve` mutations have no such marker and do need a backing that - * excludes every writer of the same keys. + * AWS `preserve` mutations have no such marker and need a backing that excludes + * every writer of the same keys. */ export interface StoreBackendStorage { get(key: string): Promise; From 8b749d406ebb612872dbf3f2c8023c8cd6e716b6 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 29 Sep 2026 11:49:08 -0400 Subject: [PATCH 14/33] Capture DATABRICKS_CONFIG_FILE so the Workbench check survives environment capture --- .../__tests__/EnvCredentialResolver.test.ts | 10 ++++++++++ .../src/store-backend/envCredentialResolver.ts | 8 ++++++-- .../src/store-backend/providerEnvMappings.ts | 2 ++ .../src/store-backend/providerEnvRegistry.ts | 4 ++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts b/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts index 1701284..8d89e45 100644 --- a/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts +++ b/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { hasEnvCredentials, resolveCredentialsFromEnv } from "../envCredentialResolver.js"; +import { captureProviderEnvironment } from "../providerEnvMappings.js"; describe("resolveCredentialsFromEnv", () => { it.each([ @@ -139,6 +140,15 @@ describe("resolveCredentialsFromEnv", () => { expect(hasEnvCredentials("databricks", env)).toBe(false); }); + it("ignores DATABRICKS_TOKEN from a captured environment when Workbench manages the profile", () => { + const captured = captureProviderEnvironment(["databricks"], { + DATABRICKS_TOKEN: "shell-pat", + DATABRICKS_CONFIG_FILE: "/home/user/.posit-workbench/databricks/cfg", + }); + expect(resolveCredentialsFromEnv("databricks", captured.environment)).toBeNull(); + expect(captured.scrubbedNames).not.toContain("DATABRICKS_CONFIG_FILE"); + }); + it("reads DATABRICKS_TOKEN when the config file is not Workbench-managed", () => { const env = { DATABRICKS_TOKEN: "shell-pat", diff --git a/packages/ai-credentials/src/store-backend/envCredentialResolver.ts b/packages/ai-credentials/src/store-backend/envCredentialResolver.ts index 4132b5d..57a2e64 100644 --- a/packages/ai-credentials/src/store-backend/envCredentialResolver.ts +++ b/packages/ai-credentials/src/store-backend/envCredentialResolver.ts @@ -18,7 +18,11 @@ */ import type { ProviderCredentials } from "../types/credentials.js"; -import { PROVIDER_ENV_MAPPINGS, type ProviderEnvMapping } from "./providerEnvMappings.js"; +import { + PROVIDER_ENV_MAPPINGS, + type ProviderEnvMapping, + readSdkCredentialEnvironment, +} from "./providerEnvMappings.js"; /** * Attempt to resolve credentials for a provider from environment variables. @@ -38,7 +42,7 @@ export function resolveCredentialsFromEnv( // A Workbench-provisioned Databricks profile outranks DATABRICKS_TOKEN; the admin credential must not be overridable from the shell. if ( providerId === "databricks" && - (envVars.DATABRICKS_CONFIG_FILE ?? "").includes("posit-workbench") + (readSdkCredentialEnvironment(envVars).databricksConfigFile ?? "").includes("posit-workbench") ) { return null; } diff --git a/packages/ai-credentials/src/store-backend/providerEnvMappings.ts b/packages/ai-credentials/src/store-backend/providerEnvMappings.ts index cae9796..933b5fc 100644 --- a/packages/ai-credentials/src/store-backend/providerEnvMappings.ts +++ b/packages/ai-credentials/src/store-backend/providerEnvMappings.ts @@ -58,6 +58,7 @@ export interface SdkCredentialEnvironment { readonly azureClientSecret?: string; readonly azureClientCertificatePath?: string; readonly azureClientCertificatePassword?: string; + readonly databricksConfigFile?: string; } export interface ProviderEnvMapping { @@ -166,6 +167,7 @@ export function readSdkCredentialEnvironment( azureClientSecret: readField(env, descriptors.azureClientSecret), azureClientCertificatePath: readField(env, descriptors.azureClientCertificatePath), azureClientCertificatePassword: readField(env, descriptors.azureClientCertificatePassword), + databricksConfigFile: readField(env, descriptors.databricksConfigFile), }; } diff --git a/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts b/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts index df6bb98..c5e074f 100644 --- a/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts +++ b/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts @@ -93,6 +93,10 @@ export const PROVIDER_ENV_MAPPINGS: Record = { clientId: scrubbed("DATABRICKS_CLIENT_ID"), clientSecret: scrubbed("DATABRICKS_CLIENT_SECRET"), }, + // Captured so a Workbench-managed profile still outranks DATABRICKS_TOKEN after capture. + sdkCredentialEnvironment: { + databricksConfigFile: ambient("DATABRICKS_CONFIG_FILE"), + }, }, litellm: { apiKey: scrubbed("LITELLM_API_KEY"), From c834de7603d368a4f33d179ba3aefd243c03e94a Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 29 Sep 2026 11:51:22 -0400 Subject: [PATCH 15/33] Authenticate Vertex chat with the inline service account, not only discovery --- .../src/model-clients/GoogleVertexClient.ts | 10 ++++- .../google-vertex-captured-auth.test.ts | 44 +++++++++++++++++++ .../src/providers/google-vertex-provider.ts | 39 +++++++++------- 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/packages/ai-provider-bridge/src/model-clients/GoogleVertexClient.ts b/packages/ai-provider-bridge/src/model-clients/GoogleVertexClient.ts index 281da0a..7bdbdc1 100644 --- a/packages/ai-provider-bridge/src/model-clients/GoogleVertexClient.ts +++ b/packages/ai-provider-bridge/src/model-clients/GoogleVertexClient.ts @@ -14,7 +14,7 @@ import { createVertex } from "@ai-sdk/google-vertex"; import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"; import type { LanguageModelV3 } from "@ai-sdk/provider"; import { streamText } from "ai"; -import { OAuth2Client, type GoogleAuthOptions } from "google-auth-library"; +import { OAuth2Client, type GoogleAuthOptions, type JWTInput } from "google-auth-library"; import { sanitizeToolCallIdsForAnthropic } from "../tool-call-ids"; import type { LMStreamPart, Logger, Protocol } from "../types"; @@ -55,6 +55,8 @@ export interface GoogleVertexClientConfig { * When set, the Vertex SDK uses this token directly instead of resolving ADC. */ accessToken?: string; + /** Inline service account (`GOOGLE_CLIENT_EMAIL` + `GOOGLE_PRIVATE_KEY`), used when no token is brokered. */ + serviceAccount?: JWTInput; /** Captured ADC file path supplied by a host that scrubbed process.env. */ googleApplicationCredentials?: string; } @@ -74,6 +76,12 @@ export class GoogleVertexClient implements ModelClient { authClient.setCredentials({ access_token: this.config.accessToken }); return { authClient }; } + if (this.config.serviceAccount) { + return { + credentials: this.config.serviceAccount, + scopes: ["https://www.googleapis.com/auth/cloud-platform"], + }; + } return this.config.googleApplicationCredentials ? { keyFilename: this.config.googleApplicationCredentials } : undefined; diff --git a/packages/ai-provider-bridge/src/model-clients/__tests__/google-vertex-captured-auth.test.ts b/packages/ai-provider-bridge/src/model-clients/__tests__/google-vertex-captured-auth.test.ts index 1da092c..6b2a0c5 100644 --- a/packages/ai-provider-bridge/src/model-clients/__tests__/google-vertex-captured-auth.test.ts +++ b/packages/ai-provider-bridge/src/model-clients/__tests__/google-vertex-captured-auth.test.ts @@ -90,4 +90,48 @@ describe("GoogleVertexClient captured authentication", () => { }), ); }); + + it("authenticates Gemini and Anthropic requests with a captured inline service account", async () => { + const captured = Object.freeze({ + GOOGLE_CLIENT_EMAIL: "sa@project-id.iam.gserviceaccount.com", + GOOGLE_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----", + }); + const registry = new ProviderRegistry(logger); + registerGoogleVertexProvider(registry, logger, undefined, captured); + const client = registry.getClientForProvider("google-vertex", { + type: "google-cloud", + project: "project-id", + location: "us-central1", + }); + if (!client) throw new Error("google-vertex client factory was not registered"); + const cancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose() {} }), + }; + const messages = [ + { role: "user" as const, content: [{ type: "text" as const, text: "hello" }] }, + ]; + + await client.chat({ model: "gemini-2.5-pro", messages, cancellationToken }); + await client.chat({ + model: "claude-sonnet-4-5", + protocol: "anthropic-messages", + messages, + cancellationToken, + }); + + const expectedAuth = { + googleAuthOptions: { + credentials: { + client_email: "sa@project-id.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + }, + scopes: ["https://www.googleapis.com/auth/cloud-platform"], + }, + }; + expect(mocks.createVertex).toHaveBeenLastCalledWith(expect.objectContaining(expectedAuth)); + expect(mocks.createVertexAnthropic).toHaveBeenLastCalledWith( + expect.objectContaining(expectedAuth), + ); + }); }); diff --git a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts index 3d902fc..c40d310 100644 --- a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts +++ b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts @@ -5,7 +5,7 @@ import type { ResolvedProviderId } from "ai-config"; import { getAnthropicModelCapabilities } from "ai-config"; import { readSdkCredentialEnvironment } from "ai-credentials/store-backend"; -import { GoogleAuth } from "google-auth-library"; +import { GoogleAuth, type JWTInput } from "google-auth-library"; import { GoogleVertexClient } from "../model-clients/GoogleVertexClient"; import type { Logger, ModelInfo, ProviderCredentials } from "../types"; @@ -71,6 +71,24 @@ const MODEL_CACHE_TTL = 60 * 60 * 1000; const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; +/** The inline service account from `GOOGLE_CLIENT_EMAIL` and `GOOGLE_PRIVATE_KEY`, or undefined when either is unset. */ +function inlineServiceAccount( + env: Readonly>, +): JWTInput | undefined { + const sdkEnvironment = readSdkCredentialEnvironment(env); + const clientEmail = sdkEnvironment.googleClientEmail; + const privateKey = sdkEnvironment.googlePrivateKey; + if (!clientEmail || !privateKey) return undefined; + return { + client_email: clientEmail, + // google-auth-library needs literal newlines; pasted keys carry escaped `\n`. + private_key: privateKey.replace(/\\n/g, "\n"), + ...(sdkEnvironment.googlePrivateKeyId && { + private_key_id: sdkEnvironment.googlePrivateKeyId, + }), + }; +} + async function tokenFrom(auth: GoogleAuth): Promise { const client = await auth.getClient(); const { token } = await client.getAccessToken(); @@ -87,21 +105,9 @@ async function tokenFrom(auth: GoogleAuth): Promise { export async function resolveGoogleVertexAccessToken( credentialEnvironment?: Readonly>, ): Promise { - const sdkEnvironment = readSdkCredentialEnvironment(credentialEnvironment ?? process.env); - const clientEmail = sdkEnvironment.googleClientEmail; - const privateKey = sdkEnvironment.googlePrivateKey; - if (clientEmail && privateKey) { - const auth = new GoogleAuth({ - credentials: { - client_email: clientEmail, - // google-auth-library needs literal newlines; pasted keys carry escaped `\n`. - private_key: privateKey.replace(/\\n/g, "\n"), - ...(sdkEnvironment.googlePrivateKeyId && { - private_key_id: sdkEnvironment.googlePrivateKeyId, - }), - }, - scopes: [CLOUD_PLATFORM_SCOPE], - }); + const serviceAccount = inlineServiceAccount(credentialEnvironment ?? process.env); + if (serviceAccount) { + const auth = new GoogleAuth({ credentials: serviceAccount, scopes: [CLOUD_PLATFORM_SCOPE] }); let token: string | undefined; try { token = await tokenFrom(auth); @@ -482,6 +488,7 @@ function createGoogleVertexClientFactory( project: credentials.project, location: credentials.location, accessToken: credentials.accessToken, + serviceAccount: inlineServiceAccount(credentialEnvironment ?? process.env), googleApplicationCredentials: credentialEnvironment ? readSdkCredentialEnvironment(credentialEnvironment).googleApplicationCredentials : undefined, From 4807847120fc19cf48c0d3389a446ab4ae18772f Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 29 Sep 2026 11:52:54 -0400 Subject: [PATCH 16/33] Keep transient Vertex token failures on the network-error path --- .../__tests__/google-vertex-provider.test.ts | 48 +++++++++++++++++++ .../src/providers/google-vertex-provider.ts | 21 ++++++++ 2 files changed, 69 insertions(+) diff --git a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts index 2b9c3d0..9089406 100644 --- a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts +++ b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts @@ -123,6 +123,33 @@ describe("registerGoogleVertexProvider", () => { ); }); + it("reports a dropped inline token exchange as a network error, not rejected credentials", async () => { + authMocks.getAccessToken.mockRejectedValueOnce( + Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }), + ); + const onProviderStatusChange = vi.fn().mockResolvedValue(undefined); + const registry = new ProviderRegistry(mockLogger); + registerGoogleVertexProvider( + registry, + mockLogger, + { onProviderStatusChange }, + { + GOOGLE_CLIENT_EMAIL: "svc@example.iam.gserviceaccount.com", + GOOGLE_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----", + }, + ); + + await registry.getModelsForProvider("google-vertex", { + type: "google-cloud", + project: "my-project", + location: "us-central1", + }); + + expect(onProviderStatusChange).toHaveBeenCalledWith( + expect.objectContaining({ providerId: "google-vertex", status: "network_error" }), + ); + }); + it("uses a captured ADC path after the ambient environment is scrubbed", async () => { const parentEnvironment: Record = { GOOGLE_APPLICATION_CREDENTIALS: "/secrets/service-account.json", @@ -274,6 +301,27 @@ describe("resolveGoogleVertexAccessToken", () => { expect(authMocks.googleAuth).toHaveBeenCalledTimes(1); }); + it.each([ + ["a connection reset", Object.assign(new Error("socket hang up"), { code: "ECONNRESET" })], + [ + "a token-service outage", + Object.assign(new Error("unavailable"), { response: { status: 503 } }), + ], + ["throttling", Object.assign(new Error("rate limited"), { response: { status: 429 } })], + ])("passes %s through unchanged instead of blaming the credentials", async (_label, error) => { + authMocks.getAccessToken.mockRejectedValueOnce(error); + await expect(resolveGoogleVertexAccessToken(inlineEnv)).rejects.toBe(error); + }); + + it("treats a token-endpoint rejection as rejected credentials", async () => { + authMocks.getAccessToken.mockRejectedValueOnce( + Object.assign(new Error("invalid_grant"), { response: { status: 400 } }), + ); + await expect(resolveGoogleVertexAccessToken(inlineEnv)).rejects.toMatchObject({ + name: "InlineServiceAccountError", + }); + }); + it("falls back to ADC when the inline vars are absent", async () => { authMocks.getAccessToken.mockResolvedValueOnce({ token: "adc-token" }); await expect(resolveGoogleVertexAccessToken({})).resolves.toBe("adc-token"); diff --git a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts index c40d310..d97efab 100644 --- a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts +++ b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts @@ -71,6 +71,26 @@ const MODEL_CACHE_TTL = 60 * 60 * 1000; const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; +const TRANSIENT_NETWORK_CODES: ReadonlySet = new Set([ + "ECONNRESET", + "ETIMEDOUT", + "ECONNREFUSED", + "ECONNABORTED", + "ENOTFOUND", + "EAI_AGAIN", + "ENETUNREACH", + "EHOSTUNREACH", +]); + +/** A token-exchange failure that says nothing about the credentials: throttling, a server error, or no response. */ +function isTransientTokenError(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const status = (error as { response?: { status?: unknown } }).response?.status; + if (typeof status === "number") return status === 429 || status >= 500; + const code = (error as { code?: unknown }).code; + return typeof code === "string" && TRANSIENT_NETWORK_CODES.has(code); +} + /** The inline service account from `GOOGLE_CLIENT_EMAIL` and `GOOGLE_PRIVATE_KEY`, or undefined when either is unset. */ function inlineServiceAccount( env: Readonly>, @@ -112,6 +132,7 @@ export async function resolveGoogleVertexAccessToken( try { token = await tokenFrom(auth); } catch (err) { + if (isTransientTokenError(err)) throw err; const message = err instanceof Error ? err.message : String(err); throw new InlineServiceAccountError(message); } From 5444c005b44d2c4063eb0a47c196c82e3d1ad7dd Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 29 Sep 2026 12:01:36 -0400 Subject: [PATCH 17/33] Strip URL fragments in normalizeFoundryBaseUrl --- packages/ai-config/src/__tests__/base-url.test.ts | 5 +++++ packages/ai-config/src/base-url.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/ai-config/src/__tests__/base-url.test.ts b/packages/ai-config/src/__tests__/base-url.test.ts index fc1e2d8..c6642cb 100644 --- a/packages/ai-config/src/__tests__/base-url.test.ts +++ b/packages/ai-config/src/__tests__/base-url.test.ts @@ -62,6 +62,11 @@ describe("normalizeFoundryBaseUrl", () => { ["https://r.openai.azure.com/", "https://r.openai.azure.com/openai/v1"], ["https://r.openai.azure.com", "https://r.openai.azure.com/openai/v1"], ["https://r.openai.azure.com?api-version=1", "https://r.openai.azure.com/openai/v1"], + ["https://r.openai.azure.com/openai/v1#section", "https://r.openai.azure.com/openai/v1"], + [ + "https://r.openai.azure.com/openai/v1/responses#frag?not-a-query", + "https://r.openai.azure.com/openai/v1", + ], ["", ""], [" ", ""], ])("normalizes %s", (input, expected) => { diff --git a/packages/ai-config/src/base-url.ts b/packages/ai-config/src/base-url.ts index b7570c2..0bb6b30 100644 --- a/packages/ai-config/src/base-url.ts +++ b/packages/ai-config/src/base-url.ts @@ -157,8 +157,8 @@ const FOUNDRY_V1_PATH = "/openai/v1"; export function normalizeFoundryBaseUrl(rawUrl: string): string { let url = rawUrl.trim(); if (!url) return ""; - const queryIndex = url.indexOf("?"); - if (queryIndex !== -1) url = url.substring(0, queryIndex); + const suffixIndex = url.search(/[?#]/); + if (suffixIndex !== -1) url = url.substring(0, suffixIndex); url = url.replace(/\/+$/, ""); if (!url) return ""; const deploymentIndex = url.indexOf("/openai/deployments/"); From e19b07f0d84653c869218c4b7e2697824fe86b67 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 29 Sep 2026 12:05:40 -0400 Subject: [PATCH 18/33] Type customProviders clientKind as a supported custom kind --- packages/ai-config/src/index.ts | 1 + packages/ai-config/src/vocabulary.ts | 4 ++++ .../src/register-all-providers.ts | 16 +++++++++------- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/ai-config/src/index.ts b/packages/ai-config/src/index.ts index 127167d..1210dbe 100644 --- a/packages/ai-config/src/index.ts +++ b/packages/ai-config/src/index.ts @@ -22,6 +22,7 @@ export { CLIENT_KIND_VALUES, CUSTOM_KIND_API_KEY_OPTIONAL_DEFAULT, isBuiltinProviderId, + isSupportedCustomClientKind, PROTOCOL_VALUES, RESERVED_PROVIDER_KEYS, SUPPORTED_CUSTOM_CLIENT_KIND_VALUES, diff --git a/packages/ai-config/src/vocabulary.ts b/packages/ai-config/src/vocabulary.ts index a7aefe8..7d074a9 100644 --- a/packages/ai-config/src/vocabulary.ts +++ b/packages/ai-config/src/vocabulary.ts @@ -136,6 +136,10 @@ export const SUPPORTED_CUSTOM_CLIENT_KIND_VALUES = [ export type SupportedCustomClientKind = (typeof SUPPORTED_CUSTOM_CLIENT_KIND_VALUES)[number]; +export function isSupportedCustomClientKind(value: string): value is SupportedCustomClientKind { + return (SUPPORTED_CUSTOM_CLIENT_KIND_VALUES as readonly string[]).includes(value); +} + /** * Kind-level `apiKeyOptional` defaults for supported custom client kinds — * whether a provider of that kind can be used without an API key when the diff --git a/packages/ai-provider-bridge/src/register-all-providers.ts b/packages/ai-provider-bridge/src/register-all-providers.ts index cf8832f..d330e4d 100644 --- a/packages/ai-provider-bridge/src/register-all-providers.ts +++ b/packages/ai-provider-bridge/src/register-all-providers.ts @@ -10,6 +10,7 @@ * lifecycle and passes it in. */ +import { isSupportedCustomClientKind } from "ai-config"; import type { ResolvedProviderId, SupportedCustomClientKind } from "ai-config"; import { @@ -92,7 +93,10 @@ export interface ProviderRegistrationConfig { /** Host-captured environment for SDK credential constructors after ambient scrubbing. */ credentialEnvironment?: Readonly>; /** `providers.custom` entries to register after the built-ins; independent of `allowedProviders`. */ - customProviders?: ReadonlyArray<{ readonly id: ResolvedProviderId; readonly clientKind: string }>; + customProviders?: ReadonlyArray<{ + readonly id: ResolvedProviderId; + readonly clientKind: SupportedCustomClientKind; + }>; } /** @@ -202,13 +206,11 @@ export function registerAllProviders( } for (const { id, clientKind } of config.customProviders ?? []) { - const registrar = ( - CUSTOM_PROVIDER_REGISTRARS as Partial> - )[clientKind]; - if (!registrar) { - throw new Error(`Unsupported custom provider kind: ${clientKind}`); + // Untyped callers (e.g. kinds read over IPC) can still pass an unsupported kind. + if (!isSupportedCustomClientKind(clientKind)) { + throw new Error(`Unsupported custom provider kind: ${String(clientKind)}`); } - registrar(registry, id, logger, config); + CUSTOM_PROVIDER_REGISTRARS[clientKind](registry, id, logger, config); logger.debug( `[registerAllProviders] Registered ${clientKind} support for custom provider "${id}"`, ); From f92bcd7105cd1745f4d554b19586f64338c821bb Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 29 Sep 2026 12:08:34 -0400 Subject: [PATCH 19/33] Document host defaults and inline Vertex service accounts in the memory bank --- memory-bank/aiConfig.md | 5 ++++- memory-bank/providerGuide.md | 12 +++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/memory-bank/aiConfig.md b/memory-bank/aiConfig.md index a391c69..b03ad9e 100644 --- a/memory-bank/aiConfig.md +++ b/memory-bank/aiConfig.md @@ -162,7 +162,10 @@ Config flows through three stages: **assemble sources → resolve → watch**. P `POSIT_AI_PROVIDERS_DEFAULT` (both remain strict JSON and are validated against the relaxed `providersConfigFragmentSchema`), plus the legacy Positron layers the loader opted into (`legacyPositronSettings` → `legacy-positron`, - `legacyPositronEnforcedSettings` → `legacy-positron-enforced`). Each + `legacyPositronEnforcedSettings` → `legacy-positron-enforced`), and the + host's `hostDefaults` fragment when the loader passes one (also `default`, + read after `POSIT_AI_PROVIDERS_DEFAULT`, for host-specific values such as + the OAuth client id the host is registered under). Each reader returns `{ source?, issues }`; present sources are tagged with their `kind` (`enforced` / `legacy-positron-enforced` / `user` / `legacy-positron` / `default`). diff --git a/memory-bank/providerGuide.md b/memory-bank/providerGuide.md index 2996c1a..70adffc 100644 --- a/memory-bank/providerGuide.md +++ b/memory-bank/providerGuide.md @@ -264,7 +264,13 @@ Providers whose auth comes from a cloud CLI / ambient identity (no stored secret) carry a credential type with **no secret material** and let the cloud SDK own the token lifecycle: -- `GoogleCloudCredentials` (`google-cloud`) — google-auth-library resolves ADC. +- `GoogleCloudCredentials` (`google-cloud`) — a brokered access token when the + host supplies one; otherwise an inline service account from + `GOOGLE_CLIENT_EMAIL` + `GOOGLE_PRIVATE_KEY` (optional `GOOGLE_PRIVATE_KEY_ID`) + when both are set; otherwise google-auth-library resolves ADC. Discovery and + chat use the same order. A rejected inline service account is an auth error + and is not retried against ADC; a transient token-service failure takes the + network-error path. - `AzureEntraCredentials` (`azure-entra`) — `baseUrl` + required `scope` + optional `tenantId`/`customHeaders`. `src/model-clients/azure-entra-token.ts` caches `getBearerTokenProvider(new DefaultAzureCredential(...), scope)` per @@ -280,8 +286,8 @@ through `ProviderRegistrationConfig`. The bridge never reads SDK wire names directly: both providers call `readSdkCredentialEnvironment` from `ai-credentials/store-backend`, which maps the ai-credentials `sdkCredentialEnvironment` declaration onto a typed struct. Vertex supplies -the captured `googleApplicationCredentials` path to both model discovery's -`GoogleAuth` and the chat SDK's `googleAuthOptions`. Foundry materializes a +the captured inline service account or `googleApplicationCredentials` path to +both model discovery's `GoogleAuth` and the chat SDK's `googleAuthOptions`. Foundry materializes a `ClientSecretCredential` or `ClientCertificateCredential` from the captured Azure values; when neither is complete it retains `DefaultAzureCredential` for managed identity and CLI sources. Because the names come from the From 93cd11b3e9284b2b68bf833fa87e0d0e749dce9a Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 29 Sep 2026 12:39:17 -0400 Subject: [PATCH 20/33] Drop credential-shaping re-exports nothing consumes --- packages/ai-provider-bridge/src/credential-shaping.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/ai-provider-bridge/src/credential-shaping.ts b/packages/ai-provider-bridge/src/credential-shaping.ts index 44e3743..b3a9733 100644 --- a/packages/ai-provider-bridge/src/credential-shaping.ts +++ b/packages/ai-provider-bridge/src/credential-shaping.ts @@ -12,15 +12,12 @@ export { CONFIG_KEY_OVERRIDES, - CUSTOM_CLIENT_KIND_AUTH_MAP, customProviderAuthMapping, - serializeSessionToken, shapeCredentials, } from "ai-credentials/types"; export type { AuthProviderMapping, CredentialConfig, CredentialConfigTarget, - SessionTokenEnvelope, StructuredBaseUrlSource, } from "ai-credentials/types"; From caa84f0b01a929c65a46127d7109df70afa722a9 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 29 Sep 2026 12:39:18 -0400 Subject: [PATCH 21/33] Choose the Vertex auth-error guidance from the credential source in use --- .../__tests__/google-vertex-provider.test.ts | 27 +++++++++++++++++++ .../src/providers/google-vertex-provider.ts | 5 ++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts index 9089406..9ceade6 100644 --- a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts +++ b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts @@ -90,6 +90,33 @@ describe("registerGoogleVertexProvider", () => { ); }); + it("points inline service-account users at their variables when Vertex rejects the minted token", async () => { + const onProviderStatusChange = vi.fn().mockResolvedValue(undefined); + const registry = new ProviderRegistry(mockLogger); + registerGoogleVertexProvider( + registry, + mockLogger, + { onProviderStatusChange }, + { + GOOGLE_CLIENT_EMAIL: "svc@example.iam.gserviceaccount.com", + GOOGLE_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----", + }, + ); + + await registry.getModelsForProvider("google-vertex", { + type: "google-cloud", + project: "my-project", + location: "us-central1", + }); + + expect(onProviderStatusChange).toHaveBeenCalledWith( + expect.objectContaining({ + status: "auth_error", + error: expect.objectContaining({ code: "inline_service_account_rejected" }), + }), + ); + }); + it("reports rejected inline service-account credentials as an auth error, not a network error", async () => { authMocks.getAccessToken.mockRejectedValueOnce(new Error("invalid_rapt")); const onProviderStatusChange = vi.fn().mockResolvedValue(undefined); diff --git a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts index d97efab..f848535 100644 --- a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts +++ b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts @@ -155,7 +155,7 @@ export async function resolveGoogleVertexAccessToken( /** * Resolve an access token for the Vertex AI REST API. * Uses a broker-provided token (e.g. from Positron auth ext) when available; - * otherwise falls back to Application Default Credentials. + * otherwise an inline service account, then Application Default Credentials. */ async function getAccessToken( brokered?: string, @@ -428,7 +428,8 @@ function createGoogleVertexModelFetcher( if (isAuthError(error)) { const isBrokeredAuth = Boolean(credentials.accessToken); const isInlineAuth = - error instanceof Error && error.name === INLINE_SERVICE_ACCOUNT_ERROR; + !isBrokeredAuth && + inlineServiceAccount(credentialEnvironment ?? process.env) !== undefined; const authMessage = isInlineAuth ? "Google Cloud rejected the service-account credentials in GOOGLE_CLIENT_EMAIL and GOOGLE_PRIVATE_KEY. Fix them, or unset them to use Application Default Credentials, then click Reload model list." : isBrokeredAuth From 9fd3772d9f8e1df7a259df02c0b6b33e3ab5c524 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 29 Sep 2026 12:40:24 -0400 Subject: [PATCH 22/33] Bring the memory bank and env-mapping docs in line with the new credential behavior --- memory-bank/architecture.md | 7 ++++-- memory-bank/credentialResolver.md | 23 +++++++++++++++---- .../src/store-backend/providerEnvMappings.ts | 11 +++++---- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index f9606ea..7154074 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -251,7 +251,10 @@ Credential resolution is split in two halves: session lookup (vscode-bound, `src Bedrock's manual-key and `fromNodeProviderChain` branches converge in `createAwsCredentialProvider()`. Converse, Anthropic Messages, Mantle inference, and both discovery clients consume the same provider-function -shape, keeping credential precedence identical across protocols. +shape, keeping credential precedence identical across protocols. When +`AWS_WEB_IDENTITY_TOKEN_FILE` is set, the chain's STS token exchange uses the +configured Bedrock region; otherwise the region is left unset so an SSO profile's +own `sso_region` applies, matching Positron's authentication extension. `AnthropicClient`'s auth parameter is a discriminated union, `AnthropicClientAuth = { apiKey: string } | { authToken: string }` — the two @@ -443,7 +446,7 @@ Positron's VS Code base includes it. ## Provider Registration -`register-all-providers.ts` registers every provider into a caller-owned `ProviderRegistry`, honoring `config.allowedProviders`. Its private registrar map is compile-time exhaustive over `PROVIDER_IDS`, while the public `ProviderRegistrationConfig` remains colocated with the orchestrator. Consumers that want to restrict the available provider set pass `allowedProviders`; there is no build-time provider filtering. +`register-all-providers.ts` registers every provider into a caller-owned `ProviderRegistry`, honoring `config.allowedProviders`. Its private registrar map is compile-time exhaustive over `PROVIDER_IDS`, while the public `ProviderRegistrationConfig` remains colocated with the orchestrator. Consumers that want to restrict the available provider set pass `allowedProviders`; there is no build-time provider filtering. `config.customProviders` lists `providers.custom` entries as `{ id, clientKind }`; each registers after the built-ins through a registrar table that is exhaustive over `SupportedCustomClientKind`, independent of `allowedProviders`, with its client factory keyed by kind (look it up with `ProviderRegistry.getClientForProviderOrKind`). ## Dependencies diff --git a/memory-bank/credentialResolver.md b/memory-bank/credentialResolver.md index ec6fdcd..940fa0b 100644 --- a/memory-bank/credentialResolver.md +++ b/memory-bank/credentialResolver.md @@ -99,7 +99,9 @@ credentials, and refresh grants. A per-provider mutex and jittered proactive-refresh window prevent duplicate renewal in one process. The store backend adds a provider-scoped transaction around stored refresh: check, lock, re-read, adopt another process's result when possible, otherwise refresh and -persist the rotated token. Environment M2M tokens never enter that transaction +persist the rotated token. The transaction notes the record's generation, and the +refreshed tokens or a refresh error persist only while the record still holds it, +so a refresh that another writer overtook writes nothing. Environment M2M tokens never enter that transaction because their derived tokens live only in process memory. ### Refresh failure policy — terminal vs. transient @@ -145,6 +147,8 @@ M2M `clientCredentialsAuth`. Explicit stored credentials win over environment credentials. With environment-only configuration, `DATABRICKS_TOKEN` wins unless `DATABRICKS_AUTH_TYPE=oauth-m2m`; environment M2M requires `DATABRICKS_HOST`, `DATABRICKS_CLIENT_ID`, and `DATABRICKS_CLIENT_SECRET`. +When `DATABRICKS_CONFIG_FILE` points at a `posit-workbench` path, the admin-managed +profile outranks `DATABRICKS_TOKEN` and the environment resolves nothing. Status exposes only source, origin, readiness, expiry, and sanitized workspace metadata. @@ -152,8 +156,10 @@ The Databricks entry in `PROVIDER_ENV_MAPPINGS` declares both PAT and M2M names. `StoreBackend` reads M2M fields through that mapping, and `captureProviderEnvironment` enumerates the same fields, so an authenticated host cannot omit `DATABRICKS_CLIENT_SECRET` from its capture/scrub inventory. -The same single-source guarantee covers the Vertex ADC path -(`GOOGLE_APPLICATION_CREDENTIALS`) and the Azure SDK names (`AZURE_*`): both +The same single-source guarantee covers the Vertex credentials (the ADC path +`GOOGLE_APPLICATION_CREDENTIALS` and the inline service account +`GOOGLE_CLIENT_EMAIL`/`GOOGLE_PRIVATE_KEY`/`GOOGLE_PRIVATE_KEY_ID`), the Azure SDK +names (`AZURE_*`), and the Databricks `DATABRICKS_CONFIG_FILE` marker: all are declared as `sdkCredentialEnvironment` descriptors on their provider entries and consumed by the bridge exclusively through `readSdkCredentialEnvironment`. @@ -205,6 +211,11 @@ resolve without any host-application import: `SUPPORTED_CUSTOM_CLIENT_KIND_VALUES ⊆ CLIENT_KIND_VALUES`. Custom `anthropic`, `openai`, and `gemini` map to required `apikey` auth; product-bound `positai`, `copilot`, and `databricks` remain excluded. +- **Custom-provider auth mapping and session tokens (`customProviderAuthMapping`, + `serializeSessionToken`)** — `customProviderAuthMapping` returns a custom + entry's mapping: the host's aggregate auth provider, with the entry name as the + scope. `serializeSessionToken` writes the Google Cloud and AWS session tokens in + the shape `shapeCredentials` reads back. ## On-disk format — `StoredProviderCredentials` @@ -257,8 +268,10 @@ Every mapped field is an `EnvironmentFieldDescriptor` (`{ name, scrub }`), so one declaration drives env resolution, host capture/scrubbing, and SDK credential construction. `scrub: true` means captured AND deleted from the ambient environment; `scrub: false` means captured only (the non-secret Azure -tenant/client IDs, which user code may legitimately read). Fields a provider -SDK reads directly are declared under `sdkCredentialEnvironment`, keyed by the +tenant/client IDs, which user code may legitimately read). Fields read outside +the API-key and OAuth mappings (by a provider SDK directly, or by a +credential-source check such as the Workbench Databricks marker) are declared +under `sdkCredentialEnvironment`, keyed by the semantic fields of `SdkCredentialEnvironment` — a misspelled key is a compile error, and a behavioral test proves every declared key is represented in the reader's result. diff --git a/packages/ai-credentials/src/store-backend/providerEnvMappings.ts b/packages/ai-credentials/src/store-backend/providerEnvMappings.ts index 933b5fc..f636a21 100644 --- a/packages/ai-credentials/src/store-backend/providerEnvMappings.ts +++ b/packages/ai-credentials/src/store-backend/providerEnvMappings.ts @@ -43,8 +43,8 @@ export interface EnvironmentFieldDescriptor { } /** - * Typed view of the environment values provider SDKs read directly (Azure - * Identity, Google ADC). Assembled by `readSdkCredentialEnvironment` from + * Typed view of the environment values read outside the API-key and OAuth + * mappings (see `sdkCredentialEnvironment`). Assembled by `readSdkCredentialEnvironment` from * the `sdkCredentialEnvironment` descriptors; the descriptor keys are typed * against this struct so a misspelled semantic key is a compile error. */ @@ -79,9 +79,10 @@ export interface ProviderEnvMapping { sessionToken?: EnvironmentFieldDescriptor; }; /** - * Environment variables the provider's SDK reads directly (bypassing the - * credential resolver), keyed by the semantic field of - * `SdkCredentialEnvironment` they populate. + * Environment variables read outside the provider's API-key and OAuth + * mappings: by the provider's SDK directly (Azure Identity, Google ADC) or + * by a credential-source check such as the Workbench-managed Databricks + * profile. Keyed by the semantic field of `SdkCredentialEnvironment` they populate. */ sdkCredentialEnvironment?: Partial< Record From 9530a15e914123c724009e42f44023040888f26b Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 29 Sep 2026 12:43:17 -0400 Subject: [PATCH 23/33] Build the hostDefaults source in one place for both loaders --- packages/ai-config/src/config-source.ts | 16 ++++++++++++++++ packages/ai-config/src/node/load-catalog.ts | 15 ++++++--------- packages/ai-config/src/node/watch-catalog.ts | 12 ++---------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/ai-config/src/config-source.ts b/packages/ai-config/src/config-source.ts index 9b7f386..117f26d 100644 --- a/packages/ai-config/src/config-source.ts +++ b/packages/ai-config/src/config-source.ts @@ -14,6 +14,7 @@ import type { SourcedConfigIssue } from "./config-issue.js"; import type { ProviderConfigSource } from "./resolve-catalog.js"; +import type { ProvidersConfigFragment } from "./types.js"; /** * A resource that can be disposed. @@ -45,3 +46,18 @@ export interface ProviderConfigSourceProvider { /** Subscribe to change signals. Returns a disposable. Optional for static sources. */ watch?(onChange: () => void): Disposable; } + +/** The host's `hostDefaults` fragment as a static `default` source; none when the host passes none. */ +export function createHostDefaultsSourceProviders( + hostDefaults: ProvidersConfigFragment | undefined, +): ProviderConfigSourceProvider[] { + if (!hostDefaults) return []; + return [ + { + read: () => ({ + source: { kind: "default", label: "host defaults", config: hostDefaults }, + issues: [], + }), + }, + ]; +} diff --git a/packages/ai-config/src/node/load-catalog.ts b/packages/ai-config/src/node/load-catalog.ts index f56cc95..0e6e6ae 100644 --- a/packages/ai-config/src/node/load-catalog.ts +++ b/packages/ai-config/src/node/load-catalog.ts @@ -6,6 +6,7 @@ import { formatConfigIssue } from "../config-issue.js"; import type { SourcedConfigIssue } from "../config-issue.js"; +import { createHostDefaultsSourceProviders } from "../config-source.js"; import { createLegacyPositronSourceProviders } from "../legacy-positron-settings/sources.js"; import { resolveProviderCatalogReport } from "../resolve-catalog.js"; import type { ResolvedProvider } from "../types.js"; @@ -29,15 +30,11 @@ export async function loadProviderCatalogReport( env, }); - const legacyProviders = createLegacyPositronSourceProviders(opts, env); - reports.push(...(await Promise.all(legacyProviders.map((provider) => provider.read())))); - - if (opts.hostDefaults) { - reports.push({ - source: { kind: "default", label: "host defaults", config: opts.hostDefaults }, - issues: [], - }); - } + const extraProviders = [ + ...createLegacyPositronSourceProviders(opts, env), + ...createHostDefaultsSourceProviders(opts.hostDefaults), + ]; + reports.push(...(await Promise.all(extraProviders.map((provider) => provider.read())))); const loaded = reports.flatMap((report) => (report.source ? [report.source] : [])); const sources = opts.transformSource ? loaded.map(opts.transformSource) : loaded; diff --git a/packages/ai-config/src/node/watch-catalog.ts b/packages/ai-config/src/node/watch-catalog.ts index 8498278..68ed194 100644 --- a/packages/ai-config/src/node/watch-catalog.ts +++ b/packages/ai-config/src/node/watch-catalog.ts @@ -20,6 +20,7 @@ import * as path from "path"; import { formatConfigIssue } from "../config-issue.js"; import type { SourcedConfigIssue } from "../config-issue.js"; +import { createHostDefaultsSourceProviders } from "../config-source.js"; import type { ProviderConfigSourceProvider, ProviderConfigSourceReadReport, @@ -68,18 +69,9 @@ export function watchResolvedProviderCatalog( createEnvSourceProvider("default", opts.defaultEnvVar ?? DEFAULT_ENV_VAR, env), // PROVIDER-SETTINGS-MIGRATION(legacy-positron) ...createLegacyPositronSourceProviders(opts, env), + ...createHostDefaultsSourceProviders(opts.hostDefaults), ]; - if (opts.hostDefaults) { - const hostDefaults = opts.hostDefaults; - sourceProviders.push({ - read: async () => ({ - source: { kind: "default", label: "host defaults", config: hostDefaults }, - issues: [], - }), - }); - } - let debounceTimer: ReturnType | undefined; let disposed = false; let previousCatalog: readonly ResolvedProvider[] | undefined; From 4cea24262c8d638cf12deaea358f362d18b308a5 Mon Sep 17 00:00:00 2001 From: Melissa Barca Date: Tue, 29 Sep 2026 13:07:37 -0400 Subject: [PATCH 24/33] Treat Vertex token-request timeouts as transient --- .../src/providers/__tests__/google-vertex-provider.test.ts | 2 ++ .../ai-provider-bridge/src/providers/google-vertex-provider.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts index 9ceade6..a48d222 100644 --- a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts +++ b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts @@ -335,6 +335,8 @@ describe("resolveGoogleVertexAccessToken", () => { Object.assign(new Error("unavailable"), { response: { status: 503 } }), ], ["throttling", Object.assign(new Error("rate limited"), { response: { status: 429 } })], + ["a request timeout", Object.assign(new Error("request timed out"), { name: "TimeoutError" })], + ["an aborted request", Object.assign(new Error("aborted"), { name: "AbortError" })], ])("passes %s through unchanged instead of blaming the credentials", async (_label, error) => { authMocks.getAccessToken.mockRejectedValueOnce(error); await expect(resolveGoogleVertexAccessToken(inlineEnv)).rejects.toBe(error); diff --git a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts index f848535..dd0aa82 100644 --- a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts +++ b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts @@ -87,6 +87,9 @@ function isTransientTokenError(error: unknown): boolean { if (typeof error !== "object" || error === null) return false; const status = (error as { response?: { status?: unknown } }).response?.status; if (typeof status === "number") return status === 429 || status >= 500; + // Request timeouts arrive as named aborts with no error code. + const name = (error as { name?: unknown }).name; + if (name === "AbortError" || name === "TimeoutError") return true; const code = (error as { code?: unknown }).code; return typeof code === "string" && TRANSIENT_NETWORK_CODES.has(code); } From 3bd04000b4ca6b9964778399a4598f3ede088e78 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Tue, 29 Sep 2026 22:04:35 -0500 Subject: [PATCH 25/33] Ignore shell Databricks M2M credentials when Workbench manages the profile --- memory-bank/credentialResolver.md | 3 +- .../src/store-backend/StoreBackend.ts | 7 ++- .../__tests__/StoreBackend.test.ts | 59 +++++++++++++++++++ .../store-backend/envCredentialResolver.ts | 21 ++++--- .../src/store-backend/providerEnvRegistry.ts | 2 +- 5 files changed, 82 insertions(+), 10 deletions(-) diff --git a/memory-bank/credentialResolver.md b/memory-bank/credentialResolver.md index 940fa0b..a04e491 100644 --- a/memory-bank/credentialResolver.md +++ b/memory-bank/credentialResolver.md @@ -148,7 +148,8 @@ credentials. With environment-only configuration, `DATABRICKS_TOKEN` wins unless `DATABRICKS_AUTH_TYPE=oauth-m2m`; environment M2M requires `DATABRICKS_HOST`, `DATABRICKS_CLIENT_ID`, and `DATABRICKS_CLIENT_SECRET`. When `DATABRICKS_CONFIG_FILE` points at a `posit-workbench` path, the admin-managed -profile outranks `DATABRICKS_TOKEN` and the environment resolves nothing. +profile outranks every Databricks credential in the environment (`DATABRICKS_TOKEN` +and the M2M variables alike) and the environment resolves nothing. Status exposes only source, origin, readiness, expiry, and sanitized workspace metadata. diff --git a/packages/ai-credentials/src/store-backend/StoreBackend.ts b/packages/ai-credentials/src/store-backend/StoreBackend.ts index 5892c88..5fee3db 100644 --- a/packages/ai-credentials/src/store-backend/StoreBackend.ts +++ b/packages/ai-credentials/src/store-backend/StoreBackend.ts @@ -18,7 +18,10 @@ import type { } from "../CredentialProvider.js"; import type { Logger, ProviderCredentials, TokenData } from "../types/index.js"; import { normalizeDatabricksHost, requireBareAuthHost, storageKeyFor } from "../types/index.js"; -import { resolveCredentialsFromEnv } from "./envCredentialResolver.js"; +import { + isWorkbenchManagedDatabricks, + resolveCredentialsFromEnv, +} from "./envCredentialResolver.js"; import { PROVIDER_ENV_MAPPINGS } from "./providerEnvMappings.js"; import { storedProviderCredentialsSchema, @@ -257,6 +260,8 @@ export function createStoreBackend(options: CreateStoreBackendOptions): MutableB return credentials ? { kind: "credentials", credentials } : { kind: "none" }; } + if (isWorkbenchManagedDatabricks(env)) return { kind: "none" }; + // External build variants ship an empty PROVIDER_ENV_MAPPINGS; guard the // dereference so Databricks resolution degrades to "none" there. const mapping = PROVIDER_ENV_MAPPINGS.databricks; diff --git a/packages/ai-credentials/src/store-backend/__tests__/StoreBackend.test.ts b/packages/ai-credentials/src/store-backend/__tests__/StoreBackend.test.ts index a987ce7..d9fa4eb 100644 --- a/packages/ai-credentials/src/store-backend/__tests__/StoreBackend.test.ts +++ b/packages/ai-credentials/src/store-backend/__tests__/StoreBackend.test.ts @@ -291,6 +291,65 @@ describe("createStoreBackend", () => { origin: "environment", }); }); + + describe("when Workbench manages the Databricks profile", () => { + const WORKBENCH_CONFIG_FILE = "/home/user/.posit-workbench/databricks/cfg"; + + it.each([ + [ + "complete M2M variables", + { + DATABRICKS_HOST: "https://workspace.test", + DATABRICKS_CLIENT_ID: "client", + DATABRICKS_CLIENT_SECRET: "secret", + }, + ], + [ + "a PAT with M2M explicitly selected", + { + DATABRICKS_AUTH_TYPE: "oauth-m2m", + DATABRICKS_TOKEN: "shell-pat", + DATABRICKS_HOST: "https://workspace.test", + DATABRICKS_CLIENT_ID: "client", + DATABRICKS_CLIENT_SECRET: "secret", + }, + ], + [ + "incomplete explicitly selected M2M", + { + DATABRICKS_AUTH_TYPE: "oauth-m2m", + DATABRICKS_CLIENT_ID: "client", + }, + ], + ["a PAT", { DATABRICKS_TOKEN: "shell-pat" }], + ])("resolves nothing from %s in the shell", async (_label, shellEnv) => { + const backend = createStoreBackend({ + store, + resolveAuthMethod, + oauthConfigForProvider: (_providerId, source) => + source?.type === "oauth-m2m" + ? { + grantType: "client-credentials", + clientId: source.clientId, + clientSecret: source.clientSecret, + tokenEndpoint: `${source.workspaceHost}/token`, + credentialBaseUrl: source.workspaceHost, + cacheKey: source.clientId, + } + : undefined, + env: { ...shellEnv, DATABRICKS_CONFIG_FILE: WORKBENCH_CONFIG_FILE }, + }); + + expect(await backend.getCredentials("databricks")).toBeNull(); + expect(await backend.acquisition?.configForProvider("databricks")).toBeUndefined(); + expect(await backend.getCredentialStatus("databricks")).toEqual({ + configured: false, + authenticated: false, + readiness: "unauthenticated", + error: undefined, + }); + }); + }); }); describe("onDidChangeCredentials", () => { diff --git a/packages/ai-credentials/src/store-backend/envCredentialResolver.ts b/packages/ai-credentials/src/store-backend/envCredentialResolver.ts index 57a2e64..2fdefde 100644 --- a/packages/ai-credentials/src/store-backend/envCredentialResolver.ts +++ b/packages/ai-credentials/src/store-backend/envCredentialResolver.ts @@ -39,13 +39,7 @@ export function resolveCredentialsFromEnv( providerId: string, envVars: Readonly> = process.env, ): ProviderCredentials | null { - // A Workbench-provisioned Databricks profile outranks DATABRICKS_TOKEN; the admin credential must not be overridable from the shell. - if ( - providerId === "databricks" && - (readSdkCredentialEnvironment(envVars).databricksConfigFile ?? "").includes("posit-workbench") - ) { - return null; - } + if (providerId === "databricks" && isWorkbenchManagedDatabricks(envVars)) return null; const mapping = PROVIDER_ENV_MAPPINGS[providerId]; if (!mapping) return null; @@ -53,6 +47,19 @@ export function resolveCredentialsFromEnv( return resolveFromMapping(mapping, envVars); } +/** + * Whether Posit Workbench provisioned the Databricks profile. The admin + * credential then outranks every Databricks credential in the shell (PAT and + * M2M alike), so environment resolution yields nothing. + */ +export function isWorkbenchManagedDatabricks( + envVars: Readonly>, +): boolean { + return (readSdkCredentialEnvironment(envVars).databricksConfigFile ?? "").includes( + "posit-workbench", + ); +} + /** * Check whether any secret env vars are set for a provider. * Useful for auth status reporting. diff --git a/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts b/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts index c5e074f..cc4eb31 100644 --- a/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts +++ b/packages/ai-credentials/src/store-backend/providerEnvRegistry.ts @@ -93,7 +93,7 @@ export const PROVIDER_ENV_MAPPINGS: Record = { clientId: scrubbed("DATABRICKS_CLIENT_ID"), clientSecret: scrubbed("DATABRICKS_CLIENT_SECRET"), }, - // Captured so a Workbench-managed profile still outranks DATABRICKS_TOKEN after capture. + // Captured so a Workbench-managed profile still outranks shell Databricks credentials after capture. sdkCredentialEnvironment: { databricksConfigFile: ambient("DATABRICKS_CONFIG_FILE"), }, From 2a86e5bf22f2ba52631c44c2208f1481cd56f64d Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Tue, 29 Sep 2026 22:22:41 -0500 Subject: [PATCH 26/33] Resolve the Vertex credential source in one place for discovery, chat and auth guidance --- memory-bank/providerGuide.md | 10 +- .../src/google-vertex-credentials.ts | 192 ++++++++++++++++++ .../src/model-clients/GoogleVertexClient.ts | 35 +--- .../google-vertex-captured-auth.test.ts | 31 ++- packages/ai-provider-bridge/src/providers.ts | 6 +- .../__tests__/google-vertex-provider.test.ts | 31 ++- .../src/providers/google-vertex-provider.ts | 175 ++++------------ 7 files changed, 313 insertions(+), 167 deletions(-) create mode 100644 packages/ai-provider-bridge/src/google-vertex-credentials.ts diff --git a/memory-bank/providerGuide.md b/memory-bank/providerGuide.md index 70adffc..6f223e8 100644 --- a/memory-bank/providerGuide.md +++ b/memory-bank/providerGuide.md @@ -285,9 +285,13 @@ Hosts that scrub credential variables pass a captured credential environment through `ProviderRegistrationConfig`. The bridge never reads SDK wire names directly: both providers call `readSdkCredentialEnvironment` from `ai-credentials/store-backend`, which maps the ai-credentials -`sdkCredentialEnvironment` declaration onto a typed struct. Vertex supplies -the captured inline service account or `googleApplicationCredentials` path to -both model discovery's `GoogleAuth` and the chat SDK's `googleAuthOptions`. Foundry materializes a +`sdkCredentialEnvironment` declaration onto a typed struct. Vertex decides its +credential source once, in `resolveGoogleVertexCredentialSource` +(`src/google-vertex-credentials.ts`): a brokered token, else the captured +inline service account, else ADC with the captured +`googleApplicationCredentials` path. Model discovery's token minting, the chat +SDK's `googleAuthOptions` and the auth-error guidance all switch on that one +source. Foundry materializes a `ClientSecretCredential` or `ClientCertificateCredential` from the captured Azure values; when neither is complete it retains `DefaultAzureCredential` for managed identity and CLI sources. Because the names come from the diff --git a/packages/ai-provider-bridge/src/google-vertex-credentials.ts b/packages/ai-provider-bridge/src/google-vertex-credentials.ts new file mode 100644 index 0000000..f375946 --- /dev/null +++ b/packages/ai-provider-bridge/src/google-vertex-credentials.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Google Vertex credential source resolution. + * + * Owns the Vertex credential precedence — a brokered access token, then an + * inline service account (`GOOGLE_CLIENT_EMAIL` + `GOOGLE_PRIVATE_KEY`), then + * Application Default Credentials — so model discovery, the chat SDK and the + * auth-error guidance all act on the same resolved source. + */ + +import { + readSdkCredentialEnvironment, + type SdkCredentialEnvironment, +} from "ai-credentials/store-backend"; +import { + GoogleAuth, + OAuth2Client, + type GoogleAuthOptions, + type JWTInput, +} from "google-auth-library"; + +type CredentialEnvironment = Readonly>; + +/** The credential Vertex requests authenticate with, in precedence order. */ +export type GoogleVertexCredentialSource = + /** A token from a credential broker (e.g. Positron's auth extension). */ + | { kind: "brokered"; accessToken: string } + /** A service account from `GOOGLE_CLIENT_EMAIL` and `GOOGLE_PRIVATE_KEY`. */ + | { kind: "inline"; serviceAccount: JWTInput } + /** + * Application Default Credentials. `keyFilename` is the captured ADC path + * from a host that scrubbed process.env; when undefined, google-auth-library + * resolves ADC from the ambient environment itself. + */ + | { kind: "adc"; keyFilename: string | undefined }; + +const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; + +const INLINE_SERVICE_ACCOUNT_ERROR = "InlineServiceAccountError"; + +/** The inline service-account variables were set but Google rejected them; ADC is deliberately not tried. */ +class InlineServiceAccountError extends Error { + constructor(cause: string) { + super(`Inline service-account credentials failed: ${cause}`); + this.name = INLINE_SERVICE_ACCOUNT_ERROR; + } +} + +/** Whether Google rejected the inline service-account credentials while minting a token. */ +export function isInlineServiceAccountError(error: unknown): boolean { + return error instanceof Error && error.name === INLINE_SERVICE_ACCOUNT_ERROR; +} + +const TRANSIENT_NETWORK_CODES: ReadonlySet = new Set([ + "ECONNRESET", + "ETIMEDOUT", + "ECONNREFUSED", + "ECONNABORTED", + "ENOTFOUND", + "EAI_AGAIN", + "ENETUNREACH", + "EHOSTUNREACH", +]); + +/** A token-exchange failure that says nothing about the credentials: throttling, a server error, or no response. */ +function isTransientTokenError(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const status = (error as { response?: { status?: unknown } }).response?.status; + if (typeof status === "number") return status === 429 || status >= 500; + // Request timeouts arrive as named aborts with no error code. + const name = (error as { name?: unknown }).name; + if (name === "AbortError" || name === "TimeoutError") return true; + const code = (error as { code?: unknown }).code; + return typeof code === "string" && TRANSIENT_NETWORK_CODES.has(code); +} + +/** The inline service account, or undefined unless both the email and the private key are set. */ +function inlineServiceAccount(sdkEnvironment: SdkCredentialEnvironment): JWTInput | undefined { + const clientEmail = sdkEnvironment.googleClientEmail; + const privateKey = sdkEnvironment.googlePrivateKey; + if (!clientEmail || !privateKey) return undefined; + return { + client_email: clientEmail, + // google-auth-library needs literal newlines; pasted keys carry escaped `\n`. + private_key: privateKey.replace(/\\n/g, "\n"), + ...(sdkEnvironment.googlePrivateKeyId && { + private_key_id: sdkEnvironment.googlePrivateKeyId, + }), + }; +} + +/** + * Decide which credential Vertex requests use: the brokered token when one is + * supplied, else the inline service account, else Application Default + * Credentials. Reads the captured `credentialEnvironment` when a scrubbing host + * supplies one, `process.env` otherwise. + */ +export function resolveGoogleVertexCredentialSource( + accessToken: string | undefined, + credentialEnvironment: CredentialEnvironment | undefined, +): GoogleVertexCredentialSource { + if (accessToken) return { kind: "brokered", accessToken }; + const sdkEnvironment = readSdkCredentialEnvironment(credentialEnvironment ?? process.env); + const serviceAccount = inlineServiceAccount(sdkEnvironment); + if (serviceAccount) return { kind: "inline", serviceAccount }; + return { + kind: "adc", + keyFilename: credentialEnvironment ? sdkEnvironment.googleApplicationCredentials : undefined, + }; +} + +/** google-auth-library options for the Vertex chat SDK, or undefined to let it resolve ADC itself. */ +export function googleVertexAuthOptions( + source: GoogleVertexCredentialSource, +): GoogleAuthOptions | undefined { + switch (source.kind) { + case "brokered": { + const authClient = new OAuth2Client(); + authClient.setCredentials({ access_token: source.accessToken }); + return { authClient }; + } + case "inline": + return { credentials: source.serviceAccount, scopes: [CLOUD_PLATFORM_SCOPE] }; + case "adc": + return source.keyFilename ? { keyFilename: source.keyFilename } : undefined; + } +} + +async function tokenFrom(auth: GoogleAuth): Promise { + const client = await auth.getClient(); + const { token } = await client.getAccessToken(); + return token ?? undefined; +} + +/** + * A cloud-platform access token for the Vertex REST API. An inline failure is + * surfaced rather than masked by an ADC "no credentials" error, because setting + * both inline variables signals explicit intent; transient token-service + * failures pass through unchanged. + */ +export async function mintGoogleVertexAccessToken( + source: GoogleVertexCredentialSource, +): Promise { + switch (source.kind) { + case "brokered": + return source.accessToken; + case "inline": { + const auth = new GoogleAuth({ + credentials: source.serviceAccount, + scopes: [CLOUD_PLATFORM_SCOPE], + }); + let token: string | undefined; + try { + token = await tokenFrom(auth); + } catch (err) { + if (isTransientTokenError(err)) throw err; + const message = err instanceof Error ? err.message : String(err); + throw new InlineServiceAccountError(message); + } + if (!token) throw new InlineServiceAccountError("no access token was returned"); + return token; + } + case "adc": { + const auth = new GoogleAuth({ + scopes: [CLOUD_PLATFORM_SCOPE], + keyFilename: source.keyFilename, + }); + const token = await tokenFrom(auth); + if (!token) { + throw new Error("Failed to obtain access token from Application Default Credentials"); + } + return token; + } + } +} + +/** + * Resolve a cloud-platform access token without a brokered token: inline + * service-account env vars (`GOOGLE_CLIENT_EMAIL` + `GOOGLE_PRIVATE_KEY`, + * optional `GOOGLE_PRIVATE_KEY_ID`) first, Application Default Credentials + * otherwise. + */ +export function resolveGoogleVertexAccessToken( + credentialEnvironment?: CredentialEnvironment, +): Promise { + return mintGoogleVertexAccessToken( + resolveGoogleVertexCredentialSource(undefined, credentialEnvironment), + ); +} diff --git a/packages/ai-provider-bridge/src/model-clients/GoogleVertexClient.ts b/packages/ai-provider-bridge/src/model-clients/GoogleVertexClient.ts index 7bdbdc1..51d6f5f 100644 --- a/packages/ai-provider-bridge/src/model-clients/GoogleVertexClient.ts +++ b/packages/ai-provider-bridge/src/model-clients/GoogleVertexClient.ts @@ -14,8 +14,11 @@ import { createVertex } from "@ai-sdk/google-vertex"; import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"; import type { LanguageModelV3 } from "@ai-sdk/provider"; import { streamText } from "ai"; -import { OAuth2Client, type GoogleAuthOptions, type JWTInput } from "google-auth-library"; +import { + type GoogleVertexCredentialSource, + googleVertexAuthOptions, +} from "../google-vertex-credentials"; import { sanitizeToolCallIdsForAnthropic } from "../tool-call-ids"; import type { LMStreamPart, Logger, Protocol } from "../types"; import { normalizeProtocol } from "../types"; @@ -50,15 +53,8 @@ export function getEffectiveLocation(modelId: string, configuredLocation: string export interface GoogleVertexClientConfig { project: string; location: string; - /** - * Pre-fetched OAuth access token from a credential broker (e.g. Positron auth ext). - * When set, the Vertex SDK uses this token directly instead of resolving ADC. - */ - accessToken?: string; - /** Inline service account (`GOOGLE_CLIENT_EMAIL` + `GOOGLE_PRIVATE_KEY`), used when no token is brokered. */ - serviceAccount?: JWTInput; - /** Captured ADC file path supplied by a host that scrubbed process.env. */ - googleApplicationCredentials?: string; + /** The credential requests authenticate with, from `resolveGoogleVertexCredentialSource`. */ + credentialSource: GoogleVertexCredentialSource; } export class GoogleVertexClient implements ModelClient { @@ -70,23 +66,6 @@ export class GoogleVertexClient implements ModelClient { this.logger = logger; } - private googleAuthOptions(): GoogleAuthOptions | undefined { - if (this.config.accessToken) { - const authClient = new OAuth2Client(); - authClient.setCredentials({ access_token: this.config.accessToken }); - return { authClient }; - } - if (this.config.serviceAccount) { - return { - credentials: this.config.serviceAccount, - scopes: ["https://www.googleapis.com/auth/cloud-platform"], - }; - } - return this.config.googleApplicationCredentials - ? { keyFilename: this.config.googleApplicationCredentials } - : undefined; - } - async chat(params: ModelClientChatParams): Promise> { const normalizedProtocol = normalizeProtocol(params.protocol); if ( @@ -162,7 +141,7 @@ export class GoogleVertexClient implements ModelClient { * `isVertexAnthropicModel()` pattern. */ private createModel(modelId: string, protocol?: Protocol): LanguageModelV3 { - const googleAuthOptions = this.googleAuthOptions(); + const googleAuthOptions = googleVertexAuthOptions(this.config.credentialSource); const useAnthropicApi = protocol ? protocol === "anthropic-messages" diff --git a/packages/ai-provider-bridge/src/model-clients/__tests__/google-vertex-captured-auth.test.ts b/packages/ai-provider-bridge/src/model-clients/__tests__/google-vertex-captured-auth.test.ts index 6b2a0c5..a4cd7a1 100644 --- a/packages/ai-provider-bridge/src/model-clients/__tests__/google-vertex-captured-auth.test.ts +++ b/packages/ai-provider-bridge/src/model-clients/__tests__/google-vertex-captured-auth.test.ts @@ -39,7 +39,7 @@ describe("GoogleVertexClient captured authentication", () => { const client = new GoogleVertexClient({ project: "project-id", location: "us-central1", - googleApplicationCredentials: "/secrets/service-account.json", + credentialSource: { kind: "adc", keyFilename: "/secrets/service-account.json" }, }); await client.chat({ @@ -134,4 +134,33 @@ describe("GoogleVertexClient captured authentication", () => { expect.objectContaining(expectedAuth), ); }); + + it("authenticates with a brokered token even when inline service-account variables are captured", async () => { + const captured = Object.freeze({ + GOOGLE_CLIENT_EMAIL: "sa@project-id.iam.gserviceaccount.com", + GOOGLE_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----", + }); + const registry = new ProviderRegistry(logger); + registerGoogleVertexProvider(registry, logger, undefined, captured); + const client = registry.getClientForProvider("google-vertex", { + type: "google-cloud", + project: "project-id", + location: "us-central1", + accessToken: "brokered-token", + }); + if (!client) throw new Error("google-vertex client factory was not registered"); + + await client.chat({ + model: "gemini-2.5-pro", + messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }], + cancellationToken: { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose() {} }), + }, + }); + + expect(mocks.createVertex).toHaveBeenLastCalledWith( + expect.objectContaining({ googleAuthOptions: { authClient: expect.anything() } }), + ); + }); }); diff --git a/packages/ai-provider-bridge/src/providers.ts b/packages/ai-provider-bridge/src/providers.ts index 5971cc2..5fa1d7a 100644 --- a/packages/ai-provider-bridge/src/providers.ts +++ b/packages/ai-provider-bridge/src/providers.ts @@ -42,8 +42,12 @@ export { registerCustomGeminiProvider, registerGeminiProvider } from "./provider export { registerCustomGoogleVertexProvider, registerGoogleVertexProvider, - resolveGoogleVertexAccessToken, } from "./providers/google-vertex-provider"; +export { + resolveGoogleVertexAccessToken, + resolveGoogleVertexCredentialSource, +} from "./google-vertex-credentials"; +export type { GoogleVertexCredentialSource } from "./google-vertex-credentials"; export type { GoogleVertexProviderCallbacks } from "./providers/google-vertex-provider"; export { registerCustomLitellmProvider, diff --git a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts index a48d222..0d75168 100644 --- a/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts +++ b/packages/ai-provider-bridge/src/providers/__tests__/google-vertex-provider.test.ts @@ -17,6 +17,7 @@ vi.mock("google-auth-library", () => ({ OAuth2Client: class {}, })); +import { resolveGoogleVertexAccessToken } from "../../google-vertex-credentials"; import { getEffectiveLocation, isVertexAnthropicModel, @@ -25,7 +26,6 @@ import type { Logger } from "../../types"; import { registerCustomGoogleVertexProvider, registerGoogleVertexProvider, - resolveGoogleVertexAccessToken, } from "../google-vertex-provider"; import { ProviderRegistry } from "../ProviderRegistry"; @@ -90,6 +90,35 @@ describe("registerGoogleVertexProvider", () => { ); }); + it("uses Positron auth guidance for a brokered token even when inline variables are set", async () => { + const onProviderStatusChange = vi.fn().mockResolvedValue(undefined); + const registry = new ProviderRegistry(mockLogger); + registerGoogleVertexProvider( + registry, + mockLogger, + { onProviderStatusChange }, + { + GOOGLE_CLIENT_EMAIL: "svc@example.iam.gserviceaccount.com", + GOOGLE_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----", + }, + ); + + await registry.getModelsForProvider("google-vertex", { + type: "google-cloud", + project: "my-project", + location: "us-central1", + accessToken: "brokered-token", + }); + + expect(authMocks.googleAuth).not.toHaveBeenCalled(); + expect(onProviderStatusChange).toHaveBeenCalledWith( + expect.objectContaining({ + status: "auth_error", + error: expect.objectContaining({ code: "google_cloud_auth_expired" }), + }), + ); + }); + it("points inline service-account users at their variables when Vertex rejects the minted token", async () => { const onProviderStatusChange = vi.fn().mockResolvedValue(undefined); const registry = new ProviderRegistry(mockLogger); diff --git a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts index dd0aa82..cb0b5f9 100644 --- a/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts +++ b/packages/ai-provider-bridge/src/providers/google-vertex-provider.ts @@ -4,9 +4,13 @@ import type { ResolvedProviderId } from "ai-config"; import { getAnthropicModelCapabilities } from "ai-config"; -import { readSdkCredentialEnvironment } from "ai-credentials/store-backend"; -import { GoogleAuth, type JWTInput } from "google-auth-library"; +import { + type GoogleVertexCredentialSource, + isInlineServiceAccountError, + mintGoogleVertexAccessToken, + resolveGoogleVertexCredentialSource, +} from "../google-vertex-credentials"; import { GoogleVertexClient } from "../model-clients/GoogleVertexClient"; import type { Logger, ModelInfo, ProviderCredentials } from "../types"; import { NOTIFICATION_ACTIONS } from "../types"; @@ -32,23 +36,13 @@ export interface GoogleVertexProviderCallbacks { }) => Promise; } -const INLINE_SERVICE_ACCOUNT_ERROR = "InlineServiceAccountError"; - -/** The inline service-account variables were set but Google rejected them; ADC is deliberately not tried. */ -class InlineServiceAccountError extends Error { - constructor(cause: string) { - super(`Inline service-account credentials failed: ${cause}`); - this.name = INLINE_SERVICE_ACCOUNT_ERROR; - } -} - /** * Check whether an error from google-auth-library or the Vertex API indicates * expired / missing ADC credentials (similar to Bedrock's `isAuthError`). */ function isAuthError(error: unknown): boolean { if (!(error instanceof Error)) return false; - if (error.name === INLINE_SERVICE_ACCOUNT_ERROR) return true; + if (isInlineServiceAccountError(error)) return true; const msg = error.message; // google-auth-library: refresh token revoked or expired if (msg.includes("invalid_grant") || msg.includes("Token has been expired or revoked")) { @@ -69,104 +63,27 @@ function isAuthError(error: unknown): boolean { // Cache TTL for models (1 hour) in milliseconds const MODEL_CACHE_TTL = 60 * 60 * 1000; -const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; - -const TRANSIENT_NETWORK_CODES: ReadonlySet = new Set([ - "ECONNRESET", - "ETIMEDOUT", - "ECONNREFUSED", - "ECONNABORTED", - "ENOTFOUND", - "EAI_AGAIN", - "ENETUNREACH", - "EHOSTUNREACH", -]); - -/** A token-exchange failure that says nothing about the credentials: throttling, a server error, or no response. */ -function isTransientTokenError(error: unknown): boolean { - if (typeof error !== "object" || error === null) return false; - const status = (error as { response?: { status?: unknown } }).response?.status; - if (typeof status === "number") return status === 429 || status >= 500; - // Request timeouts arrive as named aborts with no error code. - const name = (error as { name?: unknown }).name; - if (name === "AbortError" || name === "TimeoutError") return true; - const code = (error as { code?: unknown }).code; - return typeof code === "string" && TRANSIENT_NETWORK_CODES.has(code); -} - -/** The inline service account from `GOOGLE_CLIENT_EMAIL` and `GOOGLE_PRIVATE_KEY`, or undefined when either is unset. */ -function inlineServiceAccount( - env: Readonly>, -): JWTInput | undefined { - const sdkEnvironment = readSdkCredentialEnvironment(env); - const clientEmail = sdkEnvironment.googleClientEmail; - const privateKey = sdkEnvironment.googlePrivateKey; - if (!clientEmail || !privateKey) return undefined; - return { - client_email: clientEmail, - // google-auth-library needs literal newlines; pasted keys carry escaped `\n`. - private_key: privateKey.replace(/\\n/g, "\n"), - ...(sdkEnvironment.googlePrivateKeyId && { - private_key_id: sdkEnvironment.googlePrivateKeyId, - }), - }; -} - -async function tokenFrom(auth: GoogleAuth): Promise { - const client = await auth.getClient(); - const { token } = await client.getAccessToken(); - return token ?? undefined; -} - -/** - * Resolve a cloud-platform access token: inline service-account env vars - * (`GOOGLE_CLIENT_EMAIL` + `GOOGLE_PRIVATE_KEY`, optional - * `GOOGLE_PRIVATE_KEY_ID`) first, Application Default Credentials otherwise. - * An inline failure is surfaced rather than masked by an ADC "no credentials" - * error, because setting both vars signals explicit intent. - */ -export async function resolveGoogleVertexAccessToken( - credentialEnvironment?: Readonly>, -): Promise { - const serviceAccount = inlineServiceAccount(credentialEnvironment ?? process.env); - if (serviceAccount) { - const auth = new GoogleAuth({ credentials: serviceAccount, scopes: [CLOUD_PLATFORM_SCOPE] }); - let token: string | undefined; - try { - token = await tokenFrom(auth); - } catch (err) { - if (isTransientTokenError(err)) throw err; - const message = err instanceof Error ? err.message : String(err); - throw new InlineServiceAccountError(message); - } - if (!token) throw new InlineServiceAccountError("no access token was returned"); - return token; - } - const auth = new GoogleAuth({ - scopes: [CLOUD_PLATFORM_SCOPE], - keyFilename: credentialEnvironment - ? readSdkCredentialEnvironment(credentialEnvironment).googleApplicationCredentials - : undefined, - }); - const token = await tokenFrom(auth); - if (!token) { - throw new Error("Failed to obtain access token from Application Default Credentials"); - } - return token; -} - -/** - * Resolve an access token for the Vertex AI REST API. - * Uses a broker-provided token (e.g. from Positron auth ext) when available; - * otherwise an inline service account, then Application Default Credentials. - */ -async function getAccessToken( - brokered?: string, - credentialEnvironment?: Readonly>, -): Promise { - if (brokered) return brokered; - return resolveGoogleVertexAccessToken(credentialEnvironment); -} +/** What to tell the user when Google rejects the credential source discovery used. */ +const AUTH_ERROR_GUIDANCE: Record< + GoogleVertexCredentialSource["kind"], + { code: string; message: string } +> = { + brokered: { + code: "google_cloud_auth_expired", + message: + "Google Cloud authentication expired or is unavailable. Reconnect Google Cloud auth in Positron, then click Reload model list.", + }, + inline: { + code: "inline_service_account_rejected", + message: + "Google Cloud rejected the service-account credentials in GOOGLE_CLIENT_EMAIL and GOOGLE_PRIVATE_KEY. Fix them, or unset them to use Application Default Credentials, then click Reload model list.", + }, + adc: { + code: "adc_expired", + message: + "Google Cloud credentials expired or missing. Run 'gcloud auth application-default login' to refresh, then click Reload model list.", + }, +}; /** * Fetch models from a single Vertex AI publisher endpoint. @@ -306,6 +223,11 @@ function createGoogleVertexModelFetcher( return cachedModels; } + const credentialSource = resolveGoogleVertexCredentialSource( + credentials.accessToken, + credentialEnvironment, + ); + // 4. Try to fetch from Vertex AI API try { const location = credentials.location || "us-central1"; @@ -314,7 +236,7 @@ function createGoogleVertexModelFetcher( `[GoogleVertex] Fetching models from Vertex AI API (project=${credentials.project}, location=${location}, anthropicLocation=global)`, ); - const token = await getAccessToken(credentials.accessToken, credentialEnvironment); + const token = await mintGoogleVertexAccessToken(credentialSource); // Fetch from both publishers in parallel, collecting errors // so that if both fail we can propagate to the outer catch @@ -429,28 +351,16 @@ function createGoogleVertexModelFetcher( const errorMsg = error instanceof Error ? error.message : String(error); if (isAuthError(error)) { - const isBrokeredAuth = Boolean(credentials.accessToken); - const isInlineAuth = - !isBrokeredAuth && - inlineServiceAccount(credentialEnvironment ?? process.env) !== undefined; - const authMessage = isInlineAuth - ? "Google Cloud rejected the service-account credentials in GOOGLE_CLIENT_EMAIL and GOOGLE_PRIVATE_KEY. Fix them, or unset them to use Application Default Credentials, then click Reload model list." - : isBrokeredAuth - ? "Google Cloud authentication expired or is unavailable. Reconnect Google Cloud auth in Positron, then click Reload model list." - : "Google Cloud credentials expired or missing. Run 'gcloud auth application-default login' to refresh, then click Reload model list."; - logger.error(`[GoogleVertex] ${authMessage} Error: ${errorMsg}`); + const guidance = AUTH_ERROR_GUIDANCE[credentialSource.kind]; + logger.error(`[GoogleVertex] ${guidance.message} Error: ${errorMsg}`); await callbacks?.onProviderStatusChange?.({ providerId, authMethodId: "google-cloud", status: "auth_error", error: { - code: isInlineAuth - ? "inline_service_account_rejected" - : isBrokeredAuth - ? "google_cloud_auth_expired" - : "adc_expired", - message: authMessage, + code: guidance.code, + message: guidance.message, action: { label: "Reload model list", commandId: NOTIFICATION_ACTIONS.REFRESH_MODELS, @@ -512,11 +422,10 @@ function createGoogleVertexClientFactory( { project: credentials.project, location: credentials.location, - accessToken: credentials.accessToken, - serviceAccount: inlineServiceAccount(credentialEnvironment ?? process.env), - googleApplicationCredentials: credentialEnvironment - ? readSdkCredentialEnvironment(credentialEnvironment).googleApplicationCredentials - : undefined, + credentialSource: resolveGoogleVertexCredentialSource( + credentials.accessToken, + credentialEnvironment, + ), }, logger, ); From f8e14357e1d4129eddee696e037633aad9101839 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Tue, 29 Sep 2026 22:23:48 -0500 Subject: [PATCH 27/33] Narrow Vertex token errors without type casts --- .../src/google-vertex-credentials.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/ai-provider-bridge/src/google-vertex-credentials.ts b/packages/ai-provider-bridge/src/google-vertex-credentials.ts index f375946..76ff818 100644 --- a/packages/ai-provider-bridge/src/google-vertex-credentials.ts +++ b/packages/ai-provider-bridge/src/google-vertex-credentials.ts @@ -68,13 +68,22 @@ const TRANSIENT_NETWORK_CODES: ReadonlySet = new Set([ /** A token-exchange failure that says nothing about the credentials: throttling, a server error, or no response. */ function isTransientTokenError(error: unknown): boolean { if (typeof error !== "object" || error === null) return false; - const status = (error as { response?: { status?: unknown } }).response?.status; - if (typeof status === "number") return status === 429 || status >= 500; + if ( + "response" in error && + typeof error.response === "object" && + error.response !== null && + "status" in error.response + ) { + const { status } = error.response; + if (typeof status === "number") return status === 429 || status >= 500; + } // Request timeouts arrive as named aborts with no error code. - const name = (error as { name?: unknown }).name; - if (name === "AbortError" || name === "TimeoutError") return true; - const code = (error as { code?: unknown }).code; - return typeof code === "string" && TRANSIENT_NETWORK_CODES.has(code); + if ("name" in error && (error.name === "AbortError" || error.name === "TimeoutError")) { + return true; + } + return ( + "code" in error && typeof error.code === "string" && TRANSIENT_NETWORK_CODES.has(error.code) + ); } /** The inline service account, or undefined unless both the email and the private key are set. */ From 3314dbb494caf34d467c55dff29ff3ed05770f24 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Tue, 29 Sep 2026 22:27:31 -0500 Subject: [PATCH 28/33] Strip a bare trailing /openai in normalizeFoundryBaseUrl A pasted https://.openai.azure.com/openai normalized to .../openai/openai/v1. Replace the test that pinned /openai/v10 -> /openai/v10/openai/v1 with one asserting /openai/v1 matches only as a whole segment. --- packages/ai-config/src/__tests__/base-url.test.ts | 10 +++++++++- packages/ai-config/src/base-url.ts | 9 ++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/ai-config/src/__tests__/base-url.test.ts b/packages/ai-config/src/__tests__/base-url.test.ts index c6642cb..80417b2 100644 --- a/packages/ai-config/src/__tests__/base-url.test.ts +++ b/packages/ai-config/src/__tests__/base-url.test.ts @@ -58,7 +58,9 @@ describe("normalizeFoundryBaseUrl", () => { "https://r.openai.azure.com/openai/v1/responses?api-version=preview", "https://r.openai.azure.com/openai/v1", ], - ["https://r.openai.azure.com/openai/v10", "https://r.openai.azure.com/openai/v10/openai/v1"], + ["https://r.openai.azure.com/openai", "https://r.openai.azure.com/openai/v1"], + ["https://r.openai.azure.com/openai/", "https://r.openai.azure.com/openai/v1"], + ["https://gateway.example/foundry", "https://gateway.example/foundry/openai/v1"], ["https://r.openai.azure.com/", "https://r.openai.azure.com/openai/v1"], ["https://r.openai.azure.com", "https://r.openai.azure.com/openai/v1"], ["https://r.openai.azure.com?api-version=1", "https://r.openai.azure.com/openai/v1"], @@ -72,6 +74,12 @@ describe("normalizeFoundryBaseUrl", () => { ])("normalizes %s", (input, expected) => { expect(normalizeFoundryBaseUrl(input)).toBe(expected); }); + + it("matches /openai/v1 only as a whole path segment", () => { + expect(normalizeFoundryBaseUrl("https://r.openai.azure.com/openai/v10/chat")).not.toBe( + "https://r.openai.azure.com/openai/v1", + ); + }); }); describe("normalizeOpenRouterBaseUrl", () => { diff --git a/packages/ai-config/src/base-url.ts b/packages/ai-config/src/base-url.ts index 0bb6b30..77f745a 100644 --- a/packages/ai-config/src/base-url.ts +++ b/packages/ai-config/src/base-url.ts @@ -146,12 +146,14 @@ export function normalizeBaseUrlForProvider(providerId: BuiltinProviderId, url: return url; } -const FOUNDRY_V1_PATH = "/openai/v1"; +const FOUNDRY_OPENAI_PATH = "/openai"; +const FOUNDRY_V1_PATH = `${FOUNDRY_OPENAI_PATH}/v1`; /** * Normalize a Microsoft Foundry endpoint to its `/openai/v1` base URL: strips - * the query string, trailing slashes, any `/openai/deployments/...` suffix and - * any operation path after `/openai/v1` that users paste from the portal. + * the query string, trailing slashes, any `/openai/deployments/...` suffix, + * any operation path after `/openai/v1` that users paste from the portal, and + * a bare trailing `/openai`, so the suffix is never doubled. * Empty input stays empty. */ export function normalizeFoundryBaseUrl(rawUrl: string): string { @@ -168,6 +170,7 @@ export function normalizeFoundryBaseUrl(rawUrl: string): string { const afterV1 = url.charAt(v1Index + FOUNDRY_V1_PATH.length); if (afterV1 === "" || afterV1 === "/") url = url.substring(0, v1Index + FOUNDRY_V1_PATH.length); } + if (url.endsWith(FOUNDRY_OPENAI_PATH)) url = url.slice(0, -FOUNDRY_OPENAI_PATH.length); if (!url.endsWith(FOUNDRY_V1_PATH)) url += FOUNDRY_V1_PATH; return url; } From 0a533fedbbb7fe29909c2733938513074a4795dd Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Tue, 29 Sep 2026 22:30:04 -0500 Subject: [PATCH 29/33] Test that a refresh keeps a record another window committed mid-refresh Hold the token-endpoint response, write a newer record the way a writer outside the lock would, then release rotated tokens or invalid_grant. The stored record must survive; removing either generation check fails a case. --- .../src/__tests__/acquisition.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/ai-credentials/src/__tests__/acquisition.test.ts b/packages/ai-credentials/src/__tests__/acquisition.test.ts index 523c112..4aca730 100644 --- a/packages/ai-credentials/src/__tests__/acquisition.test.ts +++ b/packages/ai-credentials/src/__tests__/acquisition.test.ts @@ -646,6 +646,74 @@ describe("generalized store-backed acquisition", () => { expect(text).toContain("transient"); expect(text).not.toContain("x".repeat(250)); }); + + describe("when another window commits during the refresh", () => { + /** A sign-in or refresh from a window whose lock does not exclude this one. */ + function otherWindowRecord(): StoredProviderCredentials { + const expiresAt = new Date(Date.now() + 3_600_000).toISOString(); + return { + ...expiredPositaiRecord(), + generation: "other-window-generation", + oauthAuth: { + tokenData: { + accessToken: "other-access", + refreshToken: "other-refresh", + expiresAt, + tokenType: "Bearer", + scope: "prism", + }, + expiresAt, + scope: "prism", + }, + }; + } + + /** Stub fetch so the token-endpoint response is held until `respond` is called. */ + function holdTokenEndpoint() { + let requested!: () => void; + const requestSeen = new Promise((resolve) => { + requested = resolve; + }); + let respond!: (response: Response) => void; + const response = new Promise((resolve) => { + respond = resolve; + }); + vi.stubGlobal( + "fetch", + vi.fn(() => { + requested(); + return response; + }), + ); + return { requestSeen, respond }; + } + + it.each([ + [ + "rotated tokens", + ok({ access_token: "fresh-access", refresh_token: "fresh-refresh", expires_in: 3600 }), + ], + ["an invalid_grant rejection", err(400, { error: "invalid_grant" })], + ])( + "keeps the other window's record when the refresh returns %s", + async (_label, response) => { + await seedExpiredPositai(); + const tokenEndpoint = holdTokenEndpoint(); + const provider = createProvider(); + + // SingleFileStore.set does not take the cross-process lock, so this + // write lands mid-refresh the way a per-window-locked writer's would. + const refresh = provider.getCredentials("positai"); + await tokenEndpoint.requestSeen; + const newer = otherWindowRecord(); + await store.set("auth:positai:oauth", newer); + tokenEndpoint.respond(response); + await refresh; + + expect(await storedPositai()).toEqual(newer); + }, + ); + }); }); describe("AcquisitionEngine refresh policy", () => { From db1141295928ce87b5e63c3f77ce0e21fc5e5f99 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Tue, 29 Sep 2026 22:31:47 -0500 Subject: [PATCH 30/33] Skip custom provider entries with an unsupported kind instead of throwing One stale kind from an untyped caller (e.g. read over IPC) threw after the built-ins and earlier entries had registered, skipping later valid entries and failing the whole call. Log a warning and continue instead. --- .../__tests__/register-all-providers.test.ts | 28 ++++++++++++++----- .../src/register-all-providers.ts | 11 ++++++-- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/ai-provider-bridge/src/__tests__/register-all-providers.test.ts b/packages/ai-provider-bridge/src/__tests__/register-all-providers.test.ts index b1fc272..3c30ead 100644 --- a/packages/ai-provider-bridge/src/__tests__/register-all-providers.test.ts +++ b/packages/ai-provider-bridge/src/__tests__/register-all-providers.test.ts @@ -92,13 +92,27 @@ describe("registerAllProviders", () => { expect(registry.getClientForProvider("anthropic", { type: "apikey", apiKey: "k" })).toBeNull(); }); - it("rejects a custom entry whose kind has no registrar", () => { + it("skips a custom entry whose kind has no registrar and still registers later entries", () => { + const log = logger(); const registry = new ProviderRegistry(logger()); - expect(() => - registerAllProviders(registry, logger(), { - positAiBaseUrl: "https://api.posit.cloud", - customProviders: [{ id: "odd" as never, clientKind: "positai" as never }], - }), - ).toThrow("Unsupported custom provider kind: positai"); + registerAllProviders(registry, log, { + positAiBaseUrl: "https://api.posit.cloud", + allowedProviders: [], + customProviders: [ + { id: "odd" as never, clientKind: "positai" as never }, + { id: "my-gateway" as never, clientKind: "openai-compatible" }, + ], + }); + + expect(log.warn).toHaveBeenCalledWith( + '[registerAllProviders] Skipping custom provider "odd": unsupported kind positai', + ); + expect( + registry.getClientForProviderOrKind( + "my-gateway", + { type: "apikey", apiKey: "k", baseUrl: "https://gw.example/v1" }, + "openai-compatible", + ), + ).not.toBeNull(); }); }); diff --git a/packages/ai-provider-bridge/src/register-all-providers.ts b/packages/ai-provider-bridge/src/register-all-providers.ts index d330e4d..e69f474 100644 --- a/packages/ai-provider-bridge/src/register-all-providers.ts +++ b/packages/ai-provider-bridge/src/register-all-providers.ts @@ -191,7 +191,8 @@ const CUSTOM_PROVIDER_REGISTRARS = { * `config.customProviders` entries register after the built-ins, are not * filtered by `allowedProviders`, and are looked up through * `ProviderRegistry.getClientForProviderOrKind` because their client - * factories are keyed by kind. + * factories are keyed by kind. An entry with an unsupported kind is skipped + * with a warning. */ export function registerAllProviders( registry: ProviderRegistry, @@ -206,9 +207,13 @@ export function registerAllProviders( } for (const { id, clientKind } of config.customProviders ?? []) { - // Untyped callers (e.g. kinds read over IPC) can still pass an unsupported kind. + // Untyped callers (e.g. kinds read over IPC) can still pass an unsupported + // kind; skip that entry so one stale entry cannot block the rest. if (!isSupportedCustomClientKind(clientKind)) { - throw new Error(`Unsupported custom provider kind: ${String(clientKind)}`); + logger.warn( + `[registerAllProviders] Skipping custom provider "${id}": unsupported kind ${String(clientKind)}`, + ); + continue; } CUSTOM_PROVIDER_REGISTRARS[clientKind](registry, id, logger, config); logger.debug( From 93e40dd0c7bd6e50a5c1589c8dfbd1aded49be93 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Tue, 29 Sep 2026 22:37:15 -0500 Subject: [PATCH 31/33] Assert the /openai/v10 path survives Foundry normalization --- packages/ai-config/src/__tests__/base-url.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai-config/src/__tests__/base-url.test.ts b/packages/ai-config/src/__tests__/base-url.test.ts index 80417b2..2c9b278 100644 --- a/packages/ai-config/src/__tests__/base-url.test.ts +++ b/packages/ai-config/src/__tests__/base-url.test.ts @@ -76,8 +76,8 @@ describe("normalizeFoundryBaseUrl", () => { }); it("matches /openai/v1 only as a whole path segment", () => { - expect(normalizeFoundryBaseUrl("https://r.openai.azure.com/openai/v10/chat")).not.toBe( - "https://r.openai.azure.com/openai/v1", + expect(normalizeFoundryBaseUrl("https://r.openai.azure.com/openai/v10/chat")).toMatch( + /^https:\/\/r\.openai\.azure\.com\/openai\/v10\/chat(\/|$)/, ); }); }); From 2905d6d0ba3e72c32f8ac071d3c882d67533a36f Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Tue, 29 Sep 2026 22:37:43 -0500 Subject: [PATCH 32/33] Match /openai/deployments as a whole path segment in normalizeFoundryBaseUrl --- .../ai-config/src/__tests__/base-url.test.ts | 2 ++ packages/ai-config/src/base-url.ts | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/ai-config/src/__tests__/base-url.test.ts b/packages/ai-config/src/__tests__/base-url.test.ts index 2c9b278..489549e 100644 --- a/packages/ai-config/src/__tests__/base-url.test.ts +++ b/packages/ai-config/src/__tests__/base-url.test.ts @@ -48,6 +48,8 @@ describe("normalizeFoundryBaseUrl", () => { "https://r.openai.azure.com/openai/deployments/gpt-4o", "https://r.openai.azure.com/openai/v1", ], + ["https://r.openai.azure.com/openai/deployments/", "https://r.openai.azure.com/openai/v1"], + ["https://r.openai.azure.com/openai/deployments", "https://r.openai.azure.com/openai/v1"], ["https://r.openai.azure.com/openai/v1", "https://r.openai.azure.com/openai/v1"], ["https://r.openai.azure.com/openai/v1/", "https://r.openai.azure.com/openai/v1"], [ diff --git a/packages/ai-config/src/base-url.ts b/packages/ai-config/src/base-url.ts index 77f745a..f6fef8b 100644 --- a/packages/ai-config/src/base-url.ts +++ b/packages/ai-config/src/base-url.ts @@ -148,6 +148,16 @@ export function normalizeBaseUrlForProvider(providerId: BuiltinProviderId, url: const FOUNDRY_OPENAI_PATH = "/openai"; const FOUNDRY_V1_PATH = `${FOUNDRY_OPENAI_PATH}/v1`; +const FOUNDRY_DEPLOYMENTS_PATH = `${FOUNDRY_OPENAI_PATH}/deployments`; + +/** Index of the first `path` in `url` that ends at a path-segment boundary, or -1. */ +function pathSegmentIndex(url: string, path: string): number { + for (let i = url.indexOf(path); i !== -1; i = url.indexOf(path, i + 1)) { + const next = url.charAt(i + path.length); + if (next === "" || next === "/") return i; + } + return -1; +} /** * Normalize a Microsoft Foundry endpoint to its `/openai/v1` base URL: strips @@ -163,13 +173,10 @@ export function normalizeFoundryBaseUrl(rawUrl: string): string { if (suffixIndex !== -1) url = url.substring(0, suffixIndex); url = url.replace(/\/+$/, ""); if (!url) return ""; - const deploymentIndex = url.indexOf("/openai/deployments/"); + const deploymentIndex = pathSegmentIndex(url, FOUNDRY_DEPLOYMENTS_PATH); if (deploymentIndex !== -1) url = url.substring(0, deploymentIndex); - const v1Index = url.indexOf(FOUNDRY_V1_PATH); - if (v1Index !== -1) { - const afterV1 = url.charAt(v1Index + FOUNDRY_V1_PATH.length); - if (afterV1 === "" || afterV1 === "/") url = url.substring(0, v1Index + FOUNDRY_V1_PATH.length); - } + const v1Index = pathSegmentIndex(url, FOUNDRY_V1_PATH); + if (v1Index !== -1) url = url.substring(0, v1Index + FOUNDRY_V1_PATH.length); if (url.endsWith(FOUNDRY_OPENAI_PATH)) url = url.slice(0, -FOUNDRY_OPENAI_PATH.length); if (!url.endsWith(FOUNDRY_V1_PATH)) url += FOUNDRY_V1_PATH; return url; From fc73ee51c914b1c8578a3649db79153d0569dd2d Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Tue, 29 Sep 2026 22:43:21 -0500 Subject: [PATCH 33/33] Bind OAuth refresh commits to the generation of the read that supplied the tokens withRefreshTransaction noted the generation from one read, and the refresh then read the tokens again. Under a per-window lock another window could write between the two reads, so the refresh spent the newer record's refresh token and then dropped the rotated result as stale. The transaction now reads the record once and hands the operation a RefreshTransaction: the tokens from that read, plus commitTokens and commitError bound to its generation. This replaces persistRefreshedTokens, persistRefreshError and the refreshGenerations side map, so the commits can no longer be called outside a transaction. Sign-in and refresh commits share one compare-and-write path. Commits report whether they landed, so a terminal rejection that loses to another writer no longer logs "stored tokens removed". --- memory-bank/credentialResolver.md | 2 +- packages/ai-credentials/src/Backend.ts | 23 +++- .../src/__tests__/acquisition.test.ts | 117 ++++++++++++++---- packages/ai-credentials/src/acquisition.ts | 27 ++-- packages/ai-credentials/src/index.ts | 1 + .../src/store-backend/StoreBackend.ts | 114 +++++++++-------- 6 files changed, 198 insertions(+), 86 deletions(-) diff --git a/memory-bank/credentialResolver.md b/memory-bank/credentialResolver.md index a04e491..e3c1843 100644 --- a/memory-bank/credentialResolver.md +++ b/memory-bank/credentialResolver.md @@ -117,7 +117,7 @@ the `withRefreshTransaction` callback so a concurrent refresher cannot overwrite the terminal record. Every other failure is _transient_ — network errors, the 30s `AbortSignal.timeout` on the refresh exchange (so a hung fetch cannot hold the cross-process file lock), 429/5xx, unknown 4xx codes, malformed -bodies, a rejected `persistRefreshedTokens`, or the transaction itself failing +bodies, a rejected token commit, or the transaction itself failing (lock/IO) — and resolves as: return null, leave the stored record untouched, and start a ~60s in-memory per-provider cooldown so status polling cannot hammer the token endpoint during an outage. An expired cooldown is removed diff --git a/packages/ai-credentials/src/Backend.ts b/packages/ai-credentials/src/Backend.ts index 4d8b11a..a13c47e 100644 --- a/packages/ai-credentials/src/Backend.ts +++ b/packages/ai-credentials/src/Backend.ts @@ -125,6 +125,18 @@ export interface StoredOAuthTokens { export type AuthenticationCommitResult = "committed" | "superseded"; +/** + * The stored OAuth tokens a refresh read, with writes bound to the generation + * of that same read. Each commit lands only while the stored record still holds + * that generation; otherwise it writes nothing and resolves "superseded". Once + * the transaction's operation settles, commits always resolve "superseded". + */ +export interface RefreshTransaction { + tokens: StoredOAuthTokens; + commitTokens(tokens: TokenData): Promise; + commitError(error: string): Promise; +} + /** Durable hooks used by the generalized acquisition engine. */ export interface AcquisitionBackendHooks { configForProvider(providerId: string): Promise; @@ -140,9 +152,14 @@ export interface AcquisitionBackendHooks { generation: string, error: string, ): Promise; - persistRefreshedTokens(providerId: string, tokens: TokenData): Promise; - persistRefreshError(providerId: string, error: string): Promise; - withRefreshTransaction(providerId: string, operation: () => Promise): Promise; + /** + * Read the stored OAuth tokens once under the backing's lock and run + * `operation` against them; `null` when no ready OAuth tokens are stored. + */ + withRefreshTransaction( + providerId: string, + operation: (refresh: RefreshTransaction | null) => Promise, + ): Promise; shapeToken( providerId: string, accessToken: string, diff --git a/packages/ai-credentials/src/__tests__/acquisition.test.ts b/packages/ai-credentials/src/__tests__/acquisition.test.ts index 4aca730..016676d 100644 --- a/packages/ai-credentials/src/__tests__/acquisition.test.ts +++ b/packages/ai-credentials/src/__tests__/acquisition.test.ts @@ -649,8 +649,8 @@ describe("generalized store-backed acquisition", () => { describe("when another window commits during the refresh", () => { /** A sign-in or refresh from a window whose lock does not exclude this one. */ - function otherWindowRecord(): StoredProviderCredentials { - const expiresAt = new Date(Date.now() + 3_600_000).toISOString(); + function otherWindowRecord(expiresInMs = 3_600_000): StoredProviderCredentials { + const expiresAt = new Date(Date.now() + expiresInMs).toISOString(); return { ...expiredPositaiRecord(), generation: "other-window-generation", @@ -713,6 +713,73 @@ describe("generalized store-backed acquisition", () => { expect(await storedPositai()).toEqual(newer); }, ); + + it("refreshes with the tokens from the read it pins the generation to", async () => { + await seedExpiredPositai(); + const sentRefreshTokens: (string | null)[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const body = new URLSearchParams(typeof init?.body === "string" ? init.body : ""); + sentRefreshTokens.push(body.get("refresh_token")); + return ok({ + access_token: "fresh-access", + refresh_token: "fresh-refresh", + expires_in: 3600, + }); + }), + ); + // The other window's record is expiring too, so a refresh that read it + // would spend its refresh token and then drop the rotated result. + const newer = otherWindowRecord(-60_000); + // Land the other window's write right after the transaction's first + // read, the gap a per-window lock leaves open. + let inLock = false; + let injected = false; + const withLock = store.withLock.bind(store); + const get = store.get.bind(store); + vi.spyOn(store, "withLock").mockImplementation(async (fn) => { + inLock = true; + try { + return await withLock(fn); + } finally { + inLock = false; + } + }); + vi.spyOn(store, "get").mockImplementation(async (key) => { + const value = await get(key); + if (inLock && !injected) { + injected = true; + await store.set("auth:positai:oauth", newer); + } + return value; + }); + const provider = createProvider(); + + await provider.getCredentials("positai"); + + expect(injected).toBe(true); + expect(sentRefreshTokens).toEqual(["old-refresh"]); + expect(await storedPositai()).toEqual(newer); + }); + + it("reports the record as kept, not removed, when a rejection loses to the other window", async () => { + await seedExpiredPositai(); + const tokenEndpoint = holdTokenEndpoint(); + const logger = mockLogger(); + const provider = createProvider({}, receiver, logger); + + const refresh = provider.getCredentials("positai"); + await tokenEndpoint.requestSeen; + await store.set("auth:positai:oauth", otherWindowRecord()); + tokenEndpoint.respond(err(400, { error: "invalid_grant" })); + await refresh; + + expect(logger.error).not.toHaveBeenCalled(); + expect(loggedText(logger)).toContain( + "stored record changed during the refresh and was kept", + ); + }); }); }); @@ -774,28 +841,32 @@ describe("generalized store-backed acquisition", () => { beginAuthentication: () => Promise.resolve("generation"), commitAuthentication: () => Promise.resolve("committed"), finishAuthentication: () => Promise.resolve("committed"), - persistRefreshedTokens: (_providerId, tokens) => { - if (state.failPersist) { - return Promise.reject(new Error("EACCES: permission denied")); - } - state.tokens = { - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresAt: new Date(Date.now() + tokens.expiresIn * 1000).toISOString(), - tokenType: tokens.tokenType, - scope: tokens.scope, - }; - return Promise.resolve(); - }, - persistRefreshError: (_providerId, error) => { - state.tombstone = error; - state.tokens = null; - return Promise.resolve(); + withRefreshTransaction: (_providerId, operation) => { + if (state.failTransaction) return Promise.reject(new Error("ELOCKED: file is locked")); + const tokens = state.tokens; + if (!tokens) return operation(null); + return operation({ + tokens, + commitTokens: (refreshed) => { + if (state.failPersist) { + return Promise.reject(new Error("EACCES: permission denied")); + } + state.tokens = { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + expiresAt: new Date(Date.now() + refreshed.expiresIn * 1000).toISOString(), + tokenType: refreshed.tokenType, + scope: refreshed.scope, + }; + return Promise.resolve("committed"); + }, + commitError: (error) => { + state.tombstone = error; + state.tokens = null; + return Promise.resolve("committed"); + }, + }); }, - withRefreshTransaction: (_providerId, operation) => - state.failTransaction - ? Promise.reject(new Error("ELOCKED: file is locked")) - : operation(), shapeToken: (_providerId, accessToken) => ({ type: "oauth", accessToken }), notifyReady: () => {}, }; diff --git a/packages/ai-credentials/src/acquisition.ts b/packages/ai-credentials/src/acquisition.ts index 6a69607..66b2645 100644 --- a/packages/ai-credentials/src/acquisition.ts +++ b/packages/ai-credentials/src/acquisition.ts @@ -445,9 +445,9 @@ export class AcquisitionEngine { ): Promise { let accessToken: string | null; try { - accessToken = await this.hooks.withRefreshTransaction(providerId, async () => { - const current = await this.hooks.readTokens(providerId); - if (!current) return null; + accessToken = await this.hooks.withRefreshTransaction(providerId, async (refresh) => { + if (!refresh) return null; + const current = refresh.tokens; if (!this.isExpiring(current, 2)) { return current.accessToken; } @@ -469,10 +469,16 @@ export class AcquisitionEngine { // Re-auth is genuinely required. Classification and the // tombstone stay inside the transaction so a concurrent // refresher cannot overwrite the terminal record. - await this.hooks.persistRefreshError(providerId, "refresh_failed"); - this.logger?.error( - `[ai-credentials] refresh rejected for ${providerId} (terminal: ${describeRefreshError(error)}); stored tokens removed`, - ); + const result = await refresh.commitError("refresh_failed"); + if (result === "committed") { + this.logger?.error( + `[ai-credentials] refresh rejected for ${providerId} (terminal: ${describeRefreshError(error)}); stored tokens removed`, + ); + } else { + this.logger?.warn( + `[ai-credentials] refresh rejected for ${providerId} (terminal: ${describeRefreshError(error)}); stored record changed during the refresh and was kept`, + ); + } } else { this.startRefreshCooldown(providerId); this.logger?.warn( @@ -482,7 +488,12 @@ export class AcquisitionEngine { return null; } try { - await this.hooks.persistRefreshedTokens(providerId, refreshed); + const result = await refresh.commitTokens(refreshed); + if (result === "superseded") { + this.logger?.debug( + `[ai-credentials] refreshed tokens for ${providerId} not stored: stored record changed during the refresh`, + ); + } } catch (error) { // The exchange succeeded but the rotated tokens could not be // saved. Keep the old record and retry later; if the server diff --git a/packages/ai-credentials/src/index.ts b/packages/ai-credentials/src/index.ts index 7ce4f79..fc386c7 100644 --- a/packages/ai-credentials/src/index.ts +++ b/packages/ai-credentials/src/index.ts @@ -36,6 +36,7 @@ export type { OAuthGrantConfig, OAuthProviderConfig, PreparedAuthorizationCodeReceiver, + RefreshTransaction, StoredOAuthTokens, } from "./Backend.js"; export { createCredentialProvider } from "./createCredentialProvider.js"; diff --git a/packages/ai-credentials/src/store-backend/StoreBackend.ts b/packages/ai-credentials/src/store-backend/StoreBackend.ts index 5fee3db..8f9feb3 100644 --- a/packages/ai-credentials/src/store-backend/StoreBackend.ts +++ b/packages/ai-credentials/src/store-backend/StoreBackend.ts @@ -4,10 +4,12 @@ import type { AcquisitionBackendHooks, + AuthenticationCommitResult, CredentialSourceContext, MutableBackend, OAuthGrantConfig, OAuthProviderConfig, + RefreshTransaction, StoredOAuthTokens, } from "../Backend.js"; import type { @@ -555,88 +557,100 @@ export function createStoreBackend(options: CreateStoreBackendOptions): MutableB }); } - async function commitAuthentication( - providerId: string, - generation: string, - tokens: TokenData, - ): Promise<"committed" | "superseded"> { - return compareAndWrite(providerId, generation, (current) => { + type RecordBuilder = (current: NormalizedStored) => StoredProviderCredentials | null; + + function authenticatedWith(tokens: TokenData): RecordBuilder { + return (current) => { if (!current.source) return null; if (current.source.type !== "oauth-device" && current.source.type !== "oauth-u2m") return null; return authenticatedOAuthRecord(current.source, tokens, generationFactory()); - }); + }; + } + + function terminalWith(error: string): RecordBuilder { + return (current) => + current.source ? terminalOAuthRecord(current.source, generationFactory(), error) : null; + } + + async function commitAuthentication( + providerId: string, + generation: string, + tokens: TokenData, + ): Promise { + return compareAndWrite(providerId, generation, authenticatedWith(tokens)); } async function finishAuthentication( providerId: string, generation: string, error: string, - ): Promise<"committed" | "superseded"> { - return compareAndWrite(providerId, generation, (current) => { - if (!current.source) return null; - return terminalOAuthRecord(current.source, generationFactory(), error); - }); + ): Promise { + return compareAndWrite(providerId, generation, terminalWith(error)); } async function compareAndWrite( providerId: string, generation: string, - build: (current: NormalizedStored) => StoredProviderCredentials | null, - ): Promise<"committed" | "superseded"> { + build: RecordBuilder, + ): Promise { + return store.withLock(() => writeIfGeneration(providerId, generation, build)); + } + + /** + * Write `build`'s record only while the stored record still holds + * `generation`. The caller holds the store lock. + */ + async function writeIfGeneration( + providerId: string, + generation: string | undefined, + build: RecordBuilder, + ): Promise { const key = keyFor(providerId); if (!key) return "superseded"; - return store.withLock(async () => { - const current = normalize(providerId, await readRecord(providerId)); - if (!current || current.generation !== generation) return "superseded"; - const next = build(current); - if (!next) return "superseded"; - await store.set(key, next); - return "committed"; - }); + const current = normalize(providerId, await readRecord(providerId)); + if (!current || current.generation !== generation) return "superseded"; + const next = build(current); + if (!next) return "superseded"; + await store.set(key, next); + return "committed"; } - async function readTokens(providerId: string): Promise { - const normalized = await storedSource(providerId); + function readyOAuthTokens(normalized: NormalizedStored | null): StoredOAuthTokens | null { if (!normalized || normalized.readiness !== "ready" || !normalized.source) return null; if (normalized.source.type !== "oauth-device" && normalized.source.type !== "oauth-u2m") return null; return normalized.tokens ?? null; } - const refreshGenerations = new Map(); - - async function persistRefreshedTokens(providerId: string, tokens: TokenData): Promise { - const key = keyFor(providerId); - if (!key) return; - const current = normalize(providerId, await readRecord(providerId)); - if (!current?.source) return; - if (current.generation !== refreshGenerations.get(providerId)) return; - if (current.source.type !== "oauth-device" && current.source.type !== "oauth-u2m") return; - await store.set(key, authenticatedOAuthRecord(current.source, tokens, generationFactory())); - } - - async function persistRefreshError(providerId: string, error: string): Promise { - const key = keyFor(providerId); - if (!key) return; - const current = normalize(providerId, await readRecord(providerId)); - if (!current?.source) return; - if (current.generation !== refreshGenerations.get(providerId)) return; - await store.set(key, terminalOAuthRecord(current.source, generationFactory(), error)); + async function readTokens(providerId: string): Promise { + return readyOAuthTokens(await storedSource(providerId)); } - /** Note the record's current generation, then run the refresh against it. */ + /** + * Read the record once under the lock, and bind the refresh's commits to the + * generation of that same read so the tokens refreshed and the generation + * checked can never come from different records. + */ async function withRefreshTransaction( providerId: string, - operation: () => Promise, + operation: (refresh: RefreshTransaction | null) => Promise, ): Promise { return store.withLock(async () => { - const current = normalize(providerId, await readRecord(providerId)); - refreshGenerations.set(providerId, current?.generation); + const read = normalize(providerId, await readRecord(providerId)); + const tokens = readyOAuthTokens(read); + if (!read || !tokens) return operation(null); + let open = true; + const commit = async (build: RecordBuilder): Promise => + open ? writeIfGeneration(providerId, read.generation, build) : "superseded"; try { - return await operation(); + return await operation({ + tokens, + commitTokens: (refreshed) => commit(authenticatedWith(refreshed)), + commitError: (error) => commit(terminalWith(error)), + }); } finally { - refreshGenerations.delete(providerId); + open = false; } }); } @@ -648,8 +662,6 @@ export function createStoreBackend(options: CreateStoreBackendOptions): MutableB beginAuthentication, commitAuthentication, finishAuthentication, - persistRefreshedTokens, - persistRefreshError, withRefreshTransaction, shapeToken: asyncShapeToken, notifyReady(providerId) {