Files
openclaw/src/commands/auth-choice.test.ts
2026-05-09 02:47:46 +01:00

1243 lines
41 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { resolveAgentDir } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import type { ModelProviderConfig } from "../config/types.models.js";
import { __testing as providerAuthChoiceTesting } from "../plugins/provider-auth-choice.js";
import * as providerAuthChoices from "../plugins/provider-auth-choices.js";
import type { ProviderAuthMethod, ProviderAuthResult, ProviderPlugin } from "../plugins/types.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { applyAuthChoice } from "./auth-choice.apply.js";
import {
createAuthTestLifecycle,
createExitThrowingRuntime,
createWizardPrompter,
setupAuthTestEnv,
} from "./test-wizard-helpers.js";
type DetectZaiEndpoint = typeof import("../plugins/provider-zai-endpoint.js").detectZaiEndpoint;
const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
const ZAI_CODING_GLOBAL_BASE_URL = "https://api.z.ai/api/coding/paas/v4";
const ZAI_CODING_CN_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4";
const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => []));
const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../plugins/provider-install-catalog.js", () => ({
resolveProviderInstallCatalogEntry: vi.fn(() => undefined),
}));
vi.mock("./auth-choice.apply.api-providers.js", () => {
const normalizeProviderId = (value: string) => value.trim().toLowerCase();
const resolveChoiceByKind = (params: {
authChoice: string;
kind: ProviderAuthMethod["kind"];
tokenProvider?: string;
}) => {
const providerId = normalizeProviderId(params.tokenProvider ?? "");
if (!providerId) {
return params.authChoice;
}
const provider = resolvePluginProviders().find(
(entry) => normalizeProviderId(entry.id) === providerId,
);
return (
provider?.auth.find((method) => method.kind === params.kind)?.wizard?.choiceId ??
params.authChoice
);
};
return {
applyAuthChoiceApiProviders: vi.fn(async () => null),
normalizeApiKeyTokenProviderAuthChoice: (params: {
authChoice: string;
tokenProvider?: string;
}) => {
if (params.authChoice === "token" || params.authChoice === "setup-token") {
return resolveChoiceByKind({ ...params, kind: "token" });
}
if (params.authChoice === "apiKey") {
return resolveChoiceByKind({ ...params, kind: "api_key" });
}
return params.authChoice;
},
};
});
const detectZaiEndpoint = vi.hoisted(() => vi.fn<DetectZaiEndpoint>(async () => null));
vi.mock("../plugins/provider-zai-endpoint.js", () => ({
detectZaiEndpoint,
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId: () => "main",
resolveAgentDir: (_config: unknown, agentId: string) =>
`${process.env.OPENCLAW_STATE_DIR ?? "/tmp/openclaw-state"}/agents/${agentId}/agent`,
resolveAgentWorkspaceDir: (_config: unknown, agentId: string) =>
`/tmp/openclaw-workspaces/${agentId}`,
}));
vi.mock("../agents/workspace.js", () => ({
resolveDefaultAgentWorkspaceDir: () => "/tmp/openclaw-workspace",
}));
vi.mock("../plugins/setup-browser.js", () => ({
isRemoteEnvironment: () => false,
openUrl: vi.fn(async () => {}),
}));
vi.mock("../plugins/provider-oauth-flow.js", () => ({
createVpsAwareOAuthHandlers: vi.fn(),
}));
vi.mock("../plugins/provider-auth-helpers.js", () => ({
applyAuthProfileConfig: (
cfg: OpenClawConfig,
params: {
profileId: string;
provider: string;
mode: "api_key" | "aws-sdk" | "oauth" | "token";
email?: string;
displayName?: string;
},
): OpenClawConfig => ({
...cfg,
auth: {
...cfg.auth,
profiles: {
...cfg.auth?.profiles,
[params.profileId]: {
provider: params.provider,
mode: params.mode,
...(params.email ? { email: params.email } : {}),
...(params.displayName ? { displayName: params.displayName } : {}),
},
},
},
}),
}));
type StoredAuthProfile = {
key?: string;
token?: string;
keyRef?: { source: string; provider: string; id: string };
access?: string;
refresh?: string;
expires?: number;
provider?: string;
type?: string;
email?: string;
metadata?: Record<string, string>;
};
const testAuthProfileStores = vi.hoisted(
() => new Map<string, { profiles: Record<string, StoredAuthProfile> }>(),
);
// These tests verify profile payloads, not file locking; keep auth stores in memory.
function resolveTestAuthStoreKey(agentDir?: string): string {
return agentDir?.trim() || process.env.OPENCLAW_AGENT_DIR || "__main__";
}
function readTestAuthProfileStore(agentDir?: string): {
profiles: Record<string, StoredAuthProfile>;
} {
return testAuthProfileStores.get(resolveTestAuthStoreKey(agentDir)) ?? { profiles: {} };
}
function seedTestAuthProfile(params: {
profileId: string;
credential: StoredAuthProfile;
agentDir?: string;
}): void {
const key = resolveTestAuthStoreKey(params.agentDir);
const store = testAuthProfileStores.get(key) ?? { profiles: {} };
store.profiles[params.profileId] = params.credential;
testAuthProfileStores.set(key, store);
}
vi.mock("../agents/auth-profiles.js", () => ({
upsertAuthProfile: (params: {
profileId: string;
credential: StoredAuthProfile;
agentDir?: string;
}) => {
seedTestAuthProfile(params);
},
}));
function normalizeText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function normalizeProviderId(value: string): string {
return value.trim().toLowerCase();
}
function resolveProviderPluginChoice(params: { providers: ProviderPlugin[]; choice: string }) {
const choice = params.choice.trim();
if (!choice) {
return null;
}
if (choice.startsWith("provider-plugin:")) {
const payload = choice.slice("provider-plugin:".length);
const separator = payload.indexOf(":");
const providerId = separator >= 0 ? payload.slice(0, separator) : payload;
const methodId = separator >= 0 ? payload.slice(separator + 1) : undefined;
const provider = params.providers.find(
(entry) => normalizeProviderId(entry.id) === normalizeProviderId(providerId),
);
const method = methodId
? provider?.auth.find((entry) => entry.id === methodId)
: provider?.auth[0];
return provider && method ? { provider, method } : null;
}
for (const provider of params.providers) {
for (const method of provider.auth) {
if (method.wizard?.choiceId === choice) {
return { provider, method, wizard: method.wizard };
}
}
if (normalizeProviderId(provider.id) === normalizeProviderId(choice) && provider.auth[0]) {
return { provider, method: provider.auth[0] };
}
}
return null;
}
function providerConfigPatch(
providerId: string,
patch: Record<string, unknown>,
): Partial<OpenClawConfig> {
const providers: Record<string, ModelProviderConfig> = {
[providerId]: patch as ModelProviderConfig,
};
return {
models: {
providers,
},
};
}
type TestSecretRef = { source: "env"; provider: string; id: string };
type TestSecretInput = string | TestSecretRef;
function normalizeProviderInput(value: unknown): string | undefined {
const normalized = normalizeText(value).toLowerCase();
return normalized || undefined;
}
function buildApiKeyCredential(
provider: string,
input: TestSecretInput,
metadata?: Record<string, string>,
): {
type: "api_key";
provider: string;
key?: string;
keyRef?: TestSecretRef;
metadata?: Record<string, string>;
} {
if (typeof input === "string") {
return { type: "api_key", provider, key: input, ...(metadata ? { metadata } : {}) };
}
return { type: "api_key", provider, keyRef: input, ...(metadata ? { metadata } : {}) };
}
async function resolveRefApiKeyInput(params: {
env: NodeJS.ProcessEnv;
envVar: string;
prompter: WizardPrompter;
}): Promise<TestSecretInput> {
if (typeof params.prompter.select === "function") {
const source = await params.prompter.select({
message: "Choose secret reference source",
options: [
{ label: "Environment variable", value: "env" },
{ label: "Secret provider", value: "provider" },
],
});
if (source !== "env") {
await params.prompter.text?.({ message: "Enter secret provider reference" });
await params.prompter.note?.(
"Could not validate provider reference; choose an environment variable instead.",
"Reference check failed",
);
}
}
const envName =
normalizeText(await params.prompter.text?.({ message: "Enter environment variable name" })) ||
params.envVar;
await params.prompter.note?.(`Validated environment variable ${envName}.`, "Reference validated");
return { source: "env", provider: "default", id: envName };
}
async function resolveApiKeyInput(params: {
ctx: Parameters<ProviderAuthMethod["run"]>[0];
providerId: string;
expectedProviders: string[];
optionKey: string;
envVar: string;
promptMessage: string;
noteMessage?: string;
noteTitle?: string;
}): Promise<{ input: TestSecretInput; mode?: "plaintext" | "ref" }> {
const opts = (params.ctx.opts ?? {}) as Record<string, unknown>;
const flagValue = normalizeText(opts[params.optionKey]);
const token = flagValue || normalizeText(params.ctx.opts?.token);
const tokenProvider = normalizeProviderInput(
flagValue ? params.providerId : params.ctx.opts?.tokenProvider,
);
const expectedProviders = params.expectedProviders.map((provider) => provider.toLowerCase());
if (token && tokenProvider && expectedProviders.includes(tokenProvider)) {
return { input: token, mode: params.ctx.secretInputMode };
}
if (params.noteMessage) {
await params.ctx.prompter.note(params.noteMessage, params.noteTitle);
}
const env = params.ctx.env ?? process.env;
if (params.ctx.secretInputMode === "ref") {
return {
input: await resolveRefApiKeyInput({
env,
envVar: params.envVar,
prompter: params.ctx.prompter,
}),
mode: "ref",
};
}
const envValue = normalizeText(env[params.envVar]);
if (envValue) {
const useEnv = await params.ctx.prompter.confirm?.({
message: `Use ${params.envVar} from environment?`,
});
if (useEnv) {
return { input: envValue, mode: "plaintext" };
}
}
return {
input: normalizeText(await params.ctx.prompter.text({ message: params.promptMessage })),
mode: "plaintext",
};
}
async function createApiKeyProvider(params: {
providerId: string;
label: string;
choiceId: string;
optionKey: string;
flagName: `--${string}`;
envVar: string;
promptMessage: string;
defaultModel?: string;
profileId?: string;
profileIds?: string[];
expectedProviders?: string[];
noteMessage?: string;
noteTitle?: string;
applyConfig?: Partial<OpenClawConfig>;
}): Promise<ProviderPlugin> {
const profileIds =
params.profileIds && params.profileIds.length > 0
? params.profileIds
: [params.profileId ?? `${params.providerId}:default`];
return {
id: params.providerId,
label: params.label,
auth: [
{
id: "api-key",
label: params.label,
kind: "api_key",
wizard: {
choiceId: params.choiceId,
choiceLabel: params.label,
groupId: params.providerId,
groupLabel: params.label,
},
run: async (ctx) => {
const { input } = await resolveApiKeyInput({
ctx,
providerId: params.providerId,
expectedProviders: params.expectedProviders ?? [params.providerId],
optionKey: params.optionKey,
envVar: params.envVar,
promptMessage: params.promptMessage,
noteMessage: params.noteMessage,
noteTitle: params.noteTitle,
});
return {
profiles: profileIds.map((profileId) => ({
profileId,
credential: buildApiKeyCredential(
profileId.split(":", 1)[0] || params.providerId,
input,
),
})),
...(params.applyConfig ? { configPatch: params.applyConfig as OpenClawConfig } : {}),
...(params.defaultModel ? { defaultModel: params.defaultModel } : {}),
};
},
},
],
};
}
function createFixedChoiceProvider(params: {
providerId: string;
label: string;
choiceId: string;
method: ProviderAuthMethod;
}): ProviderPlugin {
return {
id: params.providerId,
label: params.label,
auth: [
{
...params.method,
wizard: {
choiceId: params.choiceId,
choiceLabel: params.label,
groupId: params.providerId,
groupLabel: params.label,
},
},
],
};
}
async function createDefaultProviderPlugins(): Promise<ProviderPlugin[]> {
const createZaiMethod = (choiceId: "zai-api-key" | "zai-coding-global"): ProviderAuthMethod => ({
id: choiceId === "zai-api-key" ? "api-key" : "coding-global",
label: "Z.AI API key",
kind: "api_key",
wizard: {
choiceId,
choiceLabel: "Z.AI API key",
groupId: "zai",
groupLabel: "Z.AI",
},
run: async (ctx) => {
const token = normalizeText(await ctx.prompter.text({ message: "Enter Z.AI API key" }));
const detectResult = await detectZaiEndpoint(
choiceId === "zai-coding-global"
? { apiKey: token, endpoint: "coding-global" }
: { apiKey: token },
);
let baseUrl = detectResult?.baseUrl;
let modelId = detectResult?.modelId;
if (!baseUrl || !modelId) {
if (choiceId === "zai-coding-global") {
baseUrl = ZAI_CODING_GLOBAL_BASE_URL;
modelId = "glm-5";
} else {
const endpoint = await ctx.prompter.select({
message: "Select Z.AI endpoint",
initialValue: "global",
options: [
{ label: "Global", value: "global" },
{ label: "Coding CN", value: "coding-cn" },
],
});
baseUrl = endpoint === "coding-cn" ? ZAI_CODING_CN_BASE_URL : ZAI_CODING_GLOBAL_BASE_URL;
modelId = "glm-5";
}
}
return {
profiles: [
{
profileId: "zai:default",
credential: buildApiKeyCredential("zai", token),
},
],
configPatch: providerConfigPatch("zai", { baseUrl }) as OpenClawConfig,
defaultModel: `zai/${modelId}`,
};
},
});
return [
await createApiKeyProvider({
providerId: "google",
label: "Gemini API key",
choiceId: "gemini-api-key",
optionKey: "geminiApiKey",
flagName: "--gemini-api-key",
envVar: "GEMINI_API_KEY",
promptMessage: "Enter Gemini API key",
defaultModel: GOOGLE_GEMINI_DEFAULT_MODEL,
}),
await createApiKeyProvider({
providerId: "huggingface",
label: "Hugging Face API key",
choiceId: "huggingface-api-key",
optionKey: "huggingfaceApiKey",
flagName: "--huggingface-api-key",
envVar: "HUGGINGFACE_HUB_TOKEN",
promptMessage: "Enter Hugging Face API key",
defaultModel: "huggingface/Qwen/Qwen3-Coder-480B-A35B-Instruct",
}),
await createApiKeyProvider({
providerId: "openai",
label: "OpenAI API key",
choiceId: "openai-api-key",
optionKey: "openaiApiKey",
flagName: "--openai-api-key",
envVar: "OPENAI_API_KEY",
promptMessage: "Enter OpenAI API key",
defaultModel: "openai/gpt-5.5",
}),
await createApiKeyProvider({
providerId: "opencode",
label: "OpenCode Zen",
choiceId: "opencode-zen",
optionKey: "opencodeZenApiKey",
flagName: "--opencode-zen-api-key",
envVar: "OPENCODE_API_KEY",
promptMessage: "Enter OpenCode API key",
profileIds: ["opencode:default", "opencode-go:default"],
defaultModel: "opencode/claude-opus-4-6",
expectedProviders: ["opencode", "opencode-go"],
noteMessage: "OpenCode uses one API key across the Zen and Go catalogs.",
noteTitle: "OpenCode",
}),
await createApiKeyProvider({
providerId: "opencode-go",
label: "OpenCode Go",
choiceId: "opencode-go",
optionKey: "opencodeGoApiKey",
flagName: "--opencode-go-api-key",
envVar: "OPENCODE_API_KEY",
promptMessage: "Enter OpenCode API key",
profileIds: ["opencode-go:default", "opencode:default"],
defaultModel: "opencode-go/kimi-k2.6",
expectedProviders: ["opencode", "opencode-go"],
noteMessage: "OpenCode uses one API key across the Zen and Go catalogs.",
noteTitle: "OpenCode",
}),
await createApiKeyProvider({
providerId: "openrouter",
label: "OpenRouter API key",
choiceId: "openrouter-api-key",
optionKey: "openrouterApiKey",
flagName: "--openrouter-api-key",
envVar: "OPENROUTER_API_KEY",
promptMessage: "Enter OpenRouter API key",
defaultModel: "openrouter/auto",
}),
await createApiKeyProvider({
providerId: "synthetic",
label: "Synthetic API key",
choiceId: "synthetic-api-key",
optionKey: "syntheticApiKey",
flagName: "--synthetic-api-key",
envVar: "SYNTHETIC_API_KEY",
promptMessage: "Enter Synthetic API key",
defaultModel: "synthetic/Synthetic-1",
}),
{
id: "zai",
label: "Z.AI",
auth: [createZaiMethod("zai-api-key"), createZaiMethod("zai-coding-global")],
},
];
}
describe("applyAuthChoice", () => {
const lifecycle = createAuthTestLifecycle([
"OPENCLAW_STATE_DIR",
"OPENCLAW_AGENT_DIR",
"PI_CODING_AGENT_DIR",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
"HF_TOKEN",
"HUGGINGFACE_HUB_TOKEN",
"GEMINI_API_KEY",
"OPENCODE_API_KEY",
"SYNTHETIC_API_KEY",
]);
let authTestRoot: string | null = null;
let authStateCounter = 0;
async function setupTempState() {
if (!authTestRoot) {
throw new Error("auth test root not initialized");
}
testAuthProfileStores.clear();
const stateDir = path.join(authTestRoot, `state-${++authStateCounter}`);
const agentDir = path.join(stateDir, "agent");
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_AGENT_DIR = agentDir;
process.env.PI_CODING_AGENT_DIR = agentDir;
}
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return createWizardPrompter(overrides, { defaultSelect: "" });
}
function createSelectFirstOption(): WizardPrompter["select"] {
return vi.fn(async (params) => params.options[0]?.value as never);
}
function createNoopMultiselect(): WizardPrompter["multiselect"] {
return vi.fn(async () => []);
}
function createApiKeyPromptHarness(
overrides: Partial<Pick<WizardPrompter, "select" | "multiselect" | "text" | "confirm">> = {},
): {
select: WizardPrompter["select"];
multiselect: WizardPrompter["multiselect"];
prompter: WizardPrompter;
runtime: ReturnType<typeof createExitThrowingRuntime>;
} {
const select = overrides.select ?? createSelectFirstOption();
const multiselect = overrides.multiselect ?? createNoopMultiselect();
return {
select,
multiselect,
prompter: createPrompter({ ...overrides, select, multiselect }),
runtime: createExitThrowingRuntime(),
};
}
async function readAuthProfiles() {
return readTestAuthProfileStore(resolveAgentDir({} as OpenClawConfig, "main"));
}
async function readAuthProfilesForAgentDir(agentDir: string) {
return readTestAuthProfileStore(agentDir);
}
async function readAuthProfile(profileId: string) {
return (await readAuthProfiles()).profiles?.[profileId];
}
let defaultProviderPlugins: ProviderPlugin[] = [];
beforeAll(async () => {
authTestRoot = (await setupAuthTestEnv("openclaw-auth-")).stateDir;
defaultProviderPlugins = await createDefaultProviderPlugins();
resolvePluginProviders.mockReturnValue(defaultProviderPlugins);
providerAuthChoiceTesting.setDepsForTest({
loadPluginProviderRuntime: async () => ({
resolvePluginProviders,
resolvePluginSetupProvider: () => undefined,
resolveProviderPluginChoice,
runProviderModelSelectedHook,
}),
});
});
afterAll(async () => {
providerAuthChoiceTesting.resetDepsForTest();
if (authTestRoot) {
await fs.rm(authTestRoot, { recursive: true, force: true });
}
});
afterEach(async () => {
vi.unstubAllGlobals();
resolvePluginProviders.mockReset();
resolvePluginProviders.mockReturnValue(defaultProviderPlugins);
runProviderModelSelectedHook.mockClear();
detectZaiEndpoint.mockReset();
detectZaiEndpoint.mockResolvedValue(null);
testAuthProfileStores.clear();
await lifecycle.cleanup();
});
it("applies Anthropic setup-token auth when the provider exposes the setup flow", async () => {
await setupTempState();
resolvePluginProviders.mockReturnValue([
createFixedChoiceProvider({
providerId: "anthropic",
label: "Anthropic",
choiceId: "setup-token",
method: {
id: "setup-token",
label: "Anthropic setup-token",
kind: "token",
run: vi.fn(
async (): Promise<ProviderAuthResult> => ({
profiles: [
{
profileId: "anthropic:default",
credential: {
type: "token",
provider: "anthropic",
token: `sk-ant-oat01-${"a".repeat(80)}`,
},
},
],
defaultModel: "anthropic/claude-sonnet-4-6",
}),
),
},
}),
]);
const result = await applyAuthChoice({
authChoice: "token",
config: {} as OpenClawConfig,
prompter: createPrompter({}),
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
opts: {
tokenProvider: "anthropic",
token: `sk-ant-oat01-${"a".repeat(80)}`,
},
});
expect(result.config.auth?.profiles?.["anthropic:default"]).toMatchObject({
provider: "anthropic",
mode: "token",
});
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
"anthropic/claude-sonnet-4-6",
);
expect((await readAuthProfile("anthropic:default"))?.token).toBe(
`sk-ant-oat01-${"a".repeat(80)}`,
);
});
it("fails fast when a removed provider auth choice is passed to the interactive flow", async () => {
const spy = vi
.spyOn(providerAuthChoices, "resolveManifestDeprecatedProviderAuthChoice")
.mockReturnValueOnce({
choiceId: "openai-codex",
} as never);
try {
await expect(
applyAuthChoice({
authChoice: "openai-codex-import",
config: {},
prompter: createPrompter({}),
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
}),
).rejects.toThrow(
'Auth choice "openai-codex-import" is no longer supported. Use "openai-codex" instead, or run openclaw onboard to choose interactively.',
);
} finally {
spy.mockRestore();
}
});
it("escapes removed provider auth choice guidance for terminal output", async () => {
const spy = vi
.spyOn(providerAuthChoices, "resolveManifestDeprecatedProviderAuthChoice")
.mockReturnValueOnce({
choiceId: "modern\nchoice",
} as never);
try {
await expect(
applyAuthChoice({
authChoice: "legacy\u001b[31mchoice",
config: {},
prompter: createPrompter({}),
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
}),
).rejects.toThrow(
'Auth choice "legacy\\u001b[31mchoice" is no longer supported. Use "modern\\nchoice" instead, or run openclaw onboard to choose interactively.',
);
} finally {
spy.mockRestore();
}
});
it("prompts and writes provider API key profiles for common providers", async () => {
const scenarios: Array<{
authChoice: "huggingface-api-key";
promptContains: string;
profileId: string;
provider: string;
token: string;
}> = [
{
authChoice: "huggingface-api-key" as const,
promptContains: "Hugging Face",
profileId: "huggingface:default",
provider: "huggingface",
token: "hf-test-token",
},
];
await setupTempState();
for (const scenario of scenarios) {
const text = vi.fn().mockResolvedValue(scenario.token);
const { prompter, runtime } = createApiKeyPromptHarness({ text });
const result = await applyAuthChoice({
authChoice: scenario.authChoice,
config: {},
prompter,
runtime,
setDefaultModel: true,
});
expect(text).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining(scenario.promptContains) }),
);
expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({
provider: scenario.provider,
mode: "api_key",
});
expect((await readAuthProfile(scenario.profileId))?.key).toBe(scenario.token);
}
});
it("uses Z.AI endpoint detection and prompts in the auth flow", async () => {
const scenarios: Array<{
authChoice: "zai-api-key" | "zai-coding-global";
token: string;
endpointSelection?: "coding-cn" | "global";
detectResult?: {
endpoint: "coding-global" | "coding-cn";
modelId: string;
baseUrl: string;
note: string;
};
shouldPromptForEndpoint: boolean;
expectedDetectCall?: { apiKey: string; endpoint?: "coding-global" | "coding-cn" };
}> = [
{
authChoice: "zai-api-key",
token: "zai-test-key",
endpointSelection: "coding-cn",
shouldPromptForEndpoint: true,
},
{
authChoice: "zai-coding-global",
token: "zai-test-key",
detectResult: {
endpoint: "coding-global",
modelId: "glm-4.7",
baseUrl: ZAI_CODING_GLOBAL_BASE_URL,
note: "Detected coding-global endpoint with GLM-4.7 fallback",
},
shouldPromptForEndpoint: false,
expectedDetectCall: { apiKey: "zai-test-key", endpoint: "coding-global" },
},
];
await setupTempState();
for (const scenario of scenarios) {
detectZaiEndpoint.mockReset();
detectZaiEndpoint.mockResolvedValue(null);
if (scenario.detectResult) {
detectZaiEndpoint.mockResolvedValueOnce(scenario.detectResult);
}
const text = vi.fn().mockResolvedValue(scenario.token);
const select = vi.fn(async (params: { message: string }) => {
if (params.message === "Select Z.AI endpoint") {
return scenario.endpointSelection ?? "global";
}
return "default";
});
const { prompter, runtime } = createApiKeyPromptHarness({
select: select as WizardPrompter["select"],
text,
});
const result = await applyAuthChoice({
authChoice: scenario.authChoice,
config: {},
prompter,
runtime,
setDefaultModel: true,
});
if (scenario.expectedDetectCall) {
expect(detectZaiEndpoint).toHaveBeenCalledWith(scenario.expectedDetectCall);
}
if (scenario.shouldPromptForEndpoint) {
expect(select).toHaveBeenCalledWith(
expect.objectContaining({ message: "Select Z.AI endpoint", initialValue: "global" }),
);
} else {
expect(select).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "Select Z.AI endpoint" }),
);
}
expect(result.config.auth?.profiles?.["zai:default"]).toMatchObject({
provider: "zai",
mode: "api_key",
});
expect((await readAuthProfile("zai:default"))?.key).toBe(scenario.token);
}
});
it("uses provided tokens without prompting across alias and direct provider choices", async () => {
const scenarios: Array<{
authChoice: "apiKey" | "gemini-api-key";
config?: OpenClawConfig;
setDefaultModel: boolean;
tokenProvider: string;
token: string;
profileId: string;
provider: string;
expectedModel?: string;
expectedModelPrefix?: string;
expectedAgentModelOverride?: string;
extraProfiles?: string[];
}> = [
{
authChoice: "apiKey",
setDefaultModel: true,
tokenProvider: " GOOGLE ",
token: "sk-gemini-token-provider-test",
profileId: "google:default",
provider: "google",
expectedModel: GOOGLE_GEMINI_DEFAULT_MODEL,
},
{
authChoice: "gemini-api-key",
config: { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } } },
setDefaultModel: false,
tokenProvider: "google",
token: "sk-gemini-test",
profileId: "google:default",
provider: "google",
expectedModel: "openai/gpt-4o-mini",
expectedAgentModelOverride: GOOGLE_GEMINI_DEFAULT_MODEL,
},
];
await setupTempState();
for (const scenario of scenarios) {
delete process.env.HF_TOKEN;
delete process.env.HUGGINGFACE_HUB_TOKEN;
const text = vi.fn().mockResolvedValue("should-not-be-used");
const confirm = vi.fn(async () => false);
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
const result = await applyAuthChoice({
authChoice: scenario.authChoice,
config: scenario.config ?? {},
prompter,
runtime,
setDefaultModel: scenario.setDefaultModel,
opts: {
tokenProvider: scenario.tokenProvider,
token: scenario.token,
},
});
expect(text).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({
provider: scenario.provider,
mode: "api_key",
});
const selectedModel = resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model);
if (scenario.expectedModel) {
expect(selectedModel).toBe(scenario.expectedModel);
}
if (scenario.expectedModelPrefix) {
expect(selectedModel?.startsWith(scenario.expectedModelPrefix)).toBe(true);
}
if (scenario.expectedAgentModelOverride) {
expect(result.agentModelOverride).toBe(scenario.expectedAgentModelOverride);
}
expect((await readAuthProfile(scenario.profileId))?.key).toBe(scenario.token);
for (const extraProfile of scenario.extraProfiles ?? []) {
expect((await readAuthProfile(extraProfile))?.key).toBe(scenario.token);
}
}
});
it("uses existing env API keys for selected providers", async () => {
const scenarios: Array<{
authChoice: "openrouter-api-key";
envKey: "OPENROUTER_API_KEY";
envValue: string;
profileId: string;
provider: string;
expectEnvPrompt: boolean;
expectedTextCalls: number;
expectedKey?: string;
expectedModel?: string;
}> = [
{
authChoice: "openrouter-api-key",
envKey: "OPENROUTER_API_KEY",
envValue: "sk-openrouter-test",
profileId: "openrouter:default",
provider: "openrouter",
expectEnvPrompt: true,
expectedTextCalls: 0,
expectedKey: "sk-openrouter-test",
expectedModel: "openrouter/auto",
},
];
await setupTempState();
for (const scenario of scenarios) {
delete process.env.SYNTHETIC_API_KEY;
delete process.env.OPENROUTER_API_KEY;
process.env[scenario.envKey] = scenario.envValue;
const text = vi.fn();
const confirm = vi.fn(async () => true);
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
const result = await applyAuthChoice({
authChoice: scenario.authChoice,
config: {},
prompter,
runtime,
setDefaultModel: true,
});
if (scenario.expectEnvPrompt) {
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining(scenario.envKey),
}),
);
} else {
expect(confirm).not.toHaveBeenCalled();
}
expect(text).toHaveBeenCalledTimes(scenario.expectedTextCalls);
expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({
provider: scenario.provider,
mode: "api_key",
});
if (scenario.expectedModel) {
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
scenario.expectedModel,
);
}
const profile = await readAuthProfile(scenario.profileId);
expect(profile?.key).toBe(scenario.expectedKey);
expect(profile?.keyRef).toBeUndefined();
}
});
it("keeps an existing default model when configure re-applies provider auth", async () => {
await setupTempState();
vi.stubEnv("OPENROUTER_API_KEY", "sk-openrouter-test");
const note = vi.fn();
const confirm = vi.fn(async () => true);
const text = vi.fn();
const existingPrimary = "anthropic/claude-opus-4-6";
const prompter = createPrompter({ text, confirm, note });
const result = await applyAuthChoice({
authChoice: "openrouter-api-key",
config: { agents: { defaults: { model: { primary: existingPrimary } } } },
prompter,
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
preserveExistingDefaultModel: true,
});
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
existingPrimary,
);
expect(result.config.agents?.defaults?.models?.["openrouter/auto"]).toEqual({});
expect(runProviderModelSelectedHook).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledWith(
"Kept existing default model anthropic/claude-opus-4-6; openrouter/auto is available.",
"Model configured",
);
});
it("enables the owning plugin for manifest provider auth choices", async () => {
await setupTempState();
const provider = createFixedChoiceProvider({
providerId: "github-copilot",
label: "GitHub Copilot",
choiceId: "github-copilot-github",
method: {
id: "github",
label: "GitHub Copilot",
kind: "token",
run: vi.fn(
async (): Promise<ProviderAuthResult> => ({
profiles: [
{
profileId: "github-copilot:github",
credential: {
type: "token",
provider: "github-copilot",
token: "gho_copilot_test",
},
},
],
defaultModel: "github-copilot/claude-opus-4.7",
}),
),
},
});
const manifestSpy = vi
.spyOn(providerAuthChoices, "resolveManifestProviderAuthChoice")
.mockReturnValue({
pluginId: "github-copilot",
providerId: "github-copilot",
methodId: "github",
choiceId: "github-copilot-github",
choiceLabel: "GitHub Copilot",
});
providerAuthChoiceTesting.setDepsForTest({
loadPluginProviderRuntime: async () => ({
resolvePluginProviders,
resolvePluginSetupProvider: () => provider,
resolveProviderPluginChoice,
runProviderModelSelectedHook,
}),
});
try {
const result = await applyAuthChoice({
authChoice: "github-copilot-github",
config: { plugins: { entries: { "github-copilot": { enabled: false } } } },
prompter: createPrompter({}),
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
preserveExistingDefaultModel: true,
});
expect(result.config.plugins?.entries?.["github-copilot"]).toEqual({ enabled: true });
expect(result.config.auth?.profiles?.["github-copilot:github"]).toMatchObject({
provider: "github-copilot",
mode: "token",
});
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
"github-copilot/claude-opus-4.7",
);
} finally {
manifestSpy.mockRestore();
providerAuthChoiceTesting.setDepsForTest({
loadPluginProviderRuntime: async () => ({
resolvePluginProviders,
resolvePluginSetupProvider: () => undefined,
resolveProviderPluginChoice,
runProviderModelSelectedHook,
}),
});
}
});
it("uses explicit env for plugin auth resolution instead of host env", async () => {
await setupTempState();
process.env.OPENAI_API_KEY = "sk-openai-host"; // pragma: allowlist secret
const env = { OPENAI_API_KEY: "sk-openai-explicit" } as NodeJS.ProcessEnv; // pragma: allowlist secret
const text = vi.fn().mockResolvedValue("should-not-be-used");
const confirm = vi.fn(async () => true);
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
const result = await applyAuthChoice({
authChoice: "openai-api-key",
config: {},
env,
prompter,
runtime,
setDefaultModel: false,
});
expect(resolvePluginProviders).toHaveBeenCalledWith(
expect.objectContaining({
env,
mode: "setup",
}),
);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("OPENAI_API_KEY"),
}),
);
expect(text).not.toHaveBeenCalled();
expect(result.config.auth?.profiles?.["openai:default"]).toMatchObject({
provider: "openai",
mode: "api_key",
});
expect((await readAuthProfile("openai:default"))?.key).toBe("sk-openai-explicit");
});
it("keeps existing default model for explicit provider keys when setDefaultModel=false", async () => {
const scenarios: Array<{
authChoice: "synthetic-api-key" | "opencode-zen";
token: string | undefined;
promptMessage: string;
existingPrimary: string;
expectedOverride: string;
profileId?: string;
profileProvider?: string;
expectedStoredKey?: string;
extraProfileId?: string;
expectProviderConfigUndefined?: "opencode";
agentId?: string;
}> = [
{
authChoice: "synthetic-api-key",
token: undefined,
promptMessage: "Enter Synthetic API key",
existingPrimary: "openai/gpt-4o-mini",
expectedOverride: "synthetic/Synthetic-1",
profileId: "synthetic:default",
profileProvider: "synthetic",
expectedStoredKey: "",
agentId: "agent-1",
},
{
authChoice: "opencode-zen",
token: "sk-opencode-zen-test",
promptMessage: "Enter OpenCode API key",
existingPrimary: "anthropic/claude-opus-4-5",
expectedOverride: "opencode/claude-opus-4-6",
profileId: "opencode:default",
profileProvider: "opencode",
extraProfileId: "opencode-go:default",
expectProviderConfigUndefined: "opencode",
},
];
await setupTempState();
for (const scenario of scenarios) {
const text = vi.fn().mockResolvedValue(scenario.token);
const { prompter, runtime } = createApiKeyPromptHarness({ text });
const result = await applyAuthChoice({
authChoice: scenario.authChoice,
config: { agents: { defaults: { model: { primary: scenario.existingPrimary } } } },
prompter,
runtime,
setDefaultModel: false,
agentId: scenario.agentId,
});
expect(text).toHaveBeenCalledWith(
expect.objectContaining({ message: scenario.promptMessage }),
);
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
scenario.existingPrimary,
);
expect(result.agentModelOverride).toBe(scenario.expectedOverride);
if (scenario.profileId && scenario.profileProvider) {
expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({
provider: scenario.profileProvider,
mode: "api_key",
});
const profileStore =
scenario.agentId && scenario.agentId !== "default"
? await readAuthProfilesForAgentDir(resolveAgentDir(result.config, scenario.agentId))
: await readAuthProfiles();
expect(profileStore.profiles?.[scenario.profileId]?.key).toBe(
scenario.expectedStoredKey ?? scenario.token,
);
expect(profileStore.profiles?.[scenario.profileId]?.key).not.toBe("undefined");
}
if (scenario.extraProfileId) {
const profileStore =
scenario.agentId && scenario.agentId !== "default"
? await readAuthProfilesForAgentDir(resolveAgentDir(result.config, scenario.agentId))
: await readAuthProfiles();
expect(profileStore.profiles?.[scenario.extraProfileId]?.key).toBe(scenario.token);
}
if (scenario.expectProviderConfigUndefined) {
expect(
result.config.models?.providers?.[scenario.expectProviderConfigUndefined],
).toBeUndefined();
}
}
});
});