diff --git a/src/agents/auth-profiles/external-oauth.test.ts b/src/agents/auth-profiles/external-oauth.test.ts index 570dea07a641..f4c1b9a17c27 100644 --- a/src/agents/auth-profiles/external-oauth.test.ts +++ b/src/agents/auth-profiles/external-oauth.test.ts @@ -11,6 +11,13 @@ import { addEnvBackedAgentCredentials } from "../agent-auth-discovery-core.js"; import { overlayExternalAuthProfiles } from "./external-auth.js"; import { testing } from "./external-auth.test-support.js"; import { readExternalCliBootstrapCredential } from "./external-cli-sync.js"; +import { registerRuntimeAuthProfileStoreMutationListener } from "./runtime-snapshots.js"; +import { + clearRuntimeAuthProfileStoreSnapshots, + ensureAuthProfileStore, + getRuntimeAuthProfileStoreSnapshot, + replaceRuntimeAuthProfileStoreSnapshots, +} from "./store.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; const resolveExternalAuthProfilesWithPluginsMock = vi.fn< @@ -55,6 +62,7 @@ function requireProfile(store: AuthProfileStore, profileId: string): Record { beforeEach(() => { + clearRuntimeAuthProfileStoreSnapshots(); resolveExternalAuthProfilesWithPluginsMock.mockReset(); resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([]); readCodexCliCredentialsCachedMock.mockReset(); @@ -63,6 +71,7 @@ describe("auth external oauth helpers", () => { }); afterEach(() => { + clearRuntimeAuthProfileStoreSnapshots(); testing.resetResolveExternalAuthProfilesForTest(); }); @@ -105,6 +114,99 @@ describe("auth external oauth helpers", () => { expect(readCodexCliCredentialsCachedMock).toHaveBeenCalledTimes(1); }); + it("publishes a usable scoped CLI bootstrap into the runtime auth owner", () => { + const agentDir = "/tmp/openclaw-external-oauth-publication"; + readCodexCliCredentialsCachedMock.mockReturnValue( + createCredential({ expires: createUsableOAuthExpiry() }), + ); + const listener = vi.fn(); + const unregister = registerRuntimeAuthProfileStoreMutationListener(listener); + try { + const scoped = ensureAuthProfileStore(agentDir, { + externalCliProviderIds: ["openai"], + allowKeychainPrompt: false, + readOnly: true, + syncExternalCli: false, + }); + + expect(scoped.profiles["openai:default"]?.type).toBe("oauth"); + expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]?.type).toBe( + "oauth", + ); + expect(listener).toHaveBeenCalledWith({ agentDir, affectsInheritedStores: false }); + } finally { + unregister(); + } + }); + + it("does not replace an explicit unresolved API-key profile with CLI OAuth", () => { + const agentDir = "/tmp/openclaw-external-oauth-explicit-owner"; + const explicit = createStore({ + "openai:default": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }); + replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: explicit }]); + readCodexCliCredentialsCachedMock.mockReturnValue( + createCredential({ expires: createUsableOAuthExpiry() }), + ); + const listener = vi.fn(); + const unregister = registerRuntimeAuthProfileStoreMutationListener(listener); + try { + const scoped = ensureAuthProfileStore(agentDir, { + externalCliProviderIds: ["openai"], + allowKeychainPrompt: false, + readOnly: true, + syncExternalCli: false, + }); + + expect(scoped.profiles["openai:default"]).toEqual(explicit.profiles["openai:default"]); + expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toEqual( + explicit.profiles["openai:default"], + ); + expect(listener).not.toHaveBeenCalled(); + } finally { + unregister(); + } + }); + + it("preserves resolved runtime refs when startup publishes scoped external auth", () => { + const agentDir = "/tmp/openclaw-external-oauth-prepared-owner"; + const resolved = createStore({ + "openai:configured": { + type: "api_key", + provider: "openai", + key: "resolved-runtime-key", + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }); + replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: resolved }]); + readCodexCliCredentialsCachedMock.mockReturnValue( + createCredential({ expires: createUsableOAuthExpiry() }), + ); + const listener = vi.fn(); + const unregister = registerRuntimeAuthProfileStoreMutationListener(listener); + try { + const hydrated = ensureAuthProfileStore(agentDir, { + externalCliProviderIds: ["openai"], + allowKeychainPrompt: false, + readOnly: true, + syncExternalCli: false, + }); + + expect(hydrated.profiles["openai:configured"]).toEqual( + resolved.profiles["openai:configured"], + ); + expect(hydrated.profiles["openai:default"]?.type).toBe("oauth"); + expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles).toEqual(hydrated.profiles); + expect(listener).toHaveBeenCalledOnce(); + } finally { + unregister(); + } + }); + it("keeps ambient Codex OAuth from outranking an env key under an api-key pin", () => { const cfg = { models: { diff --git a/src/agents/auth-profiles/runtime-materializations.ts b/src/agents/auth-profiles/runtime-materializations.ts new file mode 100644 index 000000000000..a41b8cea9441 --- /dev/null +++ b/src/agents/auth-profiles/runtime-materializations.ts @@ -0,0 +1,126 @@ +import { isDeepStrictEqual } from "node:util"; +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { resolveAuthProfileDatabasePath } from "./sqlite.js"; + +/** Secret-free proof that one exact provider/model transport completed with usable auth. */ +export type RuntimeAuthMaterialization = Readonly<{ + provider: string; + modelId: string; + modelApi: string; + modelBaseUrl: string; + requestTransportOverrides: "none" | "present"; + authMode: string; + runtimeOwnerId: string; + authProfileId?: string; +}>; + +type RuntimeAuthMaterializationMutationListener = (event: { + agentDir?: string; + affectsInheritedStores: boolean; +}) => void; + +const MAX_RUNTIME_AUTH_MATERIALIZATIONS_PER_OWNER = 64; +const materializations = new Map(); +const listeners = new Set(); + +function ownerKey(agentDir?: string): string { + return resolveAuthProfileDatabasePath(agentDir); +} + +function notify(agentDir?: string): void { + const event = { + ...(agentDir ? { agentDir } : {}), + affectsInheritedStores: agentDir === undefined, + }; + for (const listener of listeners) { + listener(event); + } +} + +export function registerRuntimeAuthMaterializationMutationListener( + listener: RuntimeAuthMaterializationMutationListener, +): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Records successful auth at the boundary that proved one exact runtime route. */ +export function recordRuntimeAuthMaterialization(params: { + agentDir?: string; + provider: string; + modelId: string; + modelApi: string; + modelBaseUrl: string; + requestTransportOverrides: "none" | "present"; + authMode: string; + runtimeOwnerId: string; + authProfileId?: string; +}): boolean { + const provider = normalizeProviderId(params.provider); + const fact: RuntimeAuthMaterialization = { + provider, + modelId: params.modelId.trim().toLowerCase(), + modelApi: params.modelApi.trim().toLowerCase(), + modelBaseUrl: params.modelBaseUrl.trim(), + requestTransportOverrides: params.requestTransportOverrides, + authMode: params.authMode.trim().toLowerCase(), + runtimeOwnerId: params.runtimeOwnerId.trim().toLowerCase(), + ...(params.authProfileId?.trim() ? { authProfileId: params.authProfileId.trim() } : {}), + }; + if (Object.values(fact).some((value) => !value)) { + return false; + } + const key = ownerKey(params.agentDir); + const existing = materializations.get(key) ?? []; + if (existing.some((candidate) => isDeepStrictEqual(candidate, fact))) { + return false; + } + materializations.set( + key, + [...existing, fact].slice(-MAX_RUNTIME_AUTH_MATERIALIZATIONS_PER_OWNER), + ); + notify(params.agentDir); + return true; +} + +/** Revokes all facts backed by one runtime owner after a classified auth failure. */ +export function revokeRuntimeAuthMaterializations(params: { + agentDir?: string; + provider: string; + runtimeOwnerId: string; +}): boolean { + const key = ownerKey(params.agentDir); + const provider = normalizeProviderId(params.provider); + const runtimeOwnerId = params.runtimeOwnerId.trim().toLowerCase(); + const existing = materializations.get(key); + if (!provider || !runtimeOwnerId || !existing) { + return false; + } + const next = existing.filter( + (fact) => fact.provider !== provider || fact.runtimeOwnerId !== runtimeOwnerId, + ); + if (next.length === existing.length) { + return false; + } + if (next.length) { + materializations.set(key, next); + } else { + materializations.delete(key); + } + notify(params.agentDir); + return true; +} + +export function getPreparedRuntimeAuthMaterializations( + agentDir?: string, +): readonly RuntimeAuthMaterialization[] { + return materializations.get(ownerKey(agentDir)) ?? []; +} + +export function clearRuntimeAuthMaterializations(agentDir?: string): void { + materializations.delete(ownerKey(agentDir)); +} + +export function clearAllRuntimeAuthMaterializations(): void { + materializations.clear(); +} diff --git a/src/agents/auth-profiles/runtime-snapshots.test.ts b/src/agents/auth-profiles/runtime-snapshots.test.ts index 540ede463465..929cb1d8f9a1 100644 --- a/src/agents/auth-profiles/runtime-snapshots.test.ts +++ b/src/agents/auth-profiles/runtime-snapshots.test.ts @@ -5,6 +5,12 @@ import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it, vi } from "vitest"; +import { + getPreparedRuntimeAuthMaterializations, + recordRuntimeAuthMaterialization, + registerRuntimeAuthMaterializationMutationListener, + revokeRuntimeAuthMaterializations, +} from "./runtime-materializations.js"; import { clearRuntimeAuthProfileStoreSnapshot, clearRuntimeAuthProfileStoreSnapshots, @@ -60,6 +66,97 @@ function expectOpenAICodexSnapshotCredential( } describe("runtime auth profile snapshots", () => { + it("marks default-owner materializations as inherited mutations", () => { + const listener = vi.fn(); + const unregister = registerRuntimeAuthMaterializationMutationListener(listener); + try { + recordRuntimeAuthMaterialization({ + provider: "openai", + modelId: "gpt-5.4", + modelApi: "openai-chatgpt-responses", + modelBaseUrl: "https://chatgpt.com/backend-api/codex", + requestTransportOverrides: "none", + authMode: "oauth", + runtimeOwnerId: "codex", + }); + + expect(listener).toHaveBeenCalledWith({ affectsInheritedStores: true }); + } finally { + unregister(); + clearRuntimeAuthProfileStoreSnapshots(); + } + }); + + it("publishes successful-auth facts without impersonating credential rotation", () => { + const agentDir = "/tmp/openclaw-auth-runtime-materialized"; + const pluginStoreListener = vi.fn(); + const materializationListener = vi.fn(); + setRuntimeAuthProfileStoreSnapshot(createStore("materialized"), agentDir); + const unregisterStore = registerRuntimeAuthProfileStoreMutationListener(pluginStoreListener); + const unregisterMaterialization = + registerRuntimeAuthMaterializationMutationListener(materializationListener); + try { + const materialization = { + agentDir, + provider: "openai", + modelId: "gpt-5.4", + modelApi: "openai-chatgpt-responses", + modelBaseUrl: "https://chatgpt.com/backend-api/codex", + requestTransportOverrides: "none", + authMode: "oauth", + runtimeOwnerId: "codex", + authProfileId: "openai:default", + } as const; + expect(recordRuntimeAuthMaterialization(materialization)).toBe(true); + expect(recordRuntimeAuthMaterialization(materialization)).toBe(false); + expect(getPreparedRuntimeAuthMaterializations(agentDir)).toEqual([ + { + provider: "openai", + modelId: "gpt-5.4", + modelApi: "openai-chatgpt-responses", + modelBaseUrl: "https://chatgpt.com/backend-api/codex", + requestTransportOverrides: "none", + authMode: "oauth", + runtimeOwnerId: "codex", + authProfileId: "openai:default", + }, + ]); + const sibling = { ...materialization, modelId: "gpt-5.5" }; + const distinctOwner = { ...materialization, runtimeOwnerId: "other-harness" }; + recordRuntimeAuthMaterialization(sibling); + recordRuntimeAuthMaterialization(distinctOwner); + expect( + revokeRuntimeAuthMaterializations({ + agentDir, + provider: "openai", + runtimeOwnerId: "codex", + }), + ).toBe(true); + expect( + revokeRuntimeAuthMaterializations({ + agentDir, + provider: "openai", + runtimeOwnerId: "codex", + }), + ).toBe(false); + expect(getPreparedRuntimeAuthMaterializations(agentDir)).toEqual([ + expect.objectContaining({ runtimeOwnerId: "other-harness", modelId: "gpt-5.4" }), + ]); + expect(materializationListener).toHaveBeenCalledTimes(4); + expect(pluginStoreListener).not.toHaveBeenCalled(); + + recordRuntimeAuthMaterialization(materialization); + + setRuntimeAuthProfileStoreSnapshot(createStore("replaced"), agentDir); + expect(getPreparedRuntimeAuthMaterializations(agentDir)).toEqual([]); + expect(pluginStoreListener).toHaveBeenCalledOnce(); + } finally { + unregisterMaterialization(); + unregisterStore(); + clearRuntimeAuthProfileStoreSnapshots(); + } + }); + it("notifies listeners only when credential ownership changes", () => { const agentDir = "/tmp/openclaw-auth-runtime-listener"; const listener = vi.fn(); diff --git a/src/agents/auth-profiles/runtime-snapshots.ts b/src/agents/auth-profiles/runtime-snapshots.ts index 64b13e88b8c2..bcfc6def53d8 100644 --- a/src/agents/auth-profiles/runtime-snapshots.ts +++ b/src/agents/auth-profiles/runtime-snapshots.ts @@ -6,6 +6,10 @@ import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import { cloneAuthProfileStore } from "./clone.js"; import { mergeAuthProfileStores } from "./persisted.js"; +import { + clearAllRuntimeAuthMaterializations, + clearRuntimeAuthMaterializations, +} from "./runtime-materializations.js"; import { resolveAuthProfileDatabasePath } from "./sqlite.js"; import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js"; @@ -208,6 +212,13 @@ function notifyRuntimeAuthStoreMutation(agentDir?: string): void { } } +function authProfilesChanged( + previous: RuntimeAuthProfileStore | undefined, + next: RuntimeAuthProfileStore | undefined, +): boolean { + return !isDeepStrictEqual(previous?.profiles ?? {}, next?.profiles ?? {}); +} + /** Observes credential snapshot changes at their lifecycle publication edge. */ export function registerRuntimeAuthProfileStoreMutationListener( listener: RuntimeAuthProfileStoreMutationListener, @@ -283,6 +294,14 @@ export function replaceRuntimeAuthProfileStoreSnapshots( if (credentialsChanged) { runtimeAuthStoreCredentialsRevision += 1; } + const next = new Map( + entries.map((entry) => [resolveRuntimeStoreKey(entry.agentDir), entry.store] as const), + ); + for (const key of new Set([...runtimeAuthStoreSnapshots.keys(), ...next.keys()])) { + if (authProfilesChanged(runtimeAuthStoreSnapshots.get(key), next.get(key))) { + clearRuntimeAuthMaterializations(path.dirname(key)); + } + } recordChangedSnapshotRevisions(entries); runtimeAuthStoreSnapshots.clear(); for (const entry of entries) { @@ -307,6 +326,7 @@ export function clearRuntimeAuthProfileStoreSnapshots(): void { runtimeAuthStoreSnapshotsRevision += 1; } runtimeAuthStoreSnapshots.clear(); + clearAllRuntimeAuthMaterializations(); runtimeAuthStoreSnapshotRevisions.clear(); if (snapshotsChanged) { notifyRuntimeAuthStoreMutation(); @@ -325,6 +345,7 @@ export function clearRuntimeAuthProfileStoreSnapshot(agentDir?: string): boolean } runtimeAuthStoreSnapshotsRevision += 1; runtimeAuthStoreSnapshots.delete(key); + clearRuntimeAuthMaterializations(agentDir); runtimeAuthStoreSnapshotRevisions.delete(key); notifyRuntimeAuthStoreMutation(agentDir); return true; @@ -346,6 +367,9 @@ export function setRuntimeAuthProfileStoreSnapshot( runtimeAuthStoreCredentialsRevision += 1; } const previousStore = runtimeAuthStoreSnapshots.get(key); + if (authProfilesChanged(previousStore, store)) { + clearRuntimeAuthMaterializations(agentDir); + } const ownerChanged = !isDeepStrictEqual(ownerState(previousStore), ownerState(store)); const snapshotChanged = !isDeepStrictEqual(previousStore, store); if (snapshotChanged) { @@ -381,6 +405,9 @@ export function noteRuntimeAuthProfileStorePersistedMutation( runtimeAuthStoreCredentialsRevision += 1; } const ownerKey = resolveRuntimeStoreKey(agentDir); + if (mutation.credentialsChanged || mutation.profileSetChanged) { + clearRuntimeAuthMaterializations(agentDir); + } const record = getOrCreatePersistedMutationRecord(ownerKey); if (mutation.profileSetChanged) { record.profileSetRevision = persistedMutationRevision; diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index fe3dd1d86bb0..525cd9376218 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -1170,7 +1170,23 @@ export function ensureAuthProfileStore( ...externalCli, }, ); - if (!runtimeStore || hasScopedExternalCliOverlay(externalCli)) { + if (!runtimeStore) { + if ( + hasScopedExternalCliOverlay(externalCli) && + (store.runtimeExternalProfileIds?.length ?? 0) > 0 + ) { + setRuntimeAuthProfileStoreSnapshot(store, effectiveAgentDir); + } + return store; + } + if (hasScopedExternalCliOverlay(externalCli)) { + // Scoped turn/control-plane resolution returns only the requested overlay, but the lifecycle + // snapshot must retain unrelated external profiles. Publish the merged owner fact so prepared + // model and chat metadata generations converge without reopening credential sources. + const materialized = mergeRuntimeExternalProfileState({ next: store, existing: runtimeStore }); + if (!isDeepStrictEqual(materialized, runtimeStore)) { + setRuntimeAuthProfileStoreSnapshot(materialized, effectiveAgentDir); + } return store; } return mergeRuntimeExternalProfileState({ diff --git a/src/agents/embedded-agent-runner/run-loop.ts b/src/agents/embedded-agent-runner/run-loop.ts index d038fd324e7e..d2a614f03f8e 100644 --- a/src/agents/embedded-agent-runner/run-loop.ts +++ b/src/agents/embedded-agent-runner/run-loop.ts @@ -264,6 +264,7 @@ export async function runPreparedEmbeddedLoop( getLastProfileId: () => preparedRuntime.snapshot().lastProfileId, getSessionId: () => sessionPromptState.sessionId, harnessOwnsTransport: () => preparedRuntime.snapshot().pluginHarnessOwnsTransport, + getRuntimeAuthOwnerId: () => preparedRuntime.snapshot().agentHarness.id, getApiKeyInfo, }); const ownsContextEngineLogicalTurnLease = params.contextEngineLogicalTurnLease === undefined; @@ -602,6 +603,11 @@ export async function runPreparedEmbeddedLoop( return terminalTimeoutResult; } + const terminalAuthPlan = preparedRuntime.snapshot().activePreparedAuthPlan; + const requestTransportOverrides = + terminalAuthPlan.modelRoute?.requestTransportOverrides ?? + terminalAuthPlan.deferredRouteSupport?.requestTransportOverrides ?? + "none"; const terminalResolution = await resolveEmbeddedRunTerminal({ runParams: params, retryState: terminalRetryState, @@ -638,6 +644,8 @@ export async function runPreparedEmbeddedLoop( modelId, modelTransportId: effectiveModel.id ?? modelId, modelTransportApi: effectiveModel.api ?? model.api, + ...(effectiveModel.baseUrl ? { modelTransportBaseUrl: effectiveModel.baseUrl } : {}), + requestTransportOverrides, authProfileId: lastProfileId, profileFailureStore, attemptAuthProfileStore, diff --git a/src/agents/embedded-agent-runner/run/auth-profile-success.ts b/src/agents/embedded-agent-runner/run/auth-profile-success.ts index 8fa72822be4b..4c7015cf4610 100644 --- a/src/agents/embedded-agent-runner/run/auth-profile-success.ts +++ b/src/agents/embedded-agent-runner/run/auth-profile-success.ts @@ -1,9 +1,14 @@ import { sanitizeForLog } from "../../../../packages/terminal-core/src/ansi.js"; +import { MODEL_APIS, type ModelApi } from "../../../config/types.models.js"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { formatErrorMessage } from "../../../infra/errors.js"; import { redactIdentifier } from "../../../logging/redact-identifier.js"; +import type { ProviderRouteOverridePresence } from "../../../plugin-sdk/provider-model-types.js"; +import { resolveProviderModelRoutes } from "../../../plugins/provider-model-routes.js"; import { looksLikeSecretSentinel, resolveSecretSentinel } from "../../../secrets/sentinel.js"; import type { AuthProfileStore } from "../../auth-profiles.js"; import { markAuthProfileSuccess } from "../../auth-profiles.js"; +import { recordRuntimeAuthMaterialization } from "../../auth-profiles/runtime-materializations.js"; import { fingerprintAuthProfileOwnerShape, fingerprintAwsSdkRuntimeOwner, @@ -13,6 +18,7 @@ import { type AgentExecutionAuthBinding, } from "../../execution-auth-binding.js"; import type { ResolvedProviderAuth } from "../../model-auth.js"; +import { modelMatchesProviderModelRoute } from "../../provider-model-route.js"; import { log } from "../logger.js"; import type { EmbeddedRunAttemptResult } from "./types.js"; @@ -75,8 +81,12 @@ export function reportEmbeddedRunSuccessfulAuthBinding(input: { apiKeyInfo: ResolvedProviderAuth | null; attempt: EmbeddedRunAttemptResult; provider: string; + agentDir?: string; modelId: string; modelApi: string; + modelBaseUrl?: string; + requestTransportOverrides?: ProviderRouteOverridePresence; + config?: OpenClawConfig; agentHarnessId: string; pluginHarnessOwnsTransport: boolean; pluginHarnessOwnsAuthBootstrap: boolean; @@ -140,6 +150,20 @@ export function reportEmbeddedRunSuccessfulAuthBinding(input: { : input.pluginHarnessOwnsTransport ? ("plugin-harness" as const) : undefined; + const materializedRoute = resolveOpaqueHarnessMaterialization(input, credential); + if (materializedRoute) { + recordRuntimeAuthMaterialization({ + agentDir: input.agentDir, + provider: input.provider, + modelId: input.modelId, + modelApi: materializedRoute.api, + modelBaseUrl: materializedRoute.baseUrl, + requestTransportOverrides: materializedRoute.requestTransportOverrides, + authMode: materializedRoute.authRequirement === "subscription" ? "oauth" : "api-key", + runtimeOwnerId: input.agentHarnessId, + ...(input.profileId ? { authProfileId: input.profileId } : {}), + }); + } input.onSuccessfulAuthBinding?.({ ...(input.profileId ? { authProfileId: input.profileId } : {}), agentHarnessId: input.agentHarnessId, @@ -158,6 +182,63 @@ export function reportEmbeddedRunSuccessfulAuthBinding(input: { }); } +function resolveOpaqueHarnessMaterialization( + input: Parameters[0], + credential: AuthProfileStore["profiles"][string] | undefined, +) { + if ( + !input.pluginHarnessOwnsAuthBootstrap || + input.apiKeyInfo || + hasInlineCredentialMaterial(credential) || + !isModelApi(input.modelApi) + ) { + return undefined; + } + const resolution = resolveProviderModelRoutes({ + provider: input.provider, + modelId: input.modelId, + api: input.modelApi, + baseUrl: input.modelBaseUrl, + config: input.config, + requestTransportOverrides: input.requestTransportOverrides ?? "none", + }); + if (resolution?.kind !== "routes") { + return undefined; + } + const routes = resolution.routes.filter( + (route) => + route.api === input.modelApi && + route.requestTransportOverrides === (input.requestTransportOverrides ?? "none") && + (!input.modelBaseUrl || + modelMatchesProviderModelRoute({ + provider: input.provider, + api: input.modelApi, + baseUrl: input.modelBaseUrl, + route, + })), + ); + return routes.length === 1 ? routes[0] : undefined; +} + +function isModelApi(value: string): value is ModelApi { + return (MODEL_APIS as readonly string[]).includes(value); +} + +function hasInlineCredentialMaterial( + credential: AuthProfileStore["profiles"][string] | undefined, +): boolean { + if (!credential) { + return false; + } + if (credential.type === "api_key") { + return Boolean(credential.key?.trim()); + } + if (credential.type === "token") { + return Boolean(credential.token?.trim()); + } + return Boolean(credential.access?.trim() && credential.refresh?.trim()); +} + function resolvePluginHarnessApiKeyInfo(input: { apiKeyInfo: ResolvedProviderAuth | null; pluginHarnessOwnsTransport: boolean; diff --git a/src/agents/embedded-agent-runner/run/failover-retry-controller.ts b/src/agents/embedded-agent-runner/run/failover-retry-controller.ts index 768e5e806c71..82b08f5844cd 100644 --- a/src/agents/embedded-agent-runner/run/failover-retry-controller.ts +++ b/src/agents/embedded-agent-runner/run/failover-retry-controller.ts @@ -5,6 +5,7 @@ import { markAuthProfileFailure, markInlineProviderApiKeyFailure, } from "../../auth-profiles.js"; +import { revokeRuntimeAuthMaterializations } from "../../auth-profiles/runtime-materializations.js"; import type { FailoverReason } from "../../embedded-agent-helpers.js"; import { FailoverError, resolveFailoverStatus } from "../../failover-error.js"; import { isConfigBackedInlineProviderApiKey, type ResolvedProviderAuth } from "../../model-auth.js"; @@ -34,6 +35,7 @@ export function createEmbeddedRunFailoverRetryController(input: { getLastProfileId: () => string | undefined; getSessionId: () => string; harnessOwnsTransport: () => boolean; + getRuntimeAuthOwnerId: () => string; getApiKeyInfo: () => ResolvedProviderAuth | null; }) { const { @@ -111,10 +113,17 @@ export function createEmbeddedRunFailoverRetryController(input: { reason?: AuthProfileFailureReason | null; modelId?: string; }) => { + const { profileId, reason } = failure; + if (input.harnessOwnsTransport() && (reason === "auth" || reason === "auth_permanent")) { + revokeRuntimeAuthMaterializations({ + agentDir, + provider, + runtimeOwnerId: input.getRuntimeAuthOwnerId(), + }); + } if (params.authProfileStateMode === "read-only") { return; } - const { profileId, reason } = failure; if (!reason) { return; } diff --git a/src/agents/embedded-agent-runner/run/terminal-resolution.ts b/src/agents/embedded-agent-runner/run/terminal-resolution.ts index 1c3e069e8505..92a51c2a4cd9 100644 --- a/src/agents/embedded-agent-runner/run/terminal-resolution.ts +++ b/src/agents/embedded-agent-runner/run/terminal-resolution.ts @@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto"; import { SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js"; import { freezeDiagnosticTraceContext } from "../../../infra/diagnostic-trace-context.js"; import type { AssistantMessage } from "../../../llm/types.js"; +import type { ProviderRouteOverridePresence } from "../../../plugin-sdk/provider-model-types.js"; import { projectAgentRunAttemptTerminal } from "../../agent-run-terminal-outcome.js"; import type { AuthProfileFailureReason, AuthProfileStore } from "../../auth-profiles.js"; import type { AgentExecutionAuthBinding } from "../../execution-auth-binding.js"; @@ -183,6 +184,8 @@ export async function resolveEmbeddedRunTerminal(input: { modelId: string; modelTransportId: string; modelTransportApi: string; + modelTransportBaseUrl?: string; + requestTransportOverrides?: ProviderRouteOverridePresence; authProfileId?: string; profileFailureStore: AuthProfileStore; attemptAuthProfileStore: AuthProfileStore; @@ -522,8 +525,12 @@ function completeEmbeddedRun( apiKeyInfo: input.apiKeyInfo, attempt: input.attempt, provider: input.provider, + agentDir: input.runParams.agentDir, modelId: input.modelTransportId, modelApi: input.modelTransportApi, + ...(input.modelTransportBaseUrl ? { modelBaseUrl: input.modelTransportBaseUrl } : {}), + requestTransportOverrides: input.requestTransportOverrides ?? "none", + config: input.runParams.config, agentHarnessId: input.agentHarnessId, pluginHarnessOwnsTransport: input.pluginHarnessOwnsTransport, pluginHarnessOwnsAuthBootstrap: input.pluginHarnessOwnsAuthBootstrap, diff --git a/src/agents/model-auth-availability.test.ts b/src/agents/model-auth-availability.test.ts index 66814186dff3..6aab5a566345 100644 --- a/src/agents/model-auth-availability.test.ts +++ b/src/agents/model-auth-availability.test.ts @@ -6,6 +6,7 @@ import type { } from "../plugin-sdk/provider-model-types.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import type { PreparedAgentCredentialModes } from "./agent-auth-credentials.js"; +import type { RuntimeAuthMaterialization } from "./auth-profiles/runtime-materializations.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; import { createModelAuthAvailabilityResolver, @@ -59,6 +60,7 @@ function evaluate(params: { preparedRuntimeAuthStore?: AuthProfileStore; syntheticAuthProviderRefs?: readonly string[]; preparedRuntimeAuthModes?: PreparedAgentCredentialModes; + preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[]; }) { return createModelAuthAvailabilityResolver({ cfg: (params.cfg ?? {}) as OpenClawConfig, @@ -68,6 +70,7 @@ function evaluate(params: { syntheticAuthProviderRefs: params.syntheticAuthProviderRefs, preparedRuntimeAuthModes: params.preparedRuntimeAuthModes, preparedRuntimeAuthStore: params.preparedRuntimeAuthStore, + preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations, }).evaluateModelAuth("openai", params.ref); } @@ -158,6 +161,67 @@ describe("createModelAuthAvailabilityResolver", () => { }, ); + it("keeps successful harness auth scoped to the exact model route", () => { + const materialization = { + provider: "openai", + modelId: "gpt-5.4", + modelApi: "openai-chatgpt-responses", + modelBaseUrl: "https://chatgpt.com/backend-api/codex", + requestTransportOverrides: "none", + authMode: "oauth", + runtimeOwnerId: "codex", + } as const; + const store = authStore({ + "openai:default": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }); + + expect( + evaluate({ + store, + ref: { modelId: "gpt-5.4" }, + preparedRuntimeAuthMaterializations: [materialization], + }), + ).toMatchObject({ + availability: true, + evidence: "runtime", + selectedRoute: subscriptionRoute, + }); + expect( + evaluate({ + store, + ref: { modelId: "gpt-5.5" }, + preparedRuntimeAuthMaterializations: [materialization], + }).availability, + ).not.toBe(true); + expect( + evaluate({ + cfg: { + models: { + providers: { + openai: { + auth: "api-key", + apiKey: "configured-platform-key", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + }, + store, + ref: { modelId: "gpt-5.4" }, + preparedRuntimeAuthMaterializations: [materialization], + }), + ).toMatchObject({ + availability: true, + evidence: "provider-config", + selectedRoute: platformRoute, + }); + }); + it.each([ { label: "resolved", key: "runtime-key", availability: true }, { label: "unresolved", key: undefined, availability: undefined }, diff --git a/src/agents/model-auth-availability.ts b/src/agents/model-auth-availability.ts index 0cb466ba26d2..903b55f207cb 100644 --- a/src/agents/model-auth-availability.ts +++ b/src/agents/model-auth-availability.ts @@ -31,6 +31,7 @@ import { resolveSecretRefReadOnlyAvailability, resolveStoredCredentialReadOnlyAvailability, } from "./auth-profiles/read-only-availability.js"; +import type { RuntimeAuthMaterialization } from "./auth-profiles/runtime-materializations.js"; import { getRuntimeAuthProfileStoreSnapshot } from "./auth-profiles/runtime-snapshots.js"; import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js"; import { @@ -71,6 +72,7 @@ import { selectProviderModelAuthSources, type ProviderModelAuthSourceSelection, } from "./provider-model-route-auth.js"; +import { modelMatchesProviderModelRoute } from "./provider-model-route.js"; const OPENAI_PROVIDER_ID = "openai"; const OPENAI_CODEX_RESPONSES_API = "openai-chatgpt-responses"; @@ -122,6 +124,7 @@ type CreateModelAuthAvailabilityResolverParams = { allowPreparedRuntimeAuth?: boolean; preparedRuntimeAuthStore?: AuthProfileStore; preparedRuntimeAuthModes?: PreparedAgentCredentialModes; + preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[]; }; type AuthTarget = ModelAuthAvailabilityRef & { @@ -888,6 +891,81 @@ export function createModelAuthAvailabilityResolver( !modelLock && !awsSdkTerminal && basePolicy.binding.kind === "profile" ? basePolicy.binding.profileId : undefined; + const explicitProfileOrder = profileOrder( + provider, + ref.modelId, + ref.preferredProfileId, + ref.lockedProfileId, + ).hasExplicitOrder; + const materializedModelId = ref.modelId + ? normalizeModelIdForProvider(provider, ref.modelId)?.toLowerCase() + : undefined; + const materialized = + !modelLock && + !bindingProfileId && + !basePolicy.required && + !explicitProfileOrder && + materializedModelId + ? params.preparedRuntimeAuthMaterializations?.find( + (fact) => + normalizeProvider(fact.provider) === provider && + fact.modelId === materializedModelId && + routeResolution.routes.some((route) => { + const configuredRequirement = + resolveProviderModelRouteAuthRequirement(configuredAuthMode); + return ( + (!configuredRequirement || configuredRequirement === route.authRequirement) && + route.runtimePolicy?.compatibleIds.some( + (runtimeId) => runtimeId.trim().toLowerCase() === fact.runtimeOwnerId, + ) === true && + route.api.toLowerCase() === fact.modelApi && + route.requestTransportOverrides === fact.requestTransportOverrides && + modelMatchesProviderModelRoute({ + provider, + api: fact.modelApi, + baseUrl: fact.modelBaseUrl, + route, + }) && + modeAllowed( + provider, + { + ...ref, + api: route.api, + baseUrl: route.baseUrl, + authRequirement: route.authRequirement, + }, + fact.authMode, + ) + ); + }), + ) + : undefined; + if (materialized) { + const selectedRoute = routeResolution.routes.find( + (route) => + route.runtimePolicy?.compatibleIds.some( + (runtimeId) => runtimeId.trim().toLowerCase() === materialized.runtimeOwnerId, + ) === true && + route.api.toLowerCase() === materialized.modelApi && + route.requestTransportOverrides === materialized.requestTransportOverrides && + modelMatchesProviderModelRoute({ + provider, + api: materialized.modelApi, + baseUrl: materialized.modelBaseUrl, + route, + }), + ); + if (selectedRoute) { + return { + availability: true, + routeResolution, + selectedRoute, + selectedAuthMode: materialized.authMode, + ...(materialized.authProfileId ? { selectedProfileId: materialized.authProfileId } : {}), + evidence: "runtime", + }; + } + } const selectedConfiguredMode = awsSdkTerminal ? "aws-sdk" : bindingProfileId diff --git a/src/agents/prepared-model-runtime-auth.ts b/src/agents/prepared-model-runtime-auth.ts new file mode 100644 index 000000000000..e3cc0995f360 --- /dev/null +++ b/src/agents/prepared-model-runtime-auth.ts @@ -0,0 +1,21 @@ +/** Secret-free successful-auth facts owned by an immutable prepared model generation. */ +import type { RuntimeAuthMaterialization } from "./auth-profiles/runtime-materializations.js"; +import type { PreparedModelRuntimeSnapshot } from "./prepared-model-runtime.types.js"; + +const materializationsBySnapshot = new WeakMap< + PreparedModelRuntimeSnapshot, + readonly RuntimeAuthMaterialization[] +>(); + +export function setPreparedModelRuntimeAuthMaterializations( + snapshot: PreparedModelRuntimeSnapshot, + materializations: readonly RuntimeAuthMaterialization[], +): void { + materializationsBySnapshot.set(snapshot, materializations); +} + +export function getPreparedModelRuntimeAuthMaterializations( + snapshot: PreparedModelRuntimeSnapshot, +): readonly RuntimeAuthMaterialization[] { + return materializationsBySnapshot.get(snapshot) ?? []; +} diff --git a/src/agents/prepared-model-runtime-materializations.ts b/src/agents/prepared-model-runtime-materializations.ts new file mode 100644 index 000000000000..6f9946c1377b --- /dev/null +++ b/src/agents/prepared-model-runtime-materializations.ts @@ -0,0 +1,65 @@ +import { + getPreparedRuntimeAuthMaterializations, + registerRuntimeAuthMaterializationMutationListener, + type RuntimeAuthMaterialization, +} from "./auth-profiles/runtime-materializations.js"; +import { setPreparedModelRuntimeAuthMaterializations } from "./prepared-model-runtime-auth.js"; +import { + normalizeOptionalDir, + type PreparedModelRuntimeOwner, +} from "./prepared-model-runtime.owner.js"; + +type MaterializationMutationEvent = { + agentDir?: string; + affectsInheritedStores: boolean; +}; + +export function registerPreparedRuntimeAuthMaterializationPublisher( + owners: ReadonlyMap, + notify: (event: { phase: "invalidated" | "published" }) => void, +): () => void { + return registerRuntimeAuthMaterializationMutationListener((event) => { + publishPreparedRuntimeAuthMaterializations({ + event, + owners, + onInvalidated: () => notify({ phase: "invalidated" }), + onPublished: () => notify({ phase: "published" }), + }); + }); +} + +function publishPreparedRuntimeAuthMaterializations(params: { + event: MaterializationMutationEvent; + owners: ReadonlyMap; + onInvalidated: () => void; + onPublished: () => void; + read?: (agentDir?: string) => readonly RuntimeAuthMaterialization[]; +}): void { + const event = { + ...params.event, + agentDir: normalizeOptionalDir(params.event.agentDir), + }; + const affectedOwners = [...params.owners.values()].flatMap((owner) => { + const affected = + event.affectsInheritedStores || + owner.input.agentDir === event.agentDir || + owner.input.inheritedAuthDir === event.agentDir; + return affected && owner.snapshot && !owner.pending && !owner.needsRefresh + ? [{ owner, snapshot: owner.snapshot }] + : []; + }); + if (affectedOwners.length === 0) { + return; + } + params.onInvalidated(); + const read = params.read ?? getPreparedRuntimeAuthMaterializations; + for (const { owner, snapshot } of affectedOwners) { + // A successful route only changes this bounded secret-free fact set. Rebuilding the model + // catalog here would pull plugin lifecycle work into the turn-completion boundary. + setPreparedModelRuntimeAuthMaterializations( + snapshot, + Object.freeze([...read(owner.input.agentDir)]), + ); + } + params.onPublished(); +} diff --git a/src/agents/prepared-model-runtime.build.ts b/src/agents/prepared-model-runtime.build.ts index ad7e9dd294b0..5e4dd725bb7e 100644 --- a/src/agents/prepared-model-runtime.build.ts +++ b/src/agents/prepared-model-runtime.build.ts @@ -3,7 +3,9 @@ import pLimit from "p-limit"; import { withTimeout } from "../node-host/with-timeout.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import { resolveUsableAgentCredentialModes } from "./agent-auth-credentials.js"; +import { getPreparedRuntimeAuthMaterializations } from "./auth-profiles/runtime-materializations.js"; import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; +import { setPreparedModelRuntimeAuthMaterializations } from "./prepared-model-runtime-auth.js"; import { PreparedModelRuntimePublicationSupersededError, toPreparedModelRuntimeError, @@ -177,7 +179,7 @@ function createSnapshot( const authStorage = AuthStorage.inMemory(credentials); return { authStorage, modelRegistry: templateModelRegistry.fork(authStorage) }; }; - return Object.freeze({ + const snapshot: PreparedModelRuntimeSnapshot = Object.freeze({ ...(input.agentId ? { agentId: input.agentId } : {}), agentDir: input.agentDir, activeProjectKeys: [], @@ -197,6 +199,11 @@ function createSnapshot( inlineProviderModels, createStores, }); + setPreparedModelRuntimeAuthMaterializations( + snapshot, + Object.freeze([...getPreparedRuntimeAuthMaterializations(input.agentDir)]), + ); + return snapshot; } async function buildSnapshotBatch( diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index 0800cab58a7a..7aa8b13c5b3e 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -135,8 +135,8 @@ function prepareAgentFacts( const env = input.env ?? process.env; const templateAuthStorage = discoverAuthStorage(input.agentDir, { config: input.config, - // Snapshot construction never initializes, migrates, or externally syncs auth. ModelRegistry - // discovery only parses the credential generation captured here. + // Prepared owners consume only the already-published runtime auth generation. External CLI + // hydration belongs to startup/control-plane and turn-time producers, never rebuilds. readOnly: true, ambientCredentials, ...(input.skipCredentials ? { skipCredentials: true } : {}), diff --git a/src/agents/prepared-model-runtime.test-harness.ts b/src/agents/prepared-model-runtime.test-harness.ts index d189aba3d286..fe78e62cd8fd 100644 --- a/src/agents/prepared-model-runtime.test-harness.ts +++ b/src/agents/prepared-model-runtime.test-harness.ts @@ -12,6 +12,8 @@ type StaticCatalogResolver = ReturnType; const preparedModelRuntimeMocks = vi.hoisted(() => ({ preparedAuthStore: undefined as import("./auth-profiles/types.js").AuthProfileStore | undefined, + preparedAuthMaterializations: + [] as import("./auth-profiles/runtime-materializations.js").RuntimeAuthMaterialization[], authStorage: { getAll: vi.fn<() => AuthStorageData>(() => ({ custom: { type: "api_key", key: "test-key" }, @@ -52,6 +54,12 @@ const preparedModelRuntimeMocks = vi.hoisted(() => ({ mutationListener: undefined as | ((event: { agentDir?: string; affectsInheritedStores: boolean }) => void) | undefined, + mutationListeners: new Set< + (event: { agentDir?: string; affectsInheritedStores: boolean }) => void + >(), + materializationListeners: new Set< + (event: { agentDir?: string; affectsInheritedStores: boolean }) => void + >(), })); vi.mock("./model-catalog.js", () => ({ @@ -108,6 +116,70 @@ vi.mock("./agent-scope.js", () => ({ }), })); +vi.mock("./auth-profiles/runtime-materializations.js", () => ({ + getPreparedRuntimeAuthMaterializations: () => + preparedModelRuntimeMocks.preparedAuthMaterializations, + registerRuntimeAuthMaterializationMutationListener: ( + listener: (event: { agentDir?: string; affectsInheritedStores: boolean }) => void, + ) => { + preparedModelRuntimeMocks.materializationListeners.add(listener); + return () => preparedModelRuntimeMocks.materializationListeners.delete(listener); + }, + recordRuntimeAuthMaterialization: (params: { + agentDir?: string; + provider: string; + modelId: string; + modelApi: string; + modelBaseUrl: string; + requestTransportOverrides: "none" | "present"; + authMode: string; + runtimeOwnerId: string; + authProfileId?: string; + }) => { + preparedModelRuntimeMocks.preparedAuthMaterializations.push({ + provider: params.provider.trim().toLowerCase(), + modelId: params.modelId.trim().toLowerCase(), + modelApi: params.modelApi.trim().toLowerCase(), + modelBaseUrl: params.modelBaseUrl, + requestTransportOverrides: params.requestTransportOverrides, + authMode: params.authMode.trim().toLowerCase(), + runtimeOwnerId: params.runtimeOwnerId.trim().toLowerCase(), + ...(params.authProfileId ? { authProfileId: params.authProfileId } : {}), + }); + const event = { + agentDir: params.agentDir, + affectsInheritedStores: params.agentDir === undefined, + }; + for (const listener of preparedModelRuntimeMocks.materializationListeners) { + listener(event); + } + return true; + }, + revokeRuntimeAuthMaterializations: (params: { + agentDir?: string; + provider: string; + runtimeOwnerId: string; + }) => { + const previousLength = preparedModelRuntimeMocks.preparedAuthMaterializations.length; + preparedModelRuntimeMocks.preparedAuthMaterializations = + preparedModelRuntimeMocks.preparedAuthMaterializations.filter( + (fact) => + fact.provider !== params.provider || fact.runtimeOwnerId !== params.runtimeOwnerId, + ); + if (preparedModelRuntimeMocks.preparedAuthMaterializations.length === previousLength) { + return false; + } + const event = { + agentDir: params.agentDir, + affectsInheritedStores: params.agentDir === undefined, + }; + for (const listener of preparedModelRuntimeMocks.materializationListeners) { + listener(event); + } + return true; + }, +})); + vi.mock("./auth-profiles/runtime-snapshots.js", () => ({ getPreparedRuntimeAuthProfileStoreSnapshot: () => preparedModelRuntimeMocks.preparedAuthStore, getRuntimeAuthProfileStoreSnapshot: () => preparedModelRuntimeMocks.preparedAuthStore, @@ -115,8 +187,9 @@ vi.mock("./auth-profiles/runtime-snapshots.js", () => ({ registerRuntimeAuthProfileStoreMutationListener: ( listener: (event: { agentDir?: string; affectsInheritedStores: boolean }) => void, ) => { - preparedModelRuntimeMocks.mutationListener = listener; - return () => {}; + preparedModelRuntimeMocks.mutationListener ??= listener; + preparedModelRuntimeMocks.mutationListeners.add(listener); + return () => preparedModelRuntimeMocks.mutationListeners.delete(listener); }, })); @@ -179,6 +252,7 @@ export function resetPreparedModelRuntimeHarness(): void { }); preparedModelRuntimeMocks.authStorage.getOAuthProviders.mockReset().mockReturnValue([]); preparedModelRuntimeMocks.preparedAuthStore = undefined; + preparedModelRuntimeMocks.preparedAuthMaterializations = []; preparedModelRuntimeMocks.modelRegistry.fork .mockReset() .mockImplementation((authStorage: unknown) => ({ authStorage })); diff --git a/src/agents/prepared-model-runtime.test.ts b/src/agents/prepared-model-runtime.test.ts index f5c063df875b..badf40d3d107 100644 --- a/src/agents/prepared-model-runtime.test.ts +++ b/src/agents/prepared-model-runtime.test.ts @@ -563,6 +563,43 @@ describe("prepared model runtime snapshots", () => { expect(secondStores.modelRegistry).not.toBe(firstStores.modelRegistry); }); + it.each([ + { + label: "usable", + credential: { type: "oauth", access: "a", refresh: "r", expires: 1 }, + expected: "oauth", + }, + { + label: "unusable", + credential: { type: "oauth", access: "", refresh: "", expires: 0 }, + expected: undefined, + }, + ] as const)( + "consumes $label startup CLI hydration without rediscovery", + async ({ credential, expected }) => { + const config = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.4" }, + models: { "openai/gpt-5.4": {} }, + }, + }, + }; + mocks.authStorage.getAll.mockReturnValue({ openai: credential }); + + const snapshot = await publishPreparedModelRuntimeSnapshot({ + config, + agentDir: "/tmp/prepared-model-runtime-cli-startup", + }); + + const discoveryOptions = mocks.discoverAuthStorage.mock.calls[0]?.[1] as { + externalCli?: unknown; + }; + expect(discoveryOptions.externalCli).toBeUndefined(); + expect(snapshot.authModes.openai).toBe(expected); + }, + ); + it("ignores request config identity until lifecycle publication", async () => { const agentDir = "/tmp/prepared-model-runtime-request-config"; const initialConfig = {}; diff --git a/src/agents/prepared-model-runtime.ts b/src/agents/prepared-model-runtime.ts index d008d0cd8a4c..3d1f4c65cb6e 100644 --- a/src/agents/prepared-model-runtime.ts +++ b/src/agents/prepared-model-runtime.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { isReservedSystemAgentId } from "../system-agent/agent-id.js"; import { registerRuntimeAuthProfileStoreMutationListener } from "./auth-profiles/runtime-snapshots.js"; +import { registerPreparedRuntimeAuthMaterializationPublisher } from "./prepared-model-runtime-materializations.js"; import { PreparedModelRuntimeOwnerNotPublishedError, PreparedModelRuntimeOwnerRetention, @@ -748,6 +749,7 @@ function invalidateForAuthMutation(event: AuthMutationEvent): void { } registerRuntimeAuthProfileStoreMutationListener(invalidateForAuthMutation); +registerPreparedRuntimeAuthMaterializationPublisher(owners, notifyPreparedModelRuntimePublication); function resetPreparedModelRuntimeSnapshotsForTest(): void { pendingModelRuntimeReplacement?.resolve(); diff --git a/src/gateway/server-chat-metadata-lifecycle.integration.test.ts b/src/gateway/server-chat-metadata-lifecycle.integration.test.ts index fc31ef76c825..d95042dc7ce8 100644 --- a/src/gateway/server-chat-metadata-lifecycle.integration.test.ts +++ b/src/gateway/server-chat-metadata-lifecycle.integration.test.ts @@ -1,6 +1,10 @@ import "../agents/prepared-model-runtime.test-harness.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { revokeRuntimeAuthMaterializations } from "../agents/auth-profiles/runtime-materializations.js"; +import { reportEmbeddedRunSuccessfulAuthBinding } from "../agents/embedded-agent-runner/run/auth-profile-success.js"; +import type { EmbeddedRunAttemptResult } from "../agents/embedded-agent-runner/run/types.js"; import { getPreparedModelCatalogOwnerSnapshot } from "../agents/prepared-model-catalog.js"; +import { getPreparedModelRuntimeAuthMaterializations } from "../agents/prepared-model-runtime-auth.js"; import { refreshPreparedModelRuntimeSnapshots } from "../agents/prepared-model-runtime.js"; import { getPreparedModelRuntimeMocks, @@ -82,6 +86,22 @@ function configureAuthFixture(kind: "secret-ref" | "external-oauth" | "unresolve }; } +function configureHarnessOwnedUnresolvedAuth() { + mocks.authStorage.getAll.mockReturnValue({ + openai: { type: "api_key", key: "openclaw-secret-ref-configured" }, + }); + mocks.preparedAuthStore = { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }, + }; +} + afterEach(async () => { vi.unstubAllEnvs(); for (const sidecar of sidecars) { @@ -125,6 +145,7 @@ async function expectAvailable( metadataSnapshot: owner.metadataSnapshot, preparedAuthStore: mocks.preparedAuthStore ?? { version: 1, profiles: {} }, preparedRuntimeAuthModes: owner.authModes, + preparedRuntimeAuthMaterializations: getPreparedModelRuntimeAuthMaterializations(owner), }); const [metadata, modelsList] = await Promise.all([ lifecycle.read({ agentId: "main" }), @@ -182,6 +203,61 @@ describe("gateway chat metadata lifecycle composition", () => { await expectAvailable(lifecycle); }); + it("publishes a successful harness auth binding before the next metadata read", async () => { + configureHarnessOwnedUnresolvedAuth(); + await publishOwner(); + const lifecycle = await createLifecycle(); + await lifecycle.attachContext(context, sidecars); + await expectAvailable(lifecycle, false); + const profileStore = mocks.preparedAuthStore; + if (!profileStore) { + throw new Error("expected unresolved prepared auth store"); + } + + reportEmbeddedRunSuccessfulAuthBinding({ + profileStore, + apiKeyInfo: null, + attempt: { + runtimeArtifact: { + id: "codex-app-server:test", + fingerprint: "codex-runtime-fingerprint", + }, + } as EmbeddedRunAttemptResult, + provider: "openai", + agentDir: "/tmp/configured-main", + modelId: "gpt-5.4", + modelApi: "openai-chatgpt-responses", + modelBaseUrl: "https://chatgpt.com/backend-api/codex", + requestTransportOverrides: "none", + config, + agentHarnessId: "codex", + pluginHarnessOwnsTransport: true, + pluginHarnessOwnsAuthBootstrap: true, + }); + + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce(); + expect(mocks.preparedAuthMaterializations).toEqual([ + expect.objectContaining({ + provider: "openai", + modelId: "gpt-5.4", + modelApi: "openai-chatgpt-responses", + modelBaseUrl: "https://chatgpt.com/backend-api/codex", + requestTransportOverrides: "none", + authMode: "oauth", + runtimeOwnerId: "codex", + }), + ]); + + await vi.waitFor(async () => await expectAvailable(lifecycle)); + + revokeRuntimeAuthMaterializations({ + agentDir: "/tmp/configured-main", + provider: "openai", + runtimeOwnerId: "codex", + }); + await vi.waitFor(async () => await expectAvailable(lifecycle, false)); + }); + it("recovers a failed catch-up when the prepared owner publishes after attachment", async () => { const lifecycle = await createLifecycle(); await lifecycle.attachContext(context, sidecars); diff --git a/src/gateway/server-methods/chat-metadata-runtime.ts b/src/gateway/server-methods/chat-metadata-runtime.ts index 0b487df1bfbc..84cf30f9e9fb 100644 --- a/src/gateway/server-methods/chat-metadata-runtime.ts +++ b/src/gateway/server-methods/chat-metadata-runtime.ts @@ -13,6 +13,7 @@ import { getPreparedModelCatalogOwnerSnapshot, type LoadPreparedModelCatalogParams, } from "../../agents/prepared-model-catalog.js"; +import { getPreparedModelRuntimeAuthMaterializations } from "../../agents/prepared-model-runtime-auth.js"; import type { PreparedModelRuntimeSnapshot } from "../../agents/prepared-model-runtime.js"; import { resolveSwarmConfig } from "../../agents/swarm-config.js"; import { resolveRuntimeConfigCacheKey } from "../../config/runtime-snapshot.js"; @@ -242,6 +243,9 @@ async function defaultBuildProjection(params: { preparedAuthStore: params.facts.authStore, // The owner records usable auth at discovery; metadata must share that exact generation fact. preparedRuntimeAuthModes: params.facts.owner.authModes, + preparedRuntimeAuthMaterializations: getPreparedModelRuntimeAuthMaterializations( + params.facts.owner, + ), ...(params.preferredProfileId ? { preferredProfileId: params.preferredProfileId } : {}), ...(params.lockedProfileId ? { lockedProfileId: params.lockedProfileId } : {}), }); diff --git a/src/gateway/server-methods/models-list-auth-resolver.ts b/src/gateway/server-methods/models-list-auth-resolver.ts index c0a09ae15d4a..e7fb241ebda1 100644 --- a/src/gateway/server-methods/models-list-auth-resolver.ts +++ b/src/gateway/server-methods/models-list-auth-resolver.ts @@ -1,9 +1,16 @@ import type { PreparedAgentCredentialModes } from "../../agents/agent-auth-credentials.js"; import { resolveAgentDir } from "../../agents/agent-scope.js"; import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js"; +import { resolveExternalCliAuthProfiles } from "../../agents/auth-profiles/external-cli-sync.js"; +import { + recordRuntimeAuthMaterialization, + type RuntimeAuthMaterialization, +} from "../../agents/auth-profiles/runtime-materializations.js"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; import { createModelAuthAvailabilityResolver, + type ModelAuthAvailabilityEvaluation, + type ModelAuthAvailabilityRef, type ModelAuthAvailabilityResolver, } from "../../agents/model-auth-availability.js"; import { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js"; @@ -41,6 +48,7 @@ export function createModelsListAuthResolver(params: { metadataSnapshot?: PluginMetadataSnapshot; preparedAuthStore?: AuthProfileStore; preparedRuntimeAuthModes?: PreparedAgentCredentialModes; + preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[]; workspaceDir: string; routeResolverFactory?: typeof createOpenAIModelRoutesResolver; }): ModelAuthAvailabilityResolver { @@ -55,7 +63,17 @@ export function createModelsListAuthResolver(params: { // A prepared projection must hydrate from its own auth-store generation. Reading the global // snapshot can mix generations; treating this store as persisted loses resolved SecretRefs. const preparedRuntimeAuthStore = params.preparedAuthStore; - return createModelAuthAvailabilityResolver({ + const externalCliProviderIds = + !params.preparedAuthStore && params.includeOpenAIExternalProfiles ? ["openai"] : []; + const externalProfileIds = new Set( + externalCliProviderIds.length + ? resolveExternalCliAuthProfiles(authStore, { + allowKeychainPrompt: false, + providerIds: externalCliProviderIds, + }).map(({ profileId }) => profileId) + : [], + ); + const resolver = createModelAuthAvailabilityResolver({ cfg: params.cfg, authStore, agentDir, @@ -63,11 +81,52 @@ export function createModelsListAuthResolver(params: { env: process.env, metadataSnapshot: params.metadataSnapshot, preparedRuntimeAuthModes: params.preparedRuntimeAuthModes, + preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations, skipSetupProviderFallback: true, syntheticAuthProviderRefs: listEnabledSyntheticAuthProviderRefs(params), - externalCliProviderIds: - !params.preparedAuthStore && params.includeOpenAIExternalProfiles ? ["openai"] : [], + externalCliProviderIds, ...(preparedRuntimeAuthStore ? { preparedRuntimeAuthStore } : {}), routeResolverFactory: params.routeResolverFactory, }); + if (externalProfileIds.size === 0) { + return resolver; + } + const evaluateModelAuth = ( + provider: string, + ref: ModelAuthAvailabilityRef = {}, + ): ModelAuthAvailabilityEvaluation => { + const evaluation = resolver.evaluateModelAuth(provider, ref); + const route = evaluation.selectedRoute; + const profileId = evaluation.selectedProfileId; + if ( + evaluation.availability === true && + route && + profileId && + externalProfileIds.has(profileId) + ) { + const modelId = ref.modelId?.trim(); + if (modelId) { + recordRuntimeAuthMaterialization({ + agentDir, + provider, + modelId, + modelApi: route.api, + modelBaseUrl: route.baseUrl, + requestTransportOverrides: route.requestTransportOverrides, + authMode: + evaluation.selectedAuthMode ?? + (route.authRequirement === "subscription" ? "oauth" : "api-key"), + runtimeOwnerId: "external-cli", + authProfileId: profileId, + }); + } + } + return evaluation; + }; + return { + ...resolver, + evaluateModelAuth, + resolveProviderAuthAvailability: (provider, ref) => + evaluateModelAuth(provider, ref).availability, + }; } diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index a804ce11cf6a..101c1e6f4bdf 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -8,6 +8,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../../agents/agent-scope.js"; +import type { RuntimeAuthMaterialization } from "../../agents/auth-profiles/runtime-materializations.js"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; import { DEFAULT_PROVIDER } from "../../agents/defaults.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; @@ -283,6 +284,7 @@ export function createGatewayAgentModelCatalogProjector(params: { metadataSnapshot?: PluginMetadataSnapshot; preparedAuthStore?: AuthProfileStore; preparedRuntimeAuthModes?: PreparedAgentCredentialModes; + preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[]; preferredProfileId?: string; lockedProfileId?: string; routeResolverFactory?: typeof createOpenAIModelRoutesResolver; @@ -338,6 +340,7 @@ export function createGatewayAgentModelCatalogProjector(params: { metadataSnapshot, ...(params.preparedAuthStore ? { preparedAuthStore: params.preparedAuthStore } : {}), preparedRuntimeAuthModes: params.preparedRuntimeAuthModes, + preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations, workspaceDir, routeResolverFactory: params.routeResolverFactory, }); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 9a081aa865eb..b192ca88285c 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -448,6 +448,67 @@ async function prewarmConfiguredPrimaryModel(params: { await publishConfiguredModelRuntimeSnapshots(params); } +type StartupExternalAuthHydrationDeps = { + listAgentIds: (cfg: OpenClawConfig) => string[]; + resolveAgentDir: (cfg: OpenClawConfig, agentId: string) => string; + collectConfiguredRefs: (cfg: OpenClawConfig, agentId: string) => readonly { value: string }[]; + hydrate: (agentDir: string, providers: readonly string[]) => void; +}; + +async function hydrateConfiguredExternalCliAuth(params: { + cfg: OpenClawConfig; + log: { warn: (msg: string) => void }; + deps?: StartupExternalAuthHydrationDeps; +}): Promise { + const deps: StartupExternalAuthHydrationDeps = + params.deps ?? + (await Promise.all([ + import("../agents/agent-scope.js"), + import("../agents/prepared-model-runtime.configured.js"), + import("../agents/auth-profiles/store.js"), + import("../agents/auth-profiles/external-cli-discovery.js"), + ]).then(([scope, configured, store, external]) => ({ + listAgentIds: scope.listAgentIds, + resolveAgentDir: scope.resolveAgentDir, + collectConfiguredRefs: configured.collectPreparedModelRuntimeConfiguredRefs, + hydrate: (agentDir: string, providers: readonly string[]) => { + const discovery = external.externalCliDiscoveryForProviders({ + cfg: params.cfg, + providers, + }); + if (discovery.mode === "none") { + return; + } + store.ensureAuthProfileStore(agentDir, { + config: params.cfg, + externalCli: discovery, + allowKeychainPrompt: false, + readOnly: true, + syncExternalCli: false, + }); + }, + }))); + const hydratedDirs = new Set(); + for (const agentId of deps.listAgentIds(params.cfg)) { + const providers = deps.collectConfiguredRefs(params.cfg, agentId).flatMap(({ value }) => { + const separator = value.indexOf("/"); + return separator > 0 ? [value.slice(0, separator)] : []; + }); + const agentDir = deps.resolveAgentDir(params.cfg, agentId); + if (providers.length === 0 || hydratedDirs.has(agentDir)) { + continue; + } + hydratedDirs.add(agentDir); + try { + deps.hydrate(agentDir, providers); + } catch (error) { + params.log.warn( + `startup external CLI auth hydration failed for agent ${agentId}: ${String(error)}`, + ); + } + } +} + async function publishConfiguredModelRuntimeSnapshots(params: { cfg: OpenClawConfig; workspaceDir?: string; @@ -587,6 +648,9 @@ export async function startGatewaySidecars(params: { ); } }); + await measureStartup(params.startupTrace, "sidecars.model-auth", () => + hydrateConfiguredExternalCliAuth({ cfg: params.cfg, log: params.log }), + ); // Agent RPC remains available when transports are disabled. Publish configured/static facts before // accepting work; live provider catalogs stay advisory and never enter the Gateway lifecycle. await measureStartup(params.startupTrace, "sidecars.model-runtime", () => @@ -1385,6 +1449,7 @@ export const testing = { providerAuthPrewarmStartDelayMs: PROVIDER_AUTH_PREWARM_START_DELAY_MS, hasRestartSentinelFast, prewarmConfiguredPrimaryModel, + hydrateConfiguredExternalCliAuth, publishConfiguredModelRuntimeSnapshots, publishStartupModelRuntime, refreshLatestUpdateRestartSentinelIfPresent, diff --git a/src/gateway/server-startup.test.ts b/src/gateway/server-startup.test.ts index 85cf91a8fa2b..7b77e5220b23 100644 --- a/src/gateway/server-startup.test.ts +++ b/src/gateway/server-startup.test.ts @@ -37,6 +37,7 @@ vi.mock("../agents/prepared-model-runtime.js", () => ({ })); let prewarmConfiguredPrimaryModel: typeof import("./server-startup-post-attach.js").testing.prewarmConfiguredPrimaryModel; +let hydrateConfiguredExternalCliAuth: typeof import("./server-startup-post-attach.js").testing.hydrateConfiguredExternalCliAuth; let publishStartupModelRuntime: typeof import("./server-startup-post-attach.js").testing.publishStartupModelRuntime; let shouldSkipStartupModelPrewarm: typeof import("./server-startup-post-attach.js").testing.shouldSkipStartupModelPrewarm; @@ -45,6 +46,7 @@ describe("gateway startup primary model warmup", () => { ({ testing: { prewarmConfiguredPrimaryModel, + hydrateConfiguredExternalCliAuth, publishStartupModelRuntime, shouldSkipStartupModelPrewarm, }, @@ -79,6 +81,29 @@ describe("gateway startup primary model warmup", () => { }); }); + it("hydrates configured external CLI auth before prepared owner publication", async () => { + const cfg = {} as OpenClawConfig; + const hydrate = vi.fn(); + + await hydrateConfiguredExternalCliAuth({ + cfg, + log: { warn: vi.fn() }, + deps: { + listAgentIds: () => ["main", "secondary"], + resolveAgentDir: (_config, agentId) => `/tmp/${agentId}`, + collectConfiguredRefs: (_config, agentId) => [ + { value: agentId === "main" ? "openai/gpt-5.4" : "anthropic/sonnet-4.6" }, + ], + hydrate, + }, + }); + + expect(hydrate).toHaveBeenCalledTimes(2); + expect(hydrate).toHaveBeenCalledWith("/tmp/main", ["openai"]); + expect(hydrate).toHaveBeenCalledWith("/tmp/secondary", ["anthropic"]); + expect(refreshPreparedModelRuntimeSnapshotsMock).not.toHaveBeenCalled(); + }); + it("prewarms the default catalog when no explicit primary model is configured", async () => { const cfg = {} as OpenClawConfig; await prewarmConfiguredPrimaryModel({