From abec88cfab3e035ac81f467820e585b28d951c7d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 11 Jun 2026 09:09:04 +0900 Subject: [PATCH] fix(auth): apply runtime request overrides everywhere --- extensions/microsoft-foundry/index.test.ts | 17 ++++--- extensions/microsoft-foundry/shared.ts | 3 ++ src/agents/btw.ts | 8 +--- src/agents/embedded-agent-runner/compact.ts | 5 +-- .../run/auth-controller.ts | 45 ++++++------------- src/agents/provider-request-config.test.ts | 28 ++++++++++++ src/agents/provider-request-config.ts | 41 +++++++++++++++++ src/agents/simple-completion-runtime.ts | 15 +++---- 8 files changed, 106 insertions(+), 56 deletions(-) diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index 8eca5e4216f7..8ab1d902e676 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -1242,7 +1242,7 @@ describe("microsoft-foundry plugin", () => { expect(normalized?.compat?.supportsReasoningEffort).toBe(false); }); - it("keeps API-key credentials scoped to auth profiles", () => { + it("deletes legacy provider-level credentials for API-key profiles", () => { const result = buildFoundryAuthResult({ profileId: "microsoft-foundry:default", apiKey: "test-api-key", @@ -1256,6 +1256,9 @@ describe("microsoft-foundry plugin", () => { expect(provider.apiKey).toBeUndefined(); expect(provider.authHeader).toBeUndefined(); expect(provider.headers).toBeUndefined(); + expect(Object.hasOwn(provider, "apiKey")).toBe(true); + expect(Object.hasOwn(provider, "authHeader")).toBe(true); + expect(Object.hasOwn(provider, "headers")).toBe(true); }); it("uses the minimum supported response token count for GPT-5 connection tests", () => { @@ -1386,7 +1389,7 @@ describe("microsoft-foundry plugin", () => { expect(provider?.models[0]?.compat).toBeUndefined(); }); - it("keeps Entra credentials scoped to auth profiles", () => { + it("deletes legacy provider-level credentials for Entra profiles", () => { const result = buildFoundryAuthResult({ profileId: "microsoft-foundry:entra", apiKey: "__entra_id_dynamic__", @@ -1401,8 +1404,9 @@ describe("microsoft-foundry plugin", () => { | Record | undefined; expect(provider?.authHeader).toBeUndefined(); - expect(Object.hasOwn(provider ?? {}, "apiKey")).toBe(false); - expect(Object.hasOwn(provider ?? {}, "headers")).toBe(false); + expect(Object.hasOwn(provider ?? {}, "apiKey")).toBe(true); + expect(Object.hasOwn(provider ?? {}, "authHeader")).toBe(true); + expect(Object.hasOwn(provider ?? {}, "headers")).toBe(true); expect(provider?.apiKey).toBeUndefined(); expect(provider?.headers).toBeUndefined(); }); @@ -1826,7 +1830,7 @@ describe("microsoft-foundry plugin", () => { await expect(getAccessTokenResultAsync()).rejects.toThrow("Azure CLI is not logged in"); }); - it("keeps API-key secret refs scoped to auth profiles", () => { + it("deletes legacy provider-level secret refs", () => { const secretRef = { source: "env" as const, provider: "default", @@ -1845,6 +1849,9 @@ describe("microsoft-foundry plugin", () => { expect(provider.apiKey).toBeUndefined(); expect(provider.authHeader).toBeUndefined(); expect(provider.headers).toBeUndefined(); + expect(Object.hasOwn(provider, "apiKey")).toBe(true); + expect(Object.hasOwn(provider, "authHeader")).toBe(true); + expect(Object.hasOwn(provider, "headers")).toBe(true); }); it("moves the selected Foundry auth profile to the front of auth.order", () => { diff --git a/extensions/microsoft-foundry/shared.ts b/extensions/microsoft-foundry/shared.ts index 829af526bbab..457016cf82af 100644 --- a/extensions/microsoft-foundry/shared.ts +++ b/extensions/microsoft-foundry/shared.ts @@ -520,6 +520,9 @@ function buildFoundryProviderConfig( return { baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint, resolvedApi), api: resolvedApi, + authHeader: undefined, + apiKey: undefined, + headers: undefined, models: deployments.map((deployment) => { const capabilities = resolveFoundryModelCapabilities( deployment.name, diff --git a/src/agents/btw.ts b/src/agents/btw.ts index b9f21f227bef..1ad3dea7a24c 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -27,6 +27,7 @@ import { EmbeddedBlockChunker, type BlockReplyChunking } from "./embedded-agent- import { resolveModelWithRegistry } from "./embedded-agent-runner/model.js"; import { getActiveEmbeddedRunSnapshot } from "./embedded-agent-runner/runs.js"; import { resolveEmbeddedAgentStreamFn } from "./embedded-agent-runner/stream-resolution.js"; +import { applyPreparedRuntimeAuthToModel } from "./provider-request-config.js"; import { resolveAvailableAgentHarnessPolicy, selectAgentHarness } from "./harness/selection.js"; import { resolveImageSanitizationLimits, @@ -483,12 +484,7 @@ export async function runBtwSideQuestion( profileId: resolvedAuthProfileId, }, }); - if (preparedAuth?.baseUrl) { - runtimeModel = { - ...runtimeModel, - baseUrl: preparedAuth.baseUrl, - }; - } + runtimeModel = applyPreparedRuntimeAuthToModel(runtimeModel, preparedAuth); if (preparedAuth?.apiKey) { apiKey = preparedAuth.apiKey; } diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index b2ef37a86884..ba316e1067bc 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -96,6 +96,7 @@ import { isFallbackSummaryError, runWithModelFallback } from "../model-fallback. import { supportsModelTools } from "../model-tool-support.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; import { wrapStreamFnTextTransforms } from "../plugin-text-transforms.js"; +import { applyPreparedRuntimeAuthToModel } from "../provider-request-config.js"; import { resolveAgentPromptSurfaceForSessionKey } from "../prompt-surface.js"; import { registerProviderStreamForModel } from "../provider-stream.js"; import { collectRuntimeChannelCapabilities } from "../runtime-capabilities.js"; @@ -671,9 +672,7 @@ async function compactEmbeddedAgentSessionDirectOnce( profileId: apiKeyInfo.profileId, }, }); - if (preparedAuth?.baseUrl) { - runtimeModel = { ...runtimeModel, baseUrl: preparedAuth.baseUrl }; - } + runtimeModel = applyPreparedRuntimeAuthToModel(runtimeModel, preparedAuth); const runtimeApiKey = preparedAuth?.apiKey ?? apiKeyInfo.apiKey; hasRuntimeAuthExchange = Boolean(preparedAuth?.apiKey); if (!runtimeApiKey) { diff --git a/src/agents/embedded-agent-runner/run/auth-controller.ts b/src/agents/embedded-agent-runner/run/auth-controller.ts index 40b868ea22b4..fb76638ce2eb 100644 --- a/src/agents/embedded-agent-runner/run/auth-controller.ts +++ b/src/agents/embedded-agent-runner/run/auth-controller.ts @@ -24,8 +24,8 @@ import { type ResolvedProviderAuth, } from "../../model-auth.js"; import { - resolveProviderRequestConfig, - sanitizeRuntimeProviderRequestOverrides, + applyPreparedRuntimeAuthToModel, + type ModelProviderRequestTransportOverrides, } from "../../provider-request-config.js"; import { clampRuntimeAuthRefreshDelayMs } from "../../runtime-auth-refresh.js"; import { @@ -88,41 +88,22 @@ export function createEmbeddedRunAuthController(params: { runtimeModel: Model; preparedAuth: { baseUrl?: string; - request?: Parameters[0]["request"]; + request?: ModelProviderRequestTransportOverrides; }; }): void => { - if (!paramsForApply.preparedAuth.baseUrl && !paramsForApply.preparedAuth.request) { + const runtimeModel = applyPreparedRuntimeAuthToModel( + paramsForApply.runtimeModel, + paramsForApply.preparedAuth, + ); + if (runtimeModel === paramsForApply.runtimeModel) { return; } - const runtimeRequestConfig = resolveProviderRequestConfig({ - provider: paramsForApply.runtimeModel.provider, - api: paramsForApply.runtimeModel.api, - baseUrl: paramsForApply.preparedAuth.baseUrl ?? paramsForApply.runtimeModel.baseUrl, - providerHeaders: - paramsForApply.runtimeModel.headers && - typeof paramsForApply.runtimeModel.headers === "object" - ? paramsForApply.runtimeModel.headers - : undefined, - request: sanitizeRuntimeProviderRequestOverrides(paramsForApply.preparedAuth.request), - capability: "llm", - transport: "stream", - }); // Runtime auth plugins may override baseUrl and safe request auth headers, - // but sanitizeRuntimeProviderRequestOverrides strips privileged transport knobs. - params.setRuntimeModel({ - ...paramsForApply.runtimeModel, - ...(paramsForApply.preparedAuth.baseUrl - ? { baseUrl: paramsForApply.preparedAuth.baseUrl } - : {}), - ...(runtimeRequestConfig.headers ? { headers: runtimeRequestConfig.headers } : {}), - }); - params.setEffectiveModel({ - ...params.getEffectiveModel(), - ...(paramsForApply.preparedAuth.baseUrl - ? { baseUrl: paramsForApply.preparedAuth.baseUrl } - : {}), - ...(runtimeRequestConfig.headers ? { headers: runtimeRequestConfig.headers } : {}), - }); + // while the shared applier strips privileged transport knobs. + params.setRuntimeModel(runtimeModel); + params.setEffectiveModel( + applyPreparedRuntimeAuthToModel(params.getEffectiveModel(), paramsForApply.preparedAuth), + ); }; const hasRefreshableRuntimeAuth = () => diff --git a/src/agents/provider-request-config.test.ts b/src/agents/provider-request-config.test.ts index a59638a2708f..2248e2030518 100644 --- a/src/agents/provider-request-config.test.ts +++ b/src/agents/provider-request-config.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { ConfiguredProviderRequest } from "../config/types.provider-request.js"; import type { SecretRef } from "../config/types.secrets.js"; import { + applyPreparedRuntimeAuthToModel, buildProviderRequestDispatcherPolicy, mergeModelProviderRequestOverrides, mergeProviderRequestOverrides, @@ -15,6 +16,33 @@ import { } from "./provider-request-config.js"; describe("provider request config", () => { + it("applies prepared runtime auth without retaining stale credential headers", () => { + const model = { + provider: "microsoft-foundry", + api: "anthropic-messages" as const, + baseUrl: "https://example.services.ai.azure.com/anthropic", + headers: { "X-Tenant": "tenant-a", "x-api-key": "old-key" }, + }; + + const bearerModel = applyPreparedRuntimeAuthToModel(model, { + request: { auth: { mode: "authorization-bearer", token: "entra-token" } }, + }); + expect(bearerModel.headers).toEqual({ + "X-Tenant": "tenant-a", + Authorization: "Bearer entra-token", + }); + + const apiKeyModel = applyPreparedRuntimeAuthToModel(bearerModel, { + request: { + auth: { mode: "header", headerName: "x-api-key", value: "profile-key" }, + }, + }); + expect(apiKeyModel.headers).toEqual({ + "X-Tenant": "tenant-a", + "x-api-key": "profile-key", + }); + }); + it("merges discovered, provider, and model headers in precedence order", () => { // Later scopes override earlier scopes: discovery < provider < model. const resolved = resolveProviderRequestConfig({ diff --git a/src/agents/provider-request-config.ts b/src/agents/provider-request-config.ts index 05efc6ab91fc..42043523e9e0 100644 --- a/src/agents/provider-request-config.ts +++ b/src/agents/provider-request-config.ts @@ -551,6 +551,47 @@ export function sanitizeRuntimeProviderRequestOverrides( }; } +/** Applies provider-prepared runtime auth overrides to a resolved model. */ +export function applyPreparedRuntimeAuthToModel< + T extends { + provider: string; + api?: RequestApi; + baseUrl?: string; + headers?: Record; + }, +>( + model: T, + preparedAuth: + | { baseUrl?: string; request?: ModelProviderRequestTransportOverrides } + | null + | undefined, +): T { + if (!preparedAuth?.baseUrl && !preparedAuth?.request) { + return model; + } + const providerHeaders = preparedAuth.request?.auth + ? Object.fromEntries( + Object.entries(model.headers ?? {}).filter( + ([key]) => !["authorization", "api-key", "x-api-key"].includes(key.toLowerCase()), + ), + ) + : model.headers; + const requestConfig = resolveProviderRequestConfig({ + provider: model.provider, + api: model.api, + baseUrl: preparedAuth.baseUrl ?? model.baseUrl, + providerHeaders, + request: sanitizeRuntimeProviderRequestOverrides(preparedAuth.request), + capability: "llm", + transport: "stream", + }); + return { + ...model, + ...(preparedAuth.baseUrl ? { baseUrl: preparedAuth.baseUrl } : {}), + headers: requestConfig.headers, + }; +} + function resolveProxyOverride( request: ProviderRequestTransportOverrides | undefined, ): ResolvedProviderRequestProxyConfig { diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts index 159d7e6db551..5ca5ae359636 100644 --- a/src/agents/simple-completion-runtime.ts +++ b/src/agents/simple-completion-runtime.ts @@ -24,6 +24,7 @@ import { type ResolvedProviderAuth, } from "./model-auth.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; +import { applyPreparedRuntimeAuthToModel } from "./provider-request-config.js"; import { buildModelAliasIndex, resolveDefaultModelForAgent, @@ -38,7 +39,7 @@ type SimpleCompletionAuthStorage = { type CompletionRuntimeCredential = { apiKey: string; - baseUrl?: string; + model: Model; }; type AllowedMissingApiKeyMode = ResolvedProviderAuth["mode"]; @@ -158,7 +159,7 @@ async function setRuntimeApiKeyForCompletion(params: { params.authStorage.setRuntimeApiKey(params.model.provider, copilotToken.token); return { apiKey: copilotToken.token, - baseUrl: copilotToken.baseUrl, + model: { ...params.model, baseUrl: copilotToken.baseUrl }, }; } const preparedAuth = await prepareProviderRuntimeAuth({ @@ -182,7 +183,7 @@ async function setRuntimeApiKeyForCompletion(params: { params.authStorage.setRuntimeApiKey(params.model.provider, runtimeApiKey); return { apiKey: runtimeApiKey, - baseUrl: preparedAuth?.baseUrl, + model: applyPreparedRuntimeAuthToModel(params.model, preparedAuth), }; } @@ -271,13 +272,7 @@ export async function prepareSimpleCompletionModel(params: { profileId: auth.profileId, }); resolvedApiKey = runtimeCredential.apiKey; - const runtimeBaseUrl = runtimeCredential.baseUrl?.trim(); - if (runtimeBaseUrl) { - resolvedModel = { - ...resolvedModel, - baseUrl: runtimeBaseUrl, - }; - } + resolvedModel = runtimeCredential.model; } const resolvedAuth: ResolvedProviderAuth = {