fix: load Claude CLI OAuth for PI auth profiles (#87167)

* test: cover auth profile SecretRef regressions

* docs: note auth profile regression coverage

* test: satisfy auth profile regression lint

* fix: load Claude CLI OAuth overlay for PI runs

* fix(agents): share external CLI auth selection with btw

* chore: remove release-owned changelog entry
This commit is contained in:
Josh Avant
2026-05-27 01:15:41 -07:00
committed by GitHub
parent cefa6777e2
commit cc704caa08
7 changed files with 1048 additions and 11 deletions

View File

@@ -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<string, string[]>;
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 }
: {}),
};
}

View File

@@ -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<string, string | undefined> = {};
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]);
});
});

View File

@@ -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, {

View File

@@ -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 =

View File

@@ -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<AgentHarness["runAttempt"]>(async () =>

View File

@@ -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) {

View File

@@ -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<string, string[]>;
lastGood?: Record<string, string>;
};
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,