diff --git a/src/agents/auth-profiles/external-cli-auth-selection.ts b/src/agents/auth-profiles/external-cli-auth-selection.ts new file mode 100644 index 00000000000..8d2b8903d2f --- /dev/null +++ b/src/agents/auth-profiles/external-cli-auth-selection.ts @@ -0,0 +1,207 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js"; +import { resolveProviderIdForAuth } from "../provider-auth-aliases.js"; +import { findNormalizedProviderValue, normalizeProviderId } from "../provider-id.js"; +import { CLAUDE_CLI_PROFILE_ID } from "./constants.js"; +import type { AuthProfileStore } from "./types.js"; + +const CLAUDE_CLI_PROVIDER_ID = "claude-cli"; + +export function resolveExternalCliAuthOverlayScopeFromSelection(params: { + provider: string; + cfg?: OpenClawConfig; + agentId?: string; + modelId?: string; + workspaceDir?: string; + store?: AuthProfileStore; + userLockedAuthProfileId?: string; +}): { + providerIds?: readonly string[]; + ignoreAutoPreferredProfile: boolean; +} { + const authScope = resolveExternalCliAuthScopeFromAuthSelection({ + provider: params.provider, + cfg: params.cfg, + workspaceDir: params.workspaceDir, + store: params.store, + userLockedAuthProfileId: params.userLockedAuthProfileId, + }); + const selectedRuntimeProvider = + resolveCliRuntimeExecutionProvider({ + provider: params.provider, + cfg: params.cfg, + agentId: params.agentId, + modelId: params.modelId, + authProfileId: params.userLockedAuthProfileId, + }) || (params.provider === CLAUDE_CLI_PROVIDER_ID ? CLAUDE_CLI_PROVIDER_ID : undefined); + const selectedProvider = + authScope.selectedProviderId ?? + (selectedRuntimeProvider === CLAUDE_CLI_PROVIDER_ID ? CLAUDE_CLI_PROVIDER_ID : undefined); + const providerIds = [ + ...new Set([ + ...authScope.providerIds, + ...(selectedRuntimeProvider === CLAUDE_CLI_PROVIDER_ID ? [CLAUDE_CLI_PROVIDER_ID] : []), + ]), + ]; + return { + ...(providerIds.length > 0 ? { providerIds } : {}), + ignoreAutoPreferredProfile: + !params.userLockedAuthProfileId && selectedProvider === CLAUDE_CLI_PROVIDER_ID, + }; +} + +function resolveExternalCliAuthScopeFromAuthSelection(params: { + provider: string; + cfg?: OpenClawConfig; + workspaceDir?: string; + store?: AuthProfileStore; + userLockedAuthProfileId?: string; +}): { + providerIds: string[]; + selectedProviderId?: string; +} { + if (params.userLockedAuthProfileId) { + const providerId = resolveExternalCliProviderIdForCompatibleAuthProfile({ + ...params, + profileId: params.userLockedAuthProfileId, + })?.externalCliProviderId; + return { + providerIds: providerId ? [providerId] : [], + ...(providerId ? { selectedProviderId: providerId } : {}), + }; + } + + const providerIds: string[] = []; + let sawCompatibleOrderedProfile = false; + let selectedProviderId: string | undefined; + for (const profileId of resolveConfiguredAuthProfileOrder(params)) { + const resolved = resolveExternalCliProviderIdForCompatibleAuthProfile({ + ...params, + profileId, + }); + if (!resolved.compatible) { + continue; + } + if (!sawCompatibleOrderedProfile) { + selectedProviderId = resolved.externalCliProviderId; + sawCompatibleOrderedProfile = true; + } + if (resolved.externalCliProviderId) { + providerIds.push(resolved.externalCliProviderId); + } + } + if (sawCompatibleOrderedProfile) { + return { + providerIds: [...new Set(providerIds)], + ...(selectedProviderId ? { selectedProviderId } : {}), + }; + } + + let compatibleProfileCount = 0; + const profileIds = [ + ...new Set([ + ...Object.keys(params.cfg?.auth?.profiles ?? {}), + ...Object.keys(params.store?.profiles ?? {}), + ]), + ]; + for (const profileId of profileIds) { + const resolved = resolveExternalCliProviderIdForCompatibleAuthProfile({ + ...params, + profileId, + }); + if (!resolved.compatible) { + continue; + } + compatibleProfileCount += 1; + if (resolved.externalCliProviderId) { + providerIds.push(resolved.externalCliProviderId); + } + } + const uniqueProviderIds = [...new Set(providerIds)]; + return { + providerIds: uniqueProviderIds, + ...(compatibleProfileCount === 1 && uniqueProviderIds[0] + ? { selectedProviderId: uniqueProviderIds[0] } + : {}), + }; +} + +function resolveConfiguredAuthProfileOrder(params: { + provider: string; + cfg?: OpenClawConfig; + workspaceDir?: string; + store?: AuthProfileStore; +}): string[] { + const providerAuthKey = resolveProviderIdForAuth(params.provider, { + config: params.cfg, + workspaceDir: params.workspaceDir, + }); + const orderedProfileIds = + resolveAuthProfileOrderEntries({ + order: params.store?.order, + provider: params.provider, + providerAuthKey, + }) ?? + resolveAuthProfileOrderEntries({ + order: params.cfg?.auth?.order, + provider: params.provider, + providerAuthKey, + }) ?? + []; + return [ + ...new Set( + orderedProfileIds + .map((profileId) => profileId?.trim()) + .filter((profileId): profileId is string => !!profileId), + ), + ]; +} + +function resolveAuthProfileOrderEntries(params: { + order?: Record; + provider: string; + providerAuthKey: string; +}): string[] | undefined { + return ( + findNormalizedProviderValue(params.order, params.providerAuthKey) ?? + (normalizeProviderId(params.providerAuthKey) === normalizeProviderId(params.provider) + ? undefined + : findNormalizedProviderValue(params.order, params.provider)) + ); +} + +function resolveExternalCliProviderIdForCompatibleAuthProfile(params: { + provider: string; + cfg?: OpenClawConfig; + workspaceDir?: string; + store?: AuthProfileStore; + profileId: string; +}): { + compatible: boolean; + externalCliProviderId?: string; +} { + const profile = params.cfg?.auth?.profiles?.[params.profileId]; + const credential = params.store?.profiles?.[params.profileId]; + const profileProvider = + profile?.provider ?? + credential?.provider ?? + (params.profileId === CLAUDE_CLI_PROFILE_ID ? CLAUDE_CLI_PROVIDER_ID : undefined); + if (!profileProvider) { + return { compatible: false }; + } + const authAliasParams = { + config: params.cfg, + workspaceDir: params.workspaceDir, + }; + const providerAuthKey = resolveProviderIdForAuth(params.provider, authAliasParams); + const profileAuthKey = resolveProviderIdForAuth(profileProvider, authAliasParams); + if (!providerAuthKey || profileAuthKey !== providerAuthKey) { + return { compatible: false }; + } + return { + compatible: true, + ...(normalizeProviderId(profileProvider) === CLAUDE_CLI_PROVIDER_ID + ? { externalCliProviderId: CLAUDE_CLI_PROVIDER_ID } + : {}), + }; +} diff --git a/src/agents/auth-profiles/store.runtime-external.test.ts b/src/agents/auth-profiles/store.runtime-external.test.ts new file mode 100644 index 00000000000..b1d309fbc5c --- /dev/null +++ b/src/agents/auth-profiles/store.runtime-external.test.ts @@ -0,0 +1,101 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { ProviderExternalAuthProfile } from "../../plugins/types.js"; +import { testing as externalAuthTesting } from "./external-auth.js"; +import { resolveAuthStorePath } from "./paths.js"; +import { getRuntimeAuthProfileStoreSnapshot } from "./runtime-snapshots.js"; +import { + clearRuntimeAuthProfileStoreSnapshots, + replaceRuntimeAuthProfileStoreSnapshots, + saveAuthProfileStore, +} from "./store.js"; +import type { AuthProfileStore, OAuthCredential } from "./types.js"; + +const envBackup: Record = {}; +const envKeys = ["OPENCLAW_STATE_DIR"]; +const tempDirs: string[] = []; + +function createRuntimeExternalCredential(): OAuthCredential { + return { + type: "oauth", + provider: "claude-cli", + access: "external-access-token", + refresh: "external-refresh-token", + expires: Date.now() + 60_000, + }; +} + +beforeEach(() => { + for (const key of envKeys) { + envBackup[key] = process.env[key]; + } + externalAuthTesting.setResolveExternalAuthProfilesForTest(() => []); + clearRuntimeAuthProfileStoreSnapshots(); +}); + +afterEach(async () => { + for (const key of envKeys) { + if (envBackup[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = envBackup[key]; + } + } + externalAuthTesting.resetResolveExternalAuthProfilesForTest(); + clearRuntimeAuthProfileStoreSnapshots(); + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("auth profile store runtime external snapshots", () => { + it("keeps runtime-only external oauth profiles in active snapshots after save", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-runtime-external-save-")); + tempDirs.push(stateDir); + process.env.OPENCLAW_STATE_DIR = stateDir; + const agentDir = path.join(stateDir, "agents", "main", "agent"); + await fs.mkdir(agentDir, { recursive: true }); + + const externalProfileId = "anthropic:claude-cli"; + const externalCredential = createRuntimeExternalCredential(); + const externalProfiles: ProviderExternalAuthProfile[] = [ + { + profileId: externalProfileId, + credential: externalCredential, + persistence: "runtime-only", + }, + ]; + externalAuthTesting.setResolveExternalAuthProfilesForTest(() => externalProfiles); + + const runtimeStore: AuthProfileStore = { + version: 1, + profiles: { + "openai:static": { + type: "api_key", + provider: "openai", + key: "sk-openai-static", // pragma: allowlist secret + }, + [externalProfileId]: externalCredential, + }, + order: { + openai: ["openai:static"], + "claude-cli": [externalProfileId], + }, + runtimeExternalProfileIds: [externalProfileId], + }; + replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: runtimeStore }]); + + saveAuthProfileStore(runtimeStore, agentDir); + + const persisted = JSON.parse( + await fs.readFile(resolveAuthStorePath(agentDir), "utf8"), + ) as AuthProfileStore; + expect(persisted.profiles[externalProfileId]).toBeUndefined(); + expect(persisted.order?.["claude-cli"]).toBeUndefined(); + + const snapshot = getRuntimeAuthProfileStoreSnapshot(agentDir); + expect(snapshot?.profiles[externalProfileId]).toEqual(externalCredential); + expect(snapshot?.runtimeExternalProfileIds).toEqual([externalProfileId]); + expect(snapshot?.order?.["claude-cli"]).toEqual([externalProfileId]); + }); +}); diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 797ce57bc50..9cb59c3535d 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -11,6 +11,8 @@ const discoverAuthStorageMock = vi.fn(); const discoverModelsMock = vi.fn(); const resolveModelAsyncMock = vi.fn(); const resolveModelWithRegistryMock = vi.fn(); +const ensureAuthProfileStoreMock = vi.fn(); +const ensureAuthProfileStoreWithoutExternalProfilesMock = vi.fn(); const getApiKeyForModelMock = vi.fn(); const requireApiKeyMock = vi.fn(); const resolveSessionAuthProfileOverrideMock = vi.fn(); @@ -62,6 +64,9 @@ vi.mock("./pi-embedded-runner/model.js", () => ({ })); vi.mock("./model-auth.js", () => ({ + ensureAuthProfileStore: (...args: unknown[]) => ensureAuthProfileStoreMock(...args), + ensureAuthProfileStoreWithoutExternalProfiles: (...args: unknown[]) => + ensureAuthProfileStoreWithoutExternalProfilesMock(...args), getApiKeyForModel: (...args: unknown[]) => getApiKeyForModelMock(...args), requireApiKey: (...args: unknown[]) => requireApiKeyMock(...args), })); @@ -367,6 +372,8 @@ describe("runBtwSideQuestion", () => { discoverAuthStorageMock.mockReset(); discoverModelsMock.mockReset(); resolveModelWithRegistryMock.mockReset(); + ensureAuthProfileStoreMock.mockReset(); + ensureAuthProfileStoreWithoutExternalProfilesMock.mockReset(); getApiKeyForModelMock.mockReset(); requireApiKeyMock.mockReset(); resolveSessionAuthProfileOverrideMock.mockReset(); @@ -405,6 +412,8 @@ describe("runBtwSideQuestion", () => { id: "claude-sonnet-4-6", api: "anthropic-messages", }); + ensureAuthProfileStoreMock.mockReturnValue({ version: 1, profiles: {} }); + ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValue({ version: 1, profiles: {} }); getApiKeyForModelMock.mockResolvedValue({ apiKey: "secret", mode: "api-key", source: "test" }); requireApiKeyMock.mockReturnValue("secret"); resolveSessionAuthProfileOverrideMock.mockResolvedValue("profile-1"); @@ -596,6 +605,203 @@ describe("runBtwSideQuestion", () => { expect(streamSimpleMock).toHaveBeenCalledTimes(1); }); + it("does not let an auto-selected stale Anthropic profile suppress Claude CLI auth for BTW", async () => { + const claudeAuthStore = { + version: 1 as const, + profiles: { + "anthropic:api": { + type: "api_key" as const, + provider: "anthropic", + key: "static-key", + }, + "anthropic:claude-cli": { + type: "oauth" as const, + provider: "claude-cli", + access: "claude-cli-access", + refresh: "claude-cli-refresh", + expires: Date.now() + 60_000, + }, + }, + }; + ensureAuthProfileStoreMock.mockReturnValueOnce(claudeAuthStore); + getApiKeyForModelMock.mockResolvedValueOnce({ + apiKey: "claude-cli-access", + mode: "oauth", + source: "profile:anthropic:claude-cli", + profileId: "anthropic:claude-cli", + }); + requireApiKeyMock.mockReturnValueOnce("claude-cli-access"); + mockDoneAnswer("Claude CLI answer."); + + const result = await runSideQuestion({ + cfg: { + auth: { + order: { anthropic: ["anthropic:claude-cli"] }, + profiles: { + "anthropic:api": { provider: "anthropic", mode: "api_key" }, + "anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" }, + }, + }, + } as never, + sessionEntry: createSessionEntry({ + authProfileOverride: "anthropic:api", + authProfileOverrideSource: "auto", + }), + }); + + expect(result).toEqual({ text: "Claude CLI answer." }); + expect(ensureAuthProfileStoreMock).toHaveBeenCalledWith(DEFAULT_AGENT_DIR, { + externalCliProviderIds: ["claude-cli"], + allowKeychainPrompt: false, + }); + expect(ensureAuthProfileStoreWithoutExternalProfilesMock).not.toHaveBeenCalled(); + expectRecordFields(mockArg(getApiKeyForModelMock, 0, 0), { + profileId: undefined, + store: claudeAuthStore, + }); + expectRecordFields(mockArg(prepareProviderRuntimeAuthMock, 0, 0), { + provider: "anthropic", + }); + expectRecordFields( + (mockArg(prepareProviderRuntimeAuthMock, 0, 0) as { context?: unknown }).context, + { + profileId: "anthropic:claude-cli", + authMode: "oauth", + }, + ); + expectRecordFields(mockArg(resolveEmbeddedAgentStreamFnMock, 0, 0), { + authProfileId: "anthropic:claude-cli", + }); + }); + + it("loads Claude CLI auth for BTW from persisted auth-store order", async () => { + const staticAuthStore = { + version: 1 as const, + profiles: {}, + order: { anthropic: ["anthropic:claude-cli"] }, + }; + const claudeAuthStore = { + version: 1 as const, + profiles: { + "anthropic:claude-cli": { + type: "oauth" as const, + provider: "claude-cli", + access: "claude-cli-access", + refresh: "claude-cli-refresh", + expires: Date.now() + 60_000, + }, + }, + }; + ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValueOnce(staticAuthStore); + ensureAuthProfileStoreMock.mockReturnValueOnce(claudeAuthStore); + getApiKeyForModelMock.mockResolvedValueOnce({ + apiKey: "claude-cli-access", + mode: "oauth", + source: "profile:anthropic:claude-cli", + profileId: "anthropic:claude-cli", + }); + requireApiKeyMock.mockReturnValueOnce("claude-cli-access"); + resolveSessionAuthProfileOverrideMock.mockResolvedValueOnce(undefined); + mockDoneAnswer("Claude CLI answer."); + + const result = await runSideQuestion(); + + expect(result).toEqual({ text: "Claude CLI answer." }); + expect(ensureAuthProfileStoreWithoutExternalProfilesMock).toHaveBeenCalledWith( + DEFAULT_AGENT_DIR, + { allowKeychainPrompt: false }, + ); + expect(ensureAuthProfileStoreMock).toHaveBeenCalledWith(DEFAULT_AGENT_DIR, { + externalCliProviderIds: ["claude-cli"], + allowKeychainPrompt: false, + }); + expectRecordFields(mockArg(getApiKeyForModelMock, 0, 0), { + profileId: undefined, + store: claudeAuthStore, + }); + }); + + it("keeps user-locked static Anthropic auth for BTW", async () => { + getApiKeyForModelMock.mockResolvedValueOnce({ + apiKey: "static-key", + mode: "api-key", + source: "profile:anthropic:api", + profileId: "anthropic:api", + }); + requireApiKeyMock.mockReturnValueOnce("static-key"); + resolveSessionAuthProfileOverrideMock.mockResolvedValueOnce("anthropic:api"); + mockDoneAnswer("Static answer."); + + await runSideQuestion({ + cfg: { + auth: { + order: { anthropic: ["anthropic:claude-cli"] }, + profiles: { + "anthropic:api": { provider: "anthropic", mode: "api_key" }, + "anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" }, + }, + }, + } as never, + sessionEntry: createSessionEntry({ + authProfileOverride: "anthropic:api", + authProfileOverrideSource: "user", + }), + }); + + expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); + expectRecordFields(mockArg(getApiKeyForModelMock, 0, 0), { + profileId: "anthropic:api", + }); + expect((mockArg(getApiKeyForModelMock, 0, 0) as { store?: unknown }).store).toBeUndefined(); + expectRecordFields( + (mockArg(prepareProviderRuntimeAuthMock, 0, 0) as { context?: unknown }).context, + { + profileId: "anthropic:api", + authMode: "api-key", + }, + ); + }); + + it("keeps legacy source-less user-locked Anthropic auth for BTW", async () => { + getApiKeyForModelMock.mockResolvedValueOnce({ + apiKey: "static-key", + mode: "api-key", + source: "profile:anthropic:api", + profileId: "anthropic:api", + }); + requireApiKeyMock.mockReturnValueOnce("static-key"); + resolveSessionAuthProfileOverrideMock.mockResolvedValueOnce("anthropic:api"); + mockDoneAnswer("Legacy static answer."); + + await runSideQuestion({ + cfg: { + auth: { + order: { anthropic: ["anthropic:claude-cli"] }, + profiles: { + "anthropic:api": { provider: "anthropic", mode: "api_key" }, + "anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" }, + }, + }, + } as never, + sessionEntry: createSessionEntry({ + authProfileOverride: "anthropic:api", + }), + }); + + expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); + expectRecordFields(mockArg(getApiKeyForModelMock, 0, 0), { + profileId: "anthropic:api", + }); + expect((mockArg(getApiKeyForModelMock, 0, 0) as { store?: unknown }).store).toBeUndefined(); + expectRecordFields( + (mockArg(prepareProviderRuntimeAuthMock, 0, 0) as { context?: unknown }).context, + { + profileId: "anthropic:api", + authMode: "api-key", + }, + ); + }); + it("applies provider runtime auth before streaming github-copilot BTW questions", async () => { resolveModelWithRegistryMock.mockReturnValue({ provider: "github-copilot", @@ -632,7 +838,7 @@ describe("runBtwSideQuestion", () => { workspaceDir: "/tmp/workspace", apiKey: "github-token", authMode: "token", - profileId: "profile-1", + profileId: "github-copilot:github", }); const [streamModel, , streamOptions] = mockCall(streamSimpleMock); expectRecordFields(streamModel, { diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 5fed358f79b..48edf62c2a3 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -15,6 +15,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js"; +import { resolveExternalCliAuthOverlayScopeFromSelection } from "./auth-profiles/external-cli-auth-selection.js"; import { resolveSessionAuthProfileOverride } from "./auth-profiles/session-override.js"; import { readBtwTranscriptMessages, resolveBtwSessionTranscriptPath } from "./btw-transcript.js"; import { resolveAvailableAgentHarnessPolicy, selectAgentHarness } from "./harness/selection.js"; @@ -22,7 +23,12 @@ import { resolveImageSanitizationLimits, type ImageSanitizationLimits, } from "./image-sanitization.js"; -import { getApiKeyForModel, requireApiKey } from "./model-auth.js"; +import { + ensureAuthProfileStore, + ensureAuthProfileStoreWithoutExternalProfiles, + getApiKeyForModel, + requireApiKey, +} from "./model-auth.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "./openai-codex-routing.js"; import { EmbeddedBlockChunker, type BlockReplyChunking } from "./pi-embedded-block-chunker.js"; @@ -61,6 +67,19 @@ function buildBtwSystemPrompt(): string { ].join("\n"); } +function resolveReturnedAuthProfileSource( + sessionEntry: StoredSessionEntry | undefined, + authProfileId: string | undefined, +): "auto" | "user" | undefined { + if (!authProfileId?.trim()) { + return undefined; + } + return ( + sessionEntry?.authProfileOverrideSource ?? + (typeof sessionEntry?.authProfileOverrideCompactionCount === "number" ? "auto" : "user") + ); +} + function buildBtwQuestionPrompt(question: string, inFlightPrompt?: string): string { const lines = [ "Answer this side question only.", @@ -269,7 +288,7 @@ async function resolveRuntimeModel(params: { return { model, authProfileId, - authProfileIdSource: params.sessionEntry?.authProfileOverrideSource, + authProfileIdSource: resolveReturnedAuthProfileSource(params.sessionEntry, authProfileId), }; } @@ -383,7 +402,7 @@ export async function runBtwSideQuestion( throw new Error("No active session context."); } - const { model, authProfileId } = await resolveRuntimeModel({ + const { model, authProfileId, authProfileIdSource } = await resolveRuntimeModel({ cfg: params.cfg, provider: params.provider, model: params.model, @@ -396,12 +415,46 @@ export async function runBtwSideQuestion( storePath: params.storePath, isNewSession: params.isNewSession, }); + let externalCliAuthScope = resolveExternalCliAuthOverlayScopeFromSelection({ + provider: model.provider, + cfg: params.cfg, + agentId: sessionAgentId, + modelId: model.id, + workspaceDir, + userLockedAuthProfileId: authProfileIdSource === "user" ? authProfileId : undefined, + }); + if (!externalCliAuthScope.providerIds) { + const noExternalAuthStore = ensureAuthProfileStoreWithoutExternalProfiles(params.agentDir, { + allowKeychainPrompt: false, + }); + externalCliAuthScope = resolveExternalCliAuthOverlayScopeFromSelection({ + provider: model.provider, + cfg: params.cfg, + agentId: sessionAgentId, + modelId: model.id, + workspaceDir, + store: noExternalAuthStore, + userLockedAuthProfileId: authProfileIdSource === "user" ? authProfileId : undefined, + }); + } + const authStore = externalCliAuthScope.providerIds + ? ensureAuthProfileStore(params.agentDir, { + externalCliProviderIds: externalCliAuthScope.providerIds, + allowKeychainPrompt: false, + }) + : undefined; + const effectiveAuthProfileId = + externalCliAuthScope.ignoreAutoPreferredProfile && authProfileIdSource !== "user" + ? undefined + : authProfileId; const apiKeyInfo = await getApiKeyForModel({ model, cfg: params.cfg, - profileId: authProfileId, + profileId: effectiveAuthProfileId, + ...(authStore ? { store: authStore } : {}), agentDir: params.agentDir, }); + const resolvedAuthProfileId = apiKeyInfo.profileId ?? effectiveAuthProfileId; let runtimeModel = model; let apiKey = apiKeyInfo.mode === "aws-sdk" && !apiKeyInfo.apiKey @@ -423,7 +476,7 @@ export async function runBtwSideQuestion( model, apiKey, authMode: apiKeyInfo.mode, - profileId: authProfileId, + profileId: resolvedAuthProfileId, }, }); if (preparedAuth?.baseUrl) { @@ -455,7 +508,7 @@ export async function runBtwSideQuestion( signal: params.opts?.abortSignal, model: runtimeModel, resolvedApiKey: apiKey, - authProfileId, + authProfileId: resolvedAuthProfileId, }); const chunker = diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index 07211bb1a63..39e31ea131b 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -273,6 +273,345 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { expect(authStoreOptions).toEqual({ allowKeychainPrompt: false }); }); + it("loads the external Claude CLI auth overlay for PI runs routed by Claude CLI OAuth", async () => { + const claudeAuthStore = { + version: 1 as const, + profiles: { + "anthropic:claude-cli": { + type: "oauth" as const, + provider: "claude-cli", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }; + mockedEnsureAuthProfileStore.mockReturnValueOnce(claudeAuthStore); + mockedResolveAuthProfileOrder.mockReturnValueOnce(["anthropic:claude-cli"]); + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "anthropic", + model: "test-model", + config: { + auth: { + order: { anthropic: ["anthropic:claude-cli"] }, + profiles: { + "anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" }, + }, + }, + }, + runId: "pi-claude-cli-oauth-auth-overlay", + }); + + expect(mockedEnsureAuthProfileStore).toHaveBeenCalledTimes(1); + expectRecordFields(mockCallArg(mockedEnsureAuthProfileStore, 0, 1), { + externalCliProviderIds: ["claude-cli"], + allowKeychainPrompt: false, + }); + expect(mockedEnsureAuthProfileStoreWithoutExternalProfiles).not.toHaveBeenCalled(); + expectMockCallFields(mockedResolveAuthProfileOrder, { + provider: "anthropic", + store: claudeAuthStore, + }); + expectMockCallFields(mockedGetApiKeyForModel, { + profileId: "anthropic:claude-cli", + }); + expectMockCallFields(mockedRunEmbeddedAttempt, { + authProfileId: "anthropic:claude-cli", + authProfileIdSource: "auto", + }); + }); + + it("loads the Claude CLI auth overlay when explicit PI runtime uses Claude CLI OAuth", async () => { + const claudeAuthStore = { + version: 1 as const, + profiles: { + "anthropic:claude-cli": { + type: "oauth" as const, + provider: "claude-cli", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }; + mockedEnsureAuthProfileStore.mockReturnValueOnce(claudeAuthStore); + mockedResolveAuthProfileOrder.mockReturnValueOnce(["anthropic:claude-cli"]); + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "anthropic", + model: "test-model", + config: { + auth: { + order: { anthropic: ["anthropic:claude-cli"] }, + profiles: { + "anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" }, + }, + }, + agents: { + defaults: { + models: { + "anthropic/test-model": { agentRuntime: { id: "pi" } }, + }, + }, + }, + }, + runId: "pi-explicit-runtime-claude-cli-oauth-overlay", + }); + + expect(mockedEnsureAuthProfileStore).toHaveBeenCalledTimes(1); + expectRecordFields(mockCallArg(mockedEnsureAuthProfileStore, 0, 1), { + externalCliProviderIds: ["claude-cli"], + allowKeychainPrompt: false, + }); + expect(mockedEnsureAuthProfileStoreWithoutExternalProfiles).not.toHaveBeenCalled(); + expectMockCallFields(mockedGetApiKeyForModel, { + profileId: "anthropic:claude-cli", + }); + }); + + it("does not let an auto-selected stale Anthropic profile suppress Claude CLI auth overlay", async () => { + const claudeAuthStore = { + version: 1 as const, + profiles: { + "anthropic:api": { + type: "api_key" as const, + provider: "anthropic", + key: "static-key", + }, + "anthropic:claude-cli": { + type: "oauth" as const, + provider: "claude-cli", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }; + mockedEnsureAuthProfileStore.mockReturnValueOnce(claudeAuthStore); + mockedResolveAuthProfileOrder.mockReturnValueOnce(["anthropic:claude-cli", "anthropic:api"]); + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "anthropic", + model: "test-model", + config: { + auth: { + order: { anthropic: ["anthropic:claude-cli"] }, + profiles: { + "anthropic:api": { provider: "anthropic", mode: "api_key" }, + "anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" }, + }, + }, + }, + authProfileId: "anthropic:api", + authProfileIdSource: "auto", + runId: "pi-auto-profile-does-not-suppress-claude-cli-overlay", + }); + + expect(mockedEnsureAuthProfileStore).toHaveBeenCalledTimes(1); + expectRecordFields(mockCallArg(mockedEnsureAuthProfileStore, 0, 1), { + externalCliProviderIds: ["claude-cli"], + allowKeychainPrompt: false, + }); + expect(mockedEnsureAuthProfileStoreWithoutExternalProfiles).not.toHaveBeenCalled(); + expectMockCallFields(mockedResolveAuthProfileOrder, { + preferredProfile: undefined, + }); + expectMockCallFields(mockedGetApiKeyForModel, { + profileId: "anthropic:claude-cli", + }); + expectMockCallFields(mockedRunEmbeddedAttempt, { + authProfileId: "anthropic:claude-cli", + authProfileIdSource: "auto", + }); + }); + + it("does not let an auto-selected stale profile suppress runtime-selected Claude CLI auth overlay", async () => { + const claudeAuthStore = { + version: 1 as const, + profiles: { + "anthropic:api": { + type: "api_key" as const, + provider: "anthropic", + key: "static-key", + }, + "anthropic:claude-cli": { + type: "oauth" as const, + provider: "claude-cli", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }; + mockedEnsureAuthProfileStore.mockReturnValueOnce(claudeAuthStore); + mockedResolveAuthProfileOrder.mockReturnValueOnce(["anthropic:claude-cli", "anthropic:api"]); + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "anthropic", + model: "test-model", + config: { + auth: { + profiles: { + "anthropic:api": { provider: "anthropic", mode: "api_key" }, + "anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" }, + }, + }, + agents: { + defaults: { + models: { + "anthropic/test-model": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + }, + authProfileId: "anthropic:api", + authProfileIdSource: "auto", + runId: "pi-auto-profile-does-not-suppress-runtime-claude-cli-overlay", + }); + + expect(mockedEnsureAuthProfileStore).toHaveBeenCalledTimes(1); + expectRecordFields(mockCallArg(mockedEnsureAuthProfileStore, 0, 1), { + externalCliProviderIds: ["claude-cli"], + allowKeychainPrompt: false, + }); + expect(mockedEnsureAuthProfileStoreWithoutExternalProfiles).not.toHaveBeenCalled(); + expectMockCallFields(mockedResolveAuthProfileOrder, { + preferredProfile: undefined, + }); + expectMockCallFields(mockedGetApiKeyForModel, { + profileId: "anthropic:claude-cli", + }); + expectMockCallFields(mockedRunEmbeddedAttempt, { + authProfileId: "anthropic:claude-cli", + authProfileIdSource: "auto", + }); + }); + + it("loads the Claude CLI auth overlay for ordered fallback profiles after direct Anthropic auth", async () => { + const authStore = { + version: 1 as const, + profiles: { + "anthropic:api": { + type: "api_key" as const, + provider: "anthropic", + key: "static-key", + }, + "anthropic:claude-cli": { + type: "oauth" as const, + provider: "claude-cli", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }; + mockedEnsureAuthProfileStore.mockReturnValueOnce(authStore); + mockedResolveAuthProfileOrder.mockReturnValueOnce(["anthropic:api", "anthropic:claude-cli"]); + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "anthropic", + model: "test-model", + config: { + auth: { + order: { anthropic: ["anthropic:api", "anthropic:claude-cli"] }, + profiles: { + "anthropic:api": { provider: "anthropic", mode: "api_key" }, + "anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" }, + }, + }, + }, + runId: "pi-direct-anthropic-with-claude-cli-fallback-overlay", + }); + + expect(mockedEnsureAuthProfileStore).toHaveBeenCalledTimes(1); + expectRecordFields(mockCallArg(mockedEnsureAuthProfileStore, 0, 1), { + externalCliProviderIds: ["claude-cli"], + allowKeychainPrompt: false, + }); + expect(mockedEnsureAuthProfileStoreWithoutExternalProfiles).not.toHaveBeenCalled(); + expectMockCallFields(mockedGetApiKeyForModel, { + profileId: "anthropic:api", + }); + }); + + it("loads the Claude CLI auth overlay from persisted auth-store order", async () => { + const staticAuthStore = { + version: 1 as const, + profiles: {}, + order: { anthropic: ["anthropic:claude-cli"] }, + }; + const claudeAuthStore = { + version: 1 as const, + profiles: { + "anthropic:claude-cli": { + type: "oauth" as const, + provider: "claude-cli", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }; + mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce(staticAuthStore); + mockedEnsureAuthProfileStore.mockReturnValueOnce(claudeAuthStore); + mockedResolveAuthProfileOrder.mockReturnValueOnce(["anthropic:claude-cli"]); + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "anthropic", + model: "test-model", + runId: "pi-store-order-claude-cli-oauth-overlay", + }); + + expect(mockedEnsureAuthProfileStoreWithoutExternalProfiles).toHaveBeenCalledTimes(1); + expect(mockedEnsureAuthProfileStore).toHaveBeenCalledTimes(1); + expectRecordFields(mockCallArg(mockedEnsureAuthProfileStore, 0, 1), { + externalCliProviderIds: ["claude-cli"], + allowKeychainPrompt: false, + }); + expectMockCallFields(mockedGetApiKeyForModel, { + profileId: "anthropic:claude-cli", + }); + }); + + it("keeps static Anthropic PI auth on the no-external auth profile store", async () => { + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "anthropic", + model: "test-model", + config: { + auth: { + order: { anthropic: ["anthropic:api"] }, + profiles: { + "anthropic:api": { provider: "anthropic", mode: "api_key" }, + "anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" }, + }, + }, + }, + runId: "pi-static-anthropic-auth-no-external-overlay", + }); + + expect(mockedEnsureAuthProfileStore).not.toHaveBeenCalled(); + expect(mockedEnsureAuthProfileStoreWithoutExternalProfiles).toHaveBeenCalledTimes(1); + expectRecordFields(mockCallArg(mockedEnsureAuthProfileStoreWithoutExternalProfiles, 0, 1), { + allowKeychainPrompt: false, + }); + }); + it("keeps non-Codex plugin harnesses on the lightweight auth profile store", async () => { const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); const pluginRunAttempt = vi.fn(async () => diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index a02c2546d7b..165ad5ebe5d 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -36,6 +36,7 @@ import { markAuthProfileSuccess, resolveAuthProfileEligibility, } from "../auth-profiles.js"; +import { resolveExternalCliAuthOverlayScopeFromSelection } from "../auth-profiles/external-cli-auth-selection.js"; import { listActiveProcessSessionReferences } from "../bash-process-references.js"; import { resolveSessionKeyForRequest, @@ -709,6 +710,37 @@ export async function runEmbeddedPiAgent( pluginHarnessOwnsTransport && provider === OPENAI_CODEX_PROVIDER_ID && effectiveModel.api === "openai-codex-responses"; + let piExternalCliAuthScope = pluginHarnessOwnsTransport + ? { ignoreAutoPreferredProfile: false } + : resolveExternalCliAuthOverlayScopeFromSelection({ + provider, + cfg: params.config, + agentId: params.agentId, + modelId, + workspaceDir: resolvedWorkspace, + userLockedAuthProfileId: + params.authProfileIdSource === "user" ? params.authProfileId : undefined, + }); + let noExternalAuthStore: AuthProfileStore | undefined; + if ( + !pluginHarnessOwnsTransport && + !pluginHarnessNeedsOpenClawAuthBootstrap && + !piExternalCliAuthScope.providerIds + ) { + noExternalAuthStore = ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + }); + piExternalCliAuthScope = resolveExternalCliAuthOverlayScopeFromSelection({ + provider, + cfg: params.config, + agentId: params.agentId, + modelId, + workspaceDir: resolvedWorkspace, + store: noExternalAuthStore, + userLockedAuthProfileId: + params.authProfileIdSource === "user" ? params.authProfileId : undefined, + }); + } const authStore = pluginHarnessOwnsTransport && !pluginHarnessNeedsOpenClawAuthBootstrap ? createEmptyAuthProfileStore() @@ -717,9 +749,15 @@ export async function runEmbeddedPiAgent( externalCliProviderIds: [OPENAI_CODEX_PROVIDER_ID], allowKeychainPrompt: false, }) - : ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { - allowKeychainPrompt: false, - }); + : piExternalCliAuthScope.providerIds + ? ensureAuthProfileStore(agentDir, { + externalCliProviderIds: piExternalCliAuthScope.providerIds, + allowKeychainPrompt: false, + }) + : (noExternalAuthStore ?? + ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + })); const attemptAuthProfileStore = pluginHarnessOwnsTransport && !pluginHarnessNeedsOpenClawAuthBootstrap ? ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { @@ -789,7 +827,9 @@ export async function runEmbeddedPiAgent( pluginHarnessProfileOrder[0]; const preferredProfileId = pluginHarnessOwnsTransport ? resolvePluginHarnessPreferredProfileId() - : requestedProfileId; + : piExternalCliAuthScope.ignoreAutoPreferredProfile && !requestedProfileIsUserLocked + ? undefined + : requestedProfileId; let lockedProfileId = requestedProfileIsUserLocked ? preferredProfileId : undefined; if (lockedProfileId) { if (pluginHarnessOwnsTransport) { diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index 766ffd3474e..1d10220765e 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -537,6 +537,97 @@ describe("secrets apply", () => { }); }); + it("preserves unrelated oauth profiles while applying auth-profile key ref targets", async () => { + const codexOAuthRef = { + id: "codex-sidecar-ref", + provider: "openai-codex", + }; + await writeJsonFile(fixture.authStorePath, { + version: 1, + profiles: { + "openai:static": { + type: "api_key", + provider: "openai", + key: "sk-openai-static", // pragma: allowlist secret + }, + "openai-codex:sidecar": { + type: "oauth", + provider: "openai-codex", + oauthRef: codexOAuthRef, + email: "codex@example.invalid", + }, + "anthropic:claude-cli": { + provider: "claude-cli", + mode: "oauth", + }, + }, + order: { + openai: ["openai:static"], + "openai-codex": ["openai-codex:sidecar"], + "claude-cli": ["anthropic:claude-cli"], + }, + lastGood: { + openai: "openai:static", + "openai-codex": "openai-codex:sidecar", + "claude-cli": "anthropic:claude-cli", + }, + }); + const plan = createPlan({ + targets: [ + { + type: "auth-profiles.api_key.key", + path: "profiles.openai:static.key", + pathSegments: ["profiles", "openai:static", "key"], + agentId: "main", + ref: OPENAI_API_KEY_ENV_REF, + }, + ], + options: { + scrubEnv: false, + scrubAuthProfilesForProviderTargets: false, + scrubLegacyAuthJson: false, + }, + }); + + const result = await runSecretsApply({ plan, env: fixture.env, write: true }); + + expect(result.changed).toBe(true); + const nextAuthStore = JSON.parse(await fs.readFile(fixture.authStorePath, "utf8")) as { + profiles: Record< + string, + { + key?: string; + keyRef?: unknown; + mode?: string; + oauthRef?: unknown; + provider?: string; + type?: string; + } + >; + order?: Record; + lastGood?: Record; + }; + expect(Object.keys(nextAuthStore.profiles).toSorted()).toEqual([ + "anthropic:claude-cli", + "openai-codex:sidecar", + "openai:static", + ]); + expect(nextAuthStore.profiles["openai:static"].key).toBeUndefined(); + expect(nextAuthStore.profiles["openai:static"].keyRef).toEqual(OPENAI_API_KEY_ENV_REF); + expect(nextAuthStore.profiles["openai-codex:sidecar"]).toMatchObject({ + type: "oauth", + provider: "openai-codex", + oauthRef: codexOAuthRef, + email: "codex@example.invalid", + }); + expect(nextAuthStore.profiles["anthropic:claude-cli"]).toEqual({ + provider: "claude-cli", + mode: "oauth", + }); + expect(nextAuthStore.order?.["openai-codex"]).toEqual(["openai-codex:sidecar"]); + expect(nextAuthStore.lastGood?.["claude-cli"]).toBe("anthropic:claude-cli"); + }); + it("creates a new auth-profiles mapping when provider metadata is supplied", async () => { const plan: SecretsApplyPlan = { version: 1,