From 48aa076d12e7a786ad634deb1e7e3c590a74bf07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 16:04:50 +0100 Subject: [PATCH] perf: optimize remaining core tests --- .../scripts/secret-scanning.mjs | 27 +- docs/style.css | 5 +- .../reply/commands-system-prompt.ts | 2 +- src/auto-reply/reply/session-updates.ts | 4 +- .../reply/strip-inbound-meta.test.ts | 4 +- src/auto-reply/status.ts | 6 +- ...command-secret-resolution.coverage.test.ts | 3 + src/config/sessions/store-entry.ts | 47 ++ src/config/sessions/store.ts | 48 +- .../isolated-agent/skills-snapshot.runtime.ts | 2 +- src/flows/doctor-health-contributions.ts | 7 +- src/gateway/config-reload.test.ts | 22 +- .../server-methods/chat-webchat-media.ts | 2 +- src/infra/backup-create.ts | 11 +- src/plugins/provider-auth-input.test.ts | 440 ++++++++++++++++++ src/terminal/links.test.ts | 4 +- ui/src/ui/markdown.ts | 1 - 17 files changed, 547 insertions(+), 88 deletions(-) create mode 100644 src/config/sessions/store-entry.ts create mode 100644 src/plugins/provider-auth-input.test.ts diff --git a/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs b/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs index 3b58f66c56a..34b9a9b0b85 100644 --- a/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs +++ b/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs @@ -52,7 +52,11 @@ function ghGraphQL(query, options = {}) { function failOnGraphQLFailure(result, message) { if (result?.gh_failed) { - const details = (result.stderr || result.stdout || `gh exited with status ${result.status}`).trim(); + const details = ( + result.stderr || + result.stdout || + `gh exited with status ${result.status}` + ).trim(); fail(`${message}: ${details}`); } if (Array.isArray(result?.errors) && result.errors.length > 0) { @@ -73,9 +77,7 @@ function formatGraphQLAfterClause(cursor) { } function findDiscussionCommentNode(nodes, discussionCommentDbId) { - return ( - nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null - ); + return nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null; } function fetchDiscussionReplyPage(commentNodeId, cursor) { @@ -169,9 +171,13 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) { while (!reply && hasMoreReplies) { const replyPage = fetchDiscussionReplyPage(topLevelComment.id, replyCursor); - failOnGraphQLFailure(replyPage, `Failed to fetch replies for discussion comment ${topLevelComment.id}`); + failOnGraphQLFailure( + replyPage, + `Failed to fetch replies for discussion comment ${topLevelComment.id}`, + ); const replies = replyPage?.data?.node?.replies; - if (!replies) fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`); + if (!replies) + fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`); reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId); hasMoreReplies = replies.pageInfo.hasNextPage; @@ -189,9 +195,7 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) { } function createDiscussionComment(discussionNodeId, body, replyToNodeId) { - const replyToClause = replyToNodeId - ? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"` - : ""; + const replyToClause = replyToNodeId ? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"` : ""; const result = ghGraphQL( `mutation { addDiscussionComment(input: { discussionId: "${escapeGraphQLString(discussionNodeId)}"${replyToClause}, body: "${escapeGraphQLString(body)}" }) { comment { id url } } }`, ); @@ -261,7 +265,10 @@ function cmdFetchContent(locationJson) { const discussionNumber = urlMatch[1]; const discussionCommentDbId = urlMatch[2]; - const { discussionId, comment } = fetchDiscussionComment(discussionNumber, discussionCommentDbId); + const { discussionId, comment } = fetchDiscussionComment( + discussionNumber, + discussionCommentDbId, + ); if (!comment) fail( `Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`, diff --git a/docs/style.css b/docs/style.css index 13ebbd47648..82ce18e66d9 100644 --- a/docs/style.css +++ b/docs/style.css @@ -82,7 +82,10 @@ html.dark .nav-tabs-underline { border-radius: 8px; background: color-mix(in oklab, rgb(var(--primary)) 4%, transparent); text-decoration: none; - transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease; + transition: + transform 0.16s ease, + border-color 0.16s ease, + background 0.16s ease; } .showcase-actions a:first-child { diff --git a/src/auto-reply/reply/commands-system-prompt.ts b/src/auto-reply/reply/commands-system-prompt.ts index 09f4b1f4110..6612ff00673 100644 --- a/src/auto-reply/reply/commands-system-prompt.ts +++ b/src/auto-reply/reply/commands-system-prompt.ts @@ -8,7 +8,7 @@ import { resolveEmbeddedFullAccessState } from "../../agents/pi-embedded-runner/ import { createOpenClawCodingTools } from "../../agents/pi-tools.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; -import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; +import { getSkillsSnapshotVersion } from "../../agents/skills/refresh-state.js"; import { buildSystemPromptParams } from "../../agents/system-prompt-params.js"; import { buildAgentSystemPrompt } from "../../agents/system-prompt.js"; import type { WorkspaceBootstrapFile } from "../../agents/workspace.js"; diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index b930a67cccd..80361a800f5 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -6,10 +6,10 @@ import { canExecRequestNode } from "../../agents/exec-defaults.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { matchesSkillFilter } from "../../agents/skills/filter.js"; import { - ensureSkillsWatcher, getSkillsSnapshotVersion, shouldRefreshSnapshotForVersion, -} from "../../agents/skills/refresh.js"; +} from "../../agents/skills/refresh-state.js"; +import { ensureSkillsWatcher } from "../../agents/skills/refresh.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions, diff --git a/src/auto-reply/reply/strip-inbound-meta.test.ts b/src/auto-reply/reply/strip-inbound-meta.test.ts index 1c07cf08287..76274143dad 100644 --- a/src/auto-reply/reply/strip-inbound-meta.test.ts +++ b/src/auto-reply/reply/strip-inbound-meta.test.ts @@ -121,7 +121,9 @@ This is plain user text`; it("strips an active-memory prompt prefix block even when earlier text precedes it", () => { const input = `Queued earlier user turn\n\n${ACTIVE_MEMORY_PREFIX_BLOCK}\n\nWhat should I grab on the way?`; - expect(stripInboundMetadata(input)).toBe("Queued earlier user turn\n\nWhat should I grab on the way?"); + expect(stripInboundMetadata(input)).toBe( + "Queued earlier user turn\n\nWhat should I grab on the way?", + ); }); it("does not strip active-memory lookalike user text without exact tag lines", () => { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 61516f983fe..c73732a94f0 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -682,9 +682,9 @@ export function buildStatusMessage(args: StatusArgs): string { const queueDetails = formatQueueDetails(args.queue); const verboseLabel = verboseLevel === "full" ? "verbose:full" : verboseLevel === "on" ? "verbose" : null; - const traceLevel = entry?.traceLevel === "raw" ? "raw" : entry?.traceLevel === "on" ? "on" : "off"; - const traceLabel = - traceLevel === "raw" ? "trace:raw" : traceLevel === "on" ? "trace" : null; + const traceLevel = + entry?.traceLevel === "raw" ? "raw" : entry?.traceLevel === "on" ? "on" : "off"; + const traceLabel = traceLevel === "raw" ? "trace:raw" : traceLevel === "on" ? "trace" : null; const pluginStatusLines = verboseLevel !== "off" ? resolveSessionPluginStatusLines(entry) : []; const pluginTraceLines = traceLevel === "on" || traceLevel === "raw" ? resolveSessionPluginTraceLines(entry) : []; diff --git a/src/cli/command-secret-resolution.coverage.test.ts b/src/cli/command-secret-resolution.coverage.test.ts index 7970ba80384..c815c6ccd4b 100644 --- a/src/cli/command-secret-resolution.coverage.test.ts +++ b/src/cli/command-secret-resolution.coverage.test.ts @@ -5,6 +5,7 @@ import { readCommandSource } from "./command-source.test-helpers.js"; const SECRET_TARGET_CALLSITES = [ bundledPluginFile("memory-core", "src/cli.runtime.ts"), "src/cli/qr-cli.ts", + "src/agents/agent-runtime-config.ts", "src/commands/agent.ts", "src/commands/channels/resolve.ts", "src/commands/channels/shared.ts", @@ -16,6 +17,7 @@ const SECRET_TARGET_CALLSITES = [ function hasSupportedTargetIdsWiring(source: string): boolean { return ( + /resolveAgentRuntimeConfig\(/.test(source) || /targetIds:\s*get[A-Za-z0-9_]+\(\)/m.test(source) || /targetIds:\s*getAgentRuntimeCommandSecretTargetIds\(/m.test(source) || /targetIds:\s*scopedTargets\.targetIds/m.test(source) || @@ -25,6 +27,7 @@ function hasSupportedTargetIdsWiring(source: string): boolean { function hasSupportedSecretResolutionWiring(source: string): boolean { return ( + /resolveAgentRuntimeConfig\(/.test(source) || /resolveCommandConfigWithSecrets\(/.test(source) || /resolveCommandSecretRefsViaGateway\(/.test(source) || /collectStatusScanOverview\(/.test(source) diff --git a/src/config/sessions/store-entry.ts b/src/config/sessions/store-entry.ts new file mode 100644 index 00000000000..458ab6a9626 --- /dev/null +++ b/src/config/sessions/store-entry.ts @@ -0,0 +1,47 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; +import type { SessionEntry } from "./types.js"; + +export function normalizeStoreSessionKey(sessionKey: string): string { + return normalizeLowercaseStringOrEmpty(sessionKey); +} + +export function resolveSessionStoreEntry(params: { + store: Record; + sessionKey: string; +}): { + normalizedKey: string; + existing: SessionEntry | undefined; + legacyKeys: string[]; +} { + const trimmedKey = params.sessionKey.trim(); + const normalizedKey = normalizeStoreSessionKey(trimmedKey); + const legacyKeySet = new Set(); + if ( + trimmedKey !== normalizedKey && + Object.prototype.hasOwnProperty.call(params.store, trimmedKey) + ) { + legacyKeySet.add(trimmedKey); + } + let existing = + params.store[normalizedKey] ?? (legacyKeySet.size > 0 ? params.store[trimmedKey] : undefined); + let existingUpdatedAt = existing?.updatedAt ?? 0; + for (const [candidateKey, candidateEntry] of Object.entries(params.store)) { + if (candidateKey === normalizedKey) { + continue; + } + if (normalizeStoreSessionKey(candidateKey) !== normalizedKey) { + continue; + } + legacyKeySet.add(candidateKey); + const candidateUpdatedAt = candidateEntry?.updatedAt ?? 0; + if (!existing || candidateUpdatedAt > existingUpdatedAt) { + existing = candidateEntry; + existingUpdatedAt = candidateUpdatedAt; + } + } + return { + normalizedKey, + existing, + legacyKeys: [...legacyKeySet], + }; +} diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 2a628c65439..e28a6aa11a3 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -7,7 +7,6 @@ import { import type { MsgContext } from "../../auto-reply/templating.js"; import { writeTextAtomic } from "../../infra/json-files.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { deliveryContextFromSession, mergeDeliveryContext, @@ -25,6 +24,7 @@ import { setSerializedSessionStore, writeSessionStoreCache, } from "./store-cache.js"; +import { normalizeStoreSessionKey, resolveSessionStoreEntry } from "./store-entry.js"; import { loadSessionStore, normalizeSessionStore } from "./store-load.js"; import { clearSessionStoreCacheForTest, @@ -55,6 +55,7 @@ export { getSessionStoreLockQueueSizeForTest, } from "./store-lock-state.js"; export { loadSessionStore } from "./store-load.js"; +export { normalizeStoreSessionKey, resolveSessionStoreEntry } from "./store-entry.js"; const log = createSubsystemLogger("sessions/store"); let sessionArchiveRuntimePromise: Promise< @@ -76,51 +77,6 @@ function removeThreadFromDeliveryContext(context?: DeliveryContext): DeliveryCon return next; } -export function normalizeStoreSessionKey(sessionKey: string): string { - return normalizeLowercaseStringOrEmpty(sessionKey); -} - -export function resolveSessionStoreEntry(params: { - store: Record; - sessionKey: string; -}): { - normalizedKey: string; - existing: SessionEntry | undefined; - legacyKeys: string[]; -} { - const trimmedKey = params.sessionKey.trim(); - const normalizedKey = normalizeStoreSessionKey(trimmedKey); - const legacyKeySet = new Set(); - if ( - trimmedKey !== normalizedKey && - Object.prototype.hasOwnProperty.call(params.store, trimmedKey) - ) { - legacyKeySet.add(trimmedKey); - } - let existing = - params.store[normalizedKey] ?? (legacyKeySet.size > 0 ? params.store[trimmedKey] : undefined); - let existingUpdatedAt = existing?.updatedAt ?? 0; - for (const [candidateKey, candidateEntry] of Object.entries(params.store)) { - if (candidateKey === normalizedKey) { - continue; - } - if (normalizeStoreSessionKey(candidateKey) !== normalizedKey) { - continue; - } - legacyKeySet.add(candidateKey); - const candidateUpdatedAt = candidateEntry?.updatedAt ?? 0; - if (!existing || candidateUpdatedAt > existingUpdatedAt) { - existing = candidateEntry; - existingUpdatedAt = candidateUpdatedAt; - } - } - return { - normalizedKey, - existing, - legacyKeys: [...legacyKeySet], - }; -} - export function setSessionWriteLockAcquirerForTests( acquirer: typeof acquireSessionWriteLock | null, ): void { diff --git a/src/cron/isolated-agent/skills-snapshot.runtime.ts b/src/cron/isolated-agent/skills-snapshot.runtime.ts index 8f3e7bffa25..8ba8be30ab0 100644 --- a/src/cron/isolated-agent/skills-snapshot.runtime.ts +++ b/src/cron/isolated-agent/skills-snapshot.runtime.ts @@ -1,5 +1,5 @@ export { canExecRequestNode } from "../../agents/exec-defaults.js"; export { resolveAgentSkillsFilter } from "../../agents/agent-scope.js"; export { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; -export { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; +export { getSkillsSnapshotVersion } from "../../agents/skills/refresh-state.js"; export { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index d5864ab01cb..3d99c976ccb 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -8,11 +8,8 @@ import { resolveHooksGmailModel, } from "../agents/model-selection.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { - maybeRepairLegacyOAuthProfileIds, - noteAuthProfileHealth, - noteLegacyCodexProviderOverride, -} from "../commands/doctor-auth.js"; +import { maybeRepairLegacyOAuthProfileIds } from "../commands/doctor-auth-legacy-oauth.js"; +import { noteAuthProfileHealth, noteLegacyCodexProviderOverride } from "../commands/doctor-auth.js"; import { noteBootstrapFileSize } from "../commands/doctor-bootstrap-size.js"; import { noteChromeMcpBrowserReadiness } from "../commands/doctor-browser.js"; import { maybeRepairBundledPluginRuntimeDeps } from "../commands/doctor-bundled-plugin-runtime-deps.js"; diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index d08e263e1c0..94a426d6d20 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -1,11 +1,11 @@ import chokidar from "chokidar"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { listChannelPlugins } from "../channels/plugins/index.js"; -import type { ChannelPlugin } from "../channels/plugins/types.js"; import { getSkillsSnapshotVersion, resetSkillsRefreshStateForTest, } from "../agents/skills/refresh-state.js"; +import { listChannelPlugins } from "../channels/plugins/index.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { ConfigFileSnapshot, ConfigWriteNotification } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; @@ -622,16 +622,14 @@ describe("startGatewayConfigReloader", () => { }); it("does not dedupe when initialInternalWriteHash is null (#67436)", async () => { - const readSnapshot = vi - .fn<() => Promise>() - .mockResolvedValueOnce( - makeSnapshot({ - config: { - gateway: { reload: { debounceMs: 0 }, auth: { mode: "token", token: "startup" } }, - }, - hash: "startup-internal-1", - }), - ); + const readSnapshot = vi.fn<() => Promise>().mockResolvedValueOnce( + makeSnapshot({ + config: { + gateway: { reload: { debounceMs: 0 }, auth: { mode: "token", token: "startup" } }, + }, + hash: "startup-internal-1", + }), + ); const harness = createReloaderHarness(readSnapshot, { initialInternalWriteHash: null, }); diff --git a/src/gateway/server-methods/chat-webchat-media.ts b/src/gateway/server-methods/chat-webchat-media.ts index d3b8745fc16..d13113f3f71 100644 --- a/src/gateway/server-methods/chat-webchat-media.ts +++ b/src/gateway/server-methods/chat-webchat-media.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; -import { assertLocalMediaAllowed, LocalMediaAccessError } from "../../media/local-media-access.js"; import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../../infra/local-file-access.js"; +import { assertLocalMediaAllowed, LocalMediaAccessError } from "../../media/local-media-access.js"; import { isAudioFileName } from "../../media/mime.js"; import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; diff --git a/src/infra/backup-create.ts b/src/infra/backup-create.ts index 4697d859cc3..54af4c553a7 100644 --- a/src/infra/backup-create.ts +++ b/src/infra/backup-create.ts @@ -3,7 +3,6 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import * as tar from "tar"; import { buildBackupArchiveBasename, buildBackupArchivePath, @@ -15,6 +14,15 @@ import { isPathWithin } from "../commands/cleanup-utils.js"; import { resolveHomeDir, resolveUserPath } from "../utils.js"; import { resolveRuntimeServiceVersion } from "../version.js"; +type TarRuntime = typeof import("tar"); + +let tarRuntimePromise: Promise | undefined; + +function loadTarRuntime(): Promise { + tarRuntimePromise ??= import("tar"); + return tarRuntimePromise; +} + export type BackupCreateOptions = { output?: string; dryRun?: boolean; @@ -342,6 +350,7 @@ export async function createBackupArchive( }); await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + const tar = await loadTarRuntime(); await tar.c( { file: tempArchivePath, diff --git a/src/plugins/provider-auth-input.test.ts b/src/plugins/provider-auth-input.test.ts new file mode 100644 index 00000000000..0f990107146 --- /dev/null +++ b/src/plugins/provider-auth-input.test.ts @@ -0,0 +1,440 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { + ensureApiKeyFromEnvOrPrompt, + ensureApiKeyFromOptionEnvOrPrompt, + maybeApplyApiKeyFromOption, + normalizeTokenProviderInput, +} from "./provider-auth-input.js"; + +const ORIGINAL_MINIMAX_API_KEY = process.env.MINIMAX_API_KEY; +const ORIGINAL_MINIMAX_OAUTH_TOKEN = process.env.MINIMAX_OAUTH_TOKEN; + +function restoreMinimaxEnv(): void { + if (ORIGINAL_MINIMAX_API_KEY === undefined) { + delete process.env.MINIMAX_API_KEY; + } else { + process.env.MINIMAX_API_KEY = ORIGINAL_MINIMAX_API_KEY; + } + if (ORIGINAL_MINIMAX_OAUTH_TOKEN === undefined) { + delete process.env.MINIMAX_OAUTH_TOKEN; + } else { + process.env.MINIMAX_OAUTH_TOKEN = ORIGINAL_MINIMAX_OAUTH_TOKEN; + } +} + +function createPrompter(params?: { + confirm?: WizardPrompter["confirm"]; + note?: WizardPrompter["note"]; + select?: WizardPrompter["select"]; + text?: WizardPrompter["text"]; +}): WizardPrompter { + return { + confirm: params?.confirm ?? (vi.fn(async () => true) as WizardPrompter["confirm"]), + note: params?.note ?? (vi.fn(async () => undefined) as WizardPrompter["note"]), + ...(params?.select ? { select: params.select } : {}), + text: params?.text ?? (vi.fn(async () => "prompt-key") as WizardPrompter["text"]), + } as unknown as WizardPrompter; +} + +function createPromptSpies(params?: { confirmResult?: boolean; textResult?: string }) { + const confirm = vi.fn(async () => params?.confirmResult ?? true); + const note = vi.fn(async () => undefined); + const text = vi.fn(async () => params?.textResult ?? "prompt-key"); + return { confirm, note, text }; +} + +function createPromptAndCredentialSpies(params?: { confirmResult?: boolean; textResult?: string }) { + return { + ...createPromptSpies(params), + setCredential: vi.fn(async () => undefined), + }; +} + +function setMinimaxEnv(params: { apiKey?: string; oauthToken?: string } = {}) { + if (params.apiKey === undefined) { + delete process.env.MINIMAX_API_KEY; + } else { + process.env.MINIMAX_API_KEY = params.apiKey; // pragma: allowlist secret + } + if (params.oauthToken === undefined) { + delete process.env.MINIMAX_OAUTH_TOKEN; + } else { + process.env.MINIMAX_OAUTH_TOKEN = params.oauthToken; // pragma: allowlist secret + } +} + +async function ensureMinimaxApiKey(params: { + config?: Parameters[0]["config"]; + env?: Parameters[0]["env"]; + confirm: WizardPrompter["confirm"]; + note?: WizardPrompter["note"]; + select?: WizardPrompter["select"]; + text: WizardPrompter["text"]; + setCredential: Parameters[0]["setCredential"]; + secretInputMode?: Parameters[0]["secretInputMode"]; +}) { + return await ensureMinimaxApiKeyInternal({ + config: params.config, + env: params.env, + prompter: createPrompter({ + confirm: params.confirm, + note: params.note, + select: params.select, + text: params.text, + }), + secretInputMode: params.secretInputMode, + setCredential: params.setCredential, + }); +} + +async function ensureMinimaxApiKeyInternal(params: { + config?: Parameters[0]["config"]; + env?: Parameters[0]["env"]; + prompter: WizardPrompter; + secretInputMode?: Parameters[0]["secretInputMode"]; + setCredential: Parameters[0]["setCredential"]; +}) { + return await ensureApiKeyFromEnvOrPrompt({ + config: params.config ?? {}, + env: params.env, + provider: "minimax", + envLabel: "MINIMAX_API_KEY", + promptMessage: "Enter key", + normalize: (value) => value.trim(), + validate: () => undefined, + prompter: params.prompter, + secretInputMode: params.secretInputMode, + setCredential: params.setCredential, + }); +} + +async function ensureMinimaxApiKeyWithEnvRefPrompter(params: { + config?: Parameters[0]["config"]; + env?: Parameters[0]["env"]; + note: WizardPrompter["note"]; + select: WizardPrompter["select"]; + setCredential: Parameters[0]["setCredential"]; + text: WizardPrompter["text"]; +}) { + return await ensureMinimaxApiKeyInternal({ + config: params.config, + env: params.env, + prompter: createPrompter({ select: params.select, text: params.text, note: params.note }), + secretInputMode: "ref", // pragma: allowlist secret + setCredential: params.setCredential, + }); +} + +async function runEnsureMinimaxApiKeyFlow(params: { confirmResult: boolean; textResult: string }) { + setMinimaxEnv({ apiKey: "env-key" }); + + const { confirm, text } = createPromptSpies({ + confirmResult: params.confirmResult, + textResult: params.textResult, + }); + const setCredential = vi.fn(async () => undefined); + const result = await ensureMinimaxApiKey({ + confirm, + text, + setCredential, + }); + + return { result, setCredential, confirm, text }; +} + +async function runMaybeApplyDemoToken(tokenProvider: string) { + const setCredential = vi.fn(async () => undefined); + const result = await maybeApplyApiKeyFromOption({ + token: " opt-key ", + tokenProvider, + expectedProviders: ["demo-provider"], + normalize: (value) => value.trim(), + setCredential, + }); + return { result, setCredential }; +} + +function expectMinimaxEnvRefCredentialStored(setCredential: ReturnType) { + expect(setCredential).toHaveBeenCalledWith( + { source: "env", provider: "default", id: "MINIMAX_API_KEY" }, + "ref", + ); +} + +async function ensureWithOptionEnvOrPrompt(params: { + token: string; + tokenProvider: string; + expectedProviders: string[]; + provider: string; + envLabel: string; + confirm: WizardPrompter["confirm"]; + note: WizardPrompter["note"]; + noteMessage: string; + noteTitle: string; + setCredential: Parameters[0]["setCredential"]; + text: WizardPrompter["text"]; +}) { + return await ensureApiKeyFromOptionEnvOrPrompt({ + token: params.token, + tokenProvider: params.tokenProvider, + config: {}, + expectedProviders: params.expectedProviders, + provider: params.provider, + envLabel: params.envLabel, + promptMessage: "Enter key", + normalize: (value) => value.trim(), + validate: () => undefined, + prompter: createPrompter({ confirm: params.confirm, note: params.note, text: params.text }), + setCredential: params.setCredential, + noteMessage: params.noteMessage, + noteTitle: params.noteTitle, + }); +} + +afterEach(() => { + restoreMinimaxEnv(); + vi.restoreAllMocks(); +}); + +describe("normalizeTokenProviderInput", () => { + it("trims and lowercases non-empty values", () => { + expect(normalizeTokenProviderInput(" DeMo-PrOvIdEr ")).toBe("demo-provider"); + expect(normalizeTokenProviderInput("")).toBeUndefined(); + }); +}); + +describe("maybeApplyApiKeyFromOption", () => { + it.each(["demo-provider", " DeMo-PrOvIdEr "])( + "stores normalized token when provider %p matches", + async (tokenProvider) => { + const { result, setCredential } = await runMaybeApplyDemoToken(tokenProvider); + + expect(result).toBe("opt-key"); + expect(setCredential).toHaveBeenCalledWith("opt-key", undefined); + }, + ); + + it("skips when provider does not match", async () => { + const setCredential = vi.fn(async () => undefined); + + const result = await maybeApplyApiKeyFromOption({ + token: "opt-key", + tokenProvider: "other-provider", + expectedProviders: ["demo-provider"], + normalize: (value) => value.trim(), + setCredential, + }); + + expect(result).toBeUndefined(); + expect(setCredential).not.toHaveBeenCalled(); + }); +}); + +describe("ensureApiKeyFromEnvOrPrompt", () => { + it("uses env credential when user confirms", async () => { + const { result, setCredential, text } = await runEnsureMinimaxApiKeyFlow({ + confirmResult: true, + textResult: "prompt-key", + }); + + expect(result).toBe("env-key"); + expect(setCredential).toHaveBeenCalledWith("env-key", "plaintext"); + expect(text).not.toHaveBeenCalled(); + }); + + it("falls back to prompt when env is declined", async () => { + const { result, setCredential, text } = await runEnsureMinimaxApiKeyFlow({ + confirmResult: false, + textResult: " prompted-key ", + }); + + expect(result).toBe("prompted-key"); + expect(setCredential).toHaveBeenCalledWith("prompted-key", "plaintext"); + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Enter key", + }), + ); + }); + + it("uses explicit inline env ref when secret-input-mode=ref selects existing env key", async () => { + setMinimaxEnv({ apiKey: "env-key" }); + + const { confirm, text, setCredential } = createPromptAndCredentialSpies({ + confirmResult: true, + textResult: "prompt-key", + }); + + const result = await ensureMinimaxApiKey({ + confirm, + text, + secretInputMode: "ref", // pragma: allowlist secret + setCredential, + }); + + expect(result).toBe("env-key"); + expectMinimaxEnvRefCredentialStored(setCredential); + expect(text).not.toHaveBeenCalled(); + }); + + it("fails ref mode without select when fallback env var is missing", async () => { + setMinimaxEnv(); + + const { confirm, text, setCredential } = createPromptAndCredentialSpies({ + confirmResult: true, + textResult: "prompt-key", + }); + + await expect( + ensureMinimaxApiKey({ + confirm, + text, + secretInputMode: "ref", // pragma: allowlist secret + setCredential, + }), + ).rejects.toThrow( + 'Environment variable "MINIMAX_API_KEY" is required for --secret-input-mode ref in non-interactive setup.', + ); + expect(setCredential).not.toHaveBeenCalled(); + }); + + it("uses explicit env for ref fallback instead of host process env", async () => { + setMinimaxEnv({ apiKey: "host-key" }); + const env = { MINIMAX_API_KEY: "explicit-key" } as NodeJS.ProcessEnv; + + const { confirm, text, setCredential } = createPromptAndCredentialSpies({ + confirmResult: true, + textResult: "prompt-key", + }); + + const result = await ensureMinimaxApiKey({ + confirm, + text, + env, + secretInputMode: "ref", // pragma: allowlist secret + setCredential, + }); + + expect(result).toBe("explicit-key"); + expectMinimaxEnvRefCredentialStored(setCredential); + }); + + it("re-prompts after provider ref validation failure and succeeds with env ref", async () => { + setMinimaxEnv({ apiKey: "env-key" }); + + const selectValues: Array<"provider" | "env" | "filemain"> = ["provider", "filemain", "env"]; + const select = vi.fn(async () => selectValues.shift() ?? "env") as WizardPrompter["select"]; + const text = vi + .fn() + .mockResolvedValueOnce("/providers/minimax/apiKey") + .mockResolvedValueOnce("MINIMAX_API_KEY"); + const note = vi.fn(async () => undefined); + const setCredential = vi.fn(async () => undefined); + + const result = await ensureMinimaxApiKeyWithEnvRefPrompter({ + config: { + secrets: { + providers: { + filemain: { + source: "file", + path: "/tmp/does-not-exist-secrets.json", + mode: "json", + }, + }, + }, + }, + select, + text, + note, + setCredential, + }); + + expect(result).toBe("env-key"); + expectMinimaxEnvRefCredentialStored(setCredential); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("Could not validate provider reference"), + "Reference check failed", + ); + }); + + it("never includes resolved env secret values in reference validation notes", async () => { + setMinimaxEnv({ apiKey: "sk-minimax-redacted-value" }); + + const select = vi.fn(async () => "env") as WizardPrompter["select"]; + const text = vi.fn().mockResolvedValue("MINIMAX_API_KEY"); + const note = vi.fn(async () => undefined); + const setCredential = vi.fn(async () => undefined); + + const result = await ensureMinimaxApiKeyWithEnvRefPrompter({ + config: {}, + select, + text, + note, + setCredential, + }); + + expect(result).toBe("sk-minimax-redacted-value"); + const noteMessages = note.mock.calls.map((call) => call.at(0) ?? "").join("\n"); + expect(noteMessages).toContain("Validated environment variable MINIMAX_API_KEY."); + expect(noteMessages).not.toContain("sk-minimax-redacted-value"); + }); +}); + +describe("ensureApiKeyFromOptionEnvOrPrompt", () => { + it("uses opts token and skips note/env/prompt", async () => { + const { confirm, note, text, setCredential } = createPromptAndCredentialSpies({ + confirmResult: true, + textResult: "prompt-key", + }); + + const result = await ensureWithOptionEnvOrPrompt({ + token: " opts-key ", + tokenProvider: " DEMO-PROVIDER ", + expectedProviders: ["demo-provider"], + provider: "demo-provider", + envLabel: "DEMO_TOKEN", + confirm, + note, + noteMessage: "Demo note", + noteTitle: "Demo", + setCredential, + text, + }); + + expect(result).toBe("opts-key"); + expect(setCredential).toHaveBeenCalledWith("opts-key", undefined); + expect(note).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect(text).not.toHaveBeenCalled(); + }); + + it("falls back to env flow and shows note when opts provider does not match", async () => { + setMinimaxEnv({ apiKey: "env-key" }); + + const { confirm, note, text, setCredential } = createPromptAndCredentialSpies({ + confirmResult: true, + textResult: "prompt-key", + }); + + const result = await ensureWithOptionEnvOrPrompt({ + token: "opts-key", + tokenProvider: "other-provider", + expectedProviders: ["minimax"], + provider: "minimax", + envLabel: "MINIMAX_API_KEY", + confirm, + note, + noteMessage: "Demo provider note", + noteTitle: "Demo provider", + setCredential, + text, + }); + + expect(result).toBe("env-key"); + expect(note).toHaveBeenCalledWith("Demo provider note", "Demo provider"); + expect(confirm).toHaveBeenCalled(); + expect(text).not.toHaveBeenCalled(); + expect(setCredential).toHaveBeenCalledWith("env-key", "plaintext"); + }); +}); diff --git a/src/terminal/links.test.ts b/src/terminal/links.test.ts index 7c070f7200d..ab78a2ed365 100644 --- a/src/terminal/links.test.ts +++ b/src/terminal/links.test.ts @@ -18,9 +18,7 @@ describe("formatDocsLink", () => { }); it("does not crash when path is undefined (regression: #67076, #67074)", () => { - expect(() => - formatDocsLink(undefined as unknown as string, "label"), - ).not.toThrow(); + expect(() => formatDocsLink(undefined as unknown as string, "label")).not.toThrow(); const out = formatDocsLink(undefined as unknown as string, "label"); expect(out).toContain("https://docs.openclaw.ai"); }); diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts index da34ab16815..8336027242b 100644 --- a/ui/src/ui/markdown.ts +++ b/ui/src/ui/markdown.ts @@ -274,7 +274,6 @@ md.linkify.add("www", { break; } return len; - }, normalize(match) { match.url = "http://" + match.url;