// Model auth tests cover provider auth status, expiry, and display helpers. import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { ConfigWriteOptions } from "../../config/io.js"; import type { ProviderPlugin } from "../../plugins/types.js"; import type { RuntimeEnv } from "../../runtime.js"; import { ProviderAuthConfigApplyError } from "../../shared/provider-auth-result.js"; type AuthRunCall = { agentDir?: string; signal?: AbortSignal; workspaceDir?: string; }; type ResolvePluginProvidersCall = { config?: unknown; includeUntrustedWorkspacePlugins?: boolean; providerRefs?: string[]; workspaceDir?: string; }; type PersistProviderAuthCall = { agentDir?: string; profiles?: Array<{ profileId?: string; credential?: { provider?: string; token?: string; tokenRef?: unknown; type?: string; }; }>; }; function readMockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0): unknown { const value = mock.mock.calls[index]?.[0]; if (!value) { throw new Error("Expected mock call argument"); } return value; } const mocks = vi.hoisted(() => ({ clackCancel: vi.fn(), clackConfirm: vi.fn(), clackIsCancel: vi.fn((value: unknown) => value === Symbol.for("clack:cancel")), clackPassword: vi.fn(), clackSelect: vi.fn(), clackText: vi.fn(), resolveDefaultAgentId: vi.fn(), resolveAgentDir: vi.fn(), resolveAgentWorkspaceDir: vi.fn(), resolveDefaultAgentWorkspaceDir: vi.fn(), isCliProvider: vi.fn(), upsertAuthProfile: vi.fn(), upsertAuthProfileWithLock: vi.fn(), persistProviderAuthProfilesAfterLogin: vi.fn(), removeProviderAuthProfilesWithLock: vi.fn(), resolvePluginProvidersCore: vi.fn(), createClackPrompter: vi.fn(), loadValidConfigSnapshotOrThrow: vi.fn(), updateConfig: vi.fn(), logConfigUpdated: vi.fn(), openUrl: vi.fn(), isRemoteEnvironment: vi.fn(() => false), validateAnthropicSetupToken: vi.fn<() => string | undefined>(() => undefined), promoteAuthProfileInOrder: vi.fn(), tryImportProviderCredential: vi.fn(), callGateway: vi.fn(), isImplicitLocalGatewayTarget: vi.fn(() => Promise.resolve(true)), resolvePluginSetupProviderCore: vi.fn(), resolvePluginSetupRegistry: vi.fn(), readSecretStoreValue: vi.fn(() => ({ ok: false as const, error: { code: "SECRET_STORE_NOT_FOUND", message: "missing" }, })), writeSecretStoreEntry: vi.fn(), deleteSecretStoreEntry: vi.fn(), })); vi.mock("../../secrets/store/secret-store.js", () => ({ readSecretStoreValue: mocks.readSecretStoreValue, writeSecretStoreEntry: mocks.writeSecretStoreEntry, deleteSecretStoreEntry: mocks.deleteSecretStoreEntry, })); vi.mock("../../agents/auth-profiles/profiles.js", () => ({ promoteAuthProfileInOrder: mocks.promoteAuthProfileInOrder, removeProviderAuthProfilesWithLock: mocks.removeProviderAuthProfilesWithLock, upsertAuthProfile: mocks.upsertAuthProfile, upsertAuthProfileWithLock: mocks.upsertAuthProfileWithLock, upsertAuthProfileWithLockOrThrow: mocks.upsertAuthProfileWithLock, })); vi.mock("../../plugins/provider-auth-persistence.js", () => ({ persistProviderAuthProfilesAfterLogin: mocks.persistProviderAuthProfilesAfterLogin, })); vi.mock("./auth-credential-import.js", () => ({ tryImportProviderCredential: mocks.tryImportProviderCredential, })); 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 } : {}), }, }, }, }), })); vi.mock("@clack/prompts", () => ({ cancel: mocks.clackCancel, confirm: mocks.clackConfirm, isCancel: mocks.clackIsCancel, password: mocks.clackPassword, select: mocks.clackSelect, text: mocks.clackText, })); vi.mock("../../agents/agent-scope.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, resolveDefaultAgentId: mocks.resolveDefaultAgentId, resolveAgentDir: mocks.resolveAgentDir, resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, }; }); vi.mock("../../agents/workspace.js", () => ({ resolveDefaultAgentWorkspaceDir: mocks.resolveDefaultAgentWorkspaceDir, })); vi.mock("../../agents/model-selection-cli.js", () => ({ isCliProvider: mocks.isCliProvider, })); vi.mock("../../plugins/providers.runtime.js", () => ({ resolvePluginProvidersCore: mocks.resolvePluginProvidersCore, })); vi.mock("../../plugins/setup-registry.js", () => ({ resolvePluginSetupProviderCore: mocks.resolvePluginSetupProviderCore, resolvePluginSetupRegistry: mocks.resolvePluginSetupRegistry, })); vi.mock("../../wizard/clack-prompter.js", () => ({ createClackPrompter: mocks.createClackPrompter, })); vi.mock("./shared.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadValidConfigSnapshotOrThrow: mocks.loadValidConfigSnapshotOrThrow, updateConfig: mocks.updateConfig, }; }); vi.mock("../../plugins/install-record-commit.js", () => ({ transformConfigWithPendingPluginInstalls: async (params: { transform: ( current: OpenClawConfig, context: { snapshot: { valid: boolean } }, ) => { nextConfig: OpenClawConfig }; writeOptions?: ConfigWriteOptions; }) => { const next = await mocks.updateConfig( (current: OpenClawConfig) => params.transform(current, { snapshot: { valid: true } }).nextConfig, undefined, params.writeOptions?.beforeCommit, params.writeOptions, ); return { nextConfig: next, result: next }; }, })); vi.mock("../../config/logging.js", () => ({ logConfigUpdated: mocks.logConfigUpdated, })); vi.mock("../../infra/browser-open.js", () => ({ openUrl: mocks.openUrl, })); vi.mock("../../infra/remote-env.js", () => ({ isRemoteEnvironment: mocks.isRemoteEnvironment, })); vi.mock("../../gateway/call.js", async () => { const requestErrors = await import("../../../packages/gateway-client/src/request-error.js"); return { callGateway: mocks.callGateway, GatewayLocalBackendSharedAuthUnavailableError: class extends Error {}, isGatewayClientRequestError: (error: unknown) => error instanceof requestErrors.GatewayClientRequestError, isImplicitLocalGatewayTarget: mocks.isImplicitLocalGatewayTarget, }; }); vi.mock("../../plugins/provider-oauth-flow.js", () => ({ createVpsAwareOAuthHandlers: vi.fn(() => ({ onAuth: vi.fn(), onPrompt: vi.fn(), })), })); vi.mock("../auth-token.js", () => ({ validateAnthropicSetupToken: mocks.validateAnthropicSetupToken, })); vi.mock("../../plugins/provider-auth-choice-helpers.js", async (importOriginal) => { const actual = await importOriginal(); const normalize = (value: string | undefined) => value?.trim().toLowerCase() ?? ""; const mergePatch = (base: T, patch: unknown): T => { if (!isRecord(base) || !isRecord(patch)) { return patch as T; } const next: Record = { ...base }; for (const [key, value] of Object.entries(patch)) { next[key] = mergePatch(next[key], value); } return next as T; }; return { ...actual, resolveProviderMatch: vi.fn((providers: ProviderPlugin[], rawProvider?: string) => { const requested = normalize(rawProvider); return ( providers.find((provider) => normalize(provider.id) === requested) ?? providers.find((provider) => provider.aliases?.some((alias) => normalize(alias) === requested), ) ?? null ); }), pickAuthMethod: vi.fn((provider: ProviderPlugin, rawMethod?: string) => { const requested = normalize(rawMethod); return ( provider.auth.find((method) => normalize(method.id) === requested) ?? provider.auth.find((method) => normalize(method.label) === requested) ?? null ); }), applyProviderAuthConfigPatch: vi.fn( (cfg: OpenClawConfig, patch: unknown, options?: { replaceDefaultModels?: boolean }) => { const merged = mergePatch(cfg, patch); if (!options?.replaceDefaultModels) { return merged; } const patchModels = (patch as { agents?: { defaults?: { models?: unknown } } })?.agents ?.defaults?.models; return isRecord(patchModels) ? { ...merged, agents: { ...merged.agents, defaults: { ...merged.agents?.defaults, models: patchModels, }, }, } : merged; }, ), applyDefaultModel: vi.fn((cfg: OpenClawConfig, model: string) => ({ ...cfg, agents: { ...cfg.agents, defaults: { ...cfg.agents?.defaults, models: { ...cfg.agents?.defaults?.models, [model]: cfg.agents?.defaults?.models?.[model] ?? {}, }, model: { ...(typeof cfg.agents?.defaults?.model === "object" && "fallbacks" in cfg.agents.defaults.model ? { fallbacks: cfg.agents.defaults.model.fallbacks } : undefined), primary: model, }, }, }, })), }; }); const { modelsAuthAddCommand, modelsAuthLoginCommand, modelsAuthPasteApiKeyCommand, modelsAuthPasteTokenCommand, modelsAuthSetupTokenCommand, runModelsAuthLoginFlowCore, } = await import("./auth.js"); function createRuntime(): RuntimeEnv { return { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; } function withInteractiveStdin() { const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; const hadOwnIsTTY = Object.hasOwn(stdin, "isTTY"); const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY"); Object.defineProperty(stdin, "isTTY", { configurable: true, enumerable: true, get: () => true, }); return () => { if (previousIsTTYDescriptor) { Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); } else if (!hadOwnIsTTY) { delete (stdin as { isTTY?: boolean }).isTTY; } }; } function withPipedStdin(input: string) { const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; const restoreInteractive = withInteractiveStdin(); const previousAsyncIteratorDescriptor = Object.getOwnPropertyDescriptor( stdin, Symbol.asyncIterator, ); Object.defineProperty(stdin, "isTTY", { configurable: true, enumerable: true, get: () => false, }); Object.defineProperty(stdin, Symbol.asyncIterator, { configurable: true, async *value() { yield input; }, }); return () => { if (previousAsyncIteratorDescriptor) { Object.defineProperty(stdin, Symbol.asyncIterator, previousAsyncIteratorDescriptor); } else { Reflect.deleteProperty(stdin, Symbol.asyncIterator); } restoreInteractive(); }; } function createProvider(params: { id: string; label?: string; auth?: ProviderPlugin["auth"]; run: NonNullable[number]["run"]; }): ProviderPlugin { return { id: params.id, label: params.label ?? params.id, auth: params.auth ?? [ { id: "oauth", label: "OAuth", kind: "oauth", run: params.run, }, ], }; } describe("modelsAuthLoginCommand", () => { let restoreStdin: (() => void) | null = null; let currentConfig: OpenClawConfig; let lastUpdatedConfig: OpenClawConfig | null; let runProviderAuth: ReturnType; beforeEach(() => { vi.clearAllMocks(); restoreStdin = withInteractiveStdin(); currentConfig = {}; lastUpdatedConfig = null; mocks.clackCancel.mockReset(); mocks.clackConfirm.mockReset(); mocks.clackIsCancel.mockImplementation( (value: unknown) => value === Symbol.for("clack:cancel"), ); mocks.clackPassword.mockReset(); mocks.clackSelect.mockReset(); mocks.clackText.mockReset(); mocks.validateAnthropicSetupToken.mockReset(); mocks.validateAnthropicSetupToken.mockReturnValue(undefined); mocks.upsertAuthProfileWithLock.mockReset(); mocks.upsertAuthProfileWithLock.mockResolvedValue({ version: 1, profiles: {} }); mocks.persistProviderAuthProfilesAfterLogin.mockReset(); mocks.persistProviderAuthProfilesAfterLogin.mockImplementation( async (params: PersistProviderAuthCall) => params.profiles ?? [], ); mocks.promoteAuthProfileInOrder.mockReset(); mocks.promoteAuthProfileInOrder.mockResolvedValue({ ok: true, value: { version: 1, profiles: {} }, }); mocks.tryImportProviderCredential.mockReset(); mocks.tryImportProviderCredential.mockResolvedValue(undefined); mocks.removeProviderAuthProfilesWithLock.mockReset(); mocks.removeProviderAuthProfilesWithLock.mockResolvedValue({ version: 1, profiles: {} }); mocks.resolveDefaultAgentId.mockReturnValue("main"); mocks.resolveAgentDir.mockReturnValue("/tmp/openclaw/agents/main"); mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw/workspace"); mocks.resolveDefaultAgentWorkspaceDir.mockReturnValue("/tmp/openclaw/workspace"); mocks.isRemoteEnvironment.mockReturnValue(false); mocks.isCliProvider.mockReturnValue(false); mocks.resolvePluginSetupProviderCore.mockReturnValue(undefined); mocks.resolvePluginSetupRegistry.mockReturnValue({ providers: [], cliBackends: [], configMigrations: [], autoEnableProbes: [], diagnostics: [], }); mocks.loadValidConfigSnapshotOrThrow.mockImplementation(async () => ({ sourceConfig: structuredClone(currentConfig), runtimeConfig: structuredClone(currentConfig), })); mocks.updateConfig.mockImplementation( async ( mutator: (cfg: OpenClawConfig) => OpenClawConfig, _selectModelRefs: unknown, beforeCommit?: ConfigWriteOptions["beforeCommit"], writeOptions?: ConfigWriteOptions, ) => { const nextConfig = mutator(currentConfig); await beforeCommit?.(); writeOptions?.assertCurrent?.(); lastUpdatedConfig = nextConfig; currentConfig = lastUpdatedConfig; return lastUpdatedConfig; }, ); mocks.createClackPrompter.mockReturnValue({ note: vi.fn(async () => {}), select: vi.fn().mockResolvedValue("keep"), }); runProviderAuth = vi.fn().mockResolvedValue({ profiles: [ { profileId: "openai:user@example.com", credential: { type: "oauth", provider: "openai", access: "access-token", refresh: "refresh-token", expires: Date.now() + 60_000, email: "user@example.com", }, }, ], defaultModel: "openai/gpt-5.5", }); mocks.resolvePluginProvidersCore.mockReturnValue([ createProvider({ id: "openai", label: "OpenAI Codex", run: runProviderAuth as ProviderPlugin["auth"][number]["run"], }), ]); mocks.callGateway.mockReset(); mocks.callGateway.mockResolvedValue({ refreshed: true }); }); afterEach(() => { restoreStdin?.(); restoreStdin = null; }); function useCoderAgentConfig() { currentConfig = { agents: { list: [{ id: "main" }, { id: "coder", workspace: "/tmp/openclaw/workspaces/coder" }], }, }; const originalConfig = currentConfig; mocks.resolveAgentDir.mockImplementation((_cfg: OpenClawConfig, agentId: string) => agentId === "coder" ? "/tmp/openclaw/agents/coder" : "/tmp/openclaw/agents/main", ); mocks.resolveAgentWorkspaceDir.mockImplementation((_cfg: OpenClawConfig, agentId: string) => agentId === "coder" ? "/tmp/openclaw/workspaces/coder" : "/tmp/openclaw/workspace", ); return originalConfig; } it("runs plugin-owned openai login", async () => { const runtime = createRuntime(); const result = await runModelsAuthLoginFlowCore({ provider: "openai", runtime, prompter: mocks.createClackPrompter(), }); expect(result).toMatchObject({ authRefresh: "refreshed" }); expect(runProviderAuth).toHaveBeenCalledOnce(); const persistCall = readMockCallArg( mocks.persistProviderAuthProfilesAfterLogin, ) as PersistProviderAuthCall; expect(persistCall.profiles?.[0]?.profileId).toBe("openai:user@example.com"); expect(persistCall.profiles?.[0]?.credential?.type).toBe("oauth"); expect(persistCall.profiles?.[0]?.credential?.provider).toBe("openai"); expect(persistCall.agentDir).toBe("/tmp/openclaw/agents/main"); expect(mocks.promoteAuthProfileInOrder).toHaveBeenCalledWith({ agentDir: "/tmp/openclaw/agents/main", provider: "openai", profileId: "openai:user@example.com", createIfMissing: false, }); expect(mocks.updateConfig).not.toHaveBeenCalled(); expect(mocks.logConfigUpdated).not.toHaveBeenCalled(); expect(lastUpdatedConfig).toBeNull(); expect(runtime.log).toHaveBeenCalledWith( "Auth profile: openai:user@example.com (openai/oauth)", ); expect(runtime.log).toHaveBeenCalledWith( "Default model available: openai/gpt-5.5 (current default unchanged; run openclaw models set openai/gpt-5.5 to apply)", ); expect(runtime.log).toHaveBeenCalledWith( "Tip: Codex-capable models can use native Codex web search. Configure the `web_search` tool with `openclaw configure --section web`. Docs: https://docs.openclaw.ai/tools/web", ); expect(mocks.callGateway).toHaveBeenCalledWith( expect.objectContaining({ params: { operation: "login", agentId: "main" }, }), ); }); it("persists a named login profile and promotes that same profile", async () => { const runtime = createRuntime(); await modelsAuthLoginCommand({ provider: "openai", profileId: "openai:work" }, runtime); expect(mocks.persistProviderAuthProfilesAfterLogin).toHaveBeenCalledWith( expect.objectContaining({ profiles: [ expect.objectContaining({ profileId: "openai:work", credential: expect.objectContaining({ provider: "openai", type: "oauth" }), }), ], }), ); expect(mocks.promoteAuthProfileInOrder).toHaveBeenCalledWith( expect.objectContaining({ provider: "openai", profileId: "openai:work" }), ); expect(runtime.log).toHaveBeenCalledWith("Auth profile: openai:work (openai/oauth)"); }); it("rejects a named login with multiple returned profiles before persisting", async () => { runProviderAuth.mockResolvedValueOnce({ profiles: [ { profileId: "openai:one", credential: { type: "api_key", provider: "openai", key: "synthetic-one" }, }, { profileId: "openai:two", credential: { type: "api_key", provider: "openai", key: "synthetic-two" }, }, ], }); await expect( modelsAuthLoginCommand({ provider: "openai", profileId: "openai:work" }, createRuntime()), ).rejects.toThrow("--profile-id requires exactly one returned auth profile"); expect(mocks.persistProviderAuthProfilesAfterLogin).not.toHaveBeenCalled(); expect(mocks.promoteAuthProfileInOrder).not.toHaveBeenCalled(); expect(mocks.updateConfig).not.toHaveBeenCalled(); expect(mocks.callGateway).not.toHaveBeenCalled(); }); it("keeps the prior auth profile when protected storage is unavailable", async () => { const runtime = createRuntime(); runProviderAuth.mockResolvedValueOnce({ profiles: [ { profileId: "github-copilot:github", credential: { type: "token", provider: "github-copilot", token: "synthetic-device-token", }, secretStorage: { kind: "store", namePrefix: "GITHUB_COPILOT_TOKEN", }, }, ], }); mocks.resolvePluginProvidersCore.mockReturnValue([ createProvider({ id: "github-copilot", label: "GitHub Copilot", run: runProviderAuth as ProviderPlugin["auth"][number]["run"], }), ]); mocks.persistProviderAuthProfilesAfterLogin.mockRejectedValueOnce( new Error("Could not write the protected secret store"), ); await expect(modelsAuthLoginCommand({ provider: "github-copilot" }, runtime)).rejects.toThrow( "Could not write the protected secret store", ); expect(mocks.persistProviderAuthProfilesAfterLogin).toHaveBeenCalledOnce(); expect(mocks.promoteAuthProfileInOrder).not.toHaveBeenCalled(); }); it.each([ { connected: true, outcome: "gateway-rejected", target: "running" }, { connected: false, outcome: "gateway-unreachable", target: "local" }, ])( "keeps saved login credentials when refresh is $outcome", async ({ connected, outcome, target }) => { const runtime = createRuntime(); mocks.callGateway.mockImplementationOnce(async (options: { onHelloOk?: () => void }) => { if (connected) { options.onHelloOk?.(); } throw new Error("refresh rejected"); }); await expect( runModelsAuthLoginFlowCore({ provider: "openai", runtime, prompter: mocks.createClackPrompter(), }), ).resolves.toMatchObject({ authRefresh: outcome }); expect(mocks.persistProviderAuthProfilesAfterLogin).toHaveBeenCalledOnce(); expect(mocks.callGateway).toHaveBeenCalledWith( expect.objectContaining({ params: { operation: "login", agentId: "main" }, }), ); expect(runtime.error).toHaveBeenCalledWith( `Warning: Model auth changes were saved, but the ${target} Gateway could not refresh them. Run \`openclaw gateway restart\` to apply the saved changes.`, ); }, ); it.each([ { source: "new", profileId: "openai:user@example.com" }, { source: "imported", profileId: "openai:imported" }, ])( "retains the $source profile and model-access choice after direct refresh fails", async ({ source, profileId }) => { currentConfig = { agents: { defaults: { model: "other/current", modelPolicy: { allow: ["other/current"] } }, }, }; if (source === "imported") { mocks.tryImportProviderCredential.mockResolvedValueOnce({ profileId, provider: "openai", mode: "oauth", configUpdated: false, }); } const onModelAccessRequested = vi.fn(); const result = await runModelsAuthLoginFlowCore({ provider: "openai", runtime: createRuntime(), prompter: mocks.createClackPrompter(), refreshAfterLogin: async () => { throw new Error("Auth publication failed."); }, onModelAccessRequested, }); expect(result).toMatchObject({ authRefresh: "gateway-rejected", profiles: [{ profileId, provider: "openai", mode: "oauth" }], }); expect(onModelAccessRequested).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ provider: "openai", policy: { path: "agents.defaults.modelPolicy.allow", refs: ["other/current"] }, }), ); expect(currentConfig.agents?.defaults).toEqual({ model: "other/current", modelPolicy: { allow: ["other/current"] }, }); }, ); it.each(["cancelled", "revoked"] as const)( "does not complete a saved login when authority is %s during a rejected refresh", async (reason) => { const controller = new AbortController(); let current = true; const onModelAccessRequested = vi.fn(); await expect( runModelsAuthLoginFlowCore({ provider: "openai", runtime: createRuntime(), prompter: mocks.createClackPrompter(), signal: controller.signal, assertCurrent: () => { if (!current) { throw new Error("Login authority ended."); } }, refreshAfterLogin: async () => { if (reason === "cancelled") { controller.abort(new Error("Login authority ended.")); } else { current = false; } throw new Error("Auth publication failed."); }, onModelAccessRequested, }), ).rejects.toThrow("Login authority ended."); expect(onModelAccessRequested).not.toHaveBeenCalled(); }, ); it("returns the refresh outcome after importing a provider credential", async () => { mocks.tryImportProviderCredential.mockResolvedValueOnce({ profileId: "openai:imported", provider: "openai", mode: "oauth", configUpdated: false, }); mocks.callGateway.mockImplementationOnce(async (options: { onHelloOk?: () => void }) => { options.onHelloOk?.(); throw new Error("refresh rejected"); }); const result = await runModelsAuthLoginFlowCore({ provider: "openai", runtime: createRuntime(), prompter: mocks.createClackPrompter(), }); expect(result).toMatchObject({ imported: true, authRefresh: "gateway-rejected", profiles: [{ profileId: "openai:imported", provider: "openai", mode: "oauth" }], }); expect(runProviderAuth).not.toHaveBeenCalled(); expect(mocks.persistProviderAuthProfilesAfterLogin).not.toHaveBeenCalled(); expect(mocks.promoteAuthProfileInOrder).toHaveBeenCalledWith( expect.objectContaining({ profileId: "openai:imported", }), ); }); it("identifies a config failure after credentials are saved and preserves its cause", async () => { const cause = new Error("config write failed"); runProviderAuth.mockResolvedValueOnce({ profiles: [ { profileId: "openai:saved", credential: { type: "token", provider: "openai", token: "fixture-token" }, }, ], configPatch: { logging: { level: "debug" } }, }); mocks.updateConfig.mockRejectedValueOnce(cause); const login = runModelsAuthLoginFlowCore({ provider: "openai", runtime: createRuntime(), prompter: mocks.createClackPrompter(), }); await expect(login).rejects.toBeInstanceOf(ProviderAuthConfigApplyError); await expect(login).rejects.toMatchObject({ name: "ProviderAuthConfigApplyError", message: "Credentials saved, but provider settings could not be applied: config write failed", cause, }); expect(mocks.persistProviderAuthProfilesAfterLogin).toHaveBeenCalledOnce(); expect(mocks.callGateway).not.toHaveBeenCalled(); }); it("creates store order for relogin when configured profiles would shadow the new profile", async () => { const runtime = createRuntime(); currentConfig = { auth: { profiles: { "openai:old-login": { provider: "openai", mode: "oauth", }, }, }, }; await modelsAuthLoginCommand({ provider: "openai" }, runtime); expect(mocks.updateConfig).not.toHaveBeenCalled(); expect(mocks.promoteAuthProfileInOrder).toHaveBeenCalledWith({ agentDir: "/tmp/openclaw/agents/main", provider: "openai", profileId: "openai:user@example.com", createIfMissing: true, createFromOrder: ["openai:old-login"], }); }); it("defaults OpenAI login to ChatGPT OAuth when API key is also available", async () => { const runtime = createRuntime(); const initialConfig = currentConfig; const runOauthAuth = vi.fn().mockResolvedValue({ profiles: [ { profileId: "openai:user@example.com", credential: { type: "oauth", provider: "openai", access: "access-token", refresh: "refresh-token", expires: Date.now() + 60_000, email: "user@example.com", }, }, ], }); const runApiKeyAuth = vi.fn().mockResolvedValue({ profiles: [] }); const select = vi.fn(); mocks.createClackPrompter.mockReturnValue({ note: vi.fn(async () => {}), select, }); mocks.resolvePluginProvidersCore.mockReturnValue([ createProvider({ id: "openai", label: "OpenAI runtime", run: runApiKeyAuth as ProviderPlugin["auth"][number]["run"], auth: [ { id: "api-key", label: "OpenAI API Key", kind: "api_key", run: runApiKeyAuth, }, ], }), ]); mocks.resolvePluginSetupProviderCore.mockReturnValue( createProvider({ id: "openai", label: "OpenAI", run: runOauthAuth as ProviderPlugin["auth"][number]["run"], auth: [ { id: "oauth", label: "ChatGPT Login", kind: "oauth", run: runOauthAuth, }, { id: "api-key", label: "OpenAI API Key", kind: "api_key", run: runApiKeyAuth, }, ], }), ); await modelsAuthLoginCommand({ provider: "openai" }, runtime); expect(mocks.resolvePluginSetupProviderCore).toHaveBeenCalledWith({ provider: "openai", config: initialConfig, workspaceDir: "/tmp/openclaw/workspace", }); expect(runOauthAuth).toHaveBeenCalledOnce(); expect(runApiKeyAuth).not.toHaveBeenCalled(); expect(select).not.toHaveBeenCalled(); }); it("honors --method api-key for OpenAI login", async () => { const runtime = createRuntime(); const runOauthAuth = vi.fn().mockResolvedValue({ profiles: [] }); const runApiKeyAuth = vi.fn().mockResolvedValue({ profiles: [] }); mocks.resolvePluginProvidersCore.mockReturnValue([ createProvider({ id: "openai", label: "OpenAI runtime", run: runApiKeyAuth as ProviderPlugin["auth"][number]["run"], auth: [ { id: "api-key", label: "OpenAI API Key", kind: "api_key", run: runApiKeyAuth, }, ], }), ]); mocks.resolvePluginSetupProviderCore.mockReturnValue( createProvider({ id: "openai", label: "OpenAI", run: runOauthAuth as ProviderPlugin["auth"][number]["run"], auth: [ { id: "oauth", label: "ChatGPT Login", kind: "oauth", run: runOauthAuth, }, { id: "api-key", label: "OpenAI API Key", kind: "api_key", run: runApiKeyAuth, }, ], }), ); await modelsAuthLoginCommand({ provider: "openai", method: "api-key" }, runtime); expect(runOauthAuth).not.toHaveBeenCalled(); expect(runApiKeyAuth).toHaveBeenCalledOnce(); }); it("prompts when a provider exposes multiple non-OAuth login methods", async () => { const runtime = createRuntime(); const runApiKeyAuth = vi.fn().mockResolvedValue({ profiles: [] }); const runCliAuth = vi.fn().mockResolvedValue({ profiles: [] }); const select = vi.fn().mockResolvedValue("cli"); mocks.createClackPrompter.mockReturnValue({ note: vi.fn(async () => {}), select, }); mocks.resolvePluginProvidersCore.mockReturnValue([ createProvider({ id: "anthropic", label: "Anthropic", run: runApiKeyAuth as ProviderPlugin["auth"][number]["run"], auth: [ { id: "api-key", label: "Anthropic API Key", kind: "api_key", run: runApiKeyAuth, }, { id: "cli", label: "Claude CLI", kind: "custom", run: runCliAuth, }, ], }), ]); await modelsAuthLoginCommand({ provider: "anthropic" }, runtime); expect(select).toHaveBeenCalledWith({ message: "Auth method for Anthropic", options: [ { value: "api-key", label: "Anthropic API Key", hint: undefined, }, { value: "cli", label: "Claude CLI", hint: undefined, }, ], }); expect(runApiKeyAuth).not.toHaveBeenCalled(); expect(runCliAuth).toHaveBeenCalledOnce(); }); it("shows local agent scope before provider auth login", async () => { const runtime = createRuntime(); const originalConfig = useCoderAgentConfig(); const note = vi.fn(async () => {}); mocks.createClackPrompter.mockReturnValue({ note, select: vi.fn() }); await modelsAuthLoginCommand({ provider: "openai", agent: "coder" }, runtime); expect(note).toHaveBeenCalledWith( [ "Scope: System / agent", "Agent: coder", "Location: the machine running OpenClaw", "For personal model accounts on a Gateway, run openclaw models accounts login --help.", ].join("\n"), "Provider sign-in", ); expect(note.mock.invocationCallOrder[0]).toBeLessThan( runProviderAuth.mock.invocationCallOrder[0]!, ); expect(mocks.resolveDefaultAgentId).not.toHaveBeenCalled(); expect(mocks.resolveAgentDir).toHaveBeenCalledWith(originalConfig, "coder"); const authRunCall = readMockCallArg(runProviderAuth) as AuthRunCall; expect(authRunCall.agentDir).toBe("/tmp/openclaw/agents/coder"); expect(authRunCall.workspaceDir).toBe("/tmp/openclaw/workspaces/coder"); expect( (readMockCallArg(mocks.persistProviderAuthProfilesAfterLogin) as PersistProviderAuthCall) .agentDir, ).toBe("/tmp/openclaw/agents/coder"); expect(mocks.callGateway).toHaveBeenCalledWith( expect.objectContaining({ params: { operation: "login", agentId: "coder" }, }), ); }); it("forwards an app-owned cancellation signal to provider auth", async () => { const runtime = createRuntime(); const abortController = new AbortController(); await runModelsAuthLoginFlowCore({ provider: "openai", method: "oauth", config: currentConfig, runtime, prompter: mocks.createClackPrompter(), signal: abortController.signal, }); expect((readMockCallArg(runProviderAuth) as AuthRunCall).signal).toBe(abortController.signal); }); it("refreshes saved credentials before presenting provider notes", async () => { const note = vi.fn(async (_message: string, title?: string) => { if (title === "Provider notes") { throw new Error("note delivery failed"); } }); mocks.createClackPrompter.mockReturnValue({ note, select: vi.fn() }); runProviderAuth.mockResolvedValue({ profiles: [ { profileId: "openai:proof", credential: { type: "token", provider: "openai", token: "fixture-token" }, }, ], notes: ["Provider sign-in details"], }); await expect(modelsAuthLoginCommand({ provider: "openai" }, createRuntime())).rejects.toThrow( "note delivery failed", ); expect(mocks.callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "models.authRefresh", params: { operation: "login", agentId: "main" }, }), ); }); it("does not persist credentials returned after app-owned cancellation", async () => { const runtime = createRuntime(); const abortController = new AbortController(); const cancellation = new Error("Login was replaced"); runProviderAuth.mockImplementationOnce(() => { abortController.abort(cancellation); return { profiles: [ { profileId: "openai:late@example.com", credential: { type: "oauth", provider: "openai", access: "late-access-token", refresh: "late-refresh-token", expires: Date.now() + 60_000, }, }, ], }; }); await expect( runModelsAuthLoginFlowCore({ provider: "openai", method: "oauth", config: currentConfig, runtime, prompter: mocks.createClackPrompter(), signal: abortController.signal, }), ).rejects.toBe(cancellation); expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled(); expect(mocks.persistProviderAuthProfilesAfterLogin).not.toHaveBeenCalled(); expect(mocks.promoteAuthProfileInOrder).not.toHaveBeenCalled(); expect(mocks.updateConfig).not.toHaveBeenCalled(); }); it("loads the owning plugin for an explicit provider even in a clean config", async () => { const runtime = createRuntime(); const runClaudeCliMigration = vi.fn().mockResolvedValue({ profiles: [], defaultModel: "claude-cli/claude-sonnet-4-6", configPatch: { agents: { defaults: { models: { "claude-cli/claude-sonnet-4-6": {}, }, }, }, }, }); mocks.resolvePluginProvidersCore.mockImplementation( (params: ResolvePluginProvidersCall | undefined) => params?.providerRefs?.[0] === "anthropic" ? [ { id: "anthropic", label: "Anthropic", auth: [ { id: "cli", label: "Claude CLI", kind: "custom", run: runClaudeCliMigration, }, ], }, ] : [], ); await modelsAuthLoginCommand( { provider: "anthropic", method: "cli", setDefault: true }, runtime, ); const providerResolutionCall = readMockCallArg( mocks.resolvePluginProvidersCore, ) as ResolvePluginProvidersCall; expect(providerResolutionCall.config).toEqual({}); expect(providerResolutionCall.workspaceDir).toBe("/tmp/openclaw/workspace"); expect(providerResolutionCall.includeUntrustedWorkspacePlugins).toBe(false); expect(providerResolutionCall.providerRefs).toEqual(["anthropic"]); expect(runClaudeCliMigration).toHaveBeenCalledOnce(); expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled(); expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({ primary: "claude-cli/claude-sonnet-4-6", }); expect(lastUpdatedConfig?.agents?.defaults?.models).toEqual({ "claude-cli/claude-sonnet-4-6": {}, }); expect(runtime.log).toHaveBeenCalledWith("Default model set to claude-cli/claude-sonnet-4-6"); }); it("runs the requested anthropic cli auth method with the full login context", async () => { const runtime = createRuntime(); currentConfig = { agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"], }, models: { "anthropic/claude-sonnet-4-6": { alias: "Sonnet" }, "anthropic/claude-opus-4-6": { alias: "Opus" }, "openai/gpt-5.2": {}, }, }, }, }; const note = vi.fn(async () => {}); const select = vi.fn(); mocks.createClackPrompter.mockReturnValue({ note, select, }); const runApiKeyAuth = vi.fn(); const runClaudeCliMigration = vi.fn().mockImplementation(async (ctx) => { expect(ctx.config).toEqual(currentConfig); expect(ctx.agentDir).toBe("/tmp/openclaw/agents/main"); expect(ctx.workspaceDir).toBe("/tmp/openclaw/workspace"); expect(ctx.prompter.note).toBe(note); expect(ctx.prompter.select).toBe(select); expect(ctx.runtime).toBe(runtime); expect(ctx.env).toBe(process.env); expect(ctx.allowSecretRefPrompt).toBe(false); expect(ctx.isRemote).toBe(false); await ctx.openUrl("https://example.com/auth"); expect(mocks.openUrl).toHaveBeenCalledWith("https://example.com/auth"); expect(ctx.oauth.createVpsAwareHandlers).toBeTypeOf("function"); return { profiles: [], defaultModel: "claude-cli/claude-sonnet-4-6", configPatch: { agents: { defaults: { model: { primary: "claude-cli/claude-sonnet-4-6", fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"], }, models: { "claude-cli/claude-sonnet-4-6": { alias: "Sonnet" }, "claude-cli/claude-opus-4-6": { alias: "Opus" }, "openai/gpt-5.2": {}, }, }, }, }, replaceDefaultModels: true, notes: [ "Claude CLI auth detected; switched Anthropic model selection to the local Claude CLI backend.", "Existing Anthropic auth profiles are kept for rollback.", ], }; }); mocks.resolvePluginProvidersCore.mockReturnValue([ { id: "anthropic", label: "Anthropic", auth: [ { id: "cli", label: "Claude CLI", kind: "custom", run: runClaudeCliMigration, }, { id: "api-key", label: "Anthropic API key", kind: "api_key", run: runApiKeyAuth, }, ], }, ]); await modelsAuthLoginCommand( { provider: "anthropic", method: "cli", setDefault: true }, runtime, ); expect(runClaudeCliMigration).toHaveBeenCalledOnce(); expect(runApiKeyAuth).not.toHaveBeenCalled(); expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled(); expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({ primary: "claude-cli/claude-sonnet-4-6", fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"], }); expect(lastUpdatedConfig?.agents?.defaults?.models).toEqual({ "claude-cli/claude-sonnet-4-6": { alias: "Sonnet" }, "claude-cli/claude-opus-4-6": { alias: "Opus" }, "openai/gpt-5.2": {}, }); expect(note).toHaveBeenCalledWith( [ "Claude CLI auth detected; switched Anthropic model selection to the local Claude CLI backend.", "Existing Anthropic auth profiles are kept for rollback.", ].join("\n"), "Provider notes", ); expect(runtime.log).toHaveBeenCalledWith("Default model set to claude-cli/claude-sonnet-4-6"); }); it("preserves other providers' allowlist entries on an openai OAuth login", async () => { const runtime = createRuntime(); const existingModels = { "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, "anthropic/claude-opus-4-6": { alias: "opus" }, "moonshot/kimi-k2.5": { alias: "kimi" }, "openai/gpt-5.5": { alias: "gpt55" }, }; currentConfig = { agents: { defaults: { models: existingModels } } }; runProviderAuth.mockResolvedValue({ profiles: [ { profileId: "openai:user@example.com", credential: { type: "oauth", provider: "openai", access: "a", refresh: "r", expires: Date.now() + 60_000, email: "user@example.com", }, }, ], configPatch: { agents: { defaults: { models: { "openai/gpt-5.5": {} } } } }, defaultModel: "openai/gpt-5.5", }); await modelsAuthLoginCommand({ provider: "openai" }, runtime); expect(currentConfig.agents?.defaults?.models).toEqual(existingModels); }); it.each([ { name: "an absent model", model: undefined }, { name: "a model string", model: "openai/gpt-5.4" }, { name: "a primary and fallbacks", model: { primary: "openai/gpt-5.4", fallbacks: [] } }, { name: "fallbacks without a primary", model: { fallbacks: ["openai/gpt-5.4"] } }, ])("preserves $name when login omits --set-default", async ({ model }) => { const runtime = createRuntime(); currentConfig = { agents: { defaults: { ...(model === undefined ? {} : { model }), models: { "openai/gpt-5.4": {}, "anthropic/claude-sonnet-4-6": {}, }, }, }, }; runProviderAuth.mockResolvedValue({ profiles: [ { profileId: "openai:default", credential: { type: "api_key", provider: "openai", key: "sk-demo" }, }, ], configPatch: { agents: { defaults: { model: { primary: "openai/gpt-5.5" }, models: { "openai/gpt-5.5": { alias: "GPT" } }, }, }, }, defaultModel: "openai/gpt-5.5", }); mocks.resolvePluginProvidersCore.mockReturnValue([ createProvider({ id: "openai", label: "OpenAI", run: runProviderAuth as ProviderPlugin["auth"][number]["run"], }), ]); await modelsAuthLoginCommand({ provider: "openai" }, runtime); expect(currentConfig.agents?.defaults?.model).toEqual(model); if (model === undefined) { expect(currentConfig.agents?.defaults).not.toHaveProperty("model"); } expect(currentConfig.agents?.defaults?.models).toEqual({ "openai/gpt-5.4": {}, "anthropic/claude-sonnet-4-6": {}, }); expect(currentConfig.auth).toBeUndefined(); expect(runtime.log).toHaveBeenCalledWith( "Default model available: openai/gpt-5.5 (current default unchanged; run openclaw models set openai/gpt-5.5 to apply)", ); }); it("normalizes retired Google Gemini login defaults before config writes", async () => { const runtime = createRuntime(); runProviderAuth.mockResolvedValue({ profiles: [ { profileId: "google:default", credential: { type: "api_key", provider: "google", key: "gemini-demo" }, }, ], defaultModel: "google/gemini-3-pro-preview", }); mocks.resolvePluginProvidersCore.mockReturnValue([ createProvider({ id: "google", label: "Google", run: runProviderAuth as ProviderPlugin["auth"][number]["run"], }), ]); await modelsAuthLoginCommand({ provider: "google", setDefault: true }, runtime); expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({ primary: "google/gemini-3.1-pro-preview", }); expect(lastUpdatedConfig?.agents?.defaults?.models).toEqual({ "google/gemini-3.1-pro-preview": {}, }); expect(runtime.log).toHaveBeenCalledWith("Default model set to google/gemini-3.1-pro-preview"); }); it("overwrites an existing primary when login uses --set-default", async () => { const runtime = createRuntime(); currentConfig = { agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" }, models: { "anthropic/claude-opus-4-6": {} }, }, }, }; await modelsAuthLoginCommand({ provider: "openai", setDefault: true }, runtime); expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({ primary: "openai/gpt-5.5", }); expect(lastUpdatedConfig?.agents?.defaults?.models).toEqual({ "anthropic/claude-opus-4-6": {}, }); expect(runtime.log).toHaveBeenCalledWith("Default model set to openai/gpt-5.5"); }); it("leaves stored auth state untouched when provider login fails", async () => { const runtime = createRuntime(); const loginFailure = new Error("provider rejected login"); runProviderAuth.mockRejectedValueOnce(loginFailure); await expect(modelsAuthLoginCommand({ provider: "openai" }, runtime)).rejects.toBe( loginFailure, ); expect(runProviderAuth).toHaveBeenCalledOnce(); expect(mocks.persistProviderAuthProfilesAfterLogin).not.toHaveBeenCalled(); expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled(); }); it("--force purges cached profiles for the provider before login", async () => { const runtime = createRuntime(); await modelsAuthLoginCommand({ provider: "openai", force: true }, runtime); expect(mocks.removeProviderAuthProfilesWithLock).toHaveBeenCalledWith({ cfg: expect.any(Object), provider: "openai", agentDir: "/tmp/openclaw/agents/main", }); expect(runProviderAuth).toHaveBeenCalledOnce(); expect(runtime.log).toHaveBeenCalledWith( expect.stringContaining('Removed cached auth profiles for provider "openai"'), ); expect(mocks.callGateway).toHaveBeenCalledTimes(2); }); it("--force refreshes after the purge when replacement login fails", async () => { const runtime = createRuntime(); runProviderAuth.mockRejectedValueOnce(new Error("provider rejected login")); await expect( modelsAuthLoginCommand({ provider: "openai", force: true }, runtime), ).rejects.toThrow("provider rejected login"); expect(mocks.removeProviderAuthProfilesWithLock).toHaveBeenCalledOnce(); expect(mocks.callGateway).toHaveBeenCalledOnce(); expect(mocks.removeProviderAuthProfilesWithLock.mock.invocationCallOrder[0]).toBeLessThan( mocks.callGateway.mock.invocationCallOrder[0]!, ); expect(mocks.callGateway.mock.invocationCallOrder[0]).toBeLessThan( runProviderAuth.mock.invocationCallOrder[0]!, ); }); it("--force does not purge when omitted", async () => { const runtime = createRuntime(); await modelsAuthLoginCommand({ provider: "openai" }, runtime); expect(mocks.removeProviderAuthProfilesWithLock).not.toHaveBeenCalled(); expect(runProviderAuth).toHaveBeenCalledOnce(); }); it("--force fails before login when purge throws", async () => { const runtime = createRuntime(); mocks.removeProviderAuthProfilesWithLock.mockRejectedValueOnce(new Error("disk full")); await expect( modelsAuthLoginCommand({ provider: "openai", force: true }, runtime), ).rejects.toThrow('Could not clear cached profiles for "openai" before re-login: disk full'); expect(runtime.error).not.toHaveBeenCalled(); expect(runProviderAuth).not.toHaveBeenCalled(); }); it("--force fails before login when purge cannot update the profile store", async () => { const runtime = createRuntime(); mocks.removeProviderAuthProfilesWithLock.mockResolvedValueOnce(null); await expect( modelsAuthLoginCommand({ provider: "openai", force: true }, runtime), ).rejects.toThrow( 'Could not clear cached profiles for "openai" before re-login: auth store is busy; close other OpenClaw commands using this state directory and retry', ); expect(runtime.error).not.toHaveBeenCalled(); expect(runProviderAuth).not.toHaveBeenCalled(); }); it.each([undefined, true])( "rejects unknown auth methods without login or purge (force=%s)", async (force) => { const runtime = createRuntime(); const runOauthAuth = vi.fn().mockResolvedValue({ profiles: [] }); const runApiKeyAuth = vi.fn().mockResolvedValue({ profiles: [] }); mocks.resolvePluginSetupProviderCore.mockReturnValue( createProvider({ id: "openai", label: "OpenAI", run: runOauthAuth as ProviderPlugin["auth"][number]["run"], auth: [ { id: "oauth", label: "ChatGPT Login", kind: "oauth", run: runOauthAuth, }, { id: "api-key", label: "OpenAI API Key", kind: "api_key", run: runApiKeyAuth, }, ], }), ); await expect( modelsAuthLoginCommand({ provider: "openai", method: "api_key", force }, runtime), ).rejects.toThrow("Unknown auth method"); expect(mocks.removeProviderAuthProfilesWithLock).not.toHaveBeenCalled(); expect(runOauthAuth).not.toHaveBeenCalled(); expect(runApiKeyAuth).not.toHaveBeenCalled(); }, ); it("reports loaded plugin providers when requested provider is unavailable", async () => { const runtime = createRuntime(); await expect(modelsAuthLoginCommand({ provider: "anthropic" }, runtime)).rejects.toThrow( 'Unknown provider "anthropic". Loaded providers: openai. Verify plugins via `openclaw plugins list --json`.', ); }); it("opens the provider picker when a CLI backend has no provider auth method", async () => { const runtime = createRuntime(); const prompter = { note: vi.fn(async () => {}), select: vi.fn(async () => "openai"), }; mocks.createClackPrompter.mockReturnValue(prompter); mocks.isCliProvider.mockImplementation((provider: string) => provider === "claude-cli"); const openaiProvider = createProvider({ id: "openai", label: "OpenAI Codex", run: runProviderAuth as ProviderPlugin["auth"][number]["run"], }); mocks.resolvePluginProvidersCore.mockImplementation( (params: ResolvePluginProvidersCall | undefined) => params?.providerRefs?.includes("claude-cli") ? [] : [openaiProvider], ); await modelsAuthLoginCommand({ provider: "claude-cli" }, runtime); expect(prompter.note).toHaveBeenCalledWith( 'Provider "claude-cli" uses its own CLI login. Select a provider with an OpenClaw auth flow.', "Provider auth", ); expect(prompter.select).toHaveBeenCalledWith( expect.objectContaining({ message: "Select a provider" }), ); expect(runProviderAuth).toHaveBeenCalledOnce(); expect(mocks.resolvePluginProvidersCore).toHaveBeenNthCalledWith( 1, expect.objectContaining({ providerRefs: ["claude-cli"] }), ); expect(mocks.resolvePluginProvidersCore).toHaveBeenNthCalledWith( 2, expect.not.objectContaining({ providerRefs: expect.anything() }), ); }); it.each(["openai-codex", "codex-cli"])( "rejects retired manual auth provider %s", async (provider) => { await expect(modelsAuthLoginCommand({ provider }, createRuntime())).rejects.toThrow( `"${provider}" is a legacy provider ID; use --provider openai.`, ); }, ); it("does not persist a cancelled manual token entry", async () => { const runtime = createRuntime(); const exitSpy = vi.spyOn(process, "exit").mockImplementation((( code?: string | number | null, ) => { throw new Error(`exit:${String(code ?? "")}`); }) as typeof process.exit); try { const cancelSymbol = Symbol.for("clack:cancel"); mocks.clackPassword.mockResolvedValue(cancelSymbol); mocks.clackIsCancel.mockImplementation((value: unknown) => value === cancelSymbol); await expect(modelsAuthPasteTokenCommand({ provider: "openai" }, runtime)).rejects.toThrow( "exit:0", ); expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled(); expect(mocks.updateConfig).not.toHaveBeenCalled(); expect(mocks.logConfigUpdated).not.toHaveBeenCalled(); } finally { exitSpy.mockRestore(); } }); it("reads the pasted token through the masked password prompt", async () => { const runtime = createRuntime(); mocks.clackPassword.mockResolvedValue("openai-token"); await modelsAuthPasteTokenCommand({ provider: "openai" }, runtime); expect(mocks.clackPassword).toHaveBeenCalledTimes(1); expect(mocks.clackText).not.toHaveBeenCalled(); expect(mocks.upsertAuthProfileWithLock).toHaveBeenCalledWith( expect.objectContaining({ credential: expect.objectContaining({ type: "token", token: "openai-token" }), }), ); expect(mocks.callGateway).toHaveBeenCalledWith( expect.objectContaining({ params: { operation: "login", agentId: "main" }, }), ); }); it("writes pasted Anthropic setup-tokens and logs the preference note", async () => { const runtime = createRuntime(); mocks.clackPassword.mockResolvedValue(`sk-ant-oat01-${"a".repeat(80)}`); await modelsAuthPasteTokenCommand({ provider: "anthropic" }, runtime); expect(mocks.upsertAuthProfileWithLock).toHaveBeenCalledWith({ profileId: "anthropic:manual", credential: { type: "token", provider: "anthropic", token: `sk-ant-oat01-${"a".repeat(80)}`, }, agentDir: "/tmp/openclaw/agents/main", }); expect(runtime.log).toHaveBeenCalledWith( "Anthropic setup-token auth is supported in OpenClaw.", ); expect(runtime.log).toHaveBeenCalledWith( "OpenClaw prefers Claude CLI reuse when it is available on the host.", ); expect(runtime.log).toHaveBeenCalledWith( "Anthropic staff told us this OpenClaw path is allowed again.", ); }); it("writes pasted tokens to the requested agent store", async () => { const runtime = createRuntime(); useCoderAgentConfig(); mocks.clackPassword.mockResolvedValue("openai-token"); await modelsAuthPasteTokenCommand({ provider: "openai", agent: "coder" }, runtime); expect(mocks.resolveDefaultAgentId).not.toHaveBeenCalled(); expect(mocks.upsertAuthProfileWithLock).toHaveBeenCalledWith({ profileId: "openai:manual", credential: { type: "token", provider: "openai", token: "openai-token", }, agentDir: "/tmp/openclaw/agents/coder", }); expect(mocks.callGateway).toHaveBeenCalledWith( expect.objectContaining({ params: { operation: "login", agentId: "coder" }, }), ); }); it("rejects pasted token expiries that cannot fit in the Date timestamp range", async () => { const runtime = createRuntime(); const nowSpy = vi.spyOn(Date, "now").mockReturnValue(MAX_DATE_TIMESTAMP_MS); mocks.clackPassword.mockResolvedValue("openai-token"); try { await expect( modelsAuthPasteTokenCommand({ provider: "openai", expiresIn: "1ms" }, runtime), ).rejects.toThrow("resulting token expiry is outside Date range"); } finally { nowSpy.mockRestore(); } expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled(); expect(mocks.updateConfig).not.toHaveBeenCalled(); }); it("rejects OpenAI API keys pasted as OpenAI Codex token material", async () => { const runtime = createRuntime(); const validateMessages: string[] = []; mocks.clackPassword.mockImplementation( async (params: { validate?: (value: string) => string | undefined }) => { const message = params.validate?.("sk-openai-chatgpt-api-key-value"); if (message) { validateMessages.push(message); throw new Error(message); } return "sk-openai-chatgpt-api-key-value"; }, ); await expect(modelsAuthPasteTokenCommand({ provider: "openai" }, runtime)).rejects.toThrow( "paste-api-key --provider openai", ); expect(validateMessages).toEqual([ "That looks like an OpenAI API key. Use openclaw models auth paste-api-key --provider openai for API-key auth.", ]); expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled(); expect(mocks.updateConfig).not.toHaveBeenCalled(); }); it("rejects piped OpenAI API keys as OpenAI Codex token material", async () => { const runtime = createRuntime(); restoreStdin?.(); restoreStdin = withPipedStdin("sk-openai-chatgpt-api-key-value\n"); await expect(modelsAuthPasteTokenCommand({ provider: "openai" }, runtime)).rejects.toThrow( "paste-api-key --provider openai", ); expect(mocks.clackPassword).not.toHaveBeenCalled(); expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled(); expect(mocks.updateConfig).not.toHaveBeenCalled(); }); it("rejects line-wrapped piped OpenAI API keys as OpenAI Codex token material", async () => { const runtime = createRuntime(); restoreStdin?.(); restoreStdin = withPipedStdin("sk-openai-\nchat-api-key-value\n"); await expect(modelsAuthPasteTokenCommand({ provider: "openai" }, runtime)).rejects.toThrow( "paste-api-key --provider openai", ); expect(mocks.clackPassword).not.toHaveBeenCalled(); expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled(); expect(mocks.updateConfig).not.toHaveBeenCalled(); }); it("rejects oversized piped auth input before buffering it", async () => { const runtime = createRuntime(); restoreStdin?.(); const oversized = "x".repeat(1024 * 1024 + 1); restoreStdin = withPipedStdin(oversized); await expect(modelsAuthPasteApiKeyCommand({ provider: "openai" }, runtime)).rejects.toThrow( "Piped auth input exceeds 1048576 bytes.", ); expect(mocks.clackPassword).not.toHaveBeenCalled(); expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled(); expect(mocks.updateConfig).not.toHaveBeenCalled(); }); it("writes pasted API keys to the requested agent store", async () => { const runtime = createRuntime(); useCoderAgentConfig(); mocks.clackPassword.mockResolvedValue("sk-openai-chatgpt-api-key-value"); await modelsAuthPasteApiKeyCommand({ provider: "openai", agent: "coder" }, runtime); expect(mocks.resolveDefaultAgentId).not.toHaveBeenCalled(); expect(mocks.upsertAuthProfileWithLock).toHaveBeenCalledWith({ profileId: "openai:manual", credential: { type: "api_key", provider: "openai", key: "sk-openai-chatgpt-api-key-value", }, agentDir: "/tmp/openclaw/agents/coder", preserveApiKeyMetadata: true, validateCurrentCredential: expect.any(Function), }); expect(lastUpdatedConfig?.auth?.profiles?.["openai:manual"]).toEqual({ provider: "openai", mode: "api_key", }); expect(runtime.log).toHaveBeenCalledWith("Auth profile: openai:manual (openai/api_key)"); expect(runtime.error).toHaveBeenCalledWith( expect.stringContaining("Gateway has not confirmed applying the provider settings"), ); expect(mocks.callGateway).toHaveBeenCalledWith( expect.objectContaining({ params: { operation: "login", agentId: "coder" }, }), ); }); it("writes piped OpenAI Codex API keys to API-key profiles", async () => { const runtime = createRuntime(); restoreStdin?.(); restoreStdin = withPipedStdin("sk-openai-chatgpt-api-key-value\n"); await modelsAuthPasteApiKeyCommand({ provider: "openai" }, runtime); expect(mocks.clackPassword).not.toHaveBeenCalled(); expect(mocks.upsertAuthProfileWithLock).toHaveBeenCalledWith({ profileId: "openai:manual", credential: { type: "api_key", provider: "openai", key: "sk-openai-chatgpt-api-key-value", }, agentDir: "/tmp/openclaw/agents/main", preserveApiKeyMetadata: true, validateCurrentCredential: expect.any(Function), }); expect(lastUpdatedConfig?.auth?.profiles?.["openai:manual"]).toEqual({ provider: "openai", mode: "api_key", }); }); it("normalizes line-wrapped piped OpenAI Codex API keys before storing", async () => { const runtime = createRuntime(); restoreStdin?.(); restoreStdin = withPipedStdin("sk-openai-\nchat-api-key-value\n"); await modelsAuthPasteApiKeyCommand({ provider: "openai" }, runtime); expect(mocks.clackPassword).not.toHaveBeenCalled(); expect(mocks.upsertAuthProfileWithLock).toHaveBeenCalledWith({ profileId: "openai:manual", credential: { type: "api_key", provider: "openai", key: "sk-openai-chat-api-key-value", }, agentDir: "/tmp/openclaw/agents/main", preserveApiKeyMetadata: true, validateCurrentCredential: expect.any(Function), }); expect(lastUpdatedConfig?.auth?.profiles?.["openai:manual"]).toEqual({ provider: "openai", mode: "api_key", }); }); it("rejects token material pasted into the OpenAI Codex API-key command", async () => { const runtime = createRuntime(); const jwtLikeToken = ["eyJhbGciOiJub25l", "eyJzdWIiOiJjb2RleCJ9", "signature123456"].join("."); const validateMessages: string[] = []; mocks.clackPassword.mockImplementation( async (params: { validate?: (value: string) => string | undefined }) => { const message = params.validate?.(jwtLikeToken); if (message) { validateMessages.push(message); throw new Error(message); } return jwtLikeToken; }, ); await expect(modelsAuthPasteApiKeyCommand({ provider: "openai" }, runtime)).rejects.toThrow( "paste-token --provider openai", ); expect(validateMessages).toEqual([ "That looks like token or OAuth material, not an OpenAI API key. Use openclaw models auth paste-token --provider openai for token auth material.", ]); expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled(); expect(mocks.updateConfig).not.toHaveBeenCalled(); }); it("rejects an unknown agent before prompting for pasted tokens", async () => { const runtime = createRuntime(); currentConfig = { agents: { list: [{ id: "main" }] } }; await expect( modelsAuthPasteTokenCommand({ provider: "openai", agent: "missing" }, runtime), ).rejects.toThrow( 'Unknown agent id "missing". Use "openclaw agents list" to see configured agents.', ); expect(mocks.clackPassword).not.toHaveBeenCalled(); expect(mocks.upsertAuthProfileWithLock).not.toHaveBeenCalled(); expect(mocks.updateConfig).not.toHaveBeenCalled(); }); it("runs token auth for any token-capable provider plugin", async () => { const runtime = createRuntime(); const runTokenAuth = vi.fn().mockResolvedValue({ profiles: [ { profileId: "moonshot:token", credential: { type: "token", provider: "moonshot", token: "moonshot-token", }, }, ], }); mocks.resolvePluginProvidersCore.mockReturnValue([ { id: "moonshot", label: "Moonshot", auth: [ { id: "setup-token", label: "setup-token", kind: "token", run: runTokenAuth, }, ], }, ]); await modelsAuthSetupTokenCommand({ provider: "moonshot", yes: true }, runtime); expect(runTokenAuth).toHaveBeenCalledOnce(); expect(mocks.persistProviderAuthProfilesAfterLogin).toHaveBeenCalledWith( expect.objectContaining({ profiles: [ { profileId: "moonshot:token", credential: { type: "token", provider: "moonshot", token: "moonshot-token", }, }, ], agentDir: "/tmp/openclaw/agents/main", }), ); }); it("uses the requested agent store for setup-token provider auth", async () => { const runtime = createRuntime(); useCoderAgentConfig(); const runTokenAuth = vi.fn().mockResolvedValue({ profiles: [ { profileId: "moonshot:token", credential: { type: "token", provider: "moonshot", token: "moonshot-token", }, }, ], }); mocks.resolvePluginProvidersCore.mockReturnValue([ { id: "moonshot", label: "Moonshot", auth: [ { id: "setup-token", label: "setup-token", kind: "token", run: runTokenAuth, }, ], }, ]); await modelsAuthSetupTokenCommand({ provider: "moonshot", yes: true, agent: "coder" }, runtime); expect(mocks.resolveDefaultAgentId).not.toHaveBeenCalled(); const tokenAuthCall = readMockCallArg(runTokenAuth) as AuthRunCall; expect(tokenAuthCall.agentDir).toBe("/tmp/openclaw/agents/coder"); expect(tokenAuthCall.workspaceDir).toBe("/tmp/openclaw/workspaces/coder"); expect( (readMockCallArg(mocks.persistProviderAuthProfilesAfterLogin) as PersistProviderAuthCall) .agentDir, ).toBe("/tmp/openclaw/agents/coder"); }); it("uses the requested agent store for interactive token auth add", async () => { const runtime = createRuntime(); useCoderAgentConfig(); const runTokenAuth = vi.fn().mockResolvedValue({ configPatch: { logging: { level: "debug" } }, profiles: [ { profileId: "moonshot:token", credential: { type: "token", provider: "moonshot", token: "moonshot-token", }, }, ], }); mocks.resolvePluginProvidersCore.mockReturnValue([ { id: "moonshot", label: "Moonshot", auth: [ { id: "setup-token", label: "setup-token", kind: "token", run: runTokenAuth, }, ], }, ]); mocks.clackSelect.mockResolvedValueOnce("moonshot").mockResolvedValueOnce("setup-token"); await modelsAuthAddCommand({ agent: "coder" }, runtime); expect(lastUpdatedConfig?.logging?.level).toBe("debug"); expect(mocks.resolveDefaultAgentId).not.toHaveBeenCalled(); const tokenAuthCall = readMockCallArg(runTokenAuth) as AuthRunCall; expect(tokenAuthCall.agentDir).toBe("/tmp/openclaw/agents/coder"); expect(tokenAuthCall.workspaceDir).toBe("/tmp/openclaw/workspaces/coder"); expect( (readMockCallArg(mocks.persistProviderAuthProfilesAfterLogin) as PersistProviderAuthCall) .agentDir, ).toBe("/tmp/openclaw/agents/coder"); }); it("keeps the requested agent store when interactive auth add falls back to paste-token", async () => { const runtime = createRuntime(); useCoderAgentConfig(); mocks.resolvePluginProvidersCore.mockReturnValue([]); mocks.clackSelect.mockResolvedValue("custom"); mocks.clackText.mockResolvedValueOnce("openai").mockResolvedValueOnce("openai:manual"); mocks.clackPassword.mockResolvedValue("openai-token"); mocks.clackConfirm.mockResolvedValue(false); await modelsAuthAddCommand({ agent: "coder" }, runtime); expect(mocks.resolveDefaultAgentId).not.toHaveBeenCalled(); expect(mocks.upsertAuthProfileWithLock).toHaveBeenCalledWith({ profileId: "openai:manual", credential: { type: "token", provider: "openai", token: "openai-token", }, agentDir: "/tmp/openclaw/agents/coder", }); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */