mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 04:56:19 +00:00
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:
207
src/agents/auth-profiles/external-cli-auth-selection.ts
Normal file
207
src/agents/auth-profiles/external-cli-auth-selection.ts
Normal 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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
101
src/agents/auth-profiles/store.runtime-external.test.ts
Normal file
101
src/agents/auth-profiles/store.runtime-external.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user