From 2ada1b71b67e75d450a28eae98ce3a1817a97f1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 7 Mar 2026 18:50:17 +0000 Subject: [PATCH] fix(models-auth): land #38951 from @MumuTW Co-authored-by: MumuTW --- CHANGELOG.md | 1 + src/commands/models/auth.test.ts | 52 ++++++++++++++++++++++++++- src/commands/models/auth.ts | 62 +++++++++++++++++++++----------- 3 files changed, 93 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d24c43d9a04..a0f8b1707e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,7 @@ Docs: https://docs.openclaw.ai - CLI/bootstrap Node version hint maintenance: replace hardcoded nvm `22` instructions in `openclaw.mjs` with `MIN_NODE_MAJOR` interpolation so future minimum-Node bumps keep startup guidance in sync automatically. (#39056) Thanks @onstash. - Discord/native slash command auth: honor `commands.allowFrom.discord` (and `commands.allowFrom["*"]`) in guild slash-command pre-dispatch authorization so allowlisted senders are no longer incorrectly rejected as unauthorized. (#38794) Thanks @jskoiz and @thewilloftheshadow. - Outbound/message target normalization: ignore empty legacy `to`/`channelId` fields when explicit `target` is provided so valid target-based sends no longer fail legacy-param validation; includes regression coverage. (#38944) Thanks @Narcooo. +- Models/auth token prompts: guard cancelled manual token prompts so `Symbol(clack:cancel)` values cannot be persisted into auth profiles; adds regression coverage for cancelled `models auth paste-token`. (#38951) Thanks @MumuTW. ## 2026.3.2 diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index c05c1480096..d5e383d775e 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -3,10 +3,16 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; const mocks = vi.hoisted(() => ({ + clackCancel: vi.fn(), + clackConfirm: vi.fn(), + clackIsCancel: vi.fn((value: unknown) => value === Symbol.for("clack:cancel")), + clackSelect: vi.fn(), + clackText: vi.fn(), resolveDefaultAgentId: vi.fn(), resolveAgentDir: vi.fn(), resolveAgentWorkspaceDir: vi.fn(), resolveDefaultAgentWorkspaceDir: vi.fn(), + upsertAuthProfile: vi.fn(), resolvePluginProviders: vi.fn(), createClackPrompter: vi.fn(), loginOpenAICodexOAuth: vi.fn(), @@ -17,6 +23,14 @@ const mocks = vi.hoisted(() => ({ openUrl: vi.fn(), })); +vi.mock("@clack/prompts", () => ({ + cancel: mocks.clackCancel, + confirm: mocks.clackConfirm, + isCancel: mocks.clackIsCancel, + select: mocks.clackSelect, + text: mocks.clackText, +})); + vi.mock("../../agents/agent-scope.js", () => ({ resolveDefaultAgentId: mocks.resolveDefaultAgentId, resolveAgentDir: mocks.resolveAgentDir, @@ -27,6 +41,10 @@ vi.mock("../../agents/workspace.js", () => ({ resolveDefaultAgentWorkspaceDir: mocks.resolveDefaultAgentWorkspaceDir, })); +vi.mock("../../agents/auth-profiles.js", () => ({ + upsertAuthProfile: mocks.upsertAuthProfile, +})); + vi.mock("../../plugins/providers.js", () => ({ resolvePluginProviders: mocks.resolvePluginProviders, })); @@ -64,7 +82,7 @@ vi.mock("../onboard-helpers.js", () => ({ openUrl: mocks.openUrl, })); -const { modelsAuthLoginCommand } = await import("./auth.js"); +const { modelsAuthLoginCommand, modelsAuthPasteTokenCommand } = await import("./auth.js"); function createRuntime(): RuntimeEnv { return { @@ -102,6 +120,14 @@ describe("modelsAuthLoginCommand", () => { restoreStdin = withInteractiveStdin(); currentConfig = {}; lastUpdatedConfig = null; + mocks.clackCancel.mockReset(); + mocks.clackConfirm.mockReset(); + mocks.clackIsCancel.mockImplementation( + (value: unknown) => value === Symbol.for("clack:cancel"), + ); + mocks.clackSelect.mockReset(); + mocks.clackText.mockReset(); + mocks.upsertAuthProfile.mockReset(); mocks.resolveDefaultAgentId.mockReturnValue("main"); mocks.resolveAgentDir.mockReturnValue("/tmp/openclaw/agents/main"); @@ -179,4 +205,28 @@ describe("modelsAuthLoginCommand", () => { "No provider plugins found.", ); }); + + 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.clackText.mockResolvedValue(cancelSymbol); + mocks.clackIsCancel.mockImplementation((value: unknown) => value === cancelSymbol); + + await expect(modelsAuthPasteTokenCommand({ provider: "openai" }, runtime)).rejects.toThrow( + "exit:0", + ); + + expect(mocks.upsertAuthProfile).not.toHaveBeenCalled(); + expect(mocks.updateConfig).not.toHaveBeenCalled(); + expect(mocks.logConfigUpdated).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + } + }); }); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 16fda7985e6..56946d590a7 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -1,4 +1,10 @@ -import { confirm as clackConfirm, select as clackSelect, text as clackText } from "@clack/prompts"; +import { + cancel, + confirm as clackConfirm, + isCancel, + select as clackSelect, + text as clackText, +} from "@clack/prompts"; import { resolveAgentDir, resolveAgentWorkspaceDir, @@ -34,24 +40,38 @@ import { } from "../provider-auth-helpers.js"; import { loadValidConfigOrThrow, updateConfig } from "./shared.js"; -const confirm = (params: Parameters[0]) => - clackConfirm({ - ...params, - message: stylePromptMessage(params.message), - }); -const text = (params: Parameters[0]) => - clackText({ - ...params, - message: stylePromptMessage(params.message), - }); -const select = (params: Parameters>[0]) => - clackSelect({ - ...params, - message: stylePromptMessage(params.message), - options: params.options.map((opt) => - opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) }, - ), - }); +function guardCancel(value: T | symbol): T { + if (typeof value === "symbol" || isCancel(value)) { + cancel("Cancelled."); + process.exit(0); + } + return value; +} + +const confirm = async (params: Parameters[0]) => + guardCancel( + await clackConfirm({ + ...params, + message: stylePromptMessage(params.message), + }), + ); +const text = async (params: Parameters[0]) => + guardCancel( + await clackText({ + ...params, + message: stylePromptMessage(params.message), + }), + ); +const select = async (params: Parameters>[0]) => + guardCancel( + await clackSelect({ + ...params, + message: stylePromptMessage(params.message), + options: params.options.map((opt) => + opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) }, + ), + }), + ); type TokenProvider = "anthropic"; @@ -165,13 +185,13 @@ export async function modelsAuthPasteTokenCommand( } export async function modelsAuthAddCommand(_opts: Record, runtime: RuntimeEnv) { - const provider = (await select({ + const provider = await select({ message: "Token provider", options: [ { value: "anthropic", label: "anthropic" }, { value: "custom", label: "custom (type provider id)" }, ], - })) as TokenProvider | "custom"; + }); const providerId = provider === "custom"