import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { AuthChoice } from "./onboard-types.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; import { ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL } from "./onboard-auth.js"; vi.mock("../providers/github-copilot-auth.js", () => ({ githubCopilotLoginCommand: vi.fn(async () => {}), })); const loginOpenAICodexOAuth = vi.hoisted(() => vi.fn(async () => null)); vi.mock("./openai-codex-oauth.js", () => ({ loginOpenAICodexOAuth, })); const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); vi.mock("../plugins/providers.js", () => ({ resolvePluginProviders, })); const noopAsync = async () => {}; const noop = () => {}; const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json"); const requireAgentDir = () => { const agentDir = process.env.OPENCLAW_AGENT_DIR; if (!agentDir) { throw new Error("OPENCLAW_AGENT_DIR not set"); } return agentDir; }; describe("applyAuthChoice", () => { const previousStateDir = process.env.OPENCLAW_STATE_DIR; const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; const previousOpenrouterKey = process.env.OPENROUTER_API_KEY; const previousHfToken = process.env.HF_TOKEN; const previousHfHubToken = process.env.HUGGINGFACE_HUB_TOKEN; const previousLitellmKey = process.env.LITELLM_API_KEY; const previousAiGatewayKey = process.env.AI_GATEWAY_API_KEY; const previousCloudflareGatewayKey = process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; const previousSshTty = process.env.SSH_TTY; const previousChutesClientId = process.env.CHUTES_CLIENT_ID; let tempStateDir: string | null = null; afterEach(async () => { vi.unstubAllGlobals(); resolvePluginProviders.mockReset(); loginOpenAICodexOAuth.mockReset(); loginOpenAICodexOAuth.mockResolvedValue(null); if (tempStateDir) { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } if (previousStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; } else { process.env.OPENCLAW_STATE_DIR = previousStateDir; } if (previousAgentDir === undefined) { delete process.env.OPENCLAW_AGENT_DIR; } else { process.env.OPENCLAW_AGENT_DIR = previousAgentDir; } if (previousPiAgentDir === undefined) { delete process.env.PI_CODING_AGENT_DIR; } else { process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; } if (previousAnthropicKey === undefined) { delete process.env.ANTHROPIC_API_KEY; } else { process.env.ANTHROPIC_API_KEY = previousAnthropicKey; } if (previousOpenrouterKey === undefined) { delete process.env.OPENROUTER_API_KEY; } else { process.env.OPENROUTER_API_KEY = previousOpenrouterKey; } if (previousHfToken === undefined) { delete process.env.HF_TOKEN; } else { process.env.HF_TOKEN = previousHfToken; } if (previousHfHubToken === undefined) { delete process.env.HUGGINGFACE_HUB_TOKEN; } else { process.env.HUGGINGFACE_HUB_TOKEN = previousHfHubToken; } if (previousLitellmKey === undefined) { delete process.env.LITELLM_API_KEY; } else { process.env.LITELLM_API_KEY = previousLitellmKey; } if (previousAiGatewayKey === undefined) { delete process.env.AI_GATEWAY_API_KEY; } else { process.env.AI_GATEWAY_API_KEY = previousAiGatewayKey; } if (previousCloudflareGatewayKey === undefined) { delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; } else { process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = previousCloudflareGatewayKey; } if (previousSshTty === undefined) { delete process.env.SSH_TTY; } else { process.env.SSH_TTY = previousSshTty; } if (previousChutesClientId === undefined) { delete process.env.CHUTES_CLIENT_ID; } else { process.env.CHUTES_CLIENT_ID = previousChutesClientId; } }); it("does not throw when openai-codex oauth fails", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; loginOpenAICodexOAuth.mockRejectedValueOnce(new Error("oauth failed")); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select: vi.fn(async () => "" as never), multiselect: vi.fn(async () => []), text: vi.fn(async () => ""), confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; await expect( applyAuthChoice({ authChoice: "openai-codex", config: {}, prompter, runtime, setDefaultModel: false, }), ).resolves.toEqual({ config: {} }); }); it("prompts and writes MiniMax API key when selecting minimax-api", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; const text = vi.fn().mockResolvedValue("sk-minimax-test"); const select: WizardPrompter["select"] = vi.fn( async (params) => params.options[0]?.value as never, ); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select, multiselect, text, confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "minimax-api", config: {}, prompter, runtime, setDefaultModel: true, }); expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: "Enter MiniMax API key" }), ); expect(result.config.auth?.profiles?.["minimax:default"]).toMatchObject({ provider: "minimax", mode: "api_key", }); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record; }; expect(parsed.profiles?.["minimax:default"]?.key).toBe("sk-minimax-test"); }); it("prompts and writes Synthetic API key when selecting synthetic-api-key", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; const text = vi.fn().mockResolvedValue("sk-synthetic-test"); const select: WizardPrompter["select"] = vi.fn( async (params) => params.options[0]?.value as never, ); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select, multiselect, text, confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "synthetic-api-key", config: {}, prompter, runtime, setDefaultModel: true, }); expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: "Enter Synthetic API key" }), ); expect(result.config.auth?.profiles?.["synthetic:default"]).toMatchObject({ provider: "synthetic", mode: "api_key", }); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record; }; expect(parsed.profiles?.["synthetic:default"]?.key).toBe("sk-synthetic-test"); }); it("prompts and writes Hugging Face API key when selecting huggingface-api-key", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; const text = vi.fn().mockResolvedValue("hf-test-token"); const select: WizardPrompter["select"] = vi.fn( async (params) => params.options[0]?.value as never, ); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select, multiselect, text, confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "huggingface-api-key", config: {}, prompter, runtime, setDefaultModel: true, }); expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining("Hugging Face") }), ); expect(result.config.auth?.profiles?.["huggingface:default"]).toMatchObject({ provider: "huggingface", mode: "api_key", }); expect(result.config.agents?.defaults?.model?.primary).toMatch(/^huggingface\/.+/); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record; }; expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-test-token"); }); it("prompts for Z.AI endpoint when selecting zai-api-key", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; const text = vi.fn().mockResolvedValue("zai-test-key"); const select = vi.fn(async (params: { message: string }) => { if (params.message === "Select Z.AI endpoint") { return "coding-cn"; } return "default"; }); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select: select as WizardPrompter["select"], multiselect, text, confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "zai-api-key", config: {}, prompter, runtime, setDefaultModel: true, }); expect(select).toHaveBeenCalledWith( expect.objectContaining({ message: "Select Z.AI endpoint", initialValue: "global" }), ); expect(result.config.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL); expect(result.config.agents?.defaults?.model?.primary).toBe("zai/glm-5"); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record; }; expect(parsed.profiles?.["zai:default"]?.key).toBe("zai-test-key"); }); it("uses endpoint-specific auth choice without prompting for Z.AI endpoint", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; const text = vi.fn().mockResolvedValue("zai-test-key"); const select = vi.fn(async () => "default"); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select: select as WizardPrompter["select"], multiselect, text, confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "zai-coding-global", config: {}, prompter, runtime, setDefaultModel: true, }); expect(select).not.toHaveBeenCalledWith( expect.objectContaining({ message: "Select Z.AI endpoint" }), ); expect(result.config.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_GLOBAL_BASE_URL); }); it("maps apiKey + tokenProvider=huggingface to huggingface-api-key flow", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; delete process.env.HF_TOKEN; delete process.env.HUGGINGFACE_HUB_TOKEN; const text = vi.fn().mockResolvedValue("should-not-be-used"); const select: WizardPrompter["select"] = vi.fn( async (params) => params.options[0]?.value as never, ); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const confirm = vi.fn(async () => false); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select, multiselect, text, confirm, progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "apiKey", config: {}, prompter, runtime, setDefaultModel: true, opts: { tokenProvider: "huggingface", token: "hf-token-provider-test", }, }); expect(result.config.auth?.profiles?.["huggingface:default"]).toMatchObject({ provider: "huggingface", mode: "api_key", }); expect(result.config.agents?.defaults?.model?.primary).toMatch(/^huggingface\/.+/); expect(text).not.toHaveBeenCalled(); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record; }; expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-token-provider-test"); }); it("does not override the global default model when selecting xai-api-key without setDefaultModel", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; const text = vi.fn().mockResolvedValue("sk-xai-test"); const select: WizardPrompter["select"] = vi.fn( async (params) => params.options[0]?.value as never, ); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select, multiselect, text, confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "xai-api-key", config: { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } } }, prompter, runtime, setDefaultModel: false, agentId: "agent-1", }); expect(text).toHaveBeenCalledWith(expect.objectContaining({ message: "Enter xAI API key" })); expect(result.config.auth?.profiles?.["xai:default"]).toMatchObject({ provider: "xai", mode: "api_key", }); expect(result.config.agents?.defaults?.model?.primary).toBe("openai/gpt-4o-mini"); expect(result.agentModelOverride).toBe("xai/grok-4"); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record; }; expect(parsed.profiles?.["xai:default"]?.key).toBe("sk-xai-test"); }); it("sets default model when selecting github-copilot", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select: vi.fn(async () => "" as never), multiselect: vi.fn(async () => []), text: vi.fn(async () => ""), confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY"); const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY"); Object.defineProperty(stdin, "isTTY", { configurable: true, enumerable: true, get: () => true, }); try { const result = await applyAuthChoice({ authChoice: "github-copilot", config: {}, prompter, runtime, setDefaultModel: true, }); expect(result.config.agents?.defaults?.model?.primary).toBe("github-copilot/gpt-4o"); } finally { if (previousIsTTYDescriptor) { Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); } else if (!hadOwnIsTTY) { delete stdin.isTTY; } } }); it("does not override the default model when selecting opencode-zen without setDefaultModel", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; const text = vi.fn().mockResolvedValue("sk-opencode-zen-test"); const select: WizardPrompter["select"] = vi.fn( async (params) => params.options[0]?.value as never, ); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select, multiselect, text, confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "opencode-zen", config: { agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" }, }, }, }, prompter, runtime, setDefaultModel: false, }); expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: "Enter OpenCode Zen API key" }), ); expect(result.config.agents?.defaults?.model?.primary).toBe("anthropic/claude-opus-4-5"); expect(result.config.models?.providers?.["opencode-zen"]).toBeUndefined(); expect(result.agentModelOverride).toBe("opencode/claude-opus-4-6"); }); it("does not persist literal 'undefined' when Anthropic API key prompt returns undefined", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; delete process.env.ANTHROPIC_API_KEY; const text = vi.fn(async () => undefined as unknown as string); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select: vi.fn(async () => "" as never), multiselect: vi.fn(async () => []), text, confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "apiKey", config: {}, prompter, runtime, setDefaultModel: false, }); expect(result.config.auth?.profiles?.["anthropic:default"]).toMatchObject({ provider: "anthropic", mode: "api_key", }); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record; }; expect(parsed.profiles?.["anthropic:default"]?.key).toBe(""); expect(parsed.profiles?.["anthropic:default"]?.key).not.toBe("undefined"); }); it("does not persist literal 'undefined' when OpenRouter API key prompt returns undefined", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; delete process.env.OPENROUTER_API_KEY; const text = vi.fn(async () => undefined as unknown as string); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select: vi.fn(async () => "" as never), multiselect: vi.fn(async () => []), text, confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "openrouter-api-key", config: {}, prompter, runtime, setDefaultModel: false, }); expect(result.config.auth?.profiles?.["openrouter:default"]).toMatchObject({ provider: "openrouter", mode: "api_key", }); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record; }; expect(parsed.profiles?.["openrouter:default"]?.key).toBe(""); expect(parsed.profiles?.["openrouter:default"]?.key).not.toBe("undefined"); }); it("uses existing OPENROUTER_API_KEY when selecting openrouter-api-key", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; process.env.OPENROUTER_API_KEY = "sk-openrouter-test"; const text = vi.fn(); const select: WizardPrompter["select"] = vi.fn( async (params) => params.options[0]?.value as never, ); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const confirm = vi.fn(async () => true); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select, multiselect, text, confirm, progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "openrouter-api-key", config: {}, prompter, runtime, setDefaultModel: true, }); expect(confirm).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining("OPENROUTER_API_KEY"), }), ); expect(text).not.toHaveBeenCalled(); expect(result.config.auth?.profiles?.["openrouter:default"]).toMatchObject({ provider: "openrouter", mode: "api_key", }); expect(result.config.agents?.defaults?.model?.primary).toBe("openrouter/auto"); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record; }; expect(parsed.profiles?.["openrouter:default"]?.key).toBe("sk-openrouter-test"); delete process.env.OPENROUTER_API_KEY; }); it("ignores legacy LiteLLM oauth profiles when selecting litellm-api-key", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; process.env.LITELLM_API_KEY = "sk-litellm-test"; const authProfilePath = authProfilePathFor(requireAgentDir()); await fs.mkdir(path.dirname(authProfilePath), { recursive: true }); await fs.writeFile( authProfilePath, JSON.stringify( { version: 1, profiles: { "litellm:legacy": { type: "oauth", provider: "litellm", access: "access-token", refresh: "refresh-token", expires: Date.now() + 60_000, }, }, }, null, 2, ), "utf8", ); const text = vi.fn(); const select: WizardPrompter["select"] = vi.fn( async (params) => params.options[0]?.value as never, ); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const confirm = vi.fn(async () => true); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select, multiselect, text, confirm, progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "litellm-api-key", config: { auth: { profiles: { "litellm:legacy": { provider: "litellm", mode: "oauth" }, }, order: { litellm: ["litellm:legacy"] }, }, }, prompter, runtime, setDefaultModel: true, }); expect(confirm).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining("LITELLM_API_KEY"), }), ); expect(text).not.toHaveBeenCalled(); expect(result.config.auth?.profiles?.["litellm:default"]).toMatchObject({ provider: "litellm", mode: "api_key", }); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record; }; expect(parsed.profiles?.["litellm:default"]).toMatchObject({ type: "api_key", key: "sk-litellm-test", }); }); it("uses existing AI_GATEWAY_API_KEY when selecting ai-gateway-api-key", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; process.env.AI_GATEWAY_API_KEY = "gateway-test-key"; const text = vi.fn(); const select: WizardPrompter["select"] = vi.fn( async (params) => params.options[0]?.value as never, ); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const confirm = vi.fn(async () => true); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select, multiselect, text, confirm, progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "ai-gateway-api-key", config: {}, prompter, runtime, setDefaultModel: true, }); expect(confirm).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining("AI_GATEWAY_API_KEY"), }), ); expect(text).not.toHaveBeenCalled(); expect(result.config.auth?.profiles?.["vercel-ai-gateway:default"]).toMatchObject({ provider: "vercel-ai-gateway", mode: "api_key", }); expect(result.config.agents?.defaults?.model?.primary).toBe( "vercel-ai-gateway/anthropic/claude-opus-4.6", ); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record; }; expect(parsed.profiles?.["vercel-ai-gateway:default"]?.key).toBe("gateway-test-key"); delete process.env.AI_GATEWAY_API_KEY; }); it("uses existing CLOUDFLARE_AI_GATEWAY_API_KEY when selecting cloudflare-ai-gateway-api-key", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = "cf-gateway-test-key"; const text = vi .fn() .mockResolvedValueOnce("cf-account-id") .mockResolvedValueOnce("cf-gateway-id"); const select: WizardPrompter["select"] = vi.fn( async (params) => params.options[0]?.value as never, ); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const confirm = vi.fn(async () => true); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select, multiselect, text, confirm, progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "cloudflare-ai-gateway-api-key", config: {}, prompter, runtime, setDefaultModel: true, }); expect(confirm).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining("CLOUDFLARE_AI_GATEWAY_API_KEY"), }), ); expect(text).toHaveBeenCalledTimes(2); expect(result.config.auth?.profiles?.["cloudflare-ai-gateway:default"]).toMatchObject({ provider: "cloudflare-ai-gateway", mode: "api_key", }); expect(result.config.agents?.defaults?.model?.primary).toBe( "cloudflare-ai-gateway/claude-sonnet-4-5", ); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record }>; }; expect(parsed.profiles?.["cloudflare-ai-gateway:default"]?.key).toBe("cf-gateway-test-key"); expect(parsed.profiles?.["cloudflare-ai-gateway:default"]?.metadata).toEqual({ accountId: "cf-account-id", gatewayId: "cf-gateway-id", }); delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; }); it("writes Chutes OAuth credentials when selecting chutes (remote/manual)", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; process.env.SSH_TTY = "1"; process.env.CHUTES_CLIENT_ID = "cid_test"; const fetchSpy = vi.fn(async (input: string | URL) => { const url = typeof input === "string" ? input : input.toString(); if (url === "https://api.chutes.ai/idp/token") { return new Response( JSON.stringify({ access_token: "at_test", refresh_token: "rt_test", expires_in: 3600, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } if (url === "https://api.chutes.ai/idp/userinfo") { return new Response(JSON.stringify({ username: "remote-user" }), { status: 200, headers: { "Content-Type": "application/json" }, }); } return new Response("not found", { status: 404 }); }); vi.stubGlobal("fetch", fetchSpy); const text = vi.fn().mockResolvedValue("code_manual"); const select: WizardPrompter["select"] = vi.fn( async (params) => params.options[0]?.value as never, ); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select, multiselect, text, confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "chutes", config: {}, prompter, runtime, setDefaultModel: false, }); expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: "Paste the redirect URL (or authorization code)", }), ); expect(result.config.auth?.profiles?.["chutes:remote-user"]).toMatchObject({ provider: "chutes", mode: "oauth", }); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record< string, { provider?: string; access?: string; refresh?: string; email?: string } >; }; expect(parsed.profiles?.["chutes:remote-user"]).toMatchObject({ provider: "chutes", access: "at_test", refresh: "rt_test", email: "remote-user", }); }); it("writes Qwen credentials when selecting qwen-portal", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; resolvePluginProviders.mockReturnValue([ { id: "qwen-portal", label: "Qwen", auth: [ { id: "device", label: "Qwen OAuth", kind: "device_code", run: vi.fn(async () => ({ profiles: [ { profileId: "qwen-portal:default", credential: { type: "oauth", provider: "qwen-portal", access: "access", refresh: "refresh", expires: Date.now() + 60 * 60 * 1000, }, }, ], configPatch: { models: { providers: { "qwen-portal": { baseUrl: "https://portal.qwen.ai/v1", apiKey: "qwen-oauth", api: "openai-completions", models: [], }, }, }, }, defaultModel: "qwen-portal/coder-model", })), }, ], }, ]); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select: vi.fn(async () => "" as never), multiselect: vi.fn(async () => []), text: vi.fn(async () => ""), confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "qwen-portal", config: {}, prompter, runtime, setDefaultModel: true, }); expect(result.config.auth?.profiles?.["qwen-portal:default"]).toMatchObject({ provider: "qwen-portal", mode: "oauth", }); expect(result.config.agents?.defaults?.model?.primary).toBe("qwen-portal/coder-model"); expect(result.config.models?.providers?.["qwen-portal"]).toMatchObject({ baseUrl: "https://portal.qwen.ai/v1", apiKey: "qwen-oauth", }); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record; }; expect(parsed.profiles?.["qwen-portal:default"]).toMatchObject({ provider: "qwen-portal", access: "access", refresh: "refresh", }); }); it("writes MiniMax credentials when selecting minimax-portal", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; resolvePluginProviders.mockReturnValue([ { id: "minimax-portal", label: "MiniMax", auth: [ { id: "oauth", label: "MiniMax OAuth (Global)", kind: "device_code", run: vi.fn(async () => ({ profiles: [ { profileId: "minimax-portal:default", credential: { type: "oauth", provider: "minimax-portal", access: "access", refresh: "refresh", expires: Date.now() + 60 * 60 * 1000, }, }, ], configPatch: { models: { providers: { "minimax-portal": { baseUrl: "https://api.minimax.io/anthropic", apiKey: "minimax-oauth", api: "anthropic-messages", models: [], }, }, }, }, defaultModel: "minimax-portal/MiniMax-M2.1", })), }, ], }, ]); const prompter: WizardPrompter = { intro: vi.fn(noopAsync), outro: vi.fn(noopAsync), note: vi.fn(noopAsync), select: vi.fn(async () => "oauth" as never), multiselect: vi.fn(async () => []), text: vi.fn(async () => ""), confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: noop, stop: noop })), }; const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn((code: number) => { throw new Error(`exit:${code}`); }), }; const result = await applyAuthChoice({ authChoice: "minimax-portal", config: {}, prompter, runtime, setDefaultModel: true, }); expect(result.config.auth?.profiles?.["minimax-portal:default"]).toMatchObject({ provider: "minimax-portal", mode: "oauth", }); expect(result.config.agents?.defaults?.model?.primary).toBe("minimax-portal/MiniMax-M2.1"); expect(result.config.models?.providers?.["minimax-portal"]).toMatchObject({ baseUrl: "https://api.minimax.io/anthropic", apiKey: "minimax-oauth", }); const authProfilePath = authProfilePathFor(requireAgentDir()); const raw = await fs.readFile(authProfilePath, "utf8"); const parsed = JSON.parse(raw) as { profiles?: Record; }; expect(parsed.profiles?.["minimax-portal:default"]).toMatchObject({ provider: "minimax-portal", access: "access", refresh: "refresh", }); }); }); describe("resolvePreferredProviderForAuthChoice", () => { it("maps github-copilot to the provider", () => { expect(resolvePreferredProviderForAuthChoice("github-copilot")).toBe("github-copilot"); }); it("maps qwen-portal to the provider", () => { expect(resolvePreferredProviderForAuthChoice("qwen-portal")).toBe("qwen-portal"); }); it("returns undefined for unknown choices", () => { expect(resolvePreferredProviderForAuthChoice("unknown" as AuthChoice)).toBeUndefined(); }); });