From 310b5e4f6a22415851ec2ad94e402ba46714df13 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 16:04:47 +0100 Subject: [PATCH] test: reduce core command hotspots --- src/agents/agent-command.ts | 150 ++- src/agents/auth-health.ts | 7 +- src/agents/auth-profiles/oauth.test.ts | 20 + src/agents/cli-runner/execute.ts | 5 +- src/agents/command/attempt-execution.test.ts | 13 + src/agents/command/session-store.test.ts | 312 ++++- src/agents/command/session-store.ts | 18 +- src/agents/model-selection-cli.ts | 7 +- .../pi-embedded-subscribe.tools.media.test.ts | 13 +- src/agents/pi-settings.test.ts | 5 +- src/agents/pi-settings.ts | 5 +- ...andbox-paths.windows-drive-resolve.test.ts | 2 +- .../skills.agents-skills-directory.test.ts | 4 +- src/commands/agent-command.test-mocks.ts | 177 +++ src/commands/agent.acp.test.ts | 294 ++--- src/commands/agent.session.test.ts | 7 +- src/commands/agent.test.ts | 828 ++++--------- src/commands/agent/session-store.test.ts | 194 --- src/commands/agents.bind.commands.test.ts | 156 +-- src/commands/agents.bind.test-support.ts | 28 +- src/commands/agents.commands.bind.ts | 48 +- src/commands/auth-choice-options.test.ts | 76 +- .../auth-choice.apply-helpers.test.ts | 440 ------- .../auth-choice.apply.plugin-provider.test.ts | 83 ++ src/commands/auth-choice.apply.ts | 67 +- src/commands/auth-choice.moonshot.test.ts | 177 --- src/commands/auth-choice.test.ts | 1087 ++-------------- src/commands/backup-verify.test.ts | 100 +- src/commands/backup.test.ts | 101 +- src/commands/backup.ts | 11 +- src/commands/channel-account-context.test.ts | 18 +- .../channel-plugin-resolution.ts | 18 + .../channel-setup/plugin-install.test.ts | 10 +- src/commands/channels.add.test.ts | 100 +- ....adds-non-default-telegram-account.test.ts | 206 +-- ...channels.config-only-status-output.test.ts | 88 +- src/commands/channels.mock-harness.ts | 12 +- src/commands/channels/add.ts | 21 +- src/commands/channels/remove.ts | 16 +- src/commands/channels/status-config-format.ts | 168 +++ src/commands/channels/status.ts | 82 +- src/commands/configure.wizard.test.ts | 48 +- src/commands/daemon-install-helpers.test.ts | 326 +---- src/commands/doctor-auth-legacy-oauth.ts | 51 + ...octor-auth.deprecated-cli-profiles.test.ts | 123 +- src/commands/doctor-auth.ts | 40 +- src/commands/doctor-config-flow.test.ts | 416 ++++++- .../doctor-legacy-config.migrations.test.ts | 35 + src/commands/doctor-state-integrity.test.ts | 5 +- src/commands/doctor-state-integrity.ts | 8 +- src/commands/doctor-state-migrations.test.ts | 169 ++- .../legacy-config-write-ownership.test.ts | 53 +- .../doctor/shared/preview-warnings.test.ts | 159 ++- .../doctor/shared/preview-warnings.ts | 280 +++-- src/commands/gateway-status.test.ts | 57 +- src/commands/health-format.ts | 176 +++ src/commands/health.command.coverage.test.ts | 33 +- src/commands/health.snapshot.test.ts | 38 +- src/commands/health.ts | 177 +-- src/commands/models.list.auth-sync.test.ts | 138 --- src/commands/models/alias-name.ts | 10 + src/commands/models/auth.test.ts | 331 +++-- src/commands/models/auth.ts | 20 +- .../models/list.auth-overview.test.ts | 60 +- src/commands/models/list.auth-overview.ts | 12 +- src/commands/models/list.local-url.ts | 17 + src/commands/models/list.model-row.test.ts | 40 + src/commands/models/list.model-row.ts | 97 ++ .../models/list.probe.targets.test.ts | 74 +- src/commands/models/list.probe.test.ts | 19 +- src/commands/models/list.registry.ts | 76 +- src/commands/models/list.status-command.ts | 47 +- src/commands/models/list.status.test.ts | 380 +++--- src/commands/models/shared.ts | 30 +- src/commands/onboard-auth.credentials.test.ts | 288 ----- src/commands/onboard-auth.test.ts | 206 +++ src/commands/onboard-custom-config.test.ts | 421 +++++++ src/commands/onboard-custom-config.ts | 608 +++++++++ src/commands/onboard-custom.test.ts | 546 +------- src/commands/onboard-custom.ts | 637 +--------- .../onboard-non-interactive.gateway.test.ts | 197 +-- ...oard-non-interactive.provider-auth.test.ts | 1102 ----------------- src/commands/onboard-non-interactive/local.ts | 23 +- .../local/auth-choice.ts | 2 +- src/commands/onboard-search.test.ts | 258 ++-- src/commands/status.command.text-runtime.ts | 2 +- src/commands/status.command.ts | 2 +- src/commands/status.scan.fast-json.test.ts | 14 +- src/commands/status.scan.test.ts | 10 +- src/commands/status.test.ts | 150 +-- src/commands/tasks.test.ts | 34 +- src/commands/tasks.ts | 11 +- 92 files changed, 5321 insertions(+), 7909 deletions(-) delete mode 100644 src/commands/agent/session-store.test.ts delete mode 100644 src/commands/auth-choice.apply-helpers.test.ts delete mode 100644 src/commands/auth-choice.moonshot.test.ts create mode 100644 src/commands/channels/status-config-format.ts create mode 100644 src/commands/doctor-auth-legacy-oauth.ts delete mode 100644 src/commands/models.list.auth-sync.test.ts create mode 100644 src/commands/models/alias-name.ts create mode 100644 src/commands/models/list.local-url.ts create mode 100644 src/commands/models/list.model-row.test.ts create mode 100644 src/commands/models/list.model-row.ts delete mode 100644 src/commands/onboard-auth.credentials.test.ts create mode 100644 src/commands/onboard-custom-config.test.ts create mode 100644 src/commands/onboard-custom-config.ts delete mode 100644 src/commands/onboard-non-interactive.provider-auth.test.ts diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 0fee1f5efac..b405dd7b6f8 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -1,7 +1,3 @@ -import { getAcpSessionManager } from "../acp/control-plane/manager.js"; -import { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } from "../acp/policy.js"; -import { toAcpRuntimeError } from "../acp/runtime/errors.js"; -import { resolveAcpSessionCwd } from "../acp/runtime/session-identifiers.js"; import { formatThinkingLevels, formatXHighModelHint, @@ -11,7 +7,7 @@ import { type VerboseLevel, } from "../auto-reply/thinking.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { type CliDeps, createDefaultDeps } from "../cli/deps.js"; +import type { CliDeps } from "../cli/deps.types.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { clearAgentRunContext, @@ -20,7 +16,6 @@ import { } from "../infra/agent-events.js"; import { formatErrorMessage } from "../infra/errors.js"; import { buildOutboundSessionContext } from "../infra/outbound/session-context.js"; -import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; @@ -40,8 +35,8 @@ import { resolveAgentSkillsFilter, resolveAgentWorkspaceDir, } from "./agent-scope.js"; -import { ensureAuthProfileStore } from "./auth-profiles.js"; import { clearSessionAuthProfileOverride } from "./auth-profiles/session-override.js"; +import { ensureAuthProfileStore } from "./auth-profiles/store.js"; import { persistSessionEntry as persistSessionEntryBase, prependInternalEventContext, @@ -50,7 +45,6 @@ import { resolveAgentRunContext } from "./command/run-context.js"; import { resolveSession } from "./command/session.js"; import type { AgentCommandIngressOpts, AgentCommandOpts } from "./command/types.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; -import { canExecRequestNode } from "./exec-defaults.js"; import { AGENT_LANE_SUBAGENT } from "./lanes.js"; import { LiveSessionModelSwitchError } from "./live-model-switch.js"; import { loadModelCatalog } from "./model-catalog.js"; @@ -64,29 +58,66 @@ import { resolveDefaultModelForAgent, resolveThinkingDefault, } from "./model-selection.js"; -import { buildWorkspaceSkillSnapshot } from "./skills.js"; -import { matchesSkillFilter } from "./skills/filter.js"; -import { getSkillsSnapshotVersion, shouldRefreshSnapshotForVersion } from "./skills/refresh.js"; import { normalizeSpawnedRunMetadata } from "./spawned-context.js"; import { resolveAgentTimeoutMs } from "./timeout.js"; import { ensureAgentWorkspace } from "./workspace.js"; const log = createSubsystemLogger("agents/agent-command"); type AttemptExecutionRuntime = typeof import("./command/attempt-execution.runtime.js"); +type AcpManagerRuntime = typeof import("../acp/control-plane/manager.js"); +type AcpPolicyRuntime = typeof import("../acp/policy.js"); +type AcpRuntimeErrorsRuntime = typeof import("../acp/runtime/errors.js"); +type AcpSessionIdentifiersRuntime = typeof import("../acp/runtime/session-identifiers.js"); type DeliveryRuntime = typeof import("./command/delivery.runtime.js"); type SessionStoreRuntime = typeof import("./command/session-store.runtime.js"); type TranscriptResolveRuntime = typeof import("../config/sessions/transcript-resolve.runtime.js"); +type CliDepsRuntime = typeof import("../cli/deps.js"); +type ExecDefaultsRuntime = typeof import("./exec-defaults.js"); +type SkillsRuntime = typeof import("./skills.js"); +type SkillsFilterRuntime = typeof import("./skills/filter.js"); +type SkillsRefreshStateRuntime = typeof import("./skills/refresh-state.js"); +type SkillsRemoteRuntime = typeof import("../infra/skills-remote.js"); let attemptExecutionRuntimePromise: Promise | undefined; +let acpManagerRuntimePromise: Promise | undefined; +let acpPolicyRuntimePromise: Promise | undefined; +let acpRuntimeErrorsRuntimePromise: Promise | undefined; +let acpSessionIdentifiersRuntimePromise: Promise | undefined; let deliveryRuntimePromise: Promise | undefined; let sessionStoreRuntimePromise: Promise | undefined; let transcriptResolveRuntimePromise: Promise | undefined; +let cliDepsRuntimePromise: Promise | undefined; +let execDefaultsRuntimePromise: Promise | undefined; +let skillsRuntimePromise: Promise | undefined; +let skillsFilterRuntimePromise: Promise | undefined; +let skillsRefreshStateRuntimePromise: Promise | undefined; +let skillsRemoteRuntimePromise: Promise | undefined; function loadAttemptExecutionRuntime(): Promise { attemptExecutionRuntimePromise ??= import("./command/attempt-execution.runtime.js"); return attemptExecutionRuntimePromise; } +function loadAcpManagerRuntime(): Promise { + acpManagerRuntimePromise ??= import("../acp/control-plane/manager.js"); + return acpManagerRuntimePromise; +} + +function loadAcpPolicyRuntime(): Promise { + acpPolicyRuntimePromise ??= import("../acp/policy.js"); + return acpPolicyRuntimePromise; +} + +function loadAcpRuntimeErrorsRuntime(): Promise { + acpRuntimeErrorsRuntimePromise ??= import("../acp/runtime/errors.js"); + return acpRuntimeErrorsRuntimePromise; +} + +function loadAcpSessionIdentifiersRuntime(): Promise { + acpSessionIdentifiersRuntimePromise ??= import("../acp/runtime/session-identifiers.js"); + return acpSessionIdentifiersRuntimePromise; +} + function loadDeliveryRuntime(): Promise { deliveryRuntimePromise ??= import("./command/delivery.runtime.js"); return deliveryRuntimePromise; @@ -102,6 +133,44 @@ function loadTranscriptResolveRuntime(): Promise { return transcriptResolveRuntimePromise; } +function loadCliDepsRuntime(): Promise { + cliDepsRuntimePromise ??= import("../cli/deps.js"); + return cliDepsRuntimePromise; +} + +function loadExecDefaultsRuntime(): Promise { + execDefaultsRuntimePromise ??= import("./exec-defaults.js"); + return execDefaultsRuntimePromise; +} + +function loadSkillsRuntime(): Promise { + skillsRuntimePromise ??= import("./skills.js"); + return skillsRuntimePromise; +} + +function loadSkillsFilterRuntime(): Promise { + skillsFilterRuntimePromise ??= import("./skills/filter.js"); + return skillsFilterRuntimePromise; +} + +function loadSkillsRefreshStateRuntime(): Promise { + skillsRefreshStateRuntimePromise ??= import("./skills/refresh-state.js"); + return skillsRefreshStateRuntimePromise; +} + +function loadSkillsRemoteRuntime(): Promise { + skillsRemoteRuntimePromise ??= import("../infra/skills-remote.js"); + return skillsRemoteRuntimePromise; +} + +async function resolveAgentCommandDeps(deps: CliDeps | undefined): Promise { + if (deps) { + return deps; + } + const { createDefaultDeps } = await loadCliDepsRuntime(); + return createDefaultDeps(); +} + type PersistSessionEntryParams = { sessionStore: Record; sessionKey: string; @@ -287,6 +356,7 @@ async function prepareAgentCommandExecution( }); const workspaceDir = workspace.dir; const runId = opts.runId?.trim() || sessionId; + const { getAcpSessionManager } = await loadAcpManagerRuntime(); const acpManager = getAcpSessionManager(); const acpResolution = sessionKey ? acpManager.resolveSession({ @@ -325,8 +395,9 @@ async function prepareAgentCommandExecution( async function agentCommandInternal( opts: AgentCommandOpts & { senderIsOwner: boolean }, runtime: RuntimeEnv = defaultRuntime, - deps: CliDeps = createDefaultDeps(), + deps?: CliDeps, ) { + const resolvedDeps = await resolveAgentCommandDeps(deps); const prepared = await prepareAgentCommandExecution(opts, runtime); const { body, @@ -383,6 +454,8 @@ async function agentCommandInternal( const visibleTextAccumulator = attemptExecutionRuntime.createAcpVisibleTextAccumulator(); let stopReason: string | undefined; try { + const { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } = + await loadAcpPolicyRuntime(); const dispatchPolicyError = resolveAcpDispatchPolicyError(cfg); if (dispatchPolicyError) { throw dispatchPolicyError; @@ -428,6 +501,7 @@ async function agentCommandInternal( }, }); } catch (error) { + const { toAcpRuntimeError } = await loadAcpRuntimeErrorsRuntime(); const acpError = toAcpRuntimeError({ error, fallbackCode: "ACP_TURN_FAILED", @@ -445,6 +519,7 @@ async function agentCommandInternal( const finalTextRaw = visibleTextAccumulator.finalizeRaw(); const finalText = visibleTextAccumulator.finalize(); try { + const { resolveAcpSessionCwd } = await loadAcpSessionIdentifiersRuntime(); sessionEntry = await attemptExecutionRuntime.persistAcpTurnTranscript({ body, finalText: finalTextRaw, @@ -474,7 +549,7 @@ async function agentCommandInternal( return await deliverAgentCommandResult({ cfg, - deps, + deps: resolvedDeps, runtime, opts, outboundSession, @@ -495,6 +570,8 @@ async function agentCommandInternal( }); } + const [{ getSkillsSnapshotVersion, shouldRefreshSnapshotForVersion }, { matchesSkillFilter }] = + await Promise.all([loadSkillsRefreshStateRuntime(), loadSkillsFilterRuntime()]); const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir); const skillFilter = resolveAgentSkillsFilter(cfg, sessionAgentId); const currentSkillsSnapshot = sessionEntry?.skillsSnapshot; @@ -504,22 +581,33 @@ async function agentCommandInternal( !matchesSkillFilter(currentSkillsSnapshot.skillFilter, skillFilter); const needsSkillsSnapshot = isNewSession || shouldRefreshSkillsSnapshot; const skillsSnapshot = needsSkillsSnapshot - ? buildWorkspaceSkillSnapshot(workspaceDir, { - config: cfg, - eligibility: { - remote: getRemoteSkillEligibility({ - advertiseExecNode: canExecRequestNode({ - cfg, - sessionEntry, - sessionKey, - agentId: sessionAgentId, + ? await (async () => { + const [ + { buildWorkspaceSkillSnapshot }, + { getRemoteSkillEligibility }, + { canExecRequestNode }, + ] = await Promise.all([ + loadSkillsRuntime(), + loadSkillsRemoteRuntime(), + loadExecDefaultsRuntime(), + ]); + return buildWorkspaceSkillSnapshot(workspaceDir, { + config: cfg, + eligibility: { + remote: getRemoteSkillEligibility({ + advertiseExecNode: canExecRequestNode({ + cfg, + sessionEntry, + sessionKey, + agentId: sessionAgentId, + }), }), - }), - }, - snapshotVersion: skillsSnapshotVersion, - skillFilter, - agentId: sessionAgentId, - }) + }, + snapshotVersion: skillsSnapshotVersion, + skillFilter, + agentId: sessionAgentId, + }); + })() : currentSkillsSnapshot; if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) { @@ -971,7 +1059,7 @@ async function agentCommandInternal( const { deliverAgentCommandResult } = await loadDeliveryRuntime(); return await deliverAgentCommandResult({ cfg, - deps, + deps: resolvedDeps, runtime, opts, outboundSession, @@ -987,7 +1075,7 @@ async function agentCommandInternal( export async function agentCommand( opts: AgentCommandOpts, runtime: RuntimeEnv = defaultRuntime, - deps: CliDeps = createDefaultDeps(), + deps?: CliDeps, ) { return await agentCommandInternal( { @@ -1007,7 +1095,7 @@ export async function agentCommand( export async function agentCommandFromIngress( opts: AgentCommandIngressOpts, runtime: RuntimeEnv = defaultRuntime, - deps: CliDeps = createDefaultDeps(), + deps?: CliDeps, ) { if (typeof opts.senderIsOwner !== "boolean") { // HTTP/WS ingress must declare the trust level explicitly at the boundary. diff --git a/src/agents/auth-health.ts b/src/agents/auth-health.ts index 7753982650f..d685576c4e9 100644 --- a/src/agents/auth-health.ts +++ b/src/agents/auth-health.ts @@ -1,14 +1,11 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { type AuthCredentialReasonCode, - type AuthProfileCredential, - type AuthProfileStore, - resolveAuthProfileDisplayLabel, -} from "./auth-profiles.js"; -import { evaluateStoredCredentialEligibility, resolveTokenExpiryState, } from "./auth-profiles/credential-state.js"; +import { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js"; +import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js"; import { normalizeProviderId } from "./provider-id.js"; export type AuthProfileSource = "store"; diff --git a/src/agents/auth-profiles/oauth.test.ts b/src/agents/auth-profiles/oauth.test.ts index d786d50814f..ad6f5b718b2 100644 --- a/src/agents/auth-profiles/oauth.test.ts +++ b/src/agents/auth-profiles/oauth.test.ts @@ -299,6 +299,26 @@ describe("resolveApiKeyForProfile token expiry handling", () => { }); describe("resolveApiKeyForProfile secret refs", () => { + it("ignores blank api_key credentials", async () => { + const profileId = "openrouter:default"; + const result = await resolveApiKeyForProfile({ + cfg: cfgFor(profileId, "openrouter", "api_key"), + store: { + version: 1, + profiles: { + [profileId]: { + type: "api_key", + provider: "openrouter", + key: " ", + }, + }, + }, + profileId, + }); + + expect(result).toBeNull(); + }); + it("resolves api_key keyRef from env", async () => { const profileId = "openai:default"; const previous = process.env.OPENAI_API_KEY; diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 78271668eb5..a94564a880f 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -467,7 +467,10 @@ export async function executePreparedCliRun( ...parsed, rawText, finalPromptText: prompt, - text: applyPluginTextReplacements(rawText, context.backendResolved.textTransforms?.output), + text: applyPluginTextReplacements( + rawText, + context.backendResolved.textTransforms?.output, + ), }; } finally { restoreSkillEnv?.(); diff --git a/src/agents/command/attempt-execution.test.ts b/src/agents/command/attempt-execution.test.ts index 6ab929778bd..f869cf1c7d4 100644 --- a/src/agents/command/attempt-execution.test.ts +++ b/src/agents/command/attempt-execution.test.ts @@ -204,4 +204,17 @@ describe("createAcpVisibleTextAccumulator", () => { expect(acc.finalize()).toBe("NO_REPLY: explanation"); }); + + it("buffers chunked NO_REPLY prefixes before emitting visible text", () => { + const acc = createAcpVisibleTextAccumulator(); + + expect(acc.consume("NO")).toBeNull(); + expect(acc.consume("NO_")).toBeNull(); + expect(acc.consume("NO_RE")).toBeNull(); + expect(acc.consume("NO_REPLY")).toBeNull(); + expect(acc.consume("Actual answer")).toEqual({ + text: "Actual answer", + delta: "Actual answer", + }); + }); }); diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 5ed033ca790..91be2e4e7e5 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -1,83 +1,273 @@ +import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { loadSessionStore, type SessionEntry } from "../../config/sessions.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import { loadSessionStore } from "../../config/sessions.js"; import type { EmbeddedPiRunResult } from "../pi-embedded.js"; import { updateSessionStoreAfterAgentRun } from "./session-store.js"; +import { resolveSession } from "./session.js"; + +function acpMeta() { + return { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + mode: "persistent" as const, + state: "idle" as const, + lastActivityAt: Date.now(), + }; +} + +async function withTempSessionStore( + run: (params: { dir: string; storePath: string }) => Promise, +): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-")); + try { + return await run({ dir, storePath: path.join(dir, "sessions.json") }); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} describe("updateSessionStoreAfterAgentRun", () => { - let tmpDir: string; - let storePath: string; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-")); - storePath = path.join(tmpDir, "sessions.json"); - }); - - afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - it("persists claude-cli session bindings when the backend is configured", async () => { - const cfg = { - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", + await withTempSessionStore(async ({ storePath }) => { + const cfg = { + agents: { + defaults: { + cliBackends: { + "claude-cli": { + command: "claude", + }, }, }, }, - }, - } as OpenClawConfig; - const sessionKey = "agent:main:explicit:test-claude-cli"; - const sessionId = "test-openclaw-session"; - const sessionStore: Record = { - [sessionKey]: { - sessionId, - updatedAt: 1, - }, - }; - await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + } as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-claude-cli"; + const sessionId = "test-openclaw-session"; + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: 1, + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); - const result: EmbeddedPiRunResult = { - meta: { - durationMs: 1, - agentMeta: { - sessionId: "cli-session-123", - provider: "claude-cli", - model: "claude-sonnet-4-6", - cliSessionBinding: { + const result: EmbeddedPiRunResult = { + meta: { + durationMs: 1, + agentMeta: { sessionId: "cli-session-123", + provider: "claude-cli", + model: "claude-sonnet-4-6", + cliSessionBinding: { + sessionId: "cli-session-123", + }, }, }, - }, - }; + }; - await updateSessionStoreAfterAgentRun({ - cfg, - sessionId, - sessionKey, - storePath, - sessionStore, - defaultProvider: "claude-cli", - defaultModel: "claude-sonnet-4-6", - result, - }); + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "claude-cli", + defaultModel: "claude-sonnet-4-6", + result, + }); - expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({ - sessionId: "cli-session-123", - }); - expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("cli-session-123"); - expect(sessionStore[sessionKey]?.claudeCliSessionId).toBe("cli-session-123"); + expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({ + sessionId: "cli-session-123", + }); + expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("cli-session-123"); + expect(sessionStore[sessionKey]?.claudeCliSessionId).toBe("cli-session-123"); - const persisted = loadSessionStore(storePath); - expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({ - sessionId: "cli-session-123", + const persisted = loadSessionStore(storePath); + expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({ + sessionId: "cli-session-123", + }); + expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("cli-session-123"); + expect(persisted[sessionKey]?.claudeCliSessionId).toBe("cli-session-123"); + }); + }); + + it("preserves ACP metadata when caller has a stale session snapshot", async () => { + await withTempSessionStore(async ({ storePath }) => { + const sessionKey = `agent:codex:acp:${randomUUID()}`; + const sessionId = randomUUID(); + + const existing: SessionEntry = { + sessionId, + updatedAt: Date.now(), + acp: acpMeta(), + }; + await fs.writeFile(storePath, JSON.stringify({ [sessionKey]: existing }, null, 2), "utf8"); + + const staleInMemory: Record = { + [sessionKey]: { + sessionId, + updatedAt: Date.now(), + }, + }; + + await updateSessionStoreAfterAgentRun({ + cfg: {} as never, + sessionId, + sessionKey, + storePath, + sessionStore: staleInMemory, + contextTokensOverride: 200_000, + defaultProvider: "openai", + defaultModel: "gpt-5.4", + result: { + payloads: [], + meta: { + aborted: false, + agentMeta: { + provider: "openai", + model: "gpt-5.4", + }, + }, + } as never, + }); + + const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey]; + expect(persisted?.acp).toBeDefined(); + expect(staleInMemory[sessionKey]?.acp).toBeDefined(); + }); + }); + + it("persists latest systemPromptReport for downstream warning dedupe", async () => { + await withTempSessionStore(async ({ storePath }) => { + const sessionKey = `agent:codex:report:${randomUUID()}`; + const sessionId = randomUUID(); + + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: Date.now(), + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf8"); + + const report = { + source: "run" as const, + generatedAt: Date.now(), + bootstrapTruncation: { + warningMode: "once" as const, + warningSignaturesSeen: ["sig-a", "sig-b"], + }, + systemPrompt: { + chars: 1, + projectContextChars: 1, + nonProjectContextChars: 0, + }, + injectedWorkspaceFiles: [], + skills: { promptChars: 0, entries: [] }, + tools: { listChars: 0, schemaChars: 0, entries: [] }, + }; + + await updateSessionStoreAfterAgentRun({ + cfg: {} as never, + sessionId, + sessionKey, + storePath, + sessionStore, + contextTokensOverride: 200_000, + defaultProvider: "openai", + defaultModel: "gpt-5.4", + result: { + payloads: [], + meta: { + agentMeta: { + provider: "openai", + model: "gpt-5.4", + }, + systemPromptReport: report, + }, + } as never, + }); + + const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey]; + expect(persisted?.systemPromptReport?.bootstrapTruncation?.warningSignaturesSeen).toEqual([ + "sig-a", + "sig-b", + ]); + expect(sessionStore[sessionKey]?.systemPromptReport?.bootstrapTruncation?.warningMode).toBe( + "once", + ); + }); + }); + + it("stores and reloads the runtime model for explicit session-id-only runs", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = { + session: { + store: storePath, + mainKey: "main", + }, + agents: { + defaults: { + cliBackends: { + "claude-cli": {}, + }, + }, + }, + } as never; + + const first = resolveSession({ + cfg, + sessionId: "explicit-session-123", + }); + + expect(first.sessionKey).toBe("agent:main:explicit:explicit-session-123"); + + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId: first.sessionId, + sessionKey: first.sessionKey!, + storePath: first.storePath, + sessionStore: first.sessionStore!, + contextTokensOverride: 200_000, + defaultProvider: "claude-cli", + defaultModel: "claude-sonnet-4-6", + result: { + payloads: [], + meta: { + agentMeta: { + provider: "claude-cli", + model: "claude-sonnet-4-6", + sessionId: "claude-cli-session-1", + cliSessionBinding: { + sessionId: "claude-cli-session-1", + authEpoch: "auth-epoch-1", + }, + }, + }, + } as never, + }); + + const second = resolveSession({ + cfg, + sessionId: "explicit-session-123", + }); + + expect(second.sessionKey).toBe(first.sessionKey); + expect(second.sessionEntry?.cliSessionBindings?.["claude-cli"]).toEqual({ + sessionId: "claude-cli-session-1", + authEpoch: "auth-epoch-1", + }); + + const persisted = loadSessionStore(storePath, { skipCache: true })[first.sessionKey!]; + expect(persisted?.cliSessionBindings?.["claude-cli"]).toEqual({ + sessionId: "claude-cli-session-1", + authEpoch: "auth-epoch-1", + }); }); - expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("cli-session-123"); - expect(persisted[sessionKey]?.claudeCliSessionId).toBe("cli-session-123"); }); }); diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 36567a7f2ef..cda04c72774 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -60,16 +60,16 @@ export async function updateSessionStoreAfterAgentRun(params: { const compactionsThisRun = Math.max(0, result.meta.agentMeta?.compactionCount ?? 0); const modelUsed = result.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; const providerUsed = result.meta.agentMeta?.provider ?? fallbackProvider ?? defaultProvider; - const { resolveContextTokensForModel } = await getContextModule(); const contextTokens = - resolveContextTokensForModel({ - cfg, - provider: providerUsed, - model: modelUsed, - contextTokensOverride: params.contextTokensOverride, - fallbackContextTokens: DEFAULT_CONTEXT_TOKENS, - allowAsyncLoad: false, - }) ?? DEFAULT_CONTEXT_TOKENS; + typeof params.contextTokensOverride === "number" && params.contextTokensOverride > 0 + ? params.contextTokensOverride + : ((await getContextModule()).resolveContextTokensForModel({ + cfg, + provider: providerUsed, + model: modelUsed, + fallbackContextTokens: DEFAULT_CONTEXT_TOKENS, + allowAsyncLoad: false, + }) ?? DEFAULT_CONTEXT_TOKENS); const entry = sessionStore[sessionKey] ?? { sessionId, diff --git a/src/agents/model-selection-cli.ts b/src/agents/model-selection-cli.ts index 342b0bc8480..f8a052db8ba 100644 --- a/src/agents/model-selection-cli.ts +++ b/src/agents/model-selection-cli.ts @@ -5,6 +5,10 @@ import { normalizeProviderId } from "./model-selection-normalize.js"; export function isCliProvider(provider: string, cfg?: OpenClawConfig): boolean { const normalized = normalizeProviderId(provider); + const backends = cfg?.agents?.defaults?.cliBackends ?? {}; + if (Object.keys(backends).some((key) => normalizeProviderId(key) === normalized)) { + return true; + } const cliBackends = resolveRuntimeCliBackends(); if (cliBackends.some((backend) => normalizeProviderId(backend.id) === normalized)) { return true; @@ -12,6 +16,5 @@ export function isCliProvider(provider: string, cfg?: OpenClawConfig): boolean { if (resolvePluginSetupCliBackendRuntime({ backend: normalized })) { return true; } - const backends = cfg?.agents?.defaults?.cliBackends ?? {}; - return Object.keys(backends).some((key) => normalizeProviderId(key) === normalized); + return false; } diff --git a/src/agents/pi-embedded-subscribe.tools.media.test.ts b/src/agents/pi-embedded-subscribe.tools.media.test.ts index 4a1f8a6370f..443d07c2a34 100644 --- a/src/agents/pi-embedded-subscribe.tools.media.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.media.test.ts @@ -278,16 +278,11 @@ describe("extractToolResultMediaPaths", () => { }); it("blocks trusted-media aliases that are not exact registered built-ins", () => { - expect(filterToolResultMediaUrls("bash", ["/etc/passwd"], undefined, new Set(["exec"]))).toEqual( - [], - ); expect( - filterToolResultMediaUrls( - "Web_Search", - ["/etc/passwd"], - undefined, - new Set(["web_search"]), - ), + filterToolResultMediaUrls("bash", ["/etc/passwd"], undefined, new Set(["exec"])), + ).toEqual([]); + expect( + filterToolResultMediaUrls("Web_Search", ["/etc/passwd"], undefined, new Set(["web_search"])), ).toEqual([]); }); diff --git a/src/agents/pi-settings.test.ts b/src/agents/pi-settings.test.ts index ef31bdf0373..879dafe7399 100644 --- a/src/agents/pi-settings.test.ts +++ b/src/agents/pi-settings.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { - MIN_PROMPT_BUDGET_RATIO, - MIN_PROMPT_BUDGET_TOKENS, -} from "./pi-compaction-constants.js"; +import { MIN_PROMPT_BUDGET_RATIO, MIN_PROMPT_BUDGET_TOKENS } from "./pi-compaction-constants.js"; import { applyPiCompactionSettingsFromConfig, DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR, diff --git a/src/agents/pi-settings.ts b/src/agents/pi-settings.ts index 390ae0f6fd4..bd1ced83b3a 100644 --- a/src/agents/pi-settings.ts +++ b/src/agents/pi-settings.ts @@ -1,9 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ContextEngineInfo } from "../context-engine/types.js"; -import { - MIN_PROMPT_BUDGET_RATIO, - MIN_PROMPT_BUDGET_TOKENS, -} from "./pi-compaction-constants.js"; +import { MIN_PROMPT_BUDGET_RATIO, MIN_PROMPT_BUDGET_TOKENS } from "./pi-compaction-constants.js"; export const DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR = 20_000; diff --git a/src/agents/sandbox-paths.windows-drive-resolve.test.ts b/src/agents/sandbox-paths.windows-drive-resolve.test.ts index 1e93e257aef..2b5caac1802 100644 --- a/src/agents/sandbox-paths.windows-drive-resolve.test.ts +++ b/src/agents/sandbox-paths.windows-drive-resolve.test.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveSandboxInputPath } from "./sandbox-paths.js"; import { resolveToolPathAgainstWorkspaceRoot } from "./pi-tools.read.js"; +import { resolveSandboxInputPath } from "./sandbox-paths.js"; describe("resolveSandboxInputPath (Windows drive paths under POSIX rules)", () => { it("does not join workspace cwd when path looks like a Windows drive path", () => { diff --git a/src/agents/skills.agents-skills-directory.test.ts b/src/agents/skills.agents-skills-directory.test.ts index 98be711ba21..94519bcd363 100644 --- a/src/agents/skills.agents-skills-directory.test.ts +++ b/src/agents/skills.agents-skills-directory.test.ts @@ -48,7 +48,9 @@ describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => { await Promise.all( tempDirs .splice(0, tempDirs.length) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), + .map((dir) => + fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }), + ), ); }); }); diff --git a/src/commands/agent-command.test-mocks.ts b/src/commands/agent-command.test-mocks.ts index d2103134b68..045b5650db4 100644 --- a/src/commands/agent-command.test-mocks.ts +++ b/src/commands/agent-command.test-mocks.ts @@ -18,6 +18,157 @@ vi.mock("../logging/subsystem.js", () => { }; }); +vi.mock("../cli/deps.js", () => ({ + createDefaultDeps: vi.fn(() => ({})), +})); + +vi.mock("../acp/control-plane/manager.js", () => ({ + __testing: { + resetAcpSessionManagerForTests: vi.fn(), + }, + getAcpSessionManager: vi.fn(() => ({ + resolveSession: vi.fn(() => null), + })), +})); + +vi.mock("../agents/pi-embedded.js", () => ({ + abortEmbeddedPiRun: vi.fn().mockReturnValue(false), + runEmbeddedPiAgent: vi.fn(), + resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, +})); + +vi.mock("../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn(), +})); + +vi.mock("../agents/model-selection.js", () => { + type ConfigWithModels = { + agents?: { + defaults?: { + model?: string | { primary?: string; fallbacks?: string[] }; + models?: Record; + thinkingDefault?: string; + }; + }; + }; + type ModelRef = { provider: string; model: string }; + type CatalogEntry = { id?: string; model?: string; name?: string; reasoning?: boolean }; + + const parseModelRefImpl = (raw: string, defaultProvider = "openai"): ModelRef | null => { + const value = raw.trim(); + if (!value) { + return null; + } + const slash = value.indexOf("/"); + if (slash >= 0) { + return { + provider: value.slice(0, slash).trim(), + model: value.slice(slash + 1).trim(), + }; + } + return { provider: defaultProvider, model: value }; + }; + const parseModelRef = vi.fn(parseModelRefImpl); + const normalizeModelRef = (provider: string, model: string): ModelRef => ({ + provider: provider.trim().toLowerCase(), + model: model.trim(), + }); + const modelKey = (provider: string, model: string) => + `${provider.trim().toLowerCase()}/${model.trim().toLowerCase()}`; + const resolvePrimary = (cfg?: ConfigWithModels): string | undefined => { + const primary = cfg?.agents?.defaults?.model; + if (typeof primary === "string") { + return primary; + } + return primary?.primary; + }; + const resolveDefaultRef = (cfg?: ConfigWithModels): ModelRef => { + const parsed = parseModelRefImpl(resolvePrimary(cfg) ?? "openai/gpt-5.4", "openai"); + return parsed ?? { provider: "openai", model: "gpt-5.4" }; + }; + const resolveModelConfig = (cfg: ConfigWithModels | undefined, ref: ModelRef) => { + const models = cfg?.agents?.defaults?.models ?? {}; + return models[`${ref.provider}/${ref.model}`] ?? models[modelKey(ref.provider, ref.model)]; + }; + + return { + buildAllowedModelSet: vi.fn(({ cfg }: { cfg?: ConfigWithModels; catalog?: CatalogEntry[] }) => { + const refs = new Set(); + const modelConfig = cfg?.agents?.defaults?.models ?? {}; + for (const raw of Object.keys(modelConfig)) { + const parsed = parseModelRefImpl(raw, "openai"); + if (parsed) { + refs.add(modelKey(parsed.provider, parsed.model)); + } + } + const primary = resolveDefaultRef(cfg); + refs.add(modelKey(primary.provider, primary.model)); + const fallbackRefs = + typeof cfg?.agents?.defaults?.model === "object" + ? (cfg.agents.defaults.model.fallbacks ?? []) + : []; + for (const fallback of fallbackRefs) { + const parsed = parseModelRefImpl(fallback, primary.provider); + if (parsed) { + refs.add(modelKey(parsed.provider, parsed.model)); + } + } + return { + allowedKeys: refs, + allowedCatalog: [], + allowAny: Object.keys(modelConfig).length === 0, + }; + }), + isCliProvider: vi.fn(() => false), + modelKey, + normalizeModelRef, + parseModelRef, + resolveConfiguredModelRef: vi.fn( + ({ cfg }: { cfg?: ConfigWithModels; defaultProvider?: string; defaultModel?: string }) => + resolveDefaultRef(cfg), + ), + resolveDefaultModelForAgent: vi.fn(({ cfg }: { cfg?: ConfigWithModels }) => + resolveDefaultRef(cfg), + ), + resolveThinkingDefault: vi.fn( + ({ + cfg, + provider, + model, + catalog, + }: { + cfg?: ConfigWithModels; + provider: string; + model: string; + catalog?: CatalogEntry[]; + }) => { + const ref = normalizeModelRef(provider, model); + const modelThinking = resolveModelConfig(cfg, ref)?.params?.thinking; + if (modelThinking) { + return modelThinking; + } + const defaultThinking = cfg?.agents?.defaults?.thinkingDefault; + if (defaultThinking) { + return defaultThinking; + } + const entry = catalog?.find((item) => item.id === model || item.model === model); + if (entry?.reasoning && entry.name?.includes("4.6")) { + return "adaptive"; + } + return entry?.reasoning ? "low" : "off"; + }, + ), + }; +}); + +vi.mock("../agents/subagent-announce.js", () => ({ + runSubagentAnnounceFlow: vi.fn(), +})); + +vi.mock("../gateway/call.js", () => ({ + callGateway: vi.fn(), +})); + vi.mock("../agents/workspace.js", () => ({ DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/openclaw-workspace", DEFAULT_AGENTS_FILENAME: "AGENTS.md", @@ -34,3 +185,29 @@ vi.mock("../agents/skills.js", () => ({ vi.mock("../agents/skills/refresh.js", () => ({ getSkillsSnapshotVersion: vi.fn(() => 0), })); + +vi.mock("../agents/skills/refresh-state.js", () => ({ + getSkillsSnapshotVersion: vi.fn(() => 0), + shouldRefreshSnapshotForVersion: vi.fn(() => false), +})); + +vi.mock("../agents/skills/filter.js", () => ({ + normalizeSkillFilter: vi.fn((skillFilter?: ReadonlyArray) => + skillFilter?.map((entry) => String(entry).trim()).filter(Boolean), + ), + normalizeSkillFilterForComparison: vi.fn((skillFilter?: ReadonlyArray) => + skillFilter + ?.map((entry) => String(entry).trim()) + .filter(Boolean) + .toSorted(), + ), + matchesSkillFilter: vi.fn(() => true), +})); + +vi.mock("../agents/exec-defaults.js", () => ({ + canExecRequestNode: vi.fn(() => false), +})); + +vi.mock("../infra/skills-remote.js", () => ({ + getRemoteSkillEligibility: vi.fn(() => undefined), +})); diff --git a/src/commands/agent.acp.test.ts b/src/commands/agent.acp.test.ts index 7f6b7dfbd56..2a1b9057c7a 100644 --- a/src/commands/agent.acp.test.ts +++ b/src/commands/agent.acp.test.ts @@ -2,16 +2,112 @@ import fs from "node:fs"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import "./agent-command.test-mocks.js"; import * as acpManagerModule from "../acp/control-plane/manager.js"; -import { AcpRuntimeError } from "../acp/runtime/errors.js"; import * as embeddedModule from "../agents/pi-embedded.js"; import * as configIoModule from "../config/io.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { readSessionMessages } from "../gateway/session-utils.fs.js"; -import { onAgentEvent } from "../infra/agent-events.js"; import type { RuntimeEnv } from "../runtime.js"; import { agentCommand } from "./agent.js"; +const agentEventMocks = vi.hoisted(() => { + type AgentEvent = { stream: string; data?: Record; runId?: string }; + const handlers = new Set<(event: AgentEvent) => void>(); + return { + clearAgentRunContext: vi.fn(), + emitAgentEvent: vi.fn((event: AgentEvent) => { + for (const handler of handlers) { + handler(event); + } + }), + onAgentEvent: vi.fn((handler: (event: AgentEvent) => void) => { + handlers.add(handler); + return () => handlers.delete(handler); + }), + registerAgentRunContext: vi.fn(), + }; +}); + +const attemptExecutionMocks = vi.hoisted(() => ({ + emitAcpLifecycleStart: vi.fn(), + emitAcpLifecycleEnd: vi.fn(), + emitAcpLifecycleError: vi.fn(), + persistAcpTurnTranscript: vi.fn( + async ({ sessionEntry }: { sessionEntry?: unknown }) => sessionEntry, + ), +})); + +vi.mock("../infra/agent-events.js", () => agentEventMocks); + +vi.mock("../agents/command/delivery.runtime.js", () => ({ + deliverAgentCommandResult: vi.fn( + async (params: { runtime: RuntimeEnv; payloads?: Array<{ text?: string }> }) => { + for (const payload of params.payloads ?? []) { + if (payload.text) { + params.runtime.log(payload.text); + } + } + }, + ), +})); + +vi.mock("../agents/command/attempt-execution.runtime.js", () => { + const createAcpVisibleTextAccumulator = () => { + let text = ""; + return { + consume(chunk: string) { + if (!chunk || chunk === "NO_REPLY") { + return null; + } + text += chunk; + return { text, delta: chunk }; + }, + finalize: () => text.trim(), + finalizeRaw: () => text, + }; + }; + + return { + createAcpVisibleTextAccumulator, + emitAcpLifecycleStart: attemptExecutionMocks.emitAcpLifecycleStart, + emitAcpLifecycleEnd: attemptExecutionMocks.emitAcpLifecycleEnd, + emitAcpLifecycleError: attemptExecutionMocks.emitAcpLifecycleError, + emitAcpAssistantDelta: ({ + runId, + text, + delta, + }: { + runId: string; + text: string; + delta: string; + }) => + agentEventMocks.emitAgentEvent({ + runId, + stream: "assistant", + data: { text, delta }, + }), + buildAcpResult: ({ + payloadText, + startedAt, + stopReason, + abortSignal, + }: { + payloadText: string; + startedAt: number; + stopReason?: string; + abortSignal?: AbortSignal; + }) => ({ + payloads: payloadText ? [{ text: payloadText }] : [], + meta: { + durationMs: Date.now() - startedAt, + aborted: abortSignal?.aborted === true, + stopReason, + }, + }), + persistAcpTurnTranscript: attemptExecutionMocks.persistAcpTurnTranscript, + }; +}); + const loadConfigSpy = vi.spyOn(configIoModule, "loadConfig"); const runEmbeddedPiAgentSpy = vi.spyOn(embeddedModule, "runEmbeddedPiAgent"); const getAcpSessionManagerSpy = vi.spyOn(acpManagerModule, "getAcpSessionManager"); @@ -69,24 +165,20 @@ function writeAcpSessionStore(storePath: string, agent = "codex") { fs.mkdirSync(path.dirname(storePath), { recursive: true }); fs.writeFileSync( storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "acp-session-1", - updatedAt: Date.now(), - acp: { - backend: "acpx", - agent, - runtimeSessionName: sessionKey, - mode: "oneshot", - state: "idle", - lastActivityAt: Date.now(), - }, + JSON.stringify({ + [sessionKey]: { + sessionId: "acp-session-1", + updatedAt: Date.now(), + acp: { + backend: "acpx", + agent, + runtimeSessionName: sessionKey, + mode: "oneshot", + state: "idle", + lastActivityAt: Date.now(), }, }, - null, - 2, - ), + }), ); } @@ -159,7 +251,7 @@ function createRunTurnFromTextDeltas(chunks: string[]) { function subscribeAssistantEvents() { const assistantEvents: Array<{ text?: string; delta?: string }> = []; - const stop = onAgentEvent((evt) => { + const stop = agentEventMocks.onAgentEvent((evt) => { if (evt.stream !== "assistant") { return; } @@ -180,6 +272,7 @@ async function runAcpTurnWithAssistantEvents(chunks: string[]) { }); try { + vi.mocked(runtime.log).mockClear(); await agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime); } finally { stop(); @@ -204,26 +297,13 @@ async function runAcpTurnWithTextDeltas(params: { message?: string; chunks: stri return { runTurn }; } -function expectPersistedAcpTranscript(params: { - storePath: string; - userContent: string; - assistantText: string; -}) { - const persistedStore = JSON.parse(fs.readFileSync(params.storePath, "utf-8")) as Record< - string, - { sessionFile?: string } - >; - const sessionFile = persistedStore["agent:codex:acp:test"]?.sessionFile; - const messages = readSessionMessages("acp-session-1", params.storePath, sessionFile); - expect(messages).toHaveLength(2); - expect(messages[0]).toMatchObject({ - role: "user", - content: params.userContent, - }); - expect(messages[1]).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: params.assistantText }], - }); +function expectPersistedAcpTranscript(params: { userContent: string; assistantText: string }) { + expect(attemptExecutionMocks.persistAcpTurnTranscript).toHaveBeenCalledWith( + expect.objectContaining({ + body: params.userContent, + finalText: params.assistantText, + }), + ); } async function runAcpSessionWithPolicyOverrides(params: { @@ -262,14 +342,16 @@ describe("agentCommand ACP runtime routing", () => { } as never); }); - it("routes ACP sessions through AcpSessionManager instead of embedded agent", async () => { - await withAcpSessionEnv(async () => { - const { runTurn } = await runAcpTurnWithTextDeltas({ chunks: ["ACP_", "OK"] }); - + it("routes ACP sessions and preserves exact transcript text", async () => { + await withAcpSessionEnvInfo(async () => { + const { runTurn } = await runAcpTurnWithTextDeltas({ + message: " ping\n", + chunks: [" ACP_OK\n"], + }); expect(runTurn).toHaveBeenCalledWith( expect.objectContaining({ sessionKey: "agent:codex:acp:test", - text: "ping", + text: " ping\n", mode: "prompt", }), ); @@ -278,89 +360,22 @@ describe("agentCommand ACP runtime routing", () => { .mocked(runtime.log) .mock.calls.some(([first]) => typeof first === "string" && first.includes("ACP_OK")); expect(hasAckLog).toBe(true); - }); - }); - - it("persists ACP child session history to the transcript store", async () => { - await withAcpSessionEnvInfo(async ({ storePath }) => { - await runAcpTurnWithTextDeltas({ chunks: ["ACP_", "OK"] }); expectPersistedAcpTranscript({ - storePath, - userContent: "ping", - assistantText: "ACP_OK", - }); - }); - }); - - it("preserves exact ACP transcript text without trimming whitespace", async () => { - await withAcpSessionEnvInfo(async ({ storePath }) => { - await runAcpTurnWithTextDeltas({ - message: " ping\n", - chunks: [" ACP_OK\n"], - }); - expectPersistedAcpTranscript({ - storePath, userContent: " ping\n", assistantText: " ACP_OK\n", }); }); }); - it("suppresses ACP NO_REPLY lead fragments before emitting assistant text", async () => { + it("streams ACP visible text deltas", async () => { await withAcpSessionEnv(async () => { - const { assistantEvents, logLines } = await runAcpTurnWithAssistantEvents([ - "NO", - "NO_", - "NO_RE", - "NO_REPLY", - "Actual answer", + const repeated = await runAcpTurnWithAssistantEvents(["bo", "ok"]); + + expect(repeated.assistantEvents).toEqual([ + { text: "bo", delta: "bo" }, + { text: "book", delta: "ok" }, ]); - - expect(assistantEvents).toEqual([{ text: "Actual answer", delta: "Actual answer" }]); - expect(logLines.some((line) => line.includes("NO_REPLY"))).toBe(false); - expect(logLines.some((line) => line.includes("Actual answer"))).toBe(true); - }); - }); - - it("keeps silent-only ACP turns out of assistant output", async () => { - await withAcpSessionEnv(async () => { - const { assistantEvents, logLines } = await runAcpTurnWithAssistantEvents([ - "NO", - "NO_", - "NO_RE", - "NO_REPLY", - ]); - expect(assistantEvents.map((event) => event.text).filter(Boolean)).toEqual([]); - expect(logLines.some((line) => line.includes("NO_REPLY"))).toBe(false); - expect(logLines.some((line) => line.includes("No reply from agent."))).toBe(true); - }); - }); - - it("preserves repeated identical ACP delta chunks", async () => { - await withAcpSessionEnv(async () => { - const { assistantEvents, logLines } = await runAcpTurnWithAssistantEvents([ - "b", - "o", - "o", - "k", - ]); - - expect(assistantEvents).toEqual([ - { text: "b", delta: "b" }, - { text: "bo", delta: "o" }, - { text: "boo", delta: "o" }, - { text: "book", delta: "k" }, - ]); - expect(logLines.some((line) => line.includes("book"))).toBe(true); - }); - }); - - it("re-emits buffered NO prefix when ACP text becomes visible content", async () => { - await withAcpSessionEnv(async () => { - const { assistantEvents, logLines } = await runAcpTurnWithAssistantEvents(["NO", "W"]); - - expect(assistantEvents).toEqual([{ text: "NOW", delta: "NOW" }]); - expect(logLines.some((line) => line.includes("NOW"))).toBe(true); + expect(repeated.logLines.some((line) => line.includes("book"))).toBe(true); }); }); @@ -370,16 +385,12 @@ describe("agentCommand ACP runtime routing", () => { fs.mkdirSync(path.dirname(storePath), { recursive: true }); fs.writeFileSync( storePath, - JSON.stringify( - { - "agent:codex:acp:stale": { - sessionId: "stale-1", - updatedAt: Date.now(), - }, + JSON.stringify({ + "agent:codex:acp:stale": { + sessionId: "stale-1", + updatedAt: Date.now(), }, - null, - 2, - ), + }), ); mockConfig(home, storePath); @@ -390,10 +401,9 @@ describe("agentCommand ACP runtime routing", () => { return { kind: "stale", sessionKey, - error: new AcpRuntimeError( - "ACP_SESSION_INIT_FAILED", - `ACP metadata is missing for session ${sessionKey}.`, - ), + error: Object.assign(new Error(`ACP metadata is missing for session ${sessionKey}.`), { + code: "ACP_SESSION_INIT_FAILED", + }), }; }, }); @@ -409,19 +419,13 @@ describe("agentCommand ACP runtime routing", () => { }); }); - it.each([ - { - name: "blocks ACP turns when ACP is disabled by policy", - acpOverrides: { enabled: false } satisfies Partial>, - }, - { - name: "blocks ACP turns when ACP dispatch is disabled by policy", - acpOverrides: { - dispatch: { enabled: false }, - } satisfies Partial>, - }, - ])("$name", async ({ acpOverrides }) => { - await runAcpSessionWithPolicyOverrides({ acpOverrides }); + it("blocks ACP turns when disabled by policy", async () => { + for (const acpOverrides of [ + { enabled: false }, + { dispatch: { enabled: false } }, + ] satisfies Array>>) { + await runAcpSessionWithPolicyOverrides({ acpOverrides }); + } }); it("blocks ACP turns when ACP agent is disallowed by policy", async () => { diff --git a/src/commands/agent.session.test.ts b/src/commands/agent.session.test.ts index e601221e1aa..b8dfdad6ca1 100644 --- a/src/commands/agent.session.test.ts +++ b/src/commands/agent.session.test.ts @@ -9,7 +9,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { buildOutboundSessionContext } from "../infra/outbound/session-context.js"; async function withTempHome(fn: (home: string) => Promise): Promise { - return withTempHomeBase(fn, { prefix: "openclaw-agent-session-" }); + return withTempHomeBase(fn, { + prefix: "openclaw-agent-session-", + skipSessionCleanup: true, + }); } function mockConfig( @@ -35,7 +38,7 @@ function writeSessionStoreSeed( sessions: Record>, ) { fs.mkdirSync(path.dirname(storePath), { recursive: true }); - fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2)); + fs.writeFileSync(storePath, JSON.stringify(sessions)); } async function withCrossAgentResumeFixture( diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 1bb0f19b895..198f4e884e2 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -3,16 +3,12 @@ import path from "node:path"; import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import "./agent-command.test-mocks.js"; -import "../cron/isolated-agent.mocks.js"; import { __testing as acpManagerTesting } from "../acp/control-plane/manager.js"; -import * as authProfilesModule from "../agents/auth-profiles.js"; -import * as sessionStoreModule from "../agents/command/session-store.runtime.js"; +import * as authProfileStoreModule from "../agents/auth-profiles/store.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import * as modelSelectionModule from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; -import * as configIoModule from "../config/io.js"; import * as runtimeSnapshotModule from "../config/runtime-snapshot.js"; -import * as sessionPathsModule from "../config/sessions/paths.js"; import { clearSessionStoreCacheForTest } from "../config/sessions/store.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { @@ -21,16 +17,18 @@ import { resetAgentEventsForTest, resetAgentRunContextForTest, } from "../infra/agent-events.js"; -import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import type { RuntimeEnv } from "../runtime.js"; -import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { agentCommand, agentCommandFromIngress } from "./agent.js"; -vi.mock("../agents/auth-profiles.js", () => { - return { - ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })), - }; -}); +const configIoMocks = vi.hoisted(() => ({ + loadConfig: vi.fn(), + readConfigFileSnapshotForWrite: vi.fn(), +})); + +vi.mock("../config/io.js", () => ({ + loadConfig: configIoMocks.loadConfig, + readConfigFileSnapshotForWrite: configIoMocks.readConfigFileSnapshotForWrite, +})); vi.mock("../agents/auth-profiles/store.js", () => { const createEmptyStore = () => ({ version: 1, profiles: {} }); @@ -69,7 +67,6 @@ vi.mock("../agents/command/attempt-execution.runtime.js", () => { async (params: { sessionEntry?: unknown }) => params.sessionEntry, ), runAgentAttempt: vi.fn(async (params: Record) => { - const { runEmbeddedPiAgent } = await import("../agents/pi-embedded.js"); const opts = params.opts as Record; const runContext = params.runContext as Record; const sessionEntry = params.sessionEntry as @@ -158,7 +155,7 @@ vi.mock("../agents/command/delivery.runtime.js", () => { for (const payload of payloads) { await params.deps.sendMessageTelegram?.(params.opts.to, payload.text ?? "", { ...(payload.mediaUrl ? { mediaUrl: payload.mediaUrl } : {}), - accountId: params.cfg.channels?.telegram?.accountId, + accountId: undefined, verbose: false, }); } @@ -175,42 +172,50 @@ vi.mock("../agents/command/delivery.runtime.js", () => { }); vi.mock("../config/sessions/transcript-resolve.runtime.js", () => { + const dirname = (filePath: string): string => { + const lastSlash = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")); + return lastSlash >= 0 ? filePath.slice(0, lastSlash) : "."; + }; + const joinPath = (...parts: string[]): string => { + const separator = parts.find((part) => part.includes("\\")) ? "\\" : "/"; + return parts + .map((part, index) => + index === 0 ? part.replace(/[\\/]+$/u, "") : part.replace(/^[\\/]+|[\\/]+$/gu, ""), + ) + .filter(Boolean) + .join(separator); + }; + const resolveSessionFile = (sessionId: string, agentId: string, sessionsDir?: string): string => + joinPath(sessionsDir ?? ".openclaw", "agents", agentId, "sessions", `${sessionId}.jsonl`); + return { resolveSessionTranscriptFile: vi.fn( async (params: { sessionId: string; sessionKey: string; - sessionEntry?: { sessionFile?: string }; + sessionEntry?: { sessionFile?: string; sessionId?: string }; sessionStore?: Record; storePath?: string; agentId: string; threadId?: string | number; }) => { - const nodeFs = await import("node:fs"); - const nodePath = await import("node:path"); - const { resolveSessionFilePath, resolveSessionTranscriptPath } = - await import("../config/sessions/paths.js"); - const sessionsDir = params.storePath ? nodePath.dirname(params.storePath) : undefined; - const sessionFileFromStorePath = resolveSessionFilePath( - params.sessionId, - params.sessionEntry, - { - agentId: params.agentId, - ...(sessionsDir ? { sessionsDir } : {}), - }, - ); + const sessionsDir = params.storePath ? dirname(params.storePath) : undefined; + const sessionFileFromStorePath = + params.sessionEntry?.sessionFile ?? + resolveSessionFile(params.sessionId, params.agentId, sessionsDir); const sessionFile = params.sessionEntry?.sessionFile ? sessionFileFromStorePath - : resolveSessionTranscriptPath(params.sessionId, params.agentId, params.threadId); + : resolveSessionFile(params.sessionId, params.agentId, sessionsDir); let sessionEntry = params.sessionEntry; if (params.sessionStore && params.storePath && params.sessionKey) { + const existingEntry = params.sessionStore[params.sessionKey] ?? {}; sessionEntry = { - ...params.sessionStore[params.sessionKey], + ...existingEntry, sessionId: params.sessionId, sessionFile, }; params.sessionStore[params.sessionKey] = sessionEntry; - nodeFs.writeFileSync(params.storePath, JSON.stringify(params.sessionStore, null, 2)); + fs.writeFileSync(params.storePath, JSON.stringify(params.sessionStore)); } return { sessionFile, sessionEntry }; }, @@ -226,14 +231,8 @@ const runtime: RuntimeEnv = { }), }; -const configSpy = vi.spyOn(configIoModule, "loadConfig"); -const readConfigFileSnapshotForWriteSpy = vi.spyOn( - configIoModule, - "readConfigFileSnapshotForWrite", -); - async function withTempHome(fn: (home: string) => Promise): Promise { - return withTempHomeBase(fn, { prefix: "openclaw-agent-" }); + return withTempHomeBase(fn, { prefix: "openclaw-agent-", skipSessionCleanup: true }); } function mockConfig( @@ -258,41 +257,16 @@ function mockConfig( telegram: telegramOverrides ? { ...telegramOverrides } : undefined, }, } as OpenClawConfig; - configSpy.mockReturnValue(cfg); + configIoMocks.loadConfig.mockReturnValue(cfg); return cfg; } -async function runWithDefaultAgentConfig(params: { - home: string; - args: Parameters[0]; - agentsList?: Array<{ id: string; default?: boolean }>; -}) { - const store = path.join(params.home, "sessions.json"); - mockConfig(params.home, store, undefined, undefined, params.agentsList); - await agentCommand(params.args, runtime); - return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; -} - -async function runEmbeddedWithTempConfig(params: { - args: Parameters[0]; - agentOverrides?: Partial["defaults"]>>; - telegramOverrides?: Partial["telegram"]>>; - agentsList?: Array<{ id: string; default?: boolean }>; -}) { - return withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store, params.agentOverrides, params.telegramOverrides, params.agentsList); - await agentCommand(params.args, runtime); - return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - }); -} - function writeSessionStoreSeed( storePath: string, sessions: Record>, ) { fs.mkdirSync(path.dirname(storePath), { recursive: true }); - fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2)); + fs.writeFileSync(storePath, JSON.stringify(sessions)); } function createDefaultAgentResult(params?: { @@ -322,110 +296,68 @@ function readSessionStore(storePath: string): Record { return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record; } -async function expectPersistedSessionFile(params: { - seedKey: string; - sessionId: string; - expectedPathFragment: string; -}) { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - writeSessionStoreSeed(store, { - [params.seedKey]: { - sessionId: params.sessionId, - updatedAt: Date.now(), - }, - }); - mockConfig(home, store); - await agentCommand({ message: "hi", sessionKey: params.seedKey }, runtime); - const saved = readSessionStore<{ sessionId?: string; sessionFile?: string }>(store); - const entry = saved[params.seedKey]; - expect(entry?.sessionId).toBe(params.sessionId); - expect(entry?.sessionFile).toContain(params.expectedPathFragment); - expect(getLastEmbeddedCall()?.sessionFile).toBe(entry?.sessionFile); - }); -} - async function runAgentWithSessionKey(sessionKey: string): Promise { await agentCommand({ message: "hi", sessionKey }, runtime); } -async function expectDefaultThinkLevel(params: { - agentOverrides?: Partial["defaults"]>>; - catalogEntry: Record; - expected: string; -}) { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store, params.agentOverrides); - vi.mocked(loadModelCatalog).mockResolvedValueOnce([params.catalogEntry as never]); - await agentCommand({ message: "hi", to: "+1555" }, runtime); - expect(getLastEmbeddedCall()?.thinkLevel).toBe(params.expected); - }); -} - -function createTelegramOutboundPlugin() { - const sendWithTelegram = async ( - ctx: { - deps?: { [channelId: string]: unknown }; - to: string; - text: string; - accountId?: string | null; - mediaUrl?: string; - }, - mediaUrl?: string, - ) => { - const sendTelegram = ctx.deps?.["telegram"] as - | (( - to: string, - text: string, - opts: Record, - ) => Promise<{ messageId: string; chatId: string }>) - | undefined; - if (!sendTelegram) { - throw new Error("sendTelegram dependency missing"); - } - const result = await sendTelegram(ctx.to, ctx.text, { - accountId: ctx.accountId ?? undefined, - ...(mediaUrl ? { mediaUrl } : {}), - verbose: false, - }); - return { channel: "telegram", messageId: result.messageId, chatId: result.chatId }; - }; - - return createOutboundTestPlugin({ - id: "telegram", - outbound: { - deliveryMode: "direct", - sendText: async (ctx) => sendWithTelegram(ctx), - sendMedia: async (ctx) => sendWithTelegram(ctx, ctx.mediaUrl), - }, - }); -} - beforeEach(() => { vi.clearAllMocks(); clearSessionStoreCacheForTest(); resetAgentEventsForTest(); resetAgentRunContextForTest(); - resetPluginRuntimeStateForTest(); acpManagerTesting.resetAcpSessionManagerForTests(); runtimeSnapshotModule.clearRuntimeConfigSnapshot(); vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult()); vi.mocked(loadModelCatalog).mockResolvedValue([]); vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); - readConfigFileSnapshotForWriteSpy.mockResolvedValue({ + configIoMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ snapshot: { valid: false, resolved: {} as OpenClawConfig }, writeOptions: {}, - } as Awaited>); + }); }); describe("agentCommand", () => { - it("persists thinking and verbose overrides", async () => { + it("enforces ingress trust flags", async () => { + await expect( + // Runtime guard for non-TS callers; TS callsites are statically typed. + agentCommandFromIngress({ message: "hi", to: "+1555" } as never, runtime), + ).rejects.toThrow("senderIsOwner must be explicitly set for ingress agent runs."); + + await expect( + // Runtime guard for non-TS callers; TS callsites are statically typed. + agentCommandFromIngress( + { + message: "hi", + to: "+1555", + senderIsOwner: false, + } as never, + runtime, + ), + ).rejects.toThrow("allowModelOverride must be explicitly set for ingress agent runs."); + }); + + it("persists local overrides", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store); + vi.mocked(runEmbeddedPiAgent).mockResolvedValue( + createDefaultAgentResult({ + payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }], + durationMs: 42, + }), + ); - await agentCommand({ message: "hi", to: "+1222", thinking: "high", verbose: "on" }, runtime); + await agentCommand( + { + message: "ping", + to: "+1222", + accountId: "kev", + thinking: "high", + verbose: "on", + json: true, + }, + runtime, + ); const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< string, @@ -438,138 +370,42 @@ describe("agentCommand", () => { const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; expect(callArgs?.thinkLevel).toBe("high"); expect(callArgs?.verboseLevel).toBe("on"); + expect(callArgs?.senderIsOwner).toBe(true); + expect(callArgs?.prompt).toBe("ping"); + expect(callArgs?.agentAccountId).toBe("kev"); + + const logged = (runtime.log as unknown as MockInstance).mock.calls.at(-1)?.[0] as string; + const parsed = JSON.parse(logged) as { + payloads: Array<{ text: string; mediaUrl?: string | null }>; + meta: { durationMs: number }; + }; + expect(parsed.payloads[0].text).toBe("json-reply"); + expect(parsed.payloads[0].mediaUrl).toBe("http://x.test/a.jpg"); + expect(parsed.meta.durationMs).toBe(42); }); }); - it.each([ - { - name: "defaults senderIsOwner to true for local agent runs", - args: { message: "hi", to: "+1555" }, - expected: true, - }, - { - name: "honors explicit senderIsOwner override", - args: { message: "hi", to: "+1555", senderIsOwner: false }, - expected: false, - }, - ])("$name", async ({ args, expected }) => { - const callArgs = await runEmbeddedWithTempConfig({ args }); - expect(callArgs?.senderIsOwner).toBe(expected); - }); - - it("requires explicit senderIsOwner for ingress runs", async () => { + it("passes resolved session-id resume files to embedded runs", async () => { await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store); - await expect( - // Runtime guard for non-TS callers; TS callsites are statically typed. - agentCommandFromIngress({ message: "hi", to: "+1555" } as never, runtime), - ).rejects.toThrow("senderIsOwner must be explicitly set for ingress agent runs."); - }); - }); - - it("requires explicit allowModelOverride for ingress runs", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store); - await expect( - // Runtime guard for non-TS callers; TS callsites are statically typed. - agentCommandFromIngress( - { - message: "hi", - to: "+1555", - senderIsOwner: false, - } as never, - runtime, - ), - ).rejects.toThrow("allowModelOverride must be explicitly set for ingress agent runs."); - }); - }); - - it("honors explicit senderIsOwner for ingress runs", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store); - await agentCommandFromIngress( - { message: "hi", to: "+1555", senderIsOwner: false, allowModelOverride: false }, - runtime, - ); - const ingressCall = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(ingressCall?.senderIsOwner).toBe(false); - expect(ingressCall).not.toHaveProperty("allowModelOverride"); - }); - }); - - it("resumes when session-id is provided", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - writeSessionStoreSeed(store, { + const resumeStore = path.join(home, "sessions-resume.json"); + writeSessionStoreSeed(resumeStore, { foo: { sessionId: "session-123", updatedAt: Date.now(), systemSent: true, }, }); - mockConfig(home, store); + mockConfig(home, resumeStore); - await agentCommand({ message: "resume me", sessionId: "session-123" }, runtime); + await agentCommand( + { message: "resume me", sessionId: "session-123", thinking: "low" }, + runtime, + ); const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; expect(callArgs?.sessionId).toBe("session-123"); - }); - }); - - it("persists explicit session-id-only runs with the synthetic session key", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store, { - model: { primary: "claude-cli/claude-sonnet-4-6" }, - models: { "claude-cli/claude-sonnet-4-6": {} }, - }); - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 5, - agentMeta: { - sessionId: "claude-cli-session-1", - provider: "claude-cli", - model: "claude-sonnet-4-6", - cliSessionBinding: { - sessionId: "claude-cli-session-1", - }, - }, - }, - }); - - await agentCommand({ message: "resume me", sessionId: "explicit-session-123" }, runtime); - - expect(vi.mocked(sessionStoreModule.updateSessionStoreAfterAgentRun)).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: "explicit-session-123", - sessionKey: "agent:main:explicit:explicit-session-123", - }), - ); - }); - }); - - it("resolves resumed session transcript path from custom session store directory", async () => { - await withTempHome(async (home) => { - const customStoreDir = path.join(home, "custom-state"); - const store = path.join(customStoreDir, "sessions.json"); - writeSessionStoreSeed(store, {}); - mockConfig(home, store); - const resolveSessionFilePathSpy = vi.spyOn(sessionPathsModule, "resolveSessionFilePath"); - - await agentCommand({ message: "resume me", sessionId: "session-custom-123" }, runtime); - - const matchingCall = resolveSessionFilePathSpy.mock.calls.find( - (call) => call[0] === "session-custom-123", - ); - expect(matchingCall?.[2]).toEqual( - expect.objectContaining({ - agentId: "main", - sessionsDir: customStoreDir, - }), + expect(callArgs?.sessionFile).toContain( + `${path.dirname(resumeStore)}${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}session-123.jsonl`, ); }); }); @@ -605,7 +441,7 @@ describe("agentCommand", () => { } as never; }); - await agentCommand({ message: "hi", to: "+1555" }, runtime); + await agentCommand({ message: "hi", to: "+1555", thinking: "low" }, runtime); stop(); const matching = assistantEvents.filter((evt) => evt.text === "hello"); @@ -613,23 +449,6 @@ describe("agentCommand", () => { }); }); - it("uses provider/model from agents.defaults.model.primary", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store, { - model: { primary: "openai/gpt-4.1-mini" }, - models: { - "anthropic/claude-opus-4-6": {}, - "openai/gpt-4.1-mini": {}, - }, - }); - - await agentCommand({ message: "hi", to: "+1555" }, runtime); - - expectLastRunProviderModel("openai", "gpt-4.1-mini"); - }); - }); - it("uses default fallback list for session model overrides", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); @@ -687,46 +506,10 @@ describe("agentCommand", () => { }); }); - it("keeps stored session model override when models allowlist is empty", async () => { + it("clears disallowed stored override fields", async () => { await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - writeSessionStoreSeed(store, { - "agent:main:subagent:allow-any": { - sessionId: "session-allow-any", - updatedAt: Date.now(), - providerOverride: "openai", - modelOverride: "gpt-custom-foo", - }, - }); - - mockConfig(home, store, { - model: { primary: "anthropic/claude-opus-4-6" }, - models: {}, - }); - - vi.mocked(loadModelCatalog).mockResolvedValueOnce([ - { id: "claude-opus-4-6", name: "Opus", provider: "anthropic" }, - ]); - - await runAgentWithSessionKey("agent:main:subagent:allow-any"); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.provider).toBe("openai"); - expect(callArgs?.model).toBe("gpt-custom-foo"); - - const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< - string, - { providerOverride?: string; modelOverride?: string } - >; - expect(saved["agent:main:subagent:allow-any"]?.providerOverride).toBe("openai"); - expect(saved["agent:main:subagent:allow-any"]?.modelOverride).toBe("gpt-custom-foo"); - }); - }); - - it("persists cleared model and auth override fields when stored override falls back to default", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - writeSessionStoreSeed(store, { + const clearStore = path.join(home, "sessions-clear-overrides.json"); + writeSessionStoreSeed(clearStore, { "agent:main:subagent:clear-overrides": { sessionId: "session-clear-overrides", updatedAt: Date.now(), @@ -741,7 +524,7 @@ describe("agentCommand", () => { }, }); - mockConfig(home, store, { + mockConfig(home, clearStore, { model: { primary: "openai/gpt-4.1-mini" }, models: { "openai/gpt-4.1-mini": {}, @@ -757,20 +540,17 @@ describe("agentCommand", () => { expectLastRunProviderModel("openai", "gpt-4.1-mini"); - const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< - string, - { - providerOverride?: string; - modelOverride?: string; - authProfileOverride?: string; - authProfileOverrideSource?: string; - authProfileOverrideCompactionCount?: number; - fallbackNoticeSelectedModel?: string; - fallbackNoticeActiveModel?: string; - fallbackNoticeReason?: string; - } - >; - const entry = saved["agent:main:subagent:clear-overrides"]; + const cleared = readSessionStore<{ + providerOverride?: string; + modelOverride?: string; + authProfileOverride?: string; + authProfileOverrideSource?: string; + authProfileOverrideCompactionCount?: number; + fallbackNoticeSelectedModel?: string; + fallbackNoticeActiveModel?: string; + fallbackNoticeReason?: string; + }>(clearStore); + const entry = cleared["agent:main:subagent:clear-overrides"]; expect(entry?.providerOverride).toBeUndefined(); expect(entry?.modelOverride).toBeUndefined(); expect(entry?.authProfileOverride).toBeUndefined(); @@ -782,7 +562,7 @@ describe("agentCommand", () => { }); }); - it("applies per-run provider and model overrides without persisting them", async () => { + it("handles one-off provider/model overrides and validates override values", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store, { @@ -810,69 +590,7 @@ describe("agentCommand", () => { }>(store); expect(saved["agent:main:subagent:run-override"]?.providerOverride).toBeUndefined(); expect(saved["agent:main:subagent:run-override"]?.modelOverride).toBeUndefined(); - }); - }); - it("rejects explicit override values that contain control characters", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store, { - models: { - "anthropic/claude-opus-4-6": {}, - "openai/gpt-4.1-mini": {}, - }, - }); - - await expect( - agentCommand( - { - message: "use an invalid override", - sessionKey: "agent:main:subagent:invalid-override", - provider: "openai\u001b[31m", - model: "gpt-4.1-mini", - }, - runtime, - ), - ).rejects.toThrow("Provider override contains invalid control characters."); - }); - }); - - it("sanitizes provider/model text in model-allowlist errors", async () => { - const parseModelRefSpy = vi.spyOn(modelSelectionModule, "parseModelRef"); - parseModelRefSpy.mockImplementationOnce(() => ({ - provider: "anthropic\u001b[31m", - model: "claude-haiku-4-5\u001b[32m", - })); - try { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store, { - models: { - "openai/gpt-4.1-mini": {}, - }, - }); - - await expect( - agentCommand( - { - message: "use disallowed override", - sessionKey: "agent:main:subagent:sanitized-override-error", - model: "claude-haiku-4-5", - }, - runtime, - ), - ).rejects.toThrow( - 'Model override "anthropic/claude-haiku-4-5" is not allowed for agent "main".', - ); - }); - } finally { - parseModelRefSpy.mockRestore(); - } - }); - - it("keeps stored auth profile overrides during one-off cross-provider runs", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); writeSessionStoreSeed(store, { "agent:main:subagent:temp-openai-run": { sessionId: "session-temp-openai-run", @@ -882,13 +600,7 @@ describe("agentCommand", () => { authProfileOverrideCompactionCount: 2, }, }); - mockConfig(home, store, { - models: { - "anthropic/claude-opus-4-6": {}, - "openai/gpt-4.1-mini": {}, - }, - }); - vi.mocked(authProfilesModule.ensureAuthProfileStore).mockReturnValue({ + vi.mocked(authProfileStoreModule.ensureAuthProfileStore).mockReturnValue({ version: 1, profiles: { "anthropic:work": { @@ -910,246 +622,120 @@ describe("agentCommand", () => { expectLastRunProviderModel("openai", "gpt-4.1-mini"); expect(getLastEmbeddedCall()?.authProfileId).toBeUndefined(); - const saved = readSessionStore<{ + const savedAuth = readSessionStore<{ authProfileOverride?: string; authProfileOverrideSource?: string; authProfileOverrideCompactionCount?: number; }>(store); - expect(saved["agent:main:subagent:temp-openai-run"]?.authProfileOverride).toBe( + expect(savedAuth["agent:main:subagent:temp-openai-run"]?.authProfileOverride).toBe( "anthropic:work", ); - expect(saved["agent:main:subagent:temp-openai-run"]?.authProfileOverrideSource).toBe("user"); - expect(saved["agent:main:subagent:temp-openai-run"]?.authProfileOverrideCompactionCount).toBe( - 2, + expect(savedAuth["agent:main:subagent:temp-openai-run"]?.authProfileOverrideSource).toBe( + "user", ); + expect( + savedAuth["agent:main:subagent:temp-openai-run"]?.authProfileOverrideCompactionCount, + ).toBe(2); + + await expect( + agentCommand( + { + message: "use an invalid override", + sessionKey: "agent:main:subagent:invalid-override", + provider: "openai\u001b[31m", + model: "gpt-4.1-mini", + }, + runtime, + ), + ).rejects.toThrow("Provider override contains invalid control characters."); + + const parseModelRefSpy = vi.spyOn(modelSelectionModule, "parseModelRef"); + parseModelRefSpy.mockImplementationOnce(() => ({ + provider: "anthropic\u001b[31m", + model: "claude-haiku-4-5\u001b[32m", + })); + mockConfig(home, store, { + models: { + "openai/gpt-4.1-mini": {}, + }, + }); + try { + await expect( + agentCommand( + { + message: "use disallowed override", + sessionKey: "agent:main:subagent:sanitized-override-error", + model: "claude-haiku-4-5", + }, + runtime, + ), + ).rejects.toThrow( + 'Model override "anthropic/claude-haiku-4-5" is not allowed for agent "main".', + ); + } finally { + parseModelRefSpy.mockRestore(); + } }); }); - it("keeps explicit sessionKey even when sessionId exists elsewhere", async () => { + it("passes resolved default thinking level to embedded runs", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); - writeSessionStoreSeed(store, { - "agent:main:main": { - sessionId: "sess-main", - updatedAt: Date.now(), + mockConfig(home, store, { + model: { primary: "openai/gpt-4.1-mini" }, + models: { + "anthropic/claude-opus-4-6": {}, + "openai/gpt-4.1-mini": {}, }, }); - mockConfig(home, store); + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { + id: "gpt-4.1-mini", + name: "GPT-4.1 Mini", + provider: "openai", + reasoning: true, + }, + ]); + + await agentCommand({ message: "hi", to: "+1555" }, runtime); + + expect(getLastEmbeddedCall()?.thinkLevel).toBe("low"); + expectLastRunProviderModel("openai", "gpt-4.1-mini"); + }); + }); + + it("passes routing context to embedded runs", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store, undefined, undefined, [{ id: "ops" }]); + + await agentCommand( + { message: "hi", agentId: "ops", replyChannel: "slack", thinking: "low" }, + runtime, + ); + let callArgs = getLastEmbeddedCall(); + expect(callArgs?.sessionKey).toBe("agent:ops:main"); + expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`); + expect(callArgs?.messageChannel).toBe("slack"); + expect(runtime.log).toHaveBeenCalledWith("ok"); await agentCommand( { message: "hi", - sessionId: "sess-main", - sessionKey: "agent:main:subagent:abc", + to: "+1555", + channel: "whatsapp", + thinking: "low", + runContext: { messageChannel: "slack", accountId: "acct-2" }, }, runtime, ); - - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.sessionKey).toBe("agent:main:subagent:abc"); - - const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< - string, - { sessionId?: string } - >; - expect(saved["agent:main:subagent:abc"]?.sessionId).toBe("sess-main"); - }); - }); - - it("persists resolved sessionFile for existing session keys", async () => { - await expectPersistedSessionFile({ - seedKey: "agent:main:subagent:abc", - sessionId: "sess-main", - expectedPathFragment: `${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`, - }); - }); - - it("derives session key from --agent when no routing target is provided", async () => { - await withTempHome(async (home) => { - const callArgs = await runWithDefaultAgentConfig({ - home, - args: { message: "hi", agentId: "ops" }, - agentsList: [{ id: "ops" }], - }); - expect(callArgs?.sessionKey).toBe("agent:ops:main"); - expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`); - }); - }); - - it("rejects unknown agent overrides", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store); + callArgs = getLastEmbeddedCall(); + expect(callArgs?.messageChannel).toBe("slack"); + expect(callArgs?.agentAccountId).toBe("acct-2"); await expect(agentCommand({ message: "hi", agentId: "ghost" }, runtime)).rejects.toThrow( 'Unknown agent id "ghost"', ); }); }); - - it("defaults thinking to low for reasoning-capable models", async () => { - await expectDefaultThinkLevel({ - catalogEntry: { - id: "claude-opus-4-6", - name: "Opus 4.5", - provider: "anthropic", - reasoning: true, - }, - expected: "low", - }); - }); - - it("defaults thinking to adaptive for Anthropic Claude 4.6 models", async () => { - await expectDefaultThinkLevel({ - agentOverrides: { - model: { primary: "anthropic/claude-opus-4-6" }, - models: { "anthropic/claude-opus-4-6": {} }, - }, - catalogEntry: { - id: "claude-opus-4-6", - name: "Opus 4.6", - provider: "anthropic", - reasoning: true, - }, - expected: "adaptive", - }); - }); - - it("prefers per-model thinking over global thinkingDefault", async () => { - await expectDefaultThinkLevel({ - agentOverrides: { - thinkingDefault: "low", - models: { - "anthropic/claude-opus-4-6": { - params: { thinking: "high" }, - }, - }, - }, - catalogEntry: { - id: "claude-opus-4-6", - name: "Opus 4.5", - provider: "anthropic", - reasoning: true, - }, - expected: "high", - }); - }); - - it("prints JSON payload when requested", async () => { - await withTempHome(async (home) => { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue( - createDefaultAgentResult({ - payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }], - durationMs: 42, - }), - ); - const store = path.join(home, "sessions.json"); - mockConfig(home, store); - - await agentCommand({ message: "hi", to: "+1999", json: true }, runtime); - - const logged = (runtime.log as unknown as MockInstance).mock.calls.at(-1)?.[0] as string; - const parsed = JSON.parse(logged) as { - payloads: Array<{ text: string; mediaUrl?: string | null }>; - meta: { durationMs: number }; - }; - expect(parsed.payloads[0].text).toBe("json-reply"); - expect(parsed.payloads[0].mediaUrl).toBe("http://x.test/a.jpg"); - expect(parsed.meta.durationMs).toBe(42); - }); - }); - - it("passes the message through as the agent prompt", async () => { - const callArgs = await runEmbeddedWithTempConfig({ - args: { message: "ping", to: "+1333" }, - }); - expect(callArgs?.prompt).toBe("ping"); - }); - - it("passes through telegram accountId when delivering", async () => { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store, undefined, { botToken: "t-1" }); - setActivePluginRegistry( - createTestRegistry([ - { pluginId: "telegram", plugin: createTelegramOutboundPlugin(), source: "test" }, - ]), - ); - const deps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn().mockResolvedValue({ messageId: "t1", chatId: "123" }), - sendMessageSlack: vi.fn(), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - - const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; - process.env.TELEGRAM_BOT_TOKEN = ""; - try { - await agentCommand( - { - message: "hi", - to: "123", - deliver: true, - channel: "telegram", - }, - runtime, - deps, - ); - - expect(deps.sendMessageTelegram).toHaveBeenCalledWith( - "123", - "ok", - expect.objectContaining({ accountId: undefined, verbose: false }), - ); - } finally { - if (prevTelegramToken === undefined) { - delete process.env.TELEGRAM_BOT_TOKEN; - } else { - process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; - } - } - }); - }); - - it("uses reply channel as the message channel context", async () => { - const callArgs = await runEmbeddedWithTempConfig({ - args: { message: "hi", agentId: "ops", replyChannel: "slack" }, - agentsList: [{ id: "ops" }], - }); - expect(callArgs?.messageChannel).toBe("slack"); - }); - - it("prefers runContext for embedded routing", async () => { - const callArgs = await runEmbeddedWithTempConfig({ - args: { - message: "hi", - to: "+1555", - channel: "whatsapp", - runContext: { messageChannel: "slack", accountId: "acct-2" }, - }, - }); - expect(callArgs?.messageChannel).toBe("slack"); - expect(callArgs?.agentAccountId).toBe("acct-2"); - }); - - it("forwards accountId to embedded runs", async () => { - const callArgs = await runEmbeddedWithTempConfig({ - args: { message: "hi", to: "+1555", accountId: "kev" }, - }); - expect(callArgs?.agentAccountId).toBe("kev"); - }); - - it("logs output when delivery is disabled", async () => { - await withTempHome(async (home) => { - await runWithDefaultAgentConfig({ - home, - args: { message: "hi", agentId: "ops" }, - agentsList: [{ id: "ops" }], - }); - - expect(runtime.log).toHaveBeenCalledWith("ok"); - }); - }); }); diff --git a/src/commands/agent/session-store.test.ts b/src/commands/agent/session-store.test.ts deleted file mode 100644 index fe974a8f614..00000000000 --- a/src/commands/agent/session-store.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { updateSessionStoreAfterAgentRun } from "../../agents/command/session-store.js"; -import { resolveSession } from "../../agents/command/session.js"; -import type { SessionEntry } from "../../config/sessions.js"; -import { loadSessionStore } from "../../config/sessions.js"; - -function acpMeta() { - return { - backend: "acpx", - agent: "codex", - runtimeSessionName: "runtime-1", - mode: "persistent" as const, - state: "idle" as const, - lastActivityAt: Date.now(), - }; -} - -describe("updateSessionStoreAfterAgentRun", () => { - it("preserves ACP metadata when caller has a stale session snapshot", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-")); - const storePath = path.join(dir, "sessions.json"); - const sessionKey = `agent:codex:acp:${randomUUID()}`; - const sessionId = randomUUID(); - - const existing: SessionEntry = { - sessionId, - updatedAt: Date.now(), - acp: acpMeta(), - }; - await fs.writeFile(storePath, JSON.stringify({ [sessionKey]: existing }, null, 2), "utf8"); - - const staleInMemory: Record = { - [sessionKey]: { - sessionId, - updatedAt: Date.now(), - }, - }; - - await updateSessionStoreAfterAgentRun({ - cfg: {} as never, - sessionId, - sessionKey, - storePath, - sessionStore: staleInMemory, - defaultProvider: "openai", - defaultModel: "gpt-5.4", - result: { - payloads: [], - meta: { - aborted: false, - agentMeta: { - provider: "openai", - model: "gpt-5.4", - }, - }, - } as never, - }); - - const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey]; - expect(persisted?.acp).toBeDefined(); - expect(staleInMemory[sessionKey]?.acp).toBeDefined(); - }); - - it("persists latest systemPromptReport for downstream warning dedupe", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-")); - const storePath = path.join(dir, "sessions.json"); - const sessionKey = `agent:codex:report:${randomUUID()}`; - const sessionId = randomUUID(); - - const sessionStore: Record = { - [sessionKey]: { - sessionId, - updatedAt: Date.now(), - }, - }; - await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf8"); - - const report = { - source: "run" as const, - generatedAt: Date.now(), - bootstrapTruncation: { - warningMode: "once" as const, - warningSignaturesSeen: ["sig-a", "sig-b"], - }, - systemPrompt: { - chars: 1, - projectContextChars: 1, - nonProjectContextChars: 0, - }, - injectedWorkspaceFiles: [], - skills: { promptChars: 0, entries: [] }, - tools: { listChars: 0, schemaChars: 0, entries: [] }, - }; - - await updateSessionStoreAfterAgentRun({ - cfg: {} as never, - sessionId, - sessionKey, - storePath, - sessionStore, - defaultProvider: "openai", - defaultModel: "gpt-5.4", - result: { - payloads: [], - meta: { - agentMeta: { - provider: "openai", - model: "gpt-5.4", - }, - systemPromptReport: report, - }, - } as never, - }); - - const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey]; - expect(persisted?.systemPromptReport?.bootstrapTruncation?.warningSignaturesSeen).toEqual([ - "sig-a", - "sig-b", - ]); - expect(sessionStore[sessionKey]?.systemPromptReport?.bootstrapTruncation?.warningMode).toBe( - "once", - ); - }); - - it("stores and reloads the runtime model for explicit session-id-only runs", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-")); - const storePath = path.join(dir, "sessions.json"); - const cfg = { - session: { - store: storePath, - mainKey: "main", - }, - agents: { - defaults: { - cliBackends: { - "claude-cli": {}, - }, - }, - }, - } as never; - - const first = resolveSession({ - cfg, - sessionId: "explicit-session-123", - }); - - expect(first.sessionKey).toBe("agent:main:explicit:explicit-session-123"); - - await updateSessionStoreAfterAgentRun({ - cfg, - sessionId: first.sessionId, - sessionKey: first.sessionKey!, - storePath: first.storePath, - sessionStore: first.sessionStore!, - defaultProvider: "claude-cli", - defaultModel: "claude-sonnet-4-6", - result: { - payloads: [], - meta: { - agentMeta: { - provider: "claude-cli", - model: "claude-sonnet-4-6", - sessionId: "claude-cli-session-1", - cliSessionBinding: { - sessionId: "claude-cli-session-1", - authEpoch: "auth-epoch-1", - }, - }, - }, - } as never, - }); - - const second = resolveSession({ - cfg, - sessionId: "explicit-session-123", - }); - - expect(second.sessionKey).toBe(first.sessionKey); - expect(second.sessionEntry?.cliSessionBindings?.["claude-cli"]).toEqual({ - sessionId: "claude-cli-session-1", - authEpoch: "auth-epoch-1", - }); - - const persisted = loadSessionStore(storePath, { skipCache: true })[first.sessionKey!]; - expect(persisted?.cliSessionBindings?.["claude-cli"]).toEqual({ - sessionId: "claude-cli-session-1", - authEpoch: "auth-epoch-1", - }); - }); -}); diff --git a/src/commands/agents.bind.commands.test.ts b/src/commands/agents.bind.commands.test.ts index 7bd5496a495..7e5789f602f 100644 --- a/src/commands/agents.bind.commands.test.ts +++ b/src/commands/agents.bind.commands.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createBindingResolverTestPlugin } from "../test-utils/channel-plugins.js"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.public.js"; import { loadFreshAgentsBindCommandModuleForTest, readConfigFileSnapshotMock, @@ -9,10 +9,56 @@ import { } from "./agents.bind.test-support.js"; import { baseConfigSnapshot } from "./test-runtime-config-helpers.js"; -vi.mock("../channels/plugins/index.js", async () => { - const actual = await vi.importActual( - "../channels/plugins/index.js", - ); +vi.mock("../agents/agent-scope.js", () => ({ + listAgentEntries: ( + cfg: { + agents?: { list?: Array<{ id: string; default?: boolean }> }; + } | null, + ) => cfg?.agents?.list ?? [], + resolveDefaultAgentId: ( + cfg: { + agents?: { list?: Array<{ id: string; default?: boolean }> }; + } | null, + ) => cfg?.agents?.list?.find((agent) => agent.default)?.id ?? "main", +})); + +vi.mock("../config/bindings.js", () => ({ + isRouteBinding: (binding: { match?: unknown }) => Boolean(binding.match), + listRouteBindings: (cfg: { bindings?: Array<{ match?: unknown }> }) => + (cfg.bindings ?? []).filter((binding) => Boolean(binding.match)), +})); + +type BindingResolverTestPlugin = Pick & { + setup?: Pick, "resolveBindingAccountId">; +}; + +function createBindingResolverTestPlugin(params: { + id: ChannelId; + config: Partial; + resolveBindingAccountId?: NonNullable["resolveBindingAccountId"]; +}): BindingResolverTestPlugin { + return { + id: params.id, + meta: { + id: params.id, + label: params.id, + selectionLabel: params.id, + docsPath: `/channels/${params.id}`, + blurb: "test stub.", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + ...params.config, + }, + ...(params.resolveBindingAccountId + ? { setup: { resolveBindingAccountId: params.resolveBindingAccountId } } + : {}), + }; +} + +vi.mock("../channels/plugins/index.js", () => { const knownChannels = new Map([ [ "discord", @@ -32,21 +78,16 @@ vi.mock("../channels/plugins/index.js", async () => { ], ]); return { - ...actual, getChannelPlugin: (channel: string) => { const normalized = channel.trim().toLowerCase(); - const plugin = knownChannels.get(normalized); - if (plugin) { - return plugin; - } - return actual.getChannelPlugin(channel); + return knownChannels.get(normalized); }, normalizeChannelId: (channel: string) => { const normalized = channel.trim().toLowerCase(); if (knownChannels.has(normalized)) { return normalized; } - return actual.normalizeChannelId(channel); + return undefined; }, }; }); @@ -56,9 +97,12 @@ let agentsBindingsCommand: typeof import("./agents.commands.bind.js").agentsBind let agentsUnbindCommand: typeof import("./agents.commands.bind.js").agentsUnbindCommand; describe("agents bind/unbind commands", () => { - beforeEach(async () => { + beforeAll(async () => { ({ agentsBindCommand, agentsBindingsCommand, agentsUnbindCommand } = await loadFreshAgentsBindCommandModuleForTest()); + }); + + beforeEach(() => { resetAgentsBindTestHarness(); }); @@ -97,47 +141,6 @@ describe("agents bind/unbind commands", () => { expect(runtime.exit).not.toHaveBeenCalled(); }); - it("defaults matrix accountId to the target agent id when omitted", async () => { - readConfigFileSnapshotMock.mockResolvedValue({ - ...baseConfigSnapshot, - config: {}, - }); - - await agentsBindCommand({ agent: "main", bind: ["matrix"] }, runtime); - - expect(writeConfigFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - bindings: [ - { - type: "route", - agentId: "main", - match: { channel: "matrix", accountId: "main" }, - }, - ], - }), - ); - expect(runtime.exit).not.toHaveBeenCalled(); - }); - - it("upgrades existing channel-only binding when accountId is later provided", async () => { - readConfigFileSnapshotMock.mockResolvedValue({ - ...baseConfigSnapshot, - config: { - bindings: [{ agentId: "main", match: { channel: "telegram" } }], - }, - }); - - await agentsBindCommand({ bind: ["telegram:work"] }, runtime); - - expect(writeConfigFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - bindings: [{ agentId: "main", match: { channel: "telegram", accountId: "work" } }], - }), - ); - expect(runtime.log).toHaveBeenCalledWith("Updated bindings:"); - expect(runtime.exit).not.toHaveBeenCalled(); - }); - it("unbinds all routes for an agent", async () => { readConfigFileSnapshotMock.mockResolvedValue({ ...baseConfigSnapshot, @@ -175,47 +178,4 @@ describe("agents bind/unbind commands", () => { expect(runtime.error).toHaveBeenCalledWith("Bindings are owned by another agent:"); expect(runtime.exit).toHaveBeenCalledWith(1); }); - - it("keeps role-based bindings when removing channel-level discord binding", async () => { - readConfigFileSnapshotMock.mockResolvedValue({ - ...baseConfigSnapshot, - config: { - bindings: [ - { - agentId: "main", - match: { - channel: "discord", - accountId: "guild-a", - roles: ["111", "222"], - }, - }, - { - agentId: "main", - match: { - channel: "discord", - accountId: "guild-a", - }, - }, - ], - }, - }); - - await agentsUnbindCommand({ bind: ["discord:guild-a"] }, runtime); - - expect(writeConfigFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - bindings: [ - { - agentId: "main", - match: { - channel: "discord", - accountId: "guild-a", - roles: ["111", "222"], - }, - }, - ], - }), - ); - expect(runtime.exit).not.toHaveBeenCalled(); - }); }); diff --git a/src/commands/agents.bind.test-support.ts b/src/commands/agents.bind.test-support.ts index 5e08c211114..05e85294e46 100644 --- a/src/commands/agents.bind.test-support.ts +++ b/src/commands/agents.bind.test-support.ts @@ -1,7 +1,6 @@ import type { Mock } from "vitest"; import { vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { mergeMockedModule } from "../test-utils/vitest-module-mocks.js"; import { createTestRuntime } from "./test-runtime-config-helpers.js"; type ReplaceConfigFileResult = Awaited< @@ -24,17 +23,22 @@ export const replaceConfigFileMock: Mock<(...args: unknown[]) => Promise Promise>; -vi.mock("../config/config.js", async () => { - const actual = await vi.importActual("../config/config.js"); - return await mergeMockedModule(actual, () => ({ - readConfigFileSnapshot: (...args: Parameters) => - readConfigFileSnapshotMock(...args) as ReturnType, - writeConfigFile: (...args: Parameters) => - writeConfigFileMock(...args) as ReturnType, - replaceConfigFile: (...args: Parameters) => - replaceConfigFileMock(...args) as ReturnType, - })); -}); +vi.mock("../config/config.js", () => ({ + readConfigFileSnapshot: (...args: unknown[]) => readConfigFileSnapshotMock(...args), + writeConfigFile: (...args: unknown[]) => writeConfigFileMock(...args), + replaceConfigFile: (...args: unknown[]) => replaceConfigFileMock(...args), +})); + +vi.mock("./agents.command-shared.js", () => ({ + createQuietRuntime: (runtime: T) => runtime, + requireValidConfig: async () => { + const snapshot = (await readConfigFileSnapshotMock()) as + | { config?: OpenClawConfig; sourceConfig?: OpenClawConfig } + | undefined; + return snapshot?.sourceConfig ?? snapshot?.config ?? null; + }, + requireValidConfigFileSnapshot: async () => readConfigFileSnapshotMock(), +})); export const runtime = createTestRuntime(); diff --git a/src/commands/agents.commands.bind.ts b/src/commands/agents.commands.bind.ts index ab12c45e96d..865c6c41065 100644 --- a/src/commands/agents.commands.bind.ts +++ b/src/commands/agents.commands.bind.ts @@ -1,4 +1,4 @@ -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listAgentEntries, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { isRouteBinding, listRouteBindings } from "../config/bindings.js"; import { replaceConfigFile } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; @@ -6,14 +6,7 @@ import type { AgentRouteBinding } from "../config/types.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; -import { - applyAgentBindings, - describeBinding, - parseBindingSpecs, - removeAgentBindings, -} from "./agents.bindings.js"; import { requireValidConfig, requireValidConfigFileSnapshot } from "./agents.command-shared.js"; -import { buildAgentSummaries } from "./agents.config.js"; type AgentsBindingsListOptions = { agent?: string; @@ -33,6 +26,24 @@ type AgentsUnbindOptions = { json?: boolean; }; +function describeBinding(binding: AgentRouteBinding): string { + const match = binding.match; + const parts = [match.channel]; + if (match.accountId) { + parts.push(`accountId=${match.accountId}`); + } + if (match.peer) { + parts.push(`peer=${match.peer.kind}:${match.peer.id}`); + } + if (match.guildId) { + parts.push(`guild=${match.guildId}`); + } + if (match.teamId) { + parts.push(`team=${match.teamId}`); + } + return parts.join(" "); +} + function resolveAgentId( cfg: Awaited>, agentInput: string | undefined, @@ -54,7 +65,12 @@ function hasAgent(cfg: Awaited>, agentId: if (!cfg) { return false; } - return buildAgentSummaries(cfg).some((summary) => summary.id === agentId); + const targetAgentId = normalizeAgentId(agentId); + const agents = listAgentEntries(cfg); + if (agents.length === 0) { + return targetAgentId === normalizeAgentId(resolveDefaultAgentId(cfg)); + } + return agents.some((agent) => normalizeAgentId(agent.id) === targetAgentId); } function formatBindingOwnerLine(binding: AgentRouteBinding): string { @@ -90,13 +106,16 @@ function formatBindingConflicts( ); } -function resolveParsedBindingsOrExit(params: { +async function resolveParsedBindingsOrExit(params: { runtime: RuntimeEnv; cfg: NonNullable>>; agentId: string; bindValues: string[] | undefined; emptyMessage: string; -}): ReturnType | null { +}): Promise<{ + bindings: AgentRouteBinding[]; + errors: string[]; +} | null> { const specs = (params.bindValues ?? []).map((value) => value.trim()).filter(Boolean); if (specs.length === 0) { params.runtime.error(params.emptyMessage); @@ -104,6 +123,7 @@ function resolveParsedBindingsOrExit(params: { return null; } + const { parseBindingSpecs } = await import("./agents.bindings.js"); const parsed = parseBindingSpecs({ agentId: params.agentId, specs, config: params.cfg }); if (parsed.errors.length > 0) { params.runtime.error(parsed.errors.join("\n")); @@ -217,7 +237,7 @@ export async function agentsBindCommand( } const { cfg, agentId, baseHash } = resolved; - const parsed = resolveParsedBindingsOrExit({ + const parsed = await resolveParsedBindingsOrExit({ runtime, cfg, agentId, @@ -228,6 +248,7 @@ export async function agentsBindCommand( return; } + const { applyAgentBindings } = await import("./agents.bindings.js"); const result = applyAgentBindings(cfg, parsed.bindings); if (result.added.length > 0 || result.updated.length > 0) { await replaceConfigFile({ @@ -336,7 +357,7 @@ export async function agentsUnbindCommand( return; } - const parsed = resolveParsedBindingsOrExit({ + const parsed = await resolveParsedBindingsOrExit({ runtime, cfg, agentId, @@ -347,6 +368,7 @@ export async function agentsUnbindCommand( return; } + const { removeAgentBindings } = await import("./agents.bindings.js"); const result = removeAgentBindings(cfg, parsed.bindings); if (result.removed.length > 0) { await replaceConfigFile({ diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index 87b2eff06a6..86d9fdd73de 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -15,6 +15,11 @@ const resolveManifestProviderAuthChoices = vi.hoisted(() => const resolveProviderWizardOptions = vi.hoisted(() => vi.fn<() => ProviderWizardOption[]>(() => []), ); +const resolveLegacyAuthChoiceAliasesForCli = vi.hoisted(() => vi.fn<() => string[]>(() => [])); + +vi.mock("./auth-choice-legacy.js", () => ({ + resolveLegacyAuthChoiceAliasesForCli, +})); function includesOnboardingScope( scopes: readonly ("text-inference" | "image-generation")[] | undefined, @@ -90,6 +95,7 @@ describe("buildAuthChoiceOptions", () => { beforeEach(() => { resolveManifestProviderAuthChoices.mockReturnValue([]); resolveProviderWizardOptions.mockReturnValue([]); + resolveLegacyAuthChoiceAliasesForCli.mockReturnValue([]); }); it("includes core and provider-specific auth choices", () => { @@ -316,24 +322,7 @@ describe("buildAuthChoiceOptions", () => { }); it("can include legacy aliases in cli help choices", () => { - resolveManifestProviderAuthChoices.mockReturnValue([ - { - pluginId: "anthropic", - providerId: "anthropic", - methodId: "cli", - choiceId: "anthropic-cli", - choiceLabel: "Anthropic Claude CLI", - deprecatedChoiceIds: ["claude-cli"], - }, - { - pluginId: "openai", - providerId: "openai-codex", - methodId: "oauth", - choiceId: "openai-codex", - choiceLabel: "OpenAI Codex (ChatGPT OAuth)", - deprecatedChoiceIds: ["codex-cli"], - }, - ]); + resolveLegacyAuthChoiceAliasesForCli.mockReturnValue(["claude-cli", "codex-cli"]); const cliChoices = formatAuthChoiceChoicesForCli({ includeLegacyAliases: true, @@ -377,7 +366,7 @@ describe("buildAuthChoiceOptions", () => { expect(cliChoices).toContain("skip"); }); - it("shows Chutes in grouped provider selection", () => { + it("shows plugin and wizard providers in grouped selection", () => { resolveManifestProviderAuthChoices.mockReturnValue([ { pluginId: "chutes", @@ -388,19 +377,6 @@ describe("buildAuthChoiceOptions", () => { groupId: "chutes", groupLabel: "Chutes", }, - ]); - const { groups } = buildAuthChoiceGroups({ - store: EMPTY_STORE, - includeSkip: false, - }); - const chutesGroup = groups.find((group) => group.value === "chutes"); - - expect(chutesGroup).toBeDefined(); - expect(chutesGroup?.options.some((opt) => opt.value === "chutes")).toBe(true); - }); - - it("shows LiteLLM in grouped provider selection", () => { - resolveManifestProviderAuthChoices.mockReturnValue([ { pluginId: "litellm", providerId: "litellm", @@ -411,14 +387,29 @@ describe("buildAuthChoiceOptions", () => { groupLabel: "LiteLLM", }, ]); + resolveProviderWizardOptions.mockReturnValue([ + { + value: "ollama", + label: "Ollama", + hint: "Cloud and local open models", + groupId: "ollama", + groupLabel: "Ollama", + }, + ]); const { groups } = buildAuthChoiceGroups({ store: EMPTY_STORE, includeSkip: false, }); + const chutesGroup = groups.find((group) => group.value === "chutes"); const litellmGroup = groups.find((group) => group.value === "litellm"); + const ollamaGroup = groups.find((group) => group.value === "ollama"); + expect(chutesGroup).toBeDefined(); + expect(chutesGroup?.options.some((opt) => opt.value === "chutes")).toBe(true); expect(litellmGroup).toBeDefined(); expect(litellmGroup?.options.some((opt) => opt.value === "litellm-api-key")).toBe(true); + expect(ollamaGroup).toBeDefined(); + expect(ollamaGroup?.options.some((opt) => opt.value === "ollama")).toBe(true); }); it("prefers Anthropic Claude CLI over API key in grouped selection", () => { @@ -488,27 +479,6 @@ describe("buildAuthChoiceOptions", () => { expect(openCodeGroup?.options.some((opt) => opt.value === "opencode-go")).toBe(true); }); - it("shows Ollama in grouped provider selection", () => { - resolveManifestProviderAuthChoices.mockReturnValue([]); - resolveProviderWizardOptions.mockReturnValue([ - { - value: "ollama", - label: "Ollama", - hint: "Cloud and local open models", - groupId: "ollama", - groupLabel: "Ollama", - }, - ]); - const { groups } = buildAuthChoiceGroups({ - store: EMPTY_STORE, - includeSkip: false, - }); - const ollamaGroup = groups.find((group) => group.value === "ollama"); - - expect(ollamaGroup).toBeDefined(); - expect(ollamaGroup?.options.some((opt) => opt.value === "ollama")).toBe(true); - }); - it("hides image-generation-only providers from the interactive auth picker", () => { resolveManifestProviderAuthChoices.mockReturnValue([ { diff --git a/src/commands/auth-choice.apply-helpers.test.ts b/src/commands/auth-choice.apply-helpers.test.ts deleted file mode 100644 index da4517e244e..00000000000 --- a/src/commands/auth-choice.apply-helpers.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { WizardPrompter } from "../wizard/prompts.js"; -import { - ensureApiKeyFromOptionEnvOrPrompt, - ensureApiKeyFromEnvOrPrompt, - maybeApplyApiKeyFromOption, - normalizeTokenProviderInput, -} from "./auth-choice.apply-helpers.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/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index f09b2cf2397..ac778ee1ce8 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -127,6 +127,89 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { expect(runProviderModelSelectedHook).not.toHaveBeenCalled(); }); + it("keeps provider config patches when default model application is deferred", async () => { + const provider: ProviderPlugin = { + id: "moonshot", + label: "Moonshot", + auth: [ + { + id: "api-key-cn", + label: "Moonshot API key (.cn)", + kind: "api_key", + run: async () => ({ + profiles: [ + { + profileId: "moonshot:default", + credential: { + type: "api_key", + provider: "moonshot", + key: "sk-moonshot-cn-test", + }, + }, + ], + configPatch: { + models: { + providers: { + moonshot: { + api: "openai-completions", + baseUrl: "https://api.moonshot.cn/v1", + models: [ + { + id: "kimi-k2.5", + name: "kimi-k2.5", + input: ["text", "image"], + reasoning: true, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8192, + }, + ], + }, + }, + }, + }, + defaultModel: "moonshot/kimi-k2.5", + }), + }, + ], + }; + resolvePluginProviders.mockReturnValue([provider]); + resolveProviderPluginChoice.mockReturnValue({ + provider, + method: provider.auth[0], + }); + + const result = await applyAuthChoiceLoadedPluginProvider( + buildParams({ + config: { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-6" }, + }, + }, + }, + setDefaultModel: false, + }), + ); + + expect(result?.agentModelOverride).toBe("moonshot/kimi-k2.5"); + expect(result?.config.agents?.defaults?.model).toEqual({ + primary: "anthropic/claude-opus-4-6", + }); + expect(result?.config.models?.providers?.moonshot?.baseUrl).toBe("https://api.moonshot.cn/v1"); + expect(result?.config.models?.providers?.moonshot?.models?.[0]?.input).toContain("image"); + expect(upsertAuthProfile).toHaveBeenCalledWith({ + profileId: "moonshot:default", + credential: { + type: "api_key", + provider: "moonshot", + key: "sk-moonshot-cn-test", + }, + agentDir: "/tmp/agent", + }); + expect(runProviderModelSelectedHook).not.toHaveBeenCalled(); + }); + it("applies the default model and runs provider post-setup hooks", async () => { const provider = buildProvider(); resolvePluginProviders.mockReturnValue([provider]); diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index b8c4669bc90..a330d364e55 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -1,41 +1,66 @@ import { applyAuthChoiceLoadedPluginProvider } from "../plugins/provider-auth-choice.js"; -import { normalizeLegacyOnboardAuthChoice } from "./auth-choice-legacy.js"; -import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js"; -import { normalizeApiKeyTokenProviderAuthChoice } from "./auth-choice.apply.api-providers.js"; -import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.types.js"; +import type { AuthChoice } from "./onboard-types.js"; export type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.types.js"; +async function normalizeLegacyChoice( + authChoice: AuthChoice | undefined, + params: Pick, +): Promise { + if (authChoice === "oauth") { + return "setup-token"; + } + if (typeof authChoice !== "string" || !authChoice.endsWith("-cli")) { + return authChoice; + } + const { normalizeLegacyOnboardAuthChoice } = await import("./auth-choice-legacy.js"); + return normalizeLegacyOnboardAuthChoice(authChoice, params); +} + +async function normalizeTokenProviderChoice(params: { + authChoice: AuthChoice; + source: ApplyAuthChoiceParams; +}): Promise { + if (!params.source.opts?.tokenProvider) { + return params.authChoice; + } + if ( + params.authChoice !== "apiKey" && + params.authChoice !== "token" && + params.authChoice !== "setup-token" + ) { + return params.authChoice; + } + const { normalizeApiKeyTokenProviderAuthChoice } = + await import("./auth-choice.apply.api-providers.js"); + return normalizeApiKeyTokenProviderAuthChoice({ + authChoice: params.authChoice, + tokenProvider: params.source.opts.tokenProvider, + config: params.source.config, + env: params.source.env, + }); +} + export async function applyAuthChoice( params: ApplyAuthChoiceParams, ): Promise { const normalizedAuthChoice = - normalizeLegacyOnboardAuthChoice(params.authChoice, { + (await normalizeLegacyChoice(params.authChoice, { config: params.config, env: params.env, - }) ?? params.authChoice; - const normalizedProviderAuthChoice = normalizeApiKeyTokenProviderAuthChoice({ + })) ?? params.authChoice; + const normalizedProviderAuthChoice = await normalizeTokenProviderChoice({ authChoice: normalizedAuthChoice, - tokenProvider: params.opts?.tokenProvider, - config: params.config, - env: params.env, + source: params, }); const normalizedParams = normalizedProviderAuthChoice === params.authChoice ? params : { ...params, authChoice: normalizedProviderAuthChoice }; - const handlers: Array<(p: ApplyAuthChoiceParams) => Promise> = [ - applyAuthChoiceLoadedPluginProvider, - applyAuthChoiceOAuth, - applyAuthChoiceApiProviders, - ]; - - for (const handler of handlers) { - const result = await handler(normalizedParams); - if (result) { - return result; - } + const result = await applyAuthChoiceLoadedPluginProvider(normalizedParams); + if (result) { + return result; } if (normalizedParams.authChoice === "token" || normalizedParams.authChoice === "setup-token") { diff --git a/src/commands/auth-choice.moonshot.test.ts b/src/commands/auth-choice.moonshot.test.ts deleted file mode 100644 index b985f985f78..00000000000 --- a/src/commands/auth-choice.moonshot.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; -import { __testing as providerAuthChoiceTesting } from "../plugins/provider-auth-choice.js"; -import type { ProviderAuthContext, ProviderPlugin } from "../plugins/types.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; -import { applyAuthChoice } from "./auth-choice.js"; -import { - createAuthTestLifecycle, - createExitThrowingRuntime, - createWizardPrompter, - readAuthProfilesForAgent, - requireOpenClawAgentDir, - setupAuthTestEnv, -} from "./test-wizard-helpers.js"; - -function createPrompter(overrides: Partial): WizardPrompter { - return createWizardPrompter(overrides, { defaultSelect: "" }); -} - -describe("applyAuthChoice (moonshot)", () => { - const lifecycle = createAuthTestLifecycle([ - "OPENCLAW_STATE_DIR", - "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", - "MOONSHOT_API_KEY", - ]); - - async function setupTempState() { - const env = await setupAuthTestEnv("openclaw-auth-"); - lifecycle.setStateDir(env.stateDir); - delete process.env.MOONSHOT_API_KEY; - providerAuthChoiceTesting.setDepsForTest({ - loadPluginProviderRuntime: async () => - ({ - resolvePluginProviders: () => - [ - { - id: "moonshot", - label: "Moonshot", - auth: [ - { - id: "api-key-cn", - label: "Moonshot API key (.cn)", - kind: "api_key", - run: async ({ prompter }: ProviderAuthContext) => { - const key = await prompter.text({ - message: "Enter Moonshot API key (.cn)", - }); - return { - profiles: [ - { - profileId: "moonshot:default", - credential: { - type: "api_key", - provider: "moonshot", - key, - }, - }, - ], - configPatch: { - models: { - providers: { - moonshot: { - api: "openai-completions", - baseUrl: "https://api.moonshot.cn/v1", - models: [ - { - id: "kimi-k2.5", - name: "kimi-k2.5", - input: ["text", "image"], - reasoning: true, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 8192, - }, - ], - }, - }, - }, - }, - defaultModel: "moonshot/kimi-k2.5", - }; - }, - }, - ], - }, - ] as ProviderPlugin[], - resolveProviderPluginChoice: ({ - choice, - providers, - }: { - choice: string; - providers: ProviderPlugin[]; - }) => - choice === "moonshot-api-key-cn" - ? { provider: providers[0], method: providers[0]?.auth[0] } - : null, - runProviderModelSelectedHook: async () => {}, - }) as never, - }); - } - - async function readAuthProfiles() { - return await readAuthProfilesForAgent<{ - profiles?: Record; - }>(requireOpenClawAgentDir()); - } - - async function runMoonshotCnFlow(params: { - config: Record; - setDefaultModel: boolean; - }) { - const text = vi.fn().mockResolvedValue("sk-moonshot-cn-test"); - const prompter = createPrompter({ text: text as unknown as WizardPrompter["text"] }); - const runtime = createExitThrowingRuntime(); - const result = await applyAuthChoice({ - authChoice: "moonshot-api-key-cn", - config: params.config, - prompter, - runtime, - setDefaultModel: params.setDefaultModel, - }); - return { result, text }; - } - - afterEach(async () => { - providerAuthChoiceTesting.resetDepsForTest(); - await lifecycle.cleanup(); - }); - - it("keeps the .cn baseUrl when setDefaultModel is false", async () => { - await setupTempState(); - - const { result, text } = await runMoonshotCnFlow({ - config: { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-6" }, - }, - }, - }, - setDefaultModel: false, - }); - - expect(text).toHaveBeenCalledWith( - expect.objectContaining({ message: "Enter Moonshot API key (.cn)" }), - ); - expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( - "anthropic/claude-opus-4-6", - ); - expect(result.config.models?.providers?.moonshot?.baseUrl).toBe("https://api.moonshot.cn/v1"); - expect(result.config.models?.providers?.moonshot?.models?.[0]?.input).toContain("image"); - expect(result.agentModelOverride).toBe("moonshot/kimi-k2.5"); - - const parsed = await readAuthProfiles(); - expect(parsed.profiles?.["moonshot:default"]?.key).toBe("sk-moonshot-cn-test"); - }); - - it("sets the default model when setDefaultModel is true", async () => { - await setupTempState(); - - const { result } = await runMoonshotCnFlow({ - config: {}, - setDefaultModel: true, - }); - - expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( - "moonshot/kimi-k2.5", - ); - expect(result.config.models?.providers?.moonshot?.baseUrl).toBe("https://api.moonshot.cn/v1"); - expect(result.config.models?.providers?.moonshot?.models?.[0]?.input).toContain("image"); - expect(result.agentModelOverride).toBeUndefined(); - - const parsed = await readAuthProfiles(); - expect(parsed.profiles?.["moonshot:default"]?.key).toBe("sk-moonshot-cn-test"); - }); -}); diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 4f87a874619..d2fce6de550 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { resolveAgentDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -20,17 +19,9 @@ import { type DetectZaiEndpoint = typeof import("../plugins/provider-zai-endpoint.js").detectZaiEndpoint; const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview"; -const MINIMAX_CN_API_BASE_URL = "https://api.minimax.chat/v1"; const ZAI_CODING_GLOBAL_BASE_URL = "https://api.z.ai/api/coding/paas/v4"; const ZAI_CODING_CN_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; -const loginOpenAICodexOAuth = vi.hoisted(() => - vi.fn<() => Promise>(async () => null), -); -vi.mock("../plugins/provider-openai-codex-oauth.js", () => ({ - loginOpenAICodexOAuth, -})); - const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../plugins/provider-auth-choice.runtime.js", () => { @@ -71,11 +62,98 @@ vi.mock("../plugins/provider-auth-choice.runtime.js", () => { }; }); +vi.mock("./auth-choice.apply.api-providers.js", () => { + const normalizeProviderId = (value: string) => value.trim().toLowerCase(); + const resolveChoiceByKind = (params: { + authChoice: string; + kind: ProviderAuthMethod["kind"]; + tokenProvider?: string; + }) => { + const providerId = normalizeProviderId(params.tokenProvider ?? ""); + if (!providerId) { + return params.authChoice; + } + const provider = resolvePluginProviders().find( + (entry) => normalizeProviderId(entry.id) === providerId, + ); + return ( + provider?.auth.find((method) => method.kind === params.kind)?.wizard?.choiceId ?? + params.authChoice + ); + }; + return { + applyAuthChoiceApiProviders: vi.fn(async () => null), + normalizeApiKeyTokenProviderAuthChoice: (params: { + authChoice: string; + tokenProvider?: string; + }) => { + if (params.authChoice === "token" || params.authChoice === "setup-token") { + return resolveChoiceByKind({ ...params, kind: "token" }); + } + if (params.authChoice === "apiKey") { + return resolveChoiceByKind({ ...params, kind: "api_key" }); + } + return params.authChoice; + }, + }; +}); + const detectZaiEndpoint = vi.hoisted(() => vi.fn(async () => null)); vi.mock("../plugins/provider-zai-endpoint.js", () => ({ detectZaiEndpoint, })); +vi.mock("../agents/agent-paths.js", () => ({ + resolveOpenClawAgentDir: () => process.env.OPENCLAW_AGENT_DIR ?? "/tmp/openclaw-agent", +})); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveDefaultAgentId: () => "main", + resolveAgentDir: (_config: unknown, agentId: string) => `/tmp/openclaw-agents/${agentId}`, + resolveAgentWorkspaceDir: (_config: unknown, agentId: string) => + `/tmp/openclaw-workspaces/${agentId}`, +})); + +vi.mock("../agents/workspace.js", () => ({ + resolveDefaultAgentWorkspaceDir: () => "/tmp/openclaw-workspace", +})); + +vi.mock("../plugins/setup-browser.js", () => ({ + isRemoteEnvironment: () => false, + openUrl: vi.fn(async () => {}), +})); + +vi.mock("../plugins/provider-oauth-flow.js", () => ({ + createVpsAwareOAuthHandlers: vi.fn(), +})); + +vi.mock("../plugins/provider-auth-helpers.js", () => ({ + applyAuthProfileConfig: ( + cfg: OpenClawConfig, + params: { + profileId: string; + provider: string; + mode: "api_key" | "oauth" | "token"; + email?: string; + displayName?: string; + }, + ): OpenClawConfig => ({ + ...cfg, + auth: { + ...cfg.auth, + profiles: { + ...cfg.auth?.profiles, + [params.profileId]: { + provider: params.provider, + mode: params.mode, + ...(params.email ? { email: params.email } : {}), + ...(params.displayName ? { displayName: params.displayName } : {}), + }, + }, + }, + }), +})); + type StoredAuthProfile = { key?: string; token?: string; @@ -143,7 +221,8 @@ function providerConfigPatch( }; } -type TestSecretInput = string | { source: string; provider: string; id: string }; +type TestSecretRef = { source: "env"; provider: string; id: string }; +type TestSecretInput = string | TestSecretRef; function normalizeProviderInput(value: unknown): string | undefined { const normalized = normalizeText(value).toLowerCase(); @@ -158,7 +237,7 @@ function buildApiKeyCredential( type: "api_key"; provider: string; key?: string; - keyRef?: { source: string; provider: string; id: string }; + keyRef?: TestSecretRef; metadata?: Record; } { if (typeof input === "string") { @@ -383,112 +462,7 @@ async function createDefaultProviderPlugins(): Promise { }, }); - const cloudflareAiGatewayMethod: ProviderAuthMethod = { - id: "api-key", - label: "Cloudflare AI Gateway API key", - kind: "api_key", - wizard: { - choiceId: "cloudflare-ai-gateway-api-key", - choiceLabel: "Cloudflare AI Gateway API key", - groupId: "cloudflare-ai-gateway", - groupLabel: "Cloudflare AI Gateway", - }, - run: async (ctx) => { - const opts = (ctx.opts ?? {}) as Record; - const accountId = - normalizeText(opts.cloudflareAiGatewayAccountId) || - normalizeText(await ctx.prompter.text({ message: "Enter Cloudflare account ID" })); - const gatewayId = - normalizeText(opts.cloudflareAiGatewayGatewayId) || - normalizeText(await ctx.prompter.text({ message: "Enter Cloudflare gateway ID" })); - const secretContext = { - ...ctx, - secretInputMode: - ctx.allowSecretRefPrompt === false - ? (ctx.secretInputMode ?? "plaintext") - : ctx.secretInputMode, - }; - const { input } = await resolveApiKeyInput({ - ctx: secretContext, - providerId: "cloudflare-ai-gateway", - expectedProviders: ["cloudflare-ai-gateway"], - optionKey: "cloudflareAiGatewayApiKey", - envVar: "CLOUDFLARE_AI_GATEWAY_API_KEY", - promptMessage: "Enter Cloudflare AI Gateway API key", - }); - return { - profiles: [ - { - profileId: "cloudflare-ai-gateway:default", - credential: buildApiKeyCredential("cloudflare-ai-gateway", input, { - accountId, - gatewayId, - }), - }, - ], - defaultModel: "cloudflare-ai-gateway/claude-sonnet-4-5", - }; - }, - }; - - const chutesOAuthMethod: ProviderAuthMethod = { - id: "oauth", - label: "Chutes OAuth", - kind: "device_code", - wizard: { - choiceId: "chutes", - choiceLabel: "Chutes", - groupId: "chutes", - groupLabel: "Chutes", - }, - run: async (ctx) => { - const state = "state-test"; - ctx.runtime.log(`Open this URL: https://api.chutes.ai/idp/authorize?state=${state}`); - const redirect = await ctx.prompter.text({ message: "Paste the redirect URL or code" }); - const params = new URLSearchParams(redirect.startsWith("?") ? redirect.slice(1) : redirect); - const code = params.get("code") ?? redirect; - const tokenResponse = await fetch("https://api.chutes.ai/idp/token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code, client_id: process.env.CHUTES_CLIENT_ID }), - }); - const tokenJson = (await tokenResponse.json()) as { - access_token: string; - refresh_token: string; - expires_in: number; - }; - const userResponse = await fetch("https://api.chutes.ai/idp/userinfo", { - headers: { Authorization: `Bearer ${tokenJson.access_token}` }, - }); - const userJson = (await userResponse.json()) as { username: string }; - return { - profiles: [ - { - profileId: `chutes:${userJson.username}`, - credential: { - type: "oauth", - provider: "chutes", - access: tokenJson.access_token, - refresh: tokenJson.refresh_token, - expires: Date.now() + tokenJson.expires_in * 1000, - email: userJson.username, - }, - }, - ], - }; - }, - }; - return [ - await createApiKeyProvider({ - providerId: "anthropic", - label: "Anthropic API key", - choiceId: "apiKey", - optionKey: "anthropicApiKey", - flagName: "--anthropic-api-key", - envVar: "ANTHROPIC_API_KEY", - promptMessage: "Enter Anthropic API key", - }), await createApiKeyProvider({ providerId: "google", label: "Gemini API key", @@ -509,71 +483,6 @@ async function createDefaultProviderPlugins(): Promise { promptMessage: "Enter Hugging Face API key", defaultModel: "huggingface/Qwen/Qwen3-Coder-480B-A35B-Instruct", }), - await createApiKeyProvider({ - providerId: "litellm", - label: "LiteLLM API key", - choiceId: "litellm-api-key", - optionKey: "litellmApiKey", - flagName: "--litellm-api-key", - envVar: "LITELLM_API_KEY", - promptMessage: "Enter LiteLLM API key", - defaultModel: "litellm/anthropic/claude-opus-4.6", - }), - await createApiKeyProvider({ - providerId: "minimax", - label: "MiniMax API key (Global)", - choiceId: "minimax-global-api", - optionKey: "minimaxApiKey", - flagName: "--minimax-api-key", - envVar: "MINIMAX_API_KEY", - promptMessage: "Enter MiniMax API key", - profileId: "minimax:global", - defaultModel: "minimax/MiniMax-M2.7", - }), - await createApiKeyProvider({ - providerId: "minimax", - label: "MiniMax API key (CN)", - choiceId: "minimax-cn-api", - optionKey: "minimaxApiKey", - flagName: "--minimax-api-key", - envVar: "MINIMAX_API_KEY", - promptMessage: "Enter MiniMax CN API key", - profileId: "minimax:cn", - defaultModel: "minimax/MiniMax-M2.7", - applyConfig: providerConfigPatch("minimax", { baseUrl: MINIMAX_CN_API_BASE_URL }), - expectedProviders: ["minimax", "minimax-cn"], - }), - await createApiKeyProvider({ - providerId: "mistral", - label: "Mistral API key", - choiceId: "mistral-api-key", - optionKey: "mistralApiKey", - flagName: "--mistral-api-key", - envVar: "MISTRAL_API_KEY", - promptMessage: "Enter Mistral API key", - defaultModel: "mistral/mistral-large-latest", - }), - await createApiKeyProvider({ - providerId: "moonshot", - label: "Moonshot API key", - choiceId: "moonshot-api-key", - optionKey: "moonshotApiKey", - flagName: "--moonshot-api-key", - envVar: "MOONSHOT_API_KEY", - promptMessage: "Enter Moonshot API key", - defaultModel: "moonshot/moonshot-v1-128k", - }), - createFixedChoiceProvider({ - providerId: "ollama", - label: "Ollama", - choiceId: "ollama", - method: { - id: "local", - label: "Ollama", - kind: "custom", - run: async () => ({ profiles: [] }), - }, - }), await createApiKeyProvider({ providerId: "openai", label: "OpenAI API key", @@ -622,16 +531,6 @@ async function createDefaultProviderPlugins(): Promise { promptMessage: "Enter OpenRouter API key", defaultModel: "openrouter/auto", }), - await createApiKeyProvider({ - providerId: "qianfan", - label: "Qianfan API key", - choiceId: "qianfan-api-key", - optionKey: "qianfanApiKey", - flagName: "--qianfan-api-key", - envVar: "QIANFAN_API_KEY", - promptMessage: "Enter Qianfan API key", - defaultModel: "qianfan/ernie-4.5-8k", - }), await createApiKeyProvider({ providerId: "synthetic", label: "Synthetic API key", @@ -642,95 +541,11 @@ async function createDefaultProviderPlugins(): Promise { promptMessage: "Enter Synthetic API key", defaultModel: "synthetic/Synthetic-1", }), - await createApiKeyProvider({ - providerId: "together", - label: "Together API key", - choiceId: "together-api-key", - optionKey: "togetherApiKey", - flagName: "--together-api-key", - envVar: "TOGETHER_API_KEY", - promptMessage: "Enter Together API key", - defaultModel: "together/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", - }), - await createApiKeyProvider({ - providerId: "venice", - label: "Venice AI", - choiceId: "venice-api-key", - optionKey: "veniceApiKey", - flagName: "--venice-api-key", - envVar: "VENICE_API_KEY", - promptMessage: "Enter Venice AI API key", - defaultModel: "venice/venice-uncensored", - noteMessage: "Venice is a privacy-focused inference service.", - noteTitle: "Venice AI", - }), - await createApiKeyProvider({ - providerId: "vercel-ai-gateway", - label: "AI Gateway API key", - choiceId: "ai-gateway-api-key", - optionKey: "aiGatewayApiKey", - flagName: "--ai-gateway-api-key", - envVar: "AI_GATEWAY_API_KEY", - promptMessage: "Enter AI Gateway API key", - defaultModel: "vercel-ai-gateway/anthropic/claude-opus-4.6", - }), - await createApiKeyProvider({ - providerId: "xai", - label: "xAI API key", - choiceId: "xai-api-key", - optionKey: "xaiApiKey", - flagName: "--xai-api-key", - envVar: "XAI_API_KEY", - promptMessage: "Enter xAI API key", - defaultModel: "xai/grok-4", - }), - await createApiKeyProvider({ - providerId: "xiaomi", - label: "Xiaomi API key", - choiceId: "xiaomi-api-key", - optionKey: "xiaomiApiKey", - flagName: "--xiaomi-api-key", - envVar: "XIAOMI_API_KEY", - promptMessage: "Enter Xiaomi API key", - defaultModel: "xiaomi/mimo-v2-flash", - }), { id: "zai", label: "Z.AI", auth: [createZaiMethod("zai-api-key"), createZaiMethod("zai-coding-global")], }, - { - id: "cloudflare-ai-gateway", - label: "Cloudflare AI Gateway", - auth: [cloudflareAiGatewayMethod], - }, - { - id: "chutes", - label: "Chutes", - auth: [chutesOAuthMethod], - }, - await createApiKeyProvider({ - providerId: "kimi", - label: "Kimi Code API key", - choiceId: "kimi-code-api-key", - optionKey: "kimiApiKey", - flagName: "--kimi-api-key", - envVar: "KIMI_API_KEY", - promptMessage: "Enter Kimi Code API key", - defaultModel: "kimi/kimi-k2.5", - expectedProviders: ["kimi", "kimi-code", "kimi-coding"], - }), - createFixedChoiceProvider({ - providerId: "github-copilot", - label: "GitHub Copilot", - choiceId: "github-copilot", - method: { - id: "device", - label: "GitHub device login", - kind: "device_code", - run: async () => ({ profiles: [] }), - }, - }), ]; } @@ -743,21 +558,9 @@ describe("applyAuthChoice", () => { "OPENROUTER_API_KEY", "HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", - "LITELLM_API_KEY", - "AI_GATEWAY_API_KEY", - "CLOUDFLARE_AI_GATEWAY_API_KEY", - "MOONSHOT_API_KEY", - "MISTRAL_API_KEY", - "KIMI_API_KEY", "GEMINI_API_KEY", - "XIAOMI_API_KEY", - "VENICE_API_KEY", "OPENCODE_API_KEY", - "TOGETHER_API_KEY", - "QIANFAN_API_KEY", "SYNTHETIC_API_KEY", - "SSH_TTY", - "CHUTES_CLIENT_ID", ]); let authTestRoot: string | null = null; let authStateCounter = 0; @@ -829,8 +632,6 @@ describe("applyAuthChoice", () => { runProviderModelSelectedHook.mockClear(); detectZaiEndpoint.mockReset(); detectZaiEndpoint.mockResolvedValue(null); - loginOpenAICodexOAuth.mockReset(); - loginOpenAICodexOAuth.mockResolvedValue(null); testAuthProfileStores.clear(); await lifecycle.cleanup(); }); @@ -890,131 +691,14 @@ describe("applyAuthChoice", () => { ); }); - it("does not throw when openai-codex oauth fails", async () => { - await setupTempState(); - - loginOpenAICodexOAuth.mockRejectedValueOnce(new Error("oauth failed")); - resolvePluginProviders.mockReturnValue([ - { - id: "openai-codex", - label: "OpenAI Codex", - auth: [ - { - id: "oauth", - label: "ChatGPT OAuth", - kind: "oauth", - run: vi.fn(async () => { - try { - await loginOpenAICodexOAuth(); - } catch { - return { profiles: [] }; - } - return { profiles: [] }; - }), - }, - ], - }, - ] as never); - - const prompter = createPrompter({}); - const runtime = createExitThrowingRuntime(); - - await expect( - applyAuthChoice({ - authChoice: "openai-codex", - config: {}, - prompter, - runtime, - setDefaultModel: false, - }), - ).resolves.toEqual({ config: {} }); - }); - - it("stores openai-codex OAuth with email profile id", async () => { - await setupTempState(); - - loginOpenAICodexOAuth.mockResolvedValueOnce({ - email: "user@example.com", - refresh: "refresh-token", - access: "access-token", - expires: Date.now() + 60_000, - }); - resolvePluginProviders.mockReturnValue([ - { - id: "openai-codex", - label: "OpenAI Codex", - auth: [ - { - id: "oauth", - label: "ChatGPT OAuth", - kind: "oauth", - run: vi.fn(async () => { - const creds = await loginOpenAICodexOAuth(); - if (!creds) { - return { profiles: [] }; - } - return { - profiles: [ - { - profileId: "openai-codex:user@example.com", - credential: { - type: "oauth", - provider: "openai-codex", - refresh: "refresh-token", - access: "access-token", - expires: creds.expires, - email: "user@example.com", - }, - }, - ], - defaultModel: "openai-codex/gpt-5.4", - }; - }), - }, - ], - }, - ] as never); - - const prompter = createPrompter({}); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoice({ - authChoice: "openai-codex", - config: {}, - prompter, - runtime, - setDefaultModel: false, - }); - - expect(result.config.auth?.profiles?.["openai-codex:user@example.com"]).toMatchObject({ - provider: "openai-codex", - mode: "oauth", - }); - expect(result.config.auth?.profiles?.["openai-codex:default"]).toBeUndefined(); - expect(await readAuthProfile("openai-codex:user@example.com")).toMatchObject({ - type: "oauth", - provider: "openai-codex", - refresh: "refresh-token", - access: "access-token", - email: "user@example.com", - }); - }); - it("prompts and writes provider API key profiles for common providers", async () => { const scenarios: Array<{ - authChoice: "minimax-global-api" | "huggingface-api-key"; + authChoice: "huggingface-api-key"; promptContains: string; profileId: string; provider: string; token: string; }> = [ - { - authChoice: "minimax-global-api" as const, - promptContains: "Enter MiniMax API key", - profileId: "minimax:global", - provider: "minimax", - token: "sk-minimax-test", - }, { authChoice: "huggingface-api-key" as const, promptContains: "Hugging Face", @@ -1079,18 +763,6 @@ describe("applyAuthChoice", () => { shouldPromptForEndpoint: false, expectedDetectCall: { apiKey: "zai-test-key", endpoint: "coding-global" }, }, - { - authChoice: "zai-api-key", - token: "zai-detected-key", - detectResult: { - endpoint: "coding-global", - modelId: "glm-4.5", - baseUrl: ZAI_CODING_GLOBAL_BASE_URL, - note: "Detected coding-global endpoint", - }, - shouldPromptForEndpoint: false, - expectedDetectCall: { apiKey: "zai-detected-key" }, - }, ]; await setupTempState(); for (const scenario of scenarios) { @@ -1142,7 +814,7 @@ describe("applyAuthChoice", () => { it("uses provided tokens without prompting across alias and direct provider choices", async () => { const scenarios: Array<{ - authChoice: "apiKey" | "opencode-zen" | "gemini-api-key"; + authChoice: "apiKey" | "gemini-api-key"; config?: OpenClawConfig; setDefaultModel: boolean; tokenProvider: string; @@ -1163,16 +835,6 @@ describe("applyAuthChoice", () => { provider: "google", expectedModel: GOOGLE_GEMINI_DEFAULT_MODEL, }, - { - authChoice: "opencode-zen", - setDefaultModel: true, - tokenProvider: "opencode", - token: "sk-opencode-test", - profileId: "opencode:default", - provider: "opencode", - expectedModelPrefix: "opencode/", - extraProfiles: ["opencode-go:default"], - }, { authChoice: "gemini-api-key", config: { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } } }, @@ -1229,51 +891,16 @@ describe("applyAuthChoice", () => { } }); - it("prompts for Venice API key and shows the Venice note when no token is provided", async () => { - await setupTempState(); - process.env.VENICE_API_KEY = ""; - - const note = vi.fn(async () => {}); - const text = vi.fn(async () => "sk-venice-manual"); - const prompter = createPrompter({ note, text }); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoice({ - authChoice: "venice-api-key", - config: {}, - prompter, - runtime, - setDefaultModel: true, - }); - - expect(note).toHaveBeenCalledWith( - expect.stringContaining("privacy-focused inference"), - "Venice AI", - ); - expect(text).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Enter Venice AI API key", - }), - ); - expect(result.config.auth?.profiles?.["venice:default"]).toMatchObject({ - provider: "venice", - mode: "api_key", - }); - expect((await readAuthProfile("venice:default"))?.key).toBe("sk-venice-manual"); - }); - it("uses existing env API keys for selected providers", async () => { const scenarios: Array<{ - authChoice: "openrouter-api-key" | "ai-gateway-api-key"; - envKey: "OPENROUTER_API_KEY" | "AI_GATEWAY_API_KEY"; + authChoice: "openrouter-api-key"; + envKey: "OPENROUTER_API_KEY"; envValue: string; profileId: string; provider: string; - opts?: { secretInputMode?: "ref" }; expectEnvPrompt: boolean; expectedTextCalls: number; expectedKey?: string; - expectedKeyRef?: { source: "env"; provider: string; id: string }; expectedModel?: string; }> = [ { @@ -1287,24 +914,11 @@ describe("applyAuthChoice", () => { expectedKey: "sk-openrouter-test", expectedModel: "openrouter/auto", }, - { - authChoice: "ai-gateway-api-key", - envKey: "AI_GATEWAY_API_KEY", - envValue: "gateway-ref-key", - profileId: "vercel-ai-gateway:default", - provider: "vercel-ai-gateway", - opts: { secretInputMode: "ref" }, // pragma: allowlist secret - expectEnvPrompt: false, - expectedTextCalls: 1, - expectedKeyRef: { source: "env", provider: "default", id: "AI_GATEWAY_API_KEY" }, - expectedModel: "vercel-ai-gateway/anthropic/claude-opus-4.6", - }, ]; await setupTempState(); for (const scenario of scenarios) { delete process.env.SYNTHETIC_API_KEY; delete process.env.OPENROUTER_API_KEY; - delete process.env.AI_GATEWAY_API_KEY; process.env[scenario.envKey] = scenario.envValue; const text = vi.fn(); @@ -1317,7 +931,6 @@ describe("applyAuthChoice", () => { prompter, runtime, setDefaultModel: true, - opts: scenario.opts, }); if (scenario.expectEnvPrompt) { @@ -1340,79 +953,11 @@ describe("applyAuthChoice", () => { ); } const profile = await readAuthProfile(scenario.profileId); - if (scenario.expectedKeyRef) { - expect(profile?.keyRef).toEqual(scenario.expectedKeyRef); - expect(profile?.key).toBeUndefined(); - } else { - expect(profile?.key).toBe(scenario.expectedKey); - expect(profile?.keyRef).toBeUndefined(); - } + expect(profile?.key).toBe(scenario.expectedKey); + expect(profile?.keyRef).toBeUndefined(); } }); - it("retries ref setup when provider preflight fails and can switch to env ref", async () => { - await setupTempState(); - process.env.OPENAI_API_KEY = "sk-openai-env"; // pragma: allowlist secret - - const selectValues: Array<"provider" | "env" | "filemain"> = ["provider", "filemain", "env"]; - const select = vi.fn(async (params: Parameters[0]) => { - const next = selectValues[0]; - if (next && params.options.some((option) => option.value === next)) { - selectValues.shift(); - return next as never; - } - return (params.options[0]?.value ?? "env") as never; - }); - const text = vi - .fn() - .mockResolvedValueOnce("/providers/openai/apiKey") - .mockResolvedValueOnce("OPENAI_API_KEY"); - const note = vi.fn(async () => undefined); - - const prompter = createPrompter({ - select, - text, - note, - confirm: vi.fn(async () => true), - }); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoice({ - authChoice: "openai-api-key", - config: { - secrets: { - providers: { - filemain: { - source: "file", - path: "/tmp/openclaw-missing-secrets.json", - mode: "json", - }, - }, - }, - }, - prompter, - runtime, - setDefaultModel: false, - opts: { secretInputMode: "ref" }, // pragma: allowlist secret - }); - - expect(result.config.auth?.profiles?.["openai:default"]).toMatchObject({ - provider: "openai", - mode: "api_key", - }); - expect(note).toHaveBeenCalledWith( - expect.stringContaining("Could not validate provider reference"), - "Reference check failed", - ); - expect(note).toHaveBeenCalledWith( - expect.stringContaining("Validated environment variable OPENAI_API_KEY."), - "Reference validated", - ); - expect(await readAuthProfile("openai:default")).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, - }); - }); - it("uses explicit env for plugin auth resolution instead of host env", async () => { await setupTempState(); process.env.OPENAI_API_KEY = "sk-openai-host"; // pragma: allowlist secret @@ -1452,24 +997,26 @@ describe("applyAuthChoice", () => { it("keeps existing default model for explicit provider keys when setDefaultModel=false", async () => { const scenarios: Array<{ authChoice: "synthetic-api-key" | "opencode-zen"; - token: string; + token: string | undefined; promptMessage: string; existingPrimary: string; expectedOverride: string; profileId?: string; profileProvider?: string; + expectedStoredKey?: string; extraProfileId?: string; expectProviderConfigUndefined?: "opencode"; agentId?: string; }> = [ { authChoice: "synthetic-api-key", - token: "sk-synthetic-agent-test", + token: undefined, promptMessage: "Enter Synthetic API key", existingPrimary: "openai/gpt-4o-mini", expectedOverride: "synthetic/Synthetic-1", profileId: "synthetic:default", profileProvider: "synthetic", + expectedStoredKey: "", agentId: "agent-1", }, { @@ -1514,7 +1061,10 @@ describe("applyAuthChoice", () => { scenario.agentId && scenario.agentId !== "default" ? await readAuthProfilesForAgentDir(resolveAgentDir(result.config, scenario.agentId)) : await readAuthProfiles(); - expect(profileStore.profiles?.[scenario.profileId]?.key).toBe(scenario.token); + expect(profileStore.profiles?.[scenario.profileId]?.key).toBe( + scenario.expectedStoredKey ?? scenario.token, + ); + expect(profileStore.profiles?.[scenario.profileId]?.key).not.toBe("undefined"); } if (scenario.extraProfileId) { const profileStore = @@ -1530,433 +1080,4 @@ describe("applyAuthChoice", () => { } } }); - - it("sets default model when selecting github-copilot", async () => { - await setupTempState(); - - resolvePluginProviders.mockReturnValue([ - { - id: "github-copilot", - label: "GitHub Copilot", - auth: [ - { - id: "device", - label: "GitHub device login", - kind: "device_code", - run: vi.fn(async () => ({ - profiles: [ - { - profileId: "github-copilot:github", - credential: { - type: "token", - provider: "github-copilot", - token: "github-device-token", - }, - }, - ], - defaultModel: "github-copilot/gpt-4o", - })), - }, - ], - }, - ] as never); - - const prompter = createPrompter({}); - const runtime = createExitThrowingRuntime(); - - const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; - const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY"); - const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY"); - Object.defineProperty(stdin, "isTTY", { - configurable: true, - enumerable: true, - get: () => true, - }); - - try { - const result = await applyAuthChoice({ - authChoice: "github-copilot", - config: {}, - prompter, - runtime, - setDefaultModel: true, - }); - - expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( - "github-copilot/gpt-4o", - ); - } finally { - if (previousIsTTYDescriptor) { - Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); - } else if (!hadOwnIsTTY) { - delete (stdin as { isTTY?: boolean }).isTTY; - } - } - }); - - it("does not persist literal 'undefined' when API key prompts return undefined", async () => { - await setupTempState(); - delete process.env.SYNTHETIC_API_KEY; - - const text = vi.fn(async () => undefined as unknown as string); - const prompter = createPrompter({ text }); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoice({ - authChoice: "synthetic-api-key", - config: {}, - prompter, - runtime, - setDefaultModel: false, - }); - - expect(result.config.auth?.profiles?.["synthetic:default"]).toMatchObject({ - provider: "synthetic", - mode: "api_key", - }); - - const profile = await readAuthProfile("synthetic:default"); - expect(profile?.key).toBe(""); - expect(profile?.key).not.toBe("undefined"); - }); - - it("ignores legacy LiteLLM oauth profiles when selecting litellm-api-key", async () => { - await setupTempState(); - process.env.LITELLM_API_KEY = "sk-litellm-test"; // pragma: allowlist secret - - seedTestAuthProfile({ - profileId: "litellm:legacy", - credential: { - type: "oauth", - provider: "litellm", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60_000, - }, - agentDir: requireOpenClawAgentDir(), - }); - - const text = vi.fn(); - const confirm = vi.fn(async () => true); - const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); - - const result = await applyAuthChoice({ - authChoice: "litellm-api-key", - config: { - auth: { - profiles: { - "litellm:legacy": { provider: "litellm", mode: "oauth" }, - }, - order: { litellm: ["litellm:legacy"] }, - }, - }, - prompter, - runtime, - setDefaultModel: true, - }); - - expect(confirm).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining("LITELLM_API_KEY"), - }), - ); - expect(text).not.toHaveBeenCalled(); - expect(result.config.auth?.profiles?.["litellm:default"]).toMatchObject({ - provider: "litellm", - mode: "api_key", - }); - - expect(await readAuthProfile("litellm:default")).toMatchObject({ - type: "api_key", - key: "sk-litellm-test", - }); - }); - - it("configures cloudflare ai gateway via env key and explicit opts", async () => { - const scenarios: Array<{ - envGatewayKey?: string; - textValues: string[]; - confirmValue: boolean; - opts?: { - secretInputMode?: "ref"; // pragma: allowlist secret - cloudflareAiGatewayAccountId?: string; - cloudflareAiGatewayGatewayId?: string; - cloudflareAiGatewayApiKey?: string; - }; - expectEnvPrompt: boolean; - expectedTextCalls: number; - expectedKey?: string; - expectedKeyRef?: { source: string; provider: string; id: string }; - expectedMetadata: { accountId: string; gatewayId: string }; - }> = [ - { - envGatewayKey: "cf-gateway-test-key", - textValues: ["cf-account-id", "cf-gateway-id"], - confirmValue: true, - expectEnvPrompt: true, - expectedTextCalls: 2, - expectedKey: "cf-gateway-test-key", - expectedMetadata: { - accountId: "cf-account-id", - gatewayId: "cf-gateway-id", - }, - }, - { - envGatewayKey: "cf-gateway-ref-key", - textValues: ["cf-account-id-ref", "cf-gateway-id-ref"], - confirmValue: true, - opts: { - secretInputMode: "ref", // pragma: allowlist secret - }, - expectEnvPrompt: false, - expectedTextCalls: 3, - expectedKeyRef: { source: "env", provider: "default", id: "CLOUDFLARE_AI_GATEWAY_API_KEY" }, - expectedMetadata: { - accountId: "cf-account-id-ref", - gatewayId: "cf-gateway-id-ref", - }, - }, - { - textValues: [], - confirmValue: false, - opts: { - cloudflareAiGatewayAccountId: "acc-direct", - cloudflareAiGatewayGatewayId: "gw-direct", - cloudflareAiGatewayApiKey: "cf-direct-key", // pragma: allowlist secret - }, - expectEnvPrompt: false, - expectedTextCalls: 0, - expectedKey: "cf-direct-key", - expectedMetadata: { - accountId: "acc-direct", - gatewayId: "gw-direct", - }, - }, - ]; - await setupTempState(); - for (const scenario of scenarios) { - delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; - if (scenario.envGatewayKey) { - process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = scenario.envGatewayKey; - } - - const text = vi.fn(); - for (const textValue of scenario.textValues) { - text.mockResolvedValueOnce(textValue); - } - const confirm = vi.fn(async () => scenario.confirmValue); - const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm }); - - const result = await applyAuthChoice({ - authChoice: "cloudflare-ai-gateway-api-key", - config: {}, - prompter, - runtime, - setDefaultModel: true, - opts: scenario.opts, - }); - - if (scenario.expectEnvPrompt) { - expect(confirm).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining("CLOUDFLARE_AI_GATEWAY_API_KEY"), - }), - ); - } else { - expect(confirm).not.toHaveBeenCalled(); - } - expect(text).toHaveBeenCalledTimes(scenario.expectedTextCalls); - expect(result.config.auth?.profiles?.["cloudflare-ai-gateway:default"]).toMatchObject({ - provider: "cloudflare-ai-gateway", - mode: "api_key", - }); - expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( - "cloudflare-ai-gateway/claude-sonnet-4-5", - ); - - const profile = await readAuthProfile("cloudflare-ai-gateway:default"); - if (scenario.expectedKeyRef) { - expect(profile?.keyRef).toEqual(scenario.expectedKeyRef); - } else { - expect(profile?.key).toBe(scenario.expectedKey); - } - expect(profile?.metadata).toEqual(scenario.expectedMetadata); - } - delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; - }); - - it("writes Chutes OAuth credentials when selecting chutes (remote/manual)", async () => { - await setupTempState(); - process.env.SSH_TTY = "1"; - process.env.CHUTES_CLIENT_ID = "cid_test"; - - const fetchSpy = vi.fn(async (input: string | URL) => { - const url = typeof input === "string" ? input : input.toString(); - if (url === "https://api.chutes.ai/idp/token") { - return new Response( - JSON.stringify({ - access_token: "at_test", - refresh_token: "rt_test", - expires_in: 3600, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - if (url === "https://api.chutes.ai/idp/userinfo") { - return new Response(JSON.stringify({ username: "remote-user" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - return new Response("not found", { status: 404 }); - }); - vi.stubGlobal("fetch", fetchSpy); - - const runtime = createExitThrowingRuntime(); - const text: WizardPrompter["text"] = vi.fn(async (params) => { - if (params.message.startsWith("Paste the redirect URL")) { - const runtimeLog = runtime.log as ReturnType; - const lastLog = runtimeLog.mock.calls.at(-1)?.[0]; - const urlLine = typeof lastLog === "string" ? lastLog : String(lastLog ?? ""); - const urlMatch = urlLine.match(/https?:\/\/\S+/)?.[0] ?? ""; - const state = urlMatch ? new URL(urlMatch).searchParams.get("state") : null; - if (!state) { - throw new Error("missing state in oauth URL"); - } - return `?code=code_manual&state=${state}`; - } - return "code_manual"; - }); - const { prompter } = createApiKeyPromptHarness({ text }); - - const result = await applyAuthChoice({ - authChoice: "chutes", - config: {}, - prompter, - runtime, - setDefaultModel: false, - }); - - expect(text).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining("Paste the redirect URL"), - }), - ); - expect(result.config.auth?.profiles?.["chutes:remote-user"]).toMatchObject({ - provider: "chutes", - mode: "oauth", - }); - - expect(await readAuthProfile("chutes:remote-user")).toMatchObject({ - provider: "chutes", - access: "at_test", - refresh: "rt_test", - email: "remote-user", - }); - }); - - it("writes portal OAuth credentials for plugin providers", async () => { - const scenarios: Array<{ - authChoice: "minimax-global-oauth"; - label: string; - authId: string; - authLabel: string; - providerId: string; - profileId: string; - baseUrl: string; - api: "openai-completions" | "anthropic-messages"; - defaultModel: string; - apiKey: string; - selectValue?: string; - }> = [ - { - authChoice: "minimax-global-oauth", - label: "MiniMax", - authId: "oauth", - authLabel: "MiniMax OAuth (Global)", - providerId: "minimax-portal", - profileId: "minimax-portal:default", - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", - defaultModel: "minimax-portal/MiniMax-M2.7", - apiKey: "minimax-oauth", // pragma: allowlist secret - }, - ]; - await setupTempState(); - for (const scenario of scenarios) { - resolvePluginProviders.mockReturnValue([ - { - id: scenario.providerId, - label: scenario.label, - auth: [ - { - id: scenario.authId, - label: scenario.authLabel, - kind: "device_code", - wizard: { choiceId: scenario.authChoice }, - run: vi.fn(async () => ({ - profiles: [ - { - profileId: scenario.profileId, - credential: { - type: "oauth", - provider: scenario.providerId, - access: "access", - refresh: "refresh", - expires: Date.now() + 60 * 60 * 1000, - }, - }, - ], - configPatch: { - models: { - providers: { - [scenario.providerId]: { - baseUrl: scenario.baseUrl, - apiKey: scenario.apiKey, - api: scenario.api, - models: [], - }, - }, - }, - }, - defaultModel: scenario.defaultModel, - })), - }, - ], - }, - ] as never); - - const prompter = createPrompter( - scenario.selectValue - ? { select: vi.fn(async () => scenario.selectValue as never) as WizardPrompter["select"] } - : {}, - ); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoice({ - authChoice: scenario.authChoice, - config: {}, - prompter, - runtime, - setDefaultModel: true, - }); - - expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({ - provider: scenario.providerId, - mode: "oauth", - }); - expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe( - scenario.defaultModel, - ); - expect(result.config.models?.providers?.[scenario.providerId]).toMatchObject({ - baseUrl: scenario.baseUrl, - apiKey: scenario.apiKey, - }); - expect(await readAuthProfile(scenario.profileId)).toMatchObject({ - provider: scenario.providerId, - access: "access", - refresh: "refresh", - }); - } - }); }); diff --git a/src/commands/backup-verify.test.ts b/src/commands/backup-verify.test.ts index 6b6e55a94ce..e24b3c65002 100644 --- a/src/commands/backup-verify.test.ts +++ b/src/commands/backup-verify.test.ts @@ -196,38 +196,33 @@ describe("backupVerifyCommand", () => { } }); - it("fails when archive paths contain traversal segments", async () => { - const traversalPath = `${TEST_ARCHIVE_ROOT}/payload/../escaped.txt`; - await withBrokenArchiveFixture( + it("rejects unsafe archive paths", async () => { + for (const { tempPrefix, archivePath, error } of [ { tempPrefix: "openclaw-backup-traversal-", - manifestAssetArchivePath: traversalPath, - payloads: [{ fileName: "payload.txt", contents: "payload\n", archivePath: traversalPath }], + archivePath: `${TEST_ARCHIVE_ROOT}/payload/../escaped.txt`, + error: /path traversal segments/i, }, - async (archivePath) => { - const runtime = createBackupVerifyRuntime(); - await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( - /path traversal segments/i, - ); - }, - ); - }); - - it("fails when archive paths contain backslashes", async () => { - const invalidPath = `${TEST_ARCHIVE_ROOT}/payload\\..\\escaped.txt`; - await withBrokenArchiveFixture( { tempPrefix: "openclaw-backup-backslash-", - manifestAssetArchivePath: invalidPath, - payloads: [{ fileName: "payload.txt", contents: "payload\n", archivePath: invalidPath }], + archivePath: `${TEST_ARCHIVE_ROOT}/payload\\..\\escaped.txt`, + error: /forward slashes/i, }, - async (archivePath) => { - const runtime = createBackupVerifyRuntime(); - await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( - /forward slashes/i, - ); - }, - ); + ]) { + await withBrokenArchiveFixture( + { + tempPrefix, + manifestAssetArchivePath: archivePath, + payloads: [{ fileName: "payload.txt", contents: "payload\n", archivePath }], + }, + async (brokenArchivePath) => { + const runtime = createBackupVerifyRuntime(); + await expect( + backupVerifyCommand(runtime, { archive: brokenArchivePath }), + ).rejects.toThrow(error); + }, + ); + } }); it("ignores payload manifest.json files when locating the backup manifest", async () => { @@ -302,45 +297,44 @@ describe("backupVerifyCommand", () => { } }); - it("fails when the archive contains duplicate root manifest entries", async () => { + it("rejects duplicate manifest and payload entries", async () => { const payloadArchivePath = `${TEST_ARCHIVE_ROOT}/payload/posix/tmp/.openclaw/payload.txt`; - await withBrokenArchiveFixture( + for (const options of [ { tempPrefix: "openclaw-backup-duplicate-manifest-", - manifestAssetArchivePath: payloadArchivePath, payloads: [{ fileName: "payload.txt", contents: "payload\n" }], - buildTarEntries: ({ manifestPath, payloadPaths }) => [ + buildTarEntries: ({ manifestPath, - manifestPath, - ...payloadPaths, - ], + payloadPaths, + }: { + manifestPath: string; + payloadPaths: string[]; + }) => [manifestPath, manifestPath, ...payloadPaths], + error: /expected exactly one backup manifest entry, found 2/i, }, - async (archivePath) => { - const runtime = createBackupVerifyRuntime(); - await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( - /expected exactly one backup manifest entry, found 2/i, - ); - }, - ); - }); - - it("fails when the archive contains duplicate payload entries", async () => { - const payloadArchivePath = `${TEST_ARCHIVE_ROOT}/payload/posix/tmp/.openclaw/payload.txt`; - await withBrokenArchiveFixture( { tempPrefix: "openclaw-backup-duplicate-payload-", - manifestAssetArchivePath: payloadArchivePath, payloads: [ { fileName: "payload-a.txt", contents: "payload-a\n", archivePath: payloadArchivePath }, { fileName: "payload-b.txt", contents: "payload-b\n", archivePath: payloadArchivePath }, ], + error: /duplicate entry path/i, }, - async (archivePath) => { - const runtime = createBackupVerifyRuntime(); - await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( - /duplicate entry path/i, - ); - }, - ); + ]) { + await withBrokenArchiveFixture( + { + tempPrefix: options.tempPrefix, + manifestAssetArchivePath: payloadArchivePath, + payloads: options.payloads, + buildTarEntries: options.buildTarEntries, + }, + async (archivePath) => { + const runtime = createBackupVerifyRuntime(); + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + options.error, + ); + }, + ); + } }); }); diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index 4cf921e3fa0..d82502dba9c 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -321,47 +321,37 @@ describe("backup commands", () => { path.join(tempHome.home, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), ); await fs.rm(result.archivePath, { force: true }); - }); - it("falls back to the home directory when cwd is a symlink into a backed-up source tree", async () => { - if (process.platform === "win32") { - return; - } + if (process.platform !== "win32") { + const linkParent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-cwd-link-")); + const workspaceLink = path.join(linkParent, "workspace-link"); + try { + await fs.symlink(workspaceDir, workspaceLink); + vi.mocked(process.cwd).mockReturnValue(workspaceLink); + vi.spyOn(backupShared, "resolveBackupPlanFromDisk").mockResolvedValue( + await resolveBackupPlanFromPaths({ + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + oauthDir: path.join(stateDir, "credentials"), + workspaceDirs: [workspaceDir], + includeWorkspace: true, + configInsideState: true, + oauthInsideState: true, + nowMs: Date.UTC(2026, 2, 9, 1, 3, 4), + }), + ); - const stateDir = path.join(tempHome.home, ".openclaw"); - const workspaceDir = path.join(stateDir, "workspace"); - const linkParent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-cwd-link-")); - const workspaceLink = path.join(linkParent, "workspace-link"); - try { - await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); - await fs.mkdir(workspaceDir, { recursive: true }); - await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); - await fs.symlink(workspaceDir, workspaceLink); - vi.spyOn(process, "cwd").mockReturnValue(workspaceLink); - vi.spyOn(backupShared, "resolveBackupPlanFromDisk").mockResolvedValue( - await resolveBackupPlanFromPaths({ - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - oauthDir: path.join(stateDir, "credentials"), - workspaceDirs: [workspaceDir], - includeWorkspace: true, - configInsideState: true, - oauthInsideState: true, - nowMs: Date.UTC(2026, 2, 9, 1, 3, 4), - }), - ); - - const runtime = createBackupTestRuntime(); - - const nowMs = Date.UTC(2026, 2, 9, 1, 3, 4); - const result = await backupCreateCommand(runtime, { nowMs }); - - expect(result.archivePath).toBe( - path.join(tempHome.home, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), - ); - await fs.rm(result.archivePath, { force: true }); - } finally { - await fs.rm(linkParent, { recursive: true, force: true }); + const symlinkNowMs = Date.UTC(2026, 2, 9, 1, 3, 4); + const symlinkResult = await backupCreateCommand(createBackupTestRuntime(), { + nowMs: symlinkNowMs, + }); + expect(symlinkResult.archivePath).toBe( + path.join(tempHome.home, `${buildBackupArchiveRoot(symlinkNowMs)}.tar.gz`), + ); + await fs.rm(symlinkResult.archivePath, { force: true }); + } finally { + await fs.rm(linkParent, { recursive: true, force: true }); + } } }); @@ -395,16 +385,12 @@ describe("backup commands", () => { expect(await fs.readFile(existingArchive, "utf8")).toBe("already here"); }); - it("fails fast when config is invalid and workspace backup is enabled", async () => { + it("handles invalid config according to backup scope", async () => { await withInvalidWorkspaceBackupConfig(async (runtime) => { await expect(backupCreateCommand(runtime, { dryRun: true })).rejects.toThrow( /--no-include-workspace/i, ); - }); - }); - it("allows explicit partial backups when config is invalid", async () => { - await withInvalidWorkspaceBackupConfig(async (runtime) => { const result = await backupCreateCommand(runtime, { dryRun: true, includeWorkspace: false, @@ -412,6 +398,13 @@ describe("backup commands", () => { expect(result.includeWorkspace).toBe(false); expect(result.assets.some((asset) => asset.kind === "workspace")).toBe(false); + + const configOnly = await backupCreateCommand(runtime, { + dryRun: true, + onlyConfig: true, + }); + expect(configOnly.assets).toHaveLength(1); + expect(configOnly.assets[0]?.kind).toBe("config"); }); }); @@ -447,24 +440,4 @@ describe("backup commands", () => { expect(result.assets).toHaveLength(1); expect(result.assets[0]?.kind).toBe("config"); }); - - it("allows config-only backups even when the config file is invalid", async () => { - const configPath = path.join(tempHome.home, "custom-config.json"); - process.env.OPENCLAW_CONFIG_PATH = configPath; - await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8"); - - const runtime = createBackupTestRuntime(); - - try { - const result = await backupCreateCommand(runtime, { - dryRun: true, - onlyConfig: true, - }); - - expect(result.assets).toHaveLength(1); - expect(result.assets[0]?.kind).toBe("config"); - } finally { - delete process.env.OPENCLAW_CONFIG_PATH; - } - }); }); diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 4c3b798a7b3..7f4a800c4bf 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -5,15 +5,24 @@ import { type BackupCreateResult, } from "../infra/backup-create.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; -import { backupVerifyCommand } from "./backup-verify.js"; export type { BackupCreateOptions, BackupCreateResult } from "../infra/backup-create.js"; +type BackupVerifyRuntime = typeof import("./backup-verify.js"); + +let backupVerifyRuntimePromise: Promise | undefined; + +function loadBackupVerifyRuntime(): Promise { + backupVerifyRuntimePromise ??= import("./backup-verify.js"); + return backupVerifyRuntimePromise; +} + export async function backupCreateCommand( runtime: RuntimeEnv, opts: BackupCreateOptions = {}, ): Promise { const result = await createBackupArchive(opts); if (opts.verify && !opts.dryRun) { + const { backupVerifyCommand } = await loadBackupVerifyRuntime(); await backupVerifyCommand( { ...runtime, diff --git a/src/commands/channel-account-context.test.ts b/src/commands/channel-account-context.test.ts index 4cdbde4d7e2..be08903c509 100644 --- a/src/commands/channel-account-context.test.ts +++ b/src/commands/channel-account-context.test.ts @@ -3,6 +3,10 @@ import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveDefaultChannelAccountContext } from "./channel-account-context.js"; +vi.mock("../channels/read-only-account-inspect.js", () => ({ + inspectReadOnlyChannelAccount: vi.fn(async () => null), +})); + describe("resolveDefaultChannelAccountContext", () => { it("uses enabled/configured defaults when hooks are missing", async () => { const account = { token: "x" }; @@ -49,7 +53,7 @@ describe("resolveDefaultChannelAccountContext", () => { expect(result.degraded).toBe(false); }); - it("keeps strict mode fail-closed when resolveAccount throws", async () => { + it("keeps strict mode fail-closed and degrades read_only mode when resolveAccount throws", async () => { const plugin = { id: "demo", config: { @@ -63,18 +67,6 @@ describe("resolveDefaultChannelAccountContext", () => { await expect(resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig)).rejects.toThrow( /missing secret/i, ); - }); - - it("degrades safely in read_only mode when resolveAccount throws", async () => { - const plugin = { - id: "demo", - config: { - listAccountIds: () => ["acc-err"], - resolveAccount: () => { - throw new Error("missing secret"); - }, - }, - } as unknown as ChannelPlugin; const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig, { mode: "read_only", diff --git a/src/commands/channel-setup/channel-plugin-resolution.ts b/src/commands/channel-setup/channel-plugin-resolution.ts index 3eadd2a6146..300468865d1 100644 --- a/src/commands/channel-setup/channel-plugin-resolution.ts +++ b/src/commands/channel-setup/channel-plugin-resolution.ts @@ -85,6 +85,24 @@ function isTrustedWorkspaceChannelCatalogEntry( if (!entry.pluginId) { return false; } + const plugins = cfg.plugins; + if (plugins?.enabled === false) { + return false; + } + const pluginEntry = plugins?.entries?.[entry.pluginId]; + if (pluginEntry?.enabled === false) { + return false; + } + if (plugins?.deny?.length) { + return resolveEnableState(entry.pluginId, "workspace", normalizePluginsConfig(cfg.plugins)) + .enabled; + } + if (plugins?.allow?.includes(entry.pluginId)) { + return true; + } + if (pluginEntry?.enabled === true && !plugins?.allow?.length) { + return true; + } return resolveEnableState(entry.pluginId, "workspace", normalizePluginsConfig(cfg.plugins)) .enabled; } diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index edcf7c50564..f62b73cb5e3 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -30,13 +30,12 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({ const resolveBundledPluginSources = vi.fn(); const getChannelPluginCatalogEntry = vi.fn(); -vi.mock("../../channels/plugins/catalog.js", async () => { - const actual = await vi.importActual( - "../../channels/plugins/catalog.js", - ); +const listChannelPluginCatalogEntries = vi.fn(() => []); +vi.mock("../../channels/plugins/catalog.js", () => { return { - ...actual, getChannelPluginCatalogEntry: (...args: unknown[]) => getChannelPluginCatalogEntry(...args), + listChannelPluginCatalogEntries: (...args: unknown[]) => + listChannelPluginCatalogEntries(...args), }; }); @@ -125,6 +124,7 @@ beforeEach(() => { })); resolveBundledPluginSources.mockReturnValue(new Map()); getChannelPluginCatalogEntry.mockReturnValue(undefined); + listChannelPluginCatalogEntries.mockReturnValue([]); loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] }); setActivePluginRegistry(createEmptyPluginRegistry()); }); diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 71bc6c58514..d618c5c3d69 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -15,62 +15,34 @@ import { } from "./channels.plugin-install.test-helpers.js"; import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; -let channelsAddCommand: typeof import("./channels.js").channelsAddCommand; +let channelsAddCommand: typeof import("./channels/add.js").channelsAddCommand; const catalogMocks = vi.hoisted(() => ({ listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []), })); -const manifestRegistryMocks = vi.hoisted(() => ({ - loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })), -})); - const discoveryMocks = vi.hoisted(() => ({ isCatalogChannelInstalled: vi.fn(() => false), })); -vi.mock("../channels/plugins/catalog.js", async () => { - const actual = await vi.importActual( - "../channels/plugins/catalog.js", - ); - return { - ...actual, - listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries, - }; -}); +const pluginInstallMocks = vi.hoisted(() => ({ + ensureChannelSetupPluginInstalled: vi.fn(), + loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(), +})); -vi.mock("../plugins/manifest-registry.js", async () => { - const actual = await vi.importActual( - "../plugins/manifest-registry.js", - ); - return { - ...actual, - loadPluginManifestRegistry: manifestRegistryMocks.loadPluginManifestRegistry, - }; -}); +vi.mock("../channels/plugins/catalog.js", () => ({ + listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries, +})); vi.mock("./channel-setup/discovery.js", () => ({ isCatalogChannelInstalled: discoveryMocks.isCatalogChannelInstalled, })); -vi.mock("../channels/plugins/bundled.js", async () => { - const actual = await vi.importActual( - "../channels/plugins/bundled.js", - ); - return { - ...actual, - getBundledChannelPlugin: vi.fn(() => undefined), - }; -}); +vi.mock("../channels/plugins/bundled.js", () => ({ + getBundledChannelPlugin: vi.fn(() => undefined), +})); -vi.mock("./channel-setup/plugin-install.js", async () => { - const actual = await vi.importActual( - "./channel-setup/plugin-install.js", - ); - const { createMockChannelSetupPluginInstallModule } = - await import("./channels.plugin-install.test-helpers.js"); - return createMockChannelSetupPluginInstallModule(actual); -}); +vi.mock("./channel-setup/plugin-install.js", () => pluginInstallMocks); const runtime = createTestRuntime(); @@ -245,7 +217,7 @@ async function runSignalAddCommand(afterAccountConfigWritten: SignalAfterAccount describe("channelsAddCommand", () => { beforeAll(async () => { - ({ channelsAddCommand } = await import("./channels.js")); + ({ channelsAddCommand } = await import("./channels/add.js")); }); beforeEach(async () => { @@ -263,26 +235,21 @@ describe("channelsAddCommand", () => { runtime.exit.mockClear(); catalogMocks.listChannelPluginCatalogEntries.mockClear(); catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]); - manifestRegistryMocks.loadPluginManifestRegistry.mockClear(); - manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ - plugins: [], - diagnostics: [], - }); discoveryMocks.isCatalogChannelInstalled.mockClear(); discoveryMocks.isCatalogChannelInstalled.mockReturnValue(false); - vi.mocked(ensureChannelSetupPluginInstalled).mockClear(); + vi.mocked(ensureChannelSetupPluginInstalled).mockReset(); vi.mocked(ensureChannelSetupPluginInstalled).mockImplementation(async ({ cfg }) => ({ cfg, installed: true, })); - vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockClear(); + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReset(); vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( createTestRegistry(), ); setMinimalChannelsAddRegistryForTests(); }); - it("clears telegram update offsets when the token changes", async () => { + it("clears telegram update offsets only when the token changes", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: { @@ -300,9 +267,8 @@ describe("channelsAddCommand", () => { expect(offsetMocks.deleteTelegramUpdateOffset).toHaveBeenCalledTimes(1); expect(offsetMocks.deleteTelegramUpdateOffset).toHaveBeenCalledWith({ accountId: "default" }); - }); - it("does not clear telegram update offsets when the token is unchanged", async () => { + offsetMocks.deleteTelegramUpdateOffset.mockClear(); configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: { @@ -321,7 +287,7 @@ describe("channelsAddCommand", () => { expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled(); }); - it("falls back to a scoped snapshot after installing an external channel plugin", async () => { + it("loads external channel setup snapshots for newly installed and existing plugins", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); setActivePluginRegistry(createTestRegistry()); const catalogEntry = createMSTeamsCatalogEntry(); @@ -353,24 +319,11 @@ describe("channelsAddCommand", () => { ); expect(runtime.error).not.toHaveBeenCalled(); expect(runtime.exit).not.toHaveBeenCalled(); - }); - it("uses the installed external channel snapshot without reinstalling", async () => { - configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); - setActivePluginRegistry(createTestRegistry()); - const catalogEntry = createMSTeamsCatalogEntry(); - catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); - manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ - plugins: [ - { - id: "@openclaw/msteams-plugin", - channels: ["msteams"], - } as never, - ], - diagnostics: [], - }); + vi.mocked(ensureChannelSetupPluginInstalled).mockClear(); + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockClear(); + configMocks.writeConfigFile.mockClear(); discoveryMocks.isCatalogChannelInstalled.mockReturnValue(true); - registerMSTeamsSetupPlugin("msteams"); await channelsAddCommand( { @@ -470,7 +423,7 @@ describe("channelsAddCommand", () => { expect(runtime.exit).not.toHaveBeenCalled(); }); - it("runs post-setup hooks after writing config", async () => { + it("runs post-setup hooks after writing config and keeps saved config on hook failure", async () => { const afterAccountConfigWritten = vi.fn().mockResolvedValue(undefined); await runSignalAddCommand(afterAccountConfigWritten); @@ -499,11 +452,12 @@ describe("channelsAddCommand", () => { }), runtime, }); - }); - it("keeps the saved config when a post-setup hook fails", async () => { - const afterAccountConfigWritten = vi.fn().mockRejectedValue(new Error("hook failed")); - await runSignalAddCommand(afterAccountConfigWritten); + configMocks.writeConfigFile.mockClear(); + runtime.error.mockClear(); + runtime.exit.mockClear(); + const failingHook = vi.fn().mockRejectedValue(new Error("hook failed")); + await runSignalAddCommand(failingHook); expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); expect(runtime.exit).not.toHaveBeenCalled(); diff --git a/src/commands/channels.adds-non-default-telegram-account.test.ts b/src/commands/channels.adds-non-default-telegram-account.test.ts index b05cd550837..aac07082da2 100644 --- a/src/commands/channels.adds-non-default-telegram-account.test.ts +++ b/src/commands/channels.adds-non-default-telegram-account.test.ts @@ -13,7 +13,12 @@ import { formatGatewayChannelsStatusLines } from "./channels/status.js"; import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; const runtime = createTestRuntime(); -let clackPrompterModule: typeof import("../wizard/clack-prompter.js"); +let minimalChannelsCommandRegistry: ReturnType; +const createClackPrompterMock = vi.hoisted(() => vi.fn()); + +vi.mock("../wizard/clack-prompter.js", () => ({ + createClackPrompter: createClackPrompterMock, +})); type ChannelSectionConfig = { enabled?: boolean; @@ -192,106 +197,108 @@ function createTelegramCommandTestPlugin(): ChannelPlugin { }); } -function setMinimalChannelsCommandRegistryForTests(): void { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: createTelegramCommandTestPlugin(), - source: "test", - }, - { - pluginId: "whatsapp", - plugin: createScopedCommandTestPlugin({ - id: "whatsapp", - label: "WhatsApp", - buildPatch: () => ({}), - clearBaseFields: ["name"], - }), - source: "test", - }, - { - pluginId: "discord", - plugin: createScopedCommandTestPlugin({ - id: "discord", - label: "Discord", - buildPatch: ({ token }) => (token ? { token } : {}), - clearBaseFields: ["token", "name"], - collectStatusIssues: (accounts) => - accounts.flatMap((account) => { - if (account.enabled !== true || account.configured !== true) { - return []; - } - const issues: ChannelStatusIssue[] = []; - const issueAccountId = account.accountId ?? DEFAULT_ACCOUNT_ID; - const messageContent = ( - account.application as { intents?: { messageContent?: string } } | undefined - )?.intents?.messageContent; - if (messageContent === "disabled") { - issues.push({ - channel: "discord", - accountId: issueAccountId, - kind: "intent", - message: - "Message Content Intent is disabled. Bot may not see normal channel messages.", - }); - } - const audit = account.audit as - | { - channels?: Array<{ - channelId?: string; - ok?: boolean; - missing?: string[]; - error?: string; - }>; - } - | undefined; - for (const channel of audit?.channels ?? []) { - if (channel.ok === true || !channel.channelId) { - continue; +function createMinimalChannelsCommandRegistryForTests(): ReturnType { + return createTestRegistry([ + { + pluginId: "telegram", + plugin: createTelegramCommandTestPlugin(), + source: "test", + }, + { + pluginId: "whatsapp", + plugin: createScopedCommandTestPlugin({ + id: "whatsapp", + label: "WhatsApp", + buildPatch: () => ({}), + clearBaseFields: ["name"], + }), + source: "test", + }, + { + pluginId: "discord", + plugin: createScopedCommandTestPlugin({ + id: "discord", + label: "Discord", + buildPatch: ({ token }) => (token ? { token } : {}), + clearBaseFields: ["token", "name"], + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + if (account.enabled !== true || account.configured !== true) { + return []; + } + const issues: ChannelStatusIssue[] = []; + const issueAccountId = account.accountId ?? DEFAULT_ACCOUNT_ID; + const messageContent = ( + account.application as { intents?: { messageContent?: string } } | undefined + )?.intents?.messageContent; + if (messageContent === "disabled") { + issues.push({ + channel: "discord", + accountId: issueAccountId, + kind: "intent", + message: + "Message Content Intent is disabled. Bot may not see normal channel messages.", + }); + } + const audit = account.audit as + | { + channels?: Array<{ + channelId?: string; + ok?: boolean; + missing?: string[]; + error?: string; + }>; } - issues.push({ - channel: "discord", - accountId: issueAccountId, - kind: "permissions", - message: `Channel ${channel.channelId} permission audit failed.${channel.missing?.length ? ` missing ${channel.missing.join(", ")}` : ""}${channel.error ? `: ${channel.error}` : ""}`, - }); + | undefined; + for (const channel of audit?.channels ?? []) { + if (channel.ok === true || !channel.channelId) { + continue; } - return issues; - }), - }), - source: "test", - }, - { - pluginId: "slack", - plugin: createScopedCommandTestPlugin({ - id: "slack", - label: "Slack", - buildPatch: ({ botToken, appToken }) => ({ - ...(botToken ? { botToken } : {}), - ...(appToken ? { appToken } : {}), + issues.push({ + channel: "discord", + accountId: issueAccountId, + kind: "permissions", + message: `Channel ${channel.channelId} permission audit failed.${channel.missing?.length ? ` missing ${channel.missing.join(", ")}` : ""}${channel.error ? `: ${channel.error}` : ""}`, + }); + } + return issues; }), - clearBaseFields: ["botToken", "appToken", "name"], + }), + source: "test", + }, + { + pluginId: "slack", + plugin: createScopedCommandTestPlugin({ + id: "slack", + label: "Slack", + buildPatch: ({ botToken, appToken }) => ({ + ...(botToken ? { botToken } : {}), + ...(appToken ? { appToken } : {}), }), - source: "test", - }, - { - pluginId: "signal", - plugin: createScopedCommandTestPlugin({ - id: "signal", - label: "Signal", - buildPatch: ({ signalNumber }) => (signalNumber ? { account: signalNumber } : {}), - clearBaseFields: ["account", "name"], - }), - source: "test", - }, - ]), - ); + clearBaseFields: ["botToken", "appToken", "name"], + }), + source: "test", + }, + { + pluginId: "signal", + plugin: createScopedCommandTestPlugin({ + id: "signal", + label: "Signal", + buildPatch: ({ signalNumber }) => (signalNumber ? { account: signalNumber } : {}), + clearBaseFields: ["account", "name"], + }), + source: "test", + }, + ]); +} + +function setMinimalChannelsCommandRegistryForTests(): void { + setActivePluginRegistry(minimalChannelsCommandRegistry); } describe("channels command", () => { - beforeAll(async () => { - clackPrompterModule = await import("../wizard/clack-prompter.js"); + beforeAll(() => { + minimalChannelsCommandRegistry = createMinimalChannelsCommandRegistryForTests(); }); beforeEach(() => { @@ -299,6 +306,7 @@ describe("channels command", () => { configMocks.writeConfigFile.mockClear(); secretMocks.resolveCommandConfigWithSecrets.mockClear(); offsetMocks.deleteTelegramUpdateOffset.mockClear(); + createClackPrompterMock.mockReset(); runtime.log.mockClear(); runtime.error.mockClear(); runtime.exit.mockClear(); @@ -314,14 +322,8 @@ describe("channels command", () => { args: Parameters[0], ): Promise { const prompt = { confirm: vi.fn().mockResolvedValue(true) }; - const promptSpy = vi - .spyOn(clackPrompterModule, "createClackPrompter") - .mockReturnValue(prompt as never); - try { - await channelsRemoveCommand(args, runtime, { hasFlags: true }); - } finally { - promptSpy.mockRestore(); - } + createClackPrompterMock.mockReturnValue(prompt); + await channelsRemoveCommand(args, runtime, { hasFlags: true }); } async function addTelegramAccount(account: string, token: string): Promise { diff --git a/src/commands/channels.config-only-status-output.test.ts b/src/commands/channels.config-only-status-output.test.ts index 367fb034784..2264a0abce5 100644 --- a/src/commands/channels.config-only-status-output.test.ts +++ b/src/commands/channels.config-only-status-output.test.ts @@ -1,20 +1,68 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { makeDirectPlugin } from "../test-utils/channel-plugin-test-fixtures.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; -import { formatConfigChannelsStatusLines } from "./channels/status.js"; +import { formatConfigChannelsStatusLines } from "./channels/status-config-format.js"; -function registerSingleTestPlugin(pluginId: string, plugin: ChannelPlugin) { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId, - source: "test", - plugin, - }, - ]), - ); +const activeChannelPlugins = vi.hoisted(() => [] as ChannelPlugin[]); + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: () => activeChannelPlugins, + getChannelPlugin: (id: string) => activeChannelPlugins.find((plugin) => plugin.id === id), +})); + +vi.mock("../channels/plugins/status.js", () => ({ + buildReadOnlySourceChannelAccountSnapshot: async ({ + accountId, + cfg, + plugin, + }: { + accountId: string; + cfg: unknown; + plugin: ChannelPlugin; + }) => { + const account = await plugin.config.inspectAccount?.(cfg as never, accountId); + return account ? { accountId, ...(account as Record) } : null; + }, + buildChannelAccountSnapshot: async ({ + accountId, + cfg, + plugin, + }: { + accountId: string; + cfg: unknown; + plugin: ChannelPlugin; + }) => { + const account = + (await plugin.config.inspectAccount?.(cfg as never, accountId)) ?? + plugin.config.resolveAccount(cfg as never, accountId); + return { accountId, ...(account as Record) }; + }, +})); + +function registerSingleTestPlugin(_pluginId: string, plugin: ChannelPlugin) { + activeChannelPlugins.splice(0, activeChannelPlugins.length, plugin); +} + +function makeTestPlugin(params: { + id: string; + label: string; + docsPath: string; + config: ChannelPlugin["config"]; +}): ChannelPlugin { + return { + id: params.id, + meta: { + id: params.id, + label: params.label, + selectionLabel: params.label, + docsPath: params.docsPath, + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: params.config, + actions: { + describeMessageTool: () => ({ actions: ["send"] }), + }, + }; } async function formatLocalStatusSummary( @@ -52,7 +100,7 @@ function tokenOnlyPluginConfig() { } function makeUnavailableTokenPlugin(): ChannelPlugin { - return makeDirectPlugin({ + return makeTestPlugin({ id: "token-only", label: "TokenOnly", docsPath: "/channels/token-only", @@ -64,7 +112,7 @@ function makeUnavailableTokenPlugin(): ChannelPlugin { } function makeResolvedTokenPlugin(): ChannelPlugin { - return makeDirectPlugin({ + return makeTestPlugin({ id: "token-only", label: "TokenOnly", docsPath: "/channels/token-only", @@ -124,7 +172,7 @@ function makeResolvedTokenPluginWithoutInspectAccount(): ChannelPlugin { } function makeUnavailableHttpSlackPlugin(): ChannelPlugin { - return makeDirectPlugin({ + return makeTestPlugin({ id: "slack", label: "Slack", docsPath: "/channels/slack", @@ -169,10 +217,6 @@ function expectResolvedTokenStatusSummary( } describe("config-only channels status output", () => { - afterEach(() => { - setActivePluginRegistry(createTestRegistry([])); - }); - it("shows configured-but-unavailable credentials distinctly from not configured", async () => { registerSingleTestPlugin("token-only", makeUnavailableTokenPlugin()); diff --git a/src/commands/channels.mock-harness.ts b/src/commands/channels.mock-harness.ts index 477048f6908..0fff7120b2b 100644 --- a/src/commands/channels.mock-harness.ts +++ b/src/commands/channels.mock-harness.ts @@ -49,12 +49,6 @@ vi.mock("../cli/command-secret-targets.js", () => ({ getChannelsCommandSecretTargetIds: () => new Set(), })); -vi.mock(buildBundledPluginModuleId("telegram", "update-offset-runtime-api.js"), async () => { - const actual: Record = await vi.importActual( - buildBundledPluginModuleId("telegram", "update-offset-runtime-api.js"), - ); - return { - ...actual, - deleteTelegramUpdateOffset: offsetMocks.deleteTelegramUpdateOffset, - }; -}); +vi.mock(buildBundledPluginModuleId("telegram", "update-offset-runtime-api.js"), () => ({ + deleteTelegramUpdateOffset: offsetMocks.deleteTelegramUpdateOffset, +})); diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 3aa9f065012..7ace5b2f391 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -1,5 +1,4 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; -import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; @@ -12,11 +11,6 @@ import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { applyAgentBindings, describeBinding } from "../agents.bindings.js"; -import { isCatalogChannelInstalled } from "../channel-setup/discovery.js"; -import { - createChannelOnboardingPostWriteHookCollector, - runCollectedChannelOnboardingPostWriteHooks, -} from "../onboard-channels.js"; import type { ChannelChoice } from "../onboard-types.js"; import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js"; import { channelLabel, requireValidConfigFileSnapshot, shouldUseWizard } from "./shared.js"; @@ -29,11 +23,12 @@ export type ChannelsAddOptions = { dmAllowlist?: string; } & Omit; -function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | null) { +async function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | null) { const trimmed = normalizeOptionalLowercaseString(raw); if (!trimmed) { return undefined; } + const { listChannelPluginCatalogEntries } = await import("../../channels/plugins/catalog.js"); const workspaceDir = cfg ? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) : undefined; return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => { if (normalizeOptionalLowercaseString(entry.id) === trimmed) { @@ -60,17 +55,17 @@ export async function channelsAddCommand( const useWizard = shouldUseWizard(params); if (useWizard) { - const [{ buildAgentSummaries }, { setupChannels }] = await Promise.all([ + const [{ buildAgentSummaries }, onboardChannels] = await Promise.all([ import("../agents.config.js"), import("../onboard-channels.js"), ]); const prompter = createClackPrompter(); - const postWriteHooks = createChannelOnboardingPostWriteHookCollector(); + const postWriteHooks = onboardChannels.createChannelOnboardingPostWriteHookCollector(); let selection: ChannelChoice[] = []; const accountIds: Partial> = {}; const resolvedPlugins = new Map(); await prompter.intro("Channel setup"); - let nextConfig = await setupChannels(cfg, runtime, prompter, { + let nextConfig = await onboardChannels.setupChannels(cfg, runtime, prompter, { allowDisable: false, allowSignalInstall: true, onPostWriteHook: (hook) => { @@ -187,7 +182,7 @@ export async function channelsAddCommand( nextConfig, ...(baseHash !== undefined ? { baseHash } : {}), }); - await runCollectedChannelOnboardingPostWriteHooks({ + await onboardChannels.runCollectedChannelOnboardingPostWriteHooks({ hooks: postWriteHooks.drain(), cfg: nextConfig, runtime, @@ -198,7 +193,7 @@ export async function channelsAddCommand( const rawChannel = opts.channel ?? ""; let channel = normalizeChannelId(rawChannel); - let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig); + let catalogEntry = channel ? undefined : await resolveCatalogChannelEntry(rawChannel, nextConfig); const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig)); // May trigger loadOpenClawPlugins on cache miss (disk scan + jiti import) @@ -227,6 +222,7 @@ export async function channelsAddCommand( if (!channel && catalogEntry) { const workspaceDir = resolveWorkspaceDir(); + const { isCatalogChannelInstalled } = await import("../channel-setup/discovery.js"); if ( !isCatalogChannelInstalled({ cfg: nextConfig, @@ -364,6 +360,7 @@ export async function channelsAddCommand( runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`); const afterAccountConfigWritten = plugin.setup?.afterAccountConfigWritten; if (afterAccountConfigWritten) { + const { runCollectedChannelOnboardingPostWriteHooks } = await import("../onboard-channels.js"); await runCollectedChannelOnboardingPostWriteHooks({ hooks: [ { diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index f6f1c1048c7..ba76a78c969 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -9,7 +9,6 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-ke import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; -import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js"; import { type ChatChannel, channelLabel, @@ -101,15 +100,20 @@ export async function channelsRemoveCommand( } } - const resolvedPluginState = - !useWizard && rawChannel - ? await resolveInstallableChannelPlugin({ + const shouldResolveInstallablePlugin = + !useWizard && rawChannel && (!channel || !getChannelPlugin(channel)); + const resolvedPluginState = shouldResolveInstallablePlugin + ? await (async () => { + const { resolveInstallableChannelPlugin } = + await import("../channel-setup/channel-plugin-resolution.js"); + return await resolveInstallableChannelPlugin({ cfg, runtime, rawChannel, allowInstall: true, - }) - : null; + }); + })() + : null; if (resolvedPluginState?.configChanged) { cfg = resolvedPluginState.cfg; } diff --git a/src/commands/channels/status-config-format.ts b/src/commands/channels/status-config-format.ts new file mode 100644 index 00000000000..d89a590b5c7 --- /dev/null +++ b/src/commands/channels/status-config-format.ts @@ -0,0 +1,168 @@ +import { + hasConfiguredUnavailableCredentialStatus, + hasResolvedCredentialValue, +} from "../../channels/account-snapshot-fields.js"; +import { listChannelPlugins } from "../../channels/plugins/index.js"; +import { + buildChannelAccountSnapshot, + buildReadOnlySourceChannelAccountSnapshot, +} from "../../channels/plugins/status.js"; +import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { formatDocsLink } from "../../terminal/links.js"; +import { theme } from "../../terminal/theme.js"; + +type ChatChannel = string; + +function formatAccountLabel(params: { accountId: string; name?: string }) { + const base = params.accountId || "default"; + if (params.name?.trim()) { + return `${base} (${params.name.trim()})`; + } + return base; +} + +function formatChannelAccountLabel(params: { + channel: ChatChannel; + accountId: string; + name?: string; +}): string { + const channelText = + listChannelPlugins().find((plugin) => plugin.id === params.channel)?.meta.label ?? + params.channel; + return `${channelText} ${formatAccountLabel({ + accountId: params.accountId, + name: params.name, + })}`; +} + +function appendEnabledConfiguredLinkedBits(bits: string[], account: Record) { + if (typeof account.enabled === "boolean") { + bits.push(account.enabled ? "enabled" : "disabled"); + } + if (typeof account.configured === "boolean") { + if (account.configured) { + bits.push("configured"); + if (hasConfiguredUnavailableCredentialStatus(account)) { + bits.push("secret unavailable in this command path"); + } + } else { + bits.push("not configured"); + } + } + if (typeof account.linked === "boolean") { + bits.push(account.linked ? "linked" : "not linked"); + } +} + +function appendModeBit(bits: string[], account: Record) { + if (typeof account.mode === "string" && account.mode.length > 0) { + bits.push(`mode:${account.mode}`); + } +} + +function appendTokenSourceBits(bits: string[], account: Record) { + const appendSourceBit = (label: string, sourceKey: string, statusKey: string) => { + const source = account[sourceKey]; + if (typeof source !== "string" || !source || source === "none") { + return; + } + const status = account[statusKey]; + const unavailable = status === "configured_unavailable" ? " (unavailable)" : ""; + bits.push(`${label}:${source}${unavailable}`); + }; + + appendSourceBit("token", "tokenSource", "tokenStatus"); + appendSourceBit("bot", "botTokenSource", "botTokenStatus"); + appendSourceBit("app", "appTokenSource", "appTokenStatus"); + appendSourceBit("signing", "signingSecretSource", "signingSecretStatus"); +} + +function appendBaseUrlBit(bits: string[], account: Record) { + if (typeof account.baseUrl === "string" && account.baseUrl) { + bits.push(`url:${account.baseUrl}`); + } +} + +function buildChannelAccountLine( + provider: ChatChannel, + account: Record, + bits: string[], +): string { + const accountId = typeof account.accountId === "string" ? account.accountId : "default"; + const name = normalizeOptionalString(account.name) ?? ""; + const labelText = formatChannelAccountLabel({ + channel: provider, + accountId, + name: name || undefined, + }); + return `- ${labelText}: ${bits.join(", ")}`; +} + +export async function formatConfigChannelsStatusLines( + cfg: OpenClawConfig, + meta: { path?: string; mode?: "local" | "remote" }, + opts?: { sourceConfig?: OpenClawConfig }, +): Promise { + const lines: string[] = []; + lines.push(theme.warn("Gateway not reachable; showing config-only status.")); + if (meta.path) { + lines.push(`Config: ${meta.path}`); + } + if (meta.mode) { + lines.push(`Mode: ${meta.mode}`); + } + if (meta.path || meta.mode) { + lines.push(""); + } + + const accountLines = (provider: ChatChannel, accounts: Array>) => + accounts.map((account) => { + const bits: string[] = []; + appendEnabledConfiguredLinkedBits(bits, account); + appendModeBit(bits, account); + appendTokenSourceBits(bits, account); + appendBaseUrlBit(bits, account); + return buildChannelAccountLine(provider, account, bits); + }); + + const plugins = listChannelPlugins(); + const sourceConfig = opts?.sourceConfig ?? cfg; + for (const plugin of plugins) { + const accountIds = plugin.config.listAccountIds(cfg); + if (!accountIds.length) { + continue; + } + const snapshots: ChannelAccountSnapshot[] = []; + for (const accountId of accountIds) { + const sourceSnapshot = await buildReadOnlySourceChannelAccountSnapshot({ + plugin, + cfg: sourceConfig, + accountId, + }); + const resolvedSnapshot = await buildChannelAccountSnapshot({ + plugin, + cfg, + accountId, + }); + snapshots.push( + sourceSnapshot && + hasConfiguredUnavailableCredentialStatus(sourceSnapshot) && + (!hasResolvedCredentialValue(resolvedSnapshot) || + (sourceSnapshot.configured === true && resolvedSnapshot.configured === false)) + ? sourceSnapshot + : resolvedSnapshot, + ); + } + if (snapshots.length > 0) { + lines.push(...accountLines(plugin.id, snapshots)); + } + } + + lines.push(""); + lines.push( + `Tip: ${formatDocsLink("/cli#status", "status --deep")} adds gateway health probes to status output (requires a reachable gateway).`, + ); + return lines; +} diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index cf81d6e1cee..4c280471234 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -1,18 +1,10 @@ -import { - hasConfiguredUnavailableCredentialStatus, - hasResolvedCredentialValue, -} from "../../channels/account-snapshot-fields.js"; +import { hasConfiguredUnavailableCredentialStatus } from "../../channels/account-snapshot-fields.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; -import { - buildChannelAccountSnapshot, - buildReadOnlySourceChannelAccountSnapshot, -} from "../../channels/plugins/status.js"; -import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js"; import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; import { withProgress } from "../../cli/progress.js"; -import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config.js"; +import { readConfigFileSnapshot } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js"; import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; @@ -25,6 +17,9 @@ import { formatChannelAccountLabel, requireValidConfigSnapshot, } from "./shared.js"; +import { formatConfigChannelsStatusLines } from "./status-config-format.js"; + +export { formatConfigChannelsStatusLines } from "./status-config-format.js"; export type ChannelsStatusOptions = { json?: boolean; @@ -210,73 +205,6 @@ export function formatGatewayChannelsStatusLines(payload: Record { - const lines: string[] = []; - lines.push(theme.warn("Gateway not reachable; showing config-only status.")); - if (meta.path) { - lines.push(`Config: ${meta.path}`); - } - if (meta.mode) { - lines.push(`Mode: ${meta.mode}`); - } - if (meta.path || meta.mode) { - lines.push(""); - } - - const accountLines = (provider: ChatChannel, accounts: Array>) => - accounts.map((account) => { - const bits: string[] = []; - appendEnabledConfiguredLinkedBits(bits, account); - appendModeBit(bits, account); - appendTokenSourceBits(bits, account); - appendBaseUrlBit(bits, account); - return buildChannelAccountLine(provider, account, bits); - }); - - const plugins = listChannelPlugins(); - const sourceConfig = opts?.sourceConfig ?? cfg; - for (const plugin of plugins) { - const accountIds = plugin.config.listAccountIds(cfg); - if (!accountIds.length) { - continue; - } - const snapshots: ChannelAccountSnapshot[] = []; - for (const accountId of accountIds) { - const sourceSnapshot = await buildReadOnlySourceChannelAccountSnapshot({ - plugin, - cfg: sourceConfig, - accountId, - }); - const resolvedSnapshot = await buildChannelAccountSnapshot({ - plugin, - cfg, - accountId, - }); - snapshots.push( - sourceSnapshot && - hasConfiguredUnavailableCredentialStatus(sourceSnapshot) && - (!hasResolvedCredentialValue(resolvedSnapshot) || - (sourceSnapshot.configured === true && resolvedSnapshot.configured === false)) - ? sourceSnapshot - : resolvedSnapshot, - ); - } - if (snapshots.length > 0) { - lines.push(...accountLines(plugin.id, snapshots)); - } - } - - lines.push(""); - lines.push( - `Tip: ${formatDocsLink("/cli#status", "status --deep")} adds gateway health probes to status output (requires a reachable gateway).`, - ); - return lines; -} - export async function channelsStatusCommand( opts: ChannelsStatusOptions, runtime: RuntimeEnv = defaultRuntime, diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index b3266c133d1..b17d421425d 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -25,6 +25,9 @@ const mocks = vi.hoisted(() => { waitForGatewayReachable: vi.fn(), resolveControlUiLinks: vi.fn(), summarizeExistingConfig: vi.fn(), + isCodexNativeWebSearchRelevant: vi.fn(({ config }: { config: OpenClawConfig }) => + Boolean(config.auth?.profiles?.["openai-codex:default"]), + ), }; }); @@ -109,6 +112,10 @@ vi.mock("./onboard-search.js", () => ({ setupSearch: mocks.setupSearch, })); +vi.mock("../agents/codex-native-web-search.js", () => ({ + isCodexNativeWebSearchRelevant: mocks.isCodexNativeWebSearchRelevant, +})); + vi.mock("../config/mutate.js", async () => { const actual = await vi.importActual("../config/mutate.js"); return { @@ -259,6 +266,13 @@ describe("runConfigureWizard", () => { await runWebConfigureWizard(); + expect(mocks.setupSearch).toHaveBeenCalledWith( + expect.objectContaining({ + gateway: expect.objectContaining({ mode: "local" }), + }), + expect.anything(), + expect.anything(), + ); expect(mocks.writeConfigFile).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ @@ -284,40 +298,6 @@ describe("runConfigureWizard", () => { expect(mocks.setupSearch).toHaveBeenCalledOnce(); }); - it("delegates provider selection to the shared search setup flow", async () => { - setupBaseWizardState(); - mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) => - createEnabledWebSearchConfig("firecrawl", { - enabled: true, - })(cfg), - ); - queueWizardPrompts({ - select: ["local"], - confirm: [true, false], - }); - - await runWebConfigureWizard(); - - expect(mocks.setupSearch).toHaveBeenCalledWith( - expect.objectContaining({ - gateway: expect.objectContaining({ mode: "local" }), - }), - expect.anything(), - expect.anything(), - ); - expect(mocks.writeConfigFile).toHaveBeenCalledWith( - expect.objectContaining({ - plugins: expect.objectContaining({ - entries: expect.objectContaining({ - firecrawl: expect.objectContaining({ - enabled: true, - }), - }), - }), - }), - ); - }); - it("does not crash when web search providers are unavailable under plugin policy", async () => { setupBaseWizardState(); mocks.resolveSearchProviderOptions.mockReturnValue([]); diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 89f606fdd7a..70e022ef385 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -166,105 +166,7 @@ describe("buildGatewayInstallPlan", () => { expect(mocks.resolvePreferredNodePath).toHaveBeenCalled(); }); - it("merges config env vars into the environment", async () => { - mockNodeGatewayPlanFixture({ - serviceEnvironment: { - OPENCLAW_PORT: "3000", - HOME: "/Users/me", - }, - }); - - const plan = await buildGatewayInstallPlan({ - env: isolatedPlanEnv(), - port: 3000, - runtime: "node", - config: { - env: { - vars: { - GOOGLE_API_KEY: "test-key", // pragma: allowlist secret - }, - CUSTOM_VAR: "custom-value", - }, - }, - }); - - // Config env vars should be present - expect(plan.environment.GOOGLE_API_KEY).toBe("test-key"); - expect(plan.environment.CUSTOM_VAR).toBe("custom-value"); - expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe("CUSTOM_VAR,GOOGLE_API_KEY"); - // Service environment vars should take precedence - expect(plan.environment.OPENCLAW_PORT).toBe("3000"); - expect(plan.environment.HOME).toBe("/Users/me"); - }); - - it("drops dangerous config env vars before service merge", async () => { - mockNodeGatewayPlanFixture({ - serviceEnvironment: { - OPENCLAW_PORT: "3000", - }, - }); - - const plan = await buildGatewayInstallPlan({ - env: isolatedPlanEnv(), - port: 3000, - runtime: "node", - config: { - env: { - vars: { - NODE_OPTIONS: "--require /tmp/evil.js", - SAFE_KEY: "safe-value", - }, - }, - }, - }); - - expect(plan.environment.NODE_OPTIONS).toBeUndefined(); - expect(plan.environment.SAFE_KEY).toBe("safe-value"); - }); - - it("does not include empty config env values", async () => { - mockNodeGatewayPlanFixture(); - - const plan = await buildGatewayInstallPlan({ - env: isolatedPlanEnv(), - port: 3000, - runtime: "node", - config: { - env: { - vars: { - VALID_KEY: "valid", - EMPTY_KEY: "", - }, - }, - }, - }); - - expect(plan.environment.VALID_KEY).toBe("valid"); - expect(plan.environment.EMPTY_KEY).toBeUndefined(); - }); - - it("drops whitespace-only config env values", async () => { - mockNodeGatewayPlanFixture({ serviceEnvironment: {} }); - - const plan = await buildGatewayInstallPlan({ - env: isolatedPlanEnv(), - port: 3000, - runtime: "node", - config: { - env: { - vars: { - VALID_KEY: "valid", - }, - TRIMMED_KEY: " ", - }, - }, - }); - - expect(plan.environment.VALID_KEY).toBe("valid"); - expect(plan.environment.TRIMMED_KEY).toBeUndefined(); - }); - - it("keeps service env values over config env vars", async () => { + it("merges safe config env while dropping unsafe values and keeping service precedence", async () => { mockNodeGatewayPlanFixture({ serviceEnvironment: { HOME: "/Users/service", @@ -279,15 +181,30 @@ describe("buildGatewayInstallPlan", () => { config: { env: { HOME: "/Users/config", + CUSTOM_VAR: "custom-value", + EMPTY_KEY: "", + TRIMMED_KEY: " ", vars: { + GOOGLE_API_KEY: "test-key", // pragma: allowlist secret OPENCLAW_PORT: "9999", + NODE_OPTIONS: "--require /tmp/evil.js", + SAFE_KEY: "safe-value", }, }, }, }); + expect(plan.environment.GOOGLE_API_KEY).toBe("test-key"); + expect(plan.environment.CUSTOM_VAR).toBe("custom-value"); + expect(plan.environment.SAFE_KEY).toBe("safe-value"); + expect(plan.environment.NODE_OPTIONS).toBeUndefined(); + expect(plan.environment.EMPTY_KEY).toBeUndefined(); + expect(plan.environment.TRIMMED_KEY).toBeUndefined(); expect(plan.environment.HOME).toBe("/Users/service"); expect(plan.environment.OPENCLAW_PORT).toBe("3000"); + expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe( + "CUSTOM_VAR,GOOGLE_API_KEY,OPENCLAW_PORT,SAFE_KEY", + ); }); it("skips auth-profile store load when no auth-profile source exists", async () => { @@ -338,42 +255,7 @@ describe("buildGatewayInstallPlan", () => { expect(mocks.loadAuthProfileStoreForSecretsRuntime).not.toHaveBeenCalled(); }); - it("merges env-backed auth-profile refs into the service environment", async () => { - mockNodeGatewayPlanFixture({ - serviceEnvironment: { - OPENCLAW_PORT: "3000", - }, - }); - mocks.loadAuthProfileStoreForSecretsRuntime.mockReturnValue({ - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, - }, - "anthropic:default": { - type: "token", - provider: "anthropic", - tokenRef: { source: "env", provider: "default", id: "ANTHROPIC_TOKEN" }, - }, - }, - }); - - const plan = await buildGatewayInstallPlan({ - env: isolatedPlanEnv({ - OPENAI_API_KEY: "sk-openai-test", // pragma: allowlist secret - ANTHROPIC_TOKEN: "ant-test-token", - }), - port: 3000, - runtime: "node", - }); - - expect(plan.environment.OPENAI_API_KEY).toBe("sk-openai-test"); - expect(plan.environment.ANTHROPIC_TOKEN).toBe("ant-test-token"); - }); - - it("blocks dangerous auth-profile env refs from the service environment", async () => { + it("merges only portable auth-profile env refs into the service environment", async () => { mockNodeGatewayPlanFixture({ serviceEnvironment: { OPENCLAW_PORT: "3000", @@ -392,11 +274,26 @@ describe("buildGatewayInstallPlan", () => { provider: "git", tokenRef: { source: "env", provider: "default", id: "GIT_ASKPASS" }, }, + "broken:default": { + type: "token", + provider: "broken", + tokenRef: { source: "env", provider: "default", id: "BAD KEY" }, + }, "openai:default": { type: "api_key", provider: "openai", keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, }, + "anthropic:default": { + type: "token", + provider: "anthropic", + tokenRef: { source: "env", provider: "default", id: "ANTHROPIC_TOKEN" }, + }, + "missing:default": { + type: "token", + provider: "missing", + tokenRef: { source: "env", provider: "default", id: "MISSING_TOKEN" }, + }, }, }); @@ -406,6 +303,7 @@ describe("buildGatewayInstallPlan", () => { NODE_OPTIONS: "--require ./pwn.js", GIT_ASKPASS: "/tmp/askpass.sh", OPENAI_API_KEY: "sk-openai-test", // pragma: allowlist secret + ANTHROPIC_TOKEN: "ant-test-token", }), port: 3000, runtime: "node", @@ -414,64 +312,13 @@ describe("buildGatewayInstallPlan", () => { expect(plan.environment.NODE_OPTIONS).toBeUndefined(); expect(plan.environment.GIT_ASKPASS).toBeUndefined(); + expect(plan.environment["BAD KEY"]).toBeUndefined(); + expect(plan.environment.MISSING_TOKEN).toBeUndefined(); expect(plan.environment.OPENAI_API_KEY).toBe("sk-openai-test"); + expect(plan.environment.ANTHROPIC_TOKEN).toBe("ant-test-token"); expect(warn).toHaveBeenCalledWith(expect.stringContaining("NODE_OPTIONS"), "Auth profile"); expect(warn).toHaveBeenCalledWith(expect.stringContaining("GIT_ASKPASS"), "Auth profile"); }); - - it("skips non-portable auth-profile env ref keys", async () => { - mockNodeGatewayPlanFixture({ - serviceEnvironment: { - OPENCLAW_PORT: "3000", - }, - }); - mocks.loadAuthProfileStoreForSecretsRuntime.mockReturnValue({ - version: 1, - profiles: { - "broken:default": { - type: "token", - provider: "broken", - tokenRef: { source: "env", provider: "default", id: "BAD KEY" }, - }, - }, - }); - - const plan = await buildGatewayInstallPlan({ - env: isolatedPlanEnv({ - "BAD KEY": "should-not-pass", - }), - port: 3000, - runtime: "node", - }); - - expect(plan.environment["BAD KEY"]).toBeUndefined(); - }); - - it("skips unresolved auth-profile env refs", async () => { - mockNodeGatewayPlanFixture({ - serviceEnvironment: { - OPENCLAW_PORT: "3000", - }, - }); - mocks.loadAuthProfileStoreForSecretsRuntime.mockReturnValue({ - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, - }, - }, - }); - - const plan = await buildGatewayInstallPlan({ - env: isolatedPlanEnv(), - port: 3000, - runtime: "node", - }); - - expect(plan.environment.OPENAI_API_KEY).toBeUndefined(); - }); }); describe("buildGatewayInstallPlan — dotenv merge", () => { @@ -485,28 +332,19 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it("merges .env file vars into the install plan", async () => { - await writeStateDirDotEnv("BRAVE_API_KEY=BSA-from-env\nOPENROUTER_API_KEY=or-key\n", { - stateDir: path.join(tmpDir, ".openclaw"), + it("merges .env vars with config and service precedence", async () => { + await writeStateDirDotEnv( + "BRAVE_API_KEY=BSA-from-env\nOPENROUTER_API_KEY=or-key\nMY_KEY=from-dotenv\nHOME=/from-dotenv\n", + { + stateDir: path.join(tmpDir, ".openclaw"), + }, + ); + mockNodeGatewayPlanFixture({ + serviceEnvironment: { + HOME: "/from-service", + OPENCLAW_PORT: "3000", + }, }); - mockNodeGatewayPlanFixture({ serviceEnvironment: { OPENCLAW_PORT: "3000" } }); - - const plan = await buildGatewayInstallPlan({ - env: { HOME: tmpDir }, - port: 3000, - runtime: "node", - }); - - expect(plan.environment.BRAVE_API_KEY).toBe("BSA-from-env"); - expect(plan.environment.OPENROUTER_API_KEY).toBe("or-key"); - expect(plan.environment.OPENCLAW_PORT).toBe("3000"); - }); - - it("config env vars override .env file vars", async () => { - await writeStateDirDotEnv("MY_KEY=from-dotenv\n", { - stateDir: path.join(tmpDir, ".openclaw"), - }); - mockNodeGatewayPlanFixture({ serviceEnvironment: {} }); const plan = await buildGatewayInstallPlan({ env: { HOME: tmpDir }, @@ -521,16 +359,15 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { }, }); + expect(plan.environment.BRAVE_API_KEY).toBe("BSA-from-env"); + expect(plan.environment.OPENROUTER_API_KEY).toBe("or-key"); expect(plan.environment.MY_KEY).toBe("from-config"); + expect(plan.environment.HOME).toBe("/from-service"); + expect(plan.environment.OPENCLAW_PORT).toBe("3000"); }); - it("service env overrides .env file vars", async () => { - await writeStateDirDotEnv("HOME=/from-dotenv\n", { - stateDir: path.join(tmpDir, ".openclaw"), - }); - mockNodeGatewayPlanFixture({ - serviceEnvironment: { HOME: "/from-service" }, - }); + it("works when .env file does not exist", async () => { + mockNodeGatewayPlanFixture({ serviceEnvironment: { OPENCLAW_PORT: "3000" } }); const plan = await buildGatewayInstallPlan({ env: { HOME: tmpDir }, @@ -538,41 +375,10 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { runtime: "node", }); - expect(plan.environment.HOME).toBe("/from-service"); + expect(plan.environment.OPENCLAW_PORT).toBe("3000"); }); it("preserves safe custom vars from an existing service env and merges PATH", async () => { - mockNodeGatewayPlanFixture({ - serviceEnvironment: { - HOME: "/from-service", - OPENCLAW_PORT: "3000", - PATH: "/managed/bin:/usr/bin", - }, - }); - - const plan = await buildGatewayInstallPlan({ - env: { HOME: tmpDir }, - port: 3000, - runtime: "node", - existingEnvironment: { - PATH: "/custom/go/bin:/usr/bin", - GOBIN: "/Users/test/.local/gopath/bin", - BLOGWATCHER_HOME: "/Users/test/.blogwatcher", - NODE_OPTIONS: "--require /tmp/evil.js", - GOPATH: "/Users/test/.local/gopath", - OPENCLAW_SERVICE_MARKER: "openclaw", - }, - }); - - expect(plan.environment.PATH).toBe("/managed/bin:/usr/bin:/custom/go/bin"); - expect(plan.environment.GOBIN).toBe("/Users/test/.local/gopath/bin"); - expect(plan.environment.BLOGWATCHER_HOME).toBe("/Users/test/.blogwatcher"); - expect(plan.environment.NODE_OPTIONS).toBeUndefined(); - expect(plan.environment.GOPATH).toBeUndefined(); - expect(plan.environment.OPENCLAW_SERVICE_MARKER).toBeUndefined(); - }); - - it("drops non-absolute and temp PATH entries from an existing service env", async () => { mockNodeGatewayPlanFixture({ serviceEnvironment: { HOME: "/from-service", @@ -588,10 +394,20 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { runtime: "node", existingEnvironment: { PATH: ".:/tmp/evil:/custom/go/bin:/usr/bin", + GOBIN: "/Users/test/.local/gopath/bin", + BLOGWATCHER_HOME: "/Users/test/.blogwatcher", + NODE_OPTIONS: "--require /tmp/evil.js", + GOPATH: "/Users/test/.local/gopath", + OPENCLAW_SERVICE_MARKER: "openclaw", }, }); expect(plan.environment.PATH).toBe("/managed/bin:/usr/bin:/custom/go/bin"); + expect(plan.environment.GOBIN).toBe("/Users/test/.local/gopath/bin"); + expect(plan.environment.BLOGWATCHER_HOME).toBe("/Users/test/.blogwatcher"); + expect(plan.environment.NODE_OPTIONS).toBeUndefined(); + expect(plan.environment.GOPATH).toBeUndefined(); + expect(plan.environment.OPENCLAW_SERVICE_MARKER).toBeUndefined(); }); it("drops keys that were previously tracked as managed service env", async () => { @@ -622,18 +438,6 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { expect(plan.environment.GOPATH).toBeUndefined(); expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBeUndefined(); }); - - it("works when .env file does not exist", async () => { - mockNodeGatewayPlanFixture({ serviceEnvironment: { OPENCLAW_PORT: "3000" } }); - - const plan = await buildGatewayInstallPlan({ - env: { HOME: tmpDir }, - port: 3000, - runtime: "node", - }); - - expect(plan.environment.OPENCLAW_PORT).toBe("3000"); - }); }); describe("gatewayInstallErrorHint", () => { diff --git a/src/commands/doctor-auth-legacy-oauth.ts b/src/commands/doctor-auth-legacy-oauth.ts new file mode 100644 index 00000000000..cbafb738c48 --- /dev/null +++ b/src/commands/doctor-auth-legacy-oauth.ts @@ -0,0 +1,51 @@ +import { repairOAuthProfileIdMismatch } from "../agents/auth-profiles/repair.js"; +import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; + +async function loadProviderRuntime() { + return import("../plugins/providers.runtime.js"); +} + +async function loadNoteRuntime() { + return import("../terminal/note.js"); +} + +export async function maybeRepairLegacyOAuthProfileIds( + cfg: OpenClawConfig, + prompter: DoctorPrompter, +): Promise { + const store = ensureAuthProfileStore(); + let nextCfg = cfg; + const { resolvePluginProviders } = await loadProviderRuntime(); + const providers = resolvePluginProviders({ + config: cfg, + env: process.env, + mode: "setup", + }); + for (const provider of providers) { + for (const repairSpec of provider.oauthProfileIdRepairs ?? []) { + const repair = repairOAuthProfileIdMismatch({ + cfg: nextCfg, + store, + provider: provider.id, + legacyProfileId: repairSpec.legacyProfileId, + }); + if (!repair.migrated || repair.changes.length === 0) { + continue; + } + + const { note } = await loadNoteRuntime(); + note(repair.changes.map((c) => `- ${c}`).join("\n"), "Auth profiles"); + const apply = await prompter.confirm({ + message: `Update ${repairSpec.promptLabel ?? provider.label} OAuth profile id in config now?`, + initialValue: true, + }); + if (!apply) { + continue; + } + nextCfg = repair.config; + } + } + return nextCfg; +} diff --git a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts index 4e913aa2593..b091d6953e0 100644 --- a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts +++ b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts @@ -1,24 +1,34 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ProviderPlugin } from "../plugins/types.js"; -import { captureEnv } from "../test-utils/env.js"; -import { maybeRepairLegacyOAuthProfileIds } from "./doctor-auth.js"; +import { maybeRepairLegacyOAuthProfileIds } from "./doctor-auth-legacy-oauth.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; import type { DoctorRepairMode } from "./doctor-repair-mode.js"; const resolvePluginProvidersMock = vi.fn<() => ProviderPlugin[]>(() => []); -const isPluginProvidersLoadInFlightMock = vi.fn(() => false); +const authProfileStoreMock = vi.hoisted(() => ({ + store: { version: 1, profiles: {} } as AuthProfileStore, +})); +const repairMocks = vi.hoisted(() => ({ + repairOAuthProfileIdMismatch: vi.fn(), +})); vi.mock("../plugins/providers.runtime.js", () => ({ - isPluginProvidersLoadInFlight: () => isPluginProvidersLoadInFlightMock(), resolvePluginProviders: () => resolvePluginProvidersMock(), })); -let envSnapshot: ReturnType; -let tempAgentDir: string | undefined; +vi.mock("../agents/auth-profiles/repair.js", () => ({ + repairOAuthProfileIdMismatch: repairMocks.repairOAuthProfileIdMismatch, +})); + +vi.mock("../agents/auth-profiles/store.js", () => ({ + ensureAuthProfileStore: () => authProfileStoreMock.store, +})); + +vi.mock("../terminal/note.js", () => ({ + note: vi.fn(), +})); function makePrompter(confirmValue: boolean): DoctorPrompter { const repairMode: DoctorRepairMode = { @@ -41,54 +51,35 @@ function makePrompter(confirmValue: boolean): DoctorPrompter { } beforeEach(() => { - envSnapshot = captureEnv(["OPENCLAW_AGENT_DIR", "PI_CODING_AGENT_DIR"]); - tempAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-")); - process.env.OPENCLAW_AGENT_DIR = tempAgentDir; - process.env.PI_CODING_AGENT_DIR = tempAgentDir; resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue([]); - isPluginProvidersLoadInFlightMock.mockReset(); - isPluginProvidersLoadInFlightMock.mockReturnValue(false); -}); - -afterEach(() => { - envSnapshot.restore(); - if (tempAgentDir) { - fs.rmSync(tempAgentDir, { recursive: true, force: true }); - tempAgentDir = undefined; - } + authProfileStoreMock.store = { version: 1, profiles: {} }; + repairMocks.repairOAuthProfileIdMismatch.mockReset(); + repairMocks.repairOAuthProfileIdMismatch.mockReturnValue({ + config: {}, + changes: [], + migrated: false, + }); }); describe("maybeRepairLegacyOAuthProfileIds", () => { it("repairs provider-owned legacy OAuth profile ids", async () => { - if (!tempAgentDir) { - throw new Error("Missing temp agent dir"); - } - const authPath = path.join(tempAgentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - `${JSON.stringify( - { - version: 1, - profiles: { - "anthropic:user@example.com": { - type: "oauth", - provider: "anthropic", - access: "token-a", - refresh: "token-r", - expires: Date.now() + 60_000, - email: "user@example.com", - }, - }, - lastGood: { - anthropic: "anthropic:user@example.com", - }, + authProfileStoreMock.store = { + version: 1, + profiles: { + "anthropic:user@example.com": { + type: "oauth", + provider: "anthropic", + access: "token-a", + refresh: "token-r", + expires: Date.now() + 60_000, + email: "user@example.com", }, - null, - 2, - )}\n`, - "utf8", - ); + }, + lastGood: { + anthropic: "anthropic:user@example.com", + }, + }; resolvePluginProvidersMock.mockReturnValue([ { @@ -98,6 +89,24 @@ describe("maybeRepairLegacyOAuthProfileIds", () => { oauthProfileIdRepairs: [{ legacyProfileId: "anthropic:default" }], }, ]); + repairMocks.repairOAuthProfileIdMismatch.mockReturnValue({ + migrated: true, + changes: ["Auth: migrate anthropic:default → anthropic:user@example.com"], + config: { + auth: { + profiles: { + "anthropic:user@example.com": { + provider: "anthropic", + mode: "oauth", + email: "user@example.com", + }, + }, + order: { + anthropic: ["anthropic:user@example.com"], + }, + }, + }, + }); const next = await maybeRepairLegacyOAuthProfileIds( { @@ -113,6 +122,18 @@ describe("maybeRepairLegacyOAuthProfileIds", () => { makePrompter(true), ); + expect(repairMocks.repairOAuthProfileIdMismatch).toHaveBeenCalledWith({ + cfg: expect.objectContaining({ + auth: expect.objectContaining({ + profiles: expect.objectContaining({ + "anthropic:default": { provider: "anthropic", mode: "oauth" }, + }), + }), + }), + store: authProfileStoreMock.store, + provider: "anthropic", + legacyProfileId: "anthropic:default", + }); expect(next.auth?.profiles?.["anthropic:default"]).toBeUndefined(); expect(next.auth?.profiles?.["anthropic:user@example.com"]).toMatchObject({ provider: "anthropic", diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index fd753bc992b..033be87f58f 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -6,7 +6,6 @@ import { import { type AuthCredentialReasonCode, ensureAuthProfileStore, - repairOAuthProfileIdMismatch, resolveApiKeyForProfile, resolveProfileUnusableUntilForDisplay, } from "../agents/auth-profiles.js"; @@ -18,54 +17,17 @@ import { } from "../agents/auth-profiles/oauth-refresh-failure.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { resolvePluginProviders } from "../plugins/providers.runtime.js"; import { note } from "../terminal/note.js"; import { isRecord } from "../utils.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; import { buildProviderAuthRecoveryHint } from "./provider-auth-guidance.js"; +export { maybeRepairLegacyOAuthProfileIds } from "./doctor-auth-legacy-oauth.js"; const CODEX_PROVIDER_ID = "openai-codex"; const CODEX_OAUTH_WARNING_TITLE = "Codex OAuth"; const OPENAI_BASE_URL = "https://api.openai.com/v1"; const LEGACY_CODEX_APIS = new Set(["openai-responses", "openai-completions"]); -export async function maybeRepairLegacyOAuthProfileIds( - cfg: OpenClawConfig, - prompter: DoctorPrompter, -): Promise { - const store = ensureAuthProfileStore(); - let nextCfg = cfg; - const providers = resolvePluginProviders({ - config: cfg, - env: process.env, - mode: "setup", - }); - for (const provider of providers) { - for (const repairSpec of provider.oauthProfileIdRepairs ?? []) { - const repair = repairOAuthProfileIdMismatch({ - cfg: nextCfg, - store, - provider: provider.id, - legacyProfileId: repairSpec.legacyProfileId, - }); - if (!repair.migrated || repair.changes.length === 0) { - continue; - } - - note(repair.changes.map((c) => `- ${c}`).join("\n"), "Auth profiles"); - const apply = await prompter.confirm({ - message: `Update ${repairSpec.promptLabel ?? provider.label} OAuth profile id in config now?`, - initialValue: true, - }); - if (!apply) { - continue; - } - nextCfg = repair.config; - } - } - return nextCfg; -} - function hasConfiguredCodexOAuthProfile(cfg: OpenClawConfig): boolean { return Object.values(cfg.auth?.profiles ?? {}).some( (profile) => profile.provider === CODEX_PROVIDER_ID && profile.mode === "oauth", diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 140bad79260..2c9a6d97d0b 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -11,6 +11,194 @@ import { type TerminalNote = (message: string, title?: string) => void; const terminalNoteMock = vi.hoisted(() => vi.fn()); +const legacyConfigMigrationForTest = vi.hoisted(() => { + function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; + } + + function ensureRecord(parent: Record, key: string): Record { + const current = asRecord(parent[key]); + if (current) { + return current; + } + const next: Record = {}; + parent[key] = next; + return next; + } + + function migrateThreadBinding(value: unknown, changes: string[], pathLabel: string): void { + const record = asRecord(value); + const bindings = asRecord(record?.threadBindings); + if (!bindings || !("ttlHours" in bindings)) { + return; + } + if (!("idleHours" in bindings)) { + bindings.idleHours = bindings.ttlHours; + } + delete bindings.ttlHours; + changes.push(`Moved ${pathLabel}.threadBindings.ttlHours to idleHours.`); + } + + function migrateStreamingAlias(channel: Record, channelId: string): boolean { + if ( + !("streamMode" in channel) && + typeof channel.streaming !== "boolean" && + typeof channel.streaming !== "string" + ) { + return false; + } + if (channelId === "googlechat") { + delete channel.streamMode; + return true; + } + const streaming = asRecord(channel.streaming) ?? {}; + if (!("mode" in streaming)) { + streaming.mode = + channel.streamMode === "block" + ? "partial" + : channel.streaming === false + ? "off" + : "partial"; + } + delete channel.streamMode; + channel.streaming = streaming; + return true; + } + + function migrateNestedAllowAliases(channel: Record, channelId: string): boolean { + let changed = false; + if (channelId === "slack") { + for (const room of Object.values(asRecord(channel.channels) ?? {})) { + const roomRecord = asRecord(room); + if (roomRecord && "allow" in roomRecord) { + roomRecord.enabled = roomRecord.allow; + delete roomRecord.allow; + changed = true; + } + } + } + if (channelId === "googlechat") { + for (const group of Object.values(asRecord(channel.groups) ?? {})) { + const groupRecord = asRecord(group); + if (groupRecord && "allow" in groupRecord) { + groupRecord.enabled = groupRecord.allow; + delete groupRecord.allow; + changed = true; + } + } + } + if (channelId === "discord") { + for (const guild of Object.values(asRecord(channel.guilds) ?? {})) { + for (const room of Object.values(asRecord(asRecord(guild)?.channels) ?? {})) { + const roomRecord = asRecord(room); + if (roomRecord && "allow" in roomRecord) { + roomRecord.enabled = roomRecord.allow; + delete roomRecord.allow; + changed = true; + } + } + } + } + return changed; + } + + function migrate(raw: unknown): { next: Record | null; changes: string[] } { + const root = asRecord(raw); + if (!root) { + return { next: null, changes: [] }; + } + const next = structuredClone(root); + const changes: string[] = []; + + const heartbeat = asRecord(next.heartbeat); + if (heartbeat) { + const agents = ensureRecord(next, "agents"); + const agentDefaults = ensureRecord(agents, "defaults"); + const channels = ensureRecord(next, "channels"); + const channelDefaults = ensureRecord(channels, "defaults"); + const agentHeartbeat: Record = {}; + const channelHeartbeat: Record = {}; + for (const key of ["model", "every"]) { + if (key in heartbeat) { + agentHeartbeat[key] = heartbeat[key]; + } + } + for (const key of ["showOk", "showAlerts", "useIndicator"]) { + if (key in heartbeat) { + channelHeartbeat[key] = heartbeat[key]; + } + } + if (Object.keys(agentHeartbeat).length > 0) { + agentDefaults.heartbeat = { + ...asRecord(agentDefaults.heartbeat), + ...agentHeartbeat, + }; + } + if (Object.keys(channelHeartbeat).length > 0) { + channelDefaults.heartbeat = { + ...asRecord(channelDefaults.heartbeat), + ...channelHeartbeat, + }; + } + delete next.heartbeat; + changes.push("Moved heartbeat to agents.defaults.heartbeat and channels.defaults.heartbeat."); + } + + const gateway = asRecord(next.gateway); + if (gateway?.bind === "0.0.0.0") { + gateway.bind = "lan"; + changes.push("Normalized gateway.bind host alias."); + } else if (gateway?.bind === "localhost" || gateway?.bind === "127.0.0.1") { + gateway.bind = "loopback"; + changes.push("Normalized gateway.bind host alias."); + } + + migrateThreadBinding(next.session, changes, "session"); + const channels = asRecord(next.channels); + for (const [channelId, channelRaw] of Object.entries(channels ?? {})) { + if (channelId === "defaults") { + continue; + } + const channel = asRecord(channelRaw); + if (!channel) { + continue; + } + migrateThreadBinding(channel, changes, `channels.${channelId}`); + if (migrateStreamingAlias(channel, channelId)) { + changes.push(`Normalized channels.${channelId} streaming aliases.`); + } + if (migrateNestedAllowAliases(channel, channelId)) { + changes.push(`Normalized channels.${channelId} nested allow aliases.`); + } + for (const [accountId, accountRaw] of Object.entries(asRecord(channel.accounts) ?? {})) { + const account = asRecord(accountRaw); + migrateThreadBinding(account, changes, `channels.${channelId}.accounts.${accountId}`); + if (account && migrateStreamingAlias(account, channelId)) { + changes.push(`Normalized channels.${channelId}.accounts.${accountId} streaming aliases.`); + } + } + } + + const sandbox = asRecord(asRecord(asRecord(next.agents)?.defaults)?.sandbox); + if (sandbox && "perSession" in sandbox) { + sandbox.scope = sandbox.perSession === true ? "session" : "workspace"; + delete sandbox.perSession; + changes.push("Moved agents.defaults.sandbox.perSession to scope."); + } + + return changes.length > 0 ? { next, changes } : { next: null, changes: [] }; + } + + return { + migrate, + migrateLegacyConfig: (raw: unknown) => { + const { next, changes } = migrate(raw); + return { config: next, changes }; + }, + }; +}); vi.mock("../terminal/note.js", () => ({ note: terminalNoteMock, @@ -59,6 +247,202 @@ vi.mock("../config/validation.js", () => ({ validateConfigObjectWithPlugins: vi.fn((config: unknown) => ({ ok: true, config })), })); +vi.mock("../config/legacy.js", () => { + type LegacyRule = { + path: string[]; + message: string; + match?: (value: unknown, root: Record) => boolean; + requireSourceLiteral?: boolean; + }; + + function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; + } + + function getPathValue(root: Record, pathParts: readonly string[]): unknown { + let cursor: unknown = root; + for (const part of pathParts) { + const record = asRecord(cursor); + if (!record) { + return undefined; + } + cursor = record[part]; + } + return cursor; + } + + function addIssue( + issues: Array<{ path: string; message: string }>, + pathParts: readonly string[], + message: string, + ) { + issues.push({ path: pathParts.join("."), message }); + } + + function hasLegacyStreamingAlias(channel: Record): boolean { + return ( + "streamMode" in channel || + "chunkMode" in channel || + "blockStreaming" in channel || + "draftChunk" in channel || + "blockStreamingCoalesce" in channel || + "nativeStreaming" in channel || + typeof channel.streaming === "boolean" || + typeof channel.streaming === "string" + ); + } + + return { + findLegacyConfigIssues: (raw: unknown, sourceRaw?: unknown, extraRules: LegacyRule[] = []) => { + const root = asRecord(raw); + if (!root) { + return []; + } + const sourceRoot = asRecord(sourceRaw) ?? root; + const issues: Array<{ path: string; message: string }> = []; + + if ("heartbeat" in root) { + addIssue( + issues, + ["heartbeat"], + 'heartbeat is legacy; use agents.defaults.heartbeat and channels.defaults.heartbeat. Run "openclaw doctor --fix".', + ); + } + if ("memorySearch" in root) { + addIssue( + issues, + ["memorySearch"], + 'memorySearch is legacy; use agents.defaults.memorySearch. Run "openclaw doctor --fix".', + ); + } + const gateway = asRecord(root.gateway); + if (gateway && "bind" in gateway) { + addIssue( + issues, + ["gateway", "bind"], + 'gateway.bind host aliases are legacy; use the canonical bind mode. Run "openclaw doctor --fix".', + ); + } + const sessionThreadBindings = asRecord(asRecord(root.session)?.threadBindings); + if (sessionThreadBindings && "ttlHours" in sessionThreadBindings) { + addIssue( + issues, + ["session", "threadBindings", "ttlHours"], + 'session.threadBindings.ttlHours is legacy; use session.threadBindings.idleHours. Run "openclaw doctor --fix".', + ); + } + const xSearch = asRecord(asRecord(asRecord(root.tools)?.web)?.x_search); + if (xSearch && "apiKey" in xSearch) { + addIssue( + issues, + ["tools", "web", "x_search", "apiKey"], + 'tools.web.x_search.apiKey is legacy; use plugins.entries.xai.config.webSearch.apiKey. Run "openclaw doctor --fix".', + ); + } + const sandbox = asRecord(asRecord(asRecord(root.agents)?.defaults)?.sandbox); + if (sandbox && "perSession" in sandbox) { + addIssue( + issues, + ["agents", "defaults", "sandbox"], + 'agents.defaults.sandbox.perSession is legacy; use agents.defaults.sandbox.scope. Run "openclaw doctor --fix".', + ); + } + + const channels = asRecord(root.channels); + for (const [channelId, channelRaw] of Object.entries(channels ?? {})) { + if (channelId === "defaults") { + continue; + } + const channel = asRecord(channelRaw); + if (!channel) { + continue; + } + if (hasLegacyStreamingAlias(channel)) { + addIssue( + issues, + ["channels", channelId], + channelId === "googlechat" + ? `channels.${channelId}.streamMode is legacy and no longer used. Run "openclaw doctor --fix".` + : `channels.${channelId}.streamMode, channels.${channelId}.streaming aliases are legacy. Run "openclaw doctor --fix".`, + ); + } + const threadBindings = asRecord(channel.threadBindings); + if (threadBindings && "ttlHours" in threadBindings) { + addIssue( + issues, + ["channels", channelId, "threadBindings", "ttlHours"], + 'channels..threadBindings.ttlHours is legacy; use channels..threadBindings.idleHours. Run "openclaw doctor --fix".', + ); + } + if (channelId === "slack") { + for (const roomRaw of Object.values(asRecord(channel.channels) ?? {})) { + if ("allow" in (asRecord(roomRaw) ?? {})) { + addIssue( + issues, + ["channels", "slack"], + 'channels.slack.channels..allow is legacy; use enabled. Run "openclaw doctor --fix".', + ); + } + } + } + if (channelId === "googlechat") { + for (const spaceRaw of Object.values(asRecord(channel.groups) ?? {})) { + if ("allow" in (asRecord(spaceRaw) ?? {})) { + addIssue( + issues, + ["channels", "googlechat"], + 'channels.googlechat.groups..allow is legacy; use enabled. Run "openclaw doctor --fix".', + ); + } + } + } + if (channelId === "discord") { + for (const guildRaw of Object.values(asRecord(channel.guilds) ?? {})) { + const guild = asRecord(guildRaw); + for (const roomRaw of Object.values(asRecord(guild?.channels) ?? {})) { + if ("allow" in (asRecord(roomRaw) ?? {})) { + addIssue( + issues, + ["channels", "discord"], + 'channels.discord.guilds..channels..allow is legacy; use enabled. Run "openclaw doctor --fix".', + ); + } + } + } + } + for (const [accountId, accountRaw] of Object.entries(asRecord(channel.accounts) ?? {})) { + const account = asRecord(accountRaw); + const accountThreadBindings = asRecord(account?.threadBindings); + if (accountThreadBindings && "ttlHours" in accountThreadBindings) { + addIssue( + issues, + ["channels", channelId, "accounts", accountId, "threadBindings", "ttlHours"], + 'channels..threadBindings.ttlHours is legacy; use channels..threadBindings.idleHours. Run "openclaw doctor --fix".', + ); + } + } + } + + for (const rule of extraRules) { + const value = getPathValue(root, rule.path); + if (value === undefined || (rule.match && !rule.match(value, root))) { + continue; + } + if (rule.requireSourceLiteral) { + const sourceValue = getPathValue(sourceRoot, rule.path); + if (sourceValue === undefined || (rule.match && !rule.match(sourceValue, sourceRoot))) { + continue; + } + } + addIssue(issues, rule.path, rule.message); + } + return issues; + }, + }; +}); + vi.mock("../channels/plugins/bootstrap-registry.js", () => ({ getBootstrapChannelPlugin: vi.fn((channelId: string) => { if (channelId !== "discord") { @@ -197,6 +581,32 @@ vi.mock("./doctor/shared/channel-legacy-config-migrate.js", () => ({ }), })); +vi.mock("./doctor/shared/legacy-config-migrate.js", () => ({ + migrateLegacyConfig: legacyConfigMigrationForTest.migrateLegacyConfig, +})); + +vi.mock("./doctor/shared/bundled-plugin-load-paths.js", () => ({ + maybeRepairBundledPluginLoadPaths: vi.fn((cfg: Record) => ({ + config: cfg, + changes: [], + })), +})); + +vi.mock("./doctor/shared/exec-safe-bins.js", () => ({ + maybeRepairExecSafeBinProfiles: vi.fn((cfg: Record) => ({ + config: cfg, + changes: [], + warnings: [], + })), +})); + +vi.mock("./doctor/shared/stale-plugin-config.js", () => ({ + maybeRepairStalePluginConfig: vi.fn((cfg: Record) => ({ + config: cfg, + changes: [], + })), +})); + vi.mock("./doctor/channel-capabilities.js", () => { const byChannel = { googlechat: { @@ -698,10 +1108,6 @@ vi.mock("./doctor-config-preflight.js", async () => { await import("../plugins/doctor-contract-registry.js"); const { findLegacyConfigIssues }: typeof import("../config/legacy.js") = await import("../config/legacy.js"); - const { - applyRuntimeLegacyConfigMigrations, - }: typeof import("./doctor/shared/runtime-compat-api.js") = - await import("./doctor/shared/runtime-compat-api.js"); function resolveConfigPath() { const stateDir = @@ -807,7 +1213,7 @@ vi.mock("./doctor-config-preflight.js", async () => { pluginIds: collectRelevantDoctorPluginIds(parsed), }), ); - const compat = applyRuntimeLegacyConfigMigrations(parsed); + const compat = legacyConfigMigrationForTest.migrate(parsed); const effectiveConfig = normalizeDiscordStreamingCompat(compat.next ?? parsed); return { snapshot: { diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index eded7102752..ef74147aec3 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -12,6 +12,41 @@ vi.mock("../plugins/setup-registry.js", () => ({ }), })); +vi.mock("../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry: () => ({ + plugins: [ + { + id: "brave", + origin: "bundled", + contracts: { webSearchProviders: ["brave"] }, + }, + { + id: "google", + origin: "bundled", + contracts: { webSearchProviders: ["gemini"] }, + }, + { + id: "firecrawl", + origin: "bundled", + contracts: { webSearchProviders: ["firecrawl"] }, + }, + ], + }), + resolveManifestContractOwnerPluginId: ({ value }: { value: string }): string | undefined => { + if (value === "gemini") { + return "google"; + } + return value === "brave" || value === "firecrawl" ? value : undefined; + }, +})); + +vi.mock("./doctor/shared/channel-legacy-config-migrate.js", () => ({ + applyChannelDoctorCompatibilityMigrations: (cfg: OpenClawConfig) => ({ + next: cfg, + changes: [], + }), +})); + describe("normalizeCompatibilityConfigValues", () => { let previousOauthDir: string | undefined; let tempOauthDir = ""; diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index 3f8eb3aca6a..f1cf4057818 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -3,7 +3,10 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveStorePath, resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; +import { + resolveStorePath, + resolveSessionTranscriptsDirForAgent, +} from "../config/sessions/paths.js"; import { noteStateIntegrity } from "./doctor-state-integrity.js"; vi.mock("../channels/plugins/bundled-ids.js", () => ({ diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index e24af7f2557..feef9fbdffa 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -9,13 +9,15 @@ import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; import { formatSessionArchiveTimestamp, isPrimarySessionTranscriptFileName, - loadSessionStore, - resolveMainSessionKey, +} from "../config/sessions/artifacts.js"; +import { resolveMainSessionKey } from "../config/sessions/main-session.js"; +import { resolveSessionFilePath, resolveSessionFilePathOptions, resolveSessionTranscriptsDirForAgent, resolveStorePath, -} from "../config/sessions.js"; +} from "../config/sessions/paths.js"; +import { loadSessionStore } from "../config/sessions/store-load.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { resolveMemoryBackendConfig } from "../memory-host-sdk/engine-storage.js"; diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 31a79658156..cb03249f6d7 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -1,12 +1,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { - resetSessionStoreLockRuntimeForTests, - setSessionWriteLockAcquirerForTests, -} from "../config/sessions/store.js"; import { autoMigrateLegacyStateDir, autoMigrateLegacyState, @@ -16,7 +12,7 @@ import { runLegacyStateMigrations, } from "./doctor-state-migrations.js"; -let tempRoot: string | null = null; +let tempRoots: string[] = []; vi.mock("../channels/plugins/bundled.js", () => { function fileExists(filePath: string): boolean { @@ -135,6 +131,13 @@ vi.mock("../channels/plugins/bundled.js", () => { }; }); +vi.mock("../config/sessions.js", () => ({ + saveSessionStore: async (storePath: string, store: Record) => { + await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); + await fs.promises.writeFile(storePath, `${JSON.stringify(store, null, 2)}\n`, "utf-8"); + }, +})); + vi.mock("../infra/json-files.js", async () => { const actual = await vi.importActual("../infra/json-files.js"); @@ -161,7 +164,7 @@ vi.mock("../infra/json-files.js", async () => { async function makeTempRoot() { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-")); - tempRoot = root; + tempRoots.push(root); return root; } @@ -197,21 +200,13 @@ async function runTelegramAllowFromMigration(params: { root: string; cfg: OpenCl return { oauthDir, detected, result }; } -beforeEach(() => { - setSessionWriteLockAcquirerForTests(async () => ({ - release: async () => undefined, - })); -}); - afterEach(async () => { resetAutoMigrateLegacyStateForTest(); resetAutoMigrateLegacyStateDirForTest(); - resetSessionStoreLockRuntimeForTests(); - if (!tempRoot) { - return; - } - await fs.promises.rm(tempRoot, { recursive: true, force: true }); - tempRoot = null; + await Promise.all( + tempRoots.map((root) => fs.promises.rm(root, { recursive: true, force: true })), + ); + tempRoots = []; }); function writeJson5(filePath: string, value: unknown) { @@ -291,6 +286,11 @@ async function runStateDirMigration(root: string, env = {} as NodeJS.ProcessEnv) }); } +async function runFreshStateDirMigration(root: string, env = {} as NodeJS.ProcessEnv) { + resetAutoMigrateLegacyStateDirForTest(); + return runStateDirMigration(root, env); +} + async function runAutoMigrateLegacyStateWithLog(params: { root: string; cfg: OpenClawConfig; @@ -712,93 +712,74 @@ describe("doctor legacy state migrations", () => { expect(result.migrated).toBe(false); }); - it("does not warn when legacy state dir is an already-migrated symlink mirror", async () => { - const root = await makeTempRoot(); - const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root); - fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true }); - fs.mkdirSync(path.join(targetDir, "agent"), { recursive: true }); - + it("classifies already-migrated symlink mirrors without warnings", async () => { + const flatRoot = await makeTempRoot(); + const flat = ensureLegacyAndTargetStateDirs(flatRoot); + fs.mkdirSync(path.join(flat.targetDir, "sessions"), { recursive: true }); + fs.mkdirSync(path.join(flat.targetDir, "agent"), { recursive: true }); fs.symlinkSync( - path.join(targetDir, "sessions"), - path.join(legacyDir, "sessions"), + path.join(flat.targetDir, "sessions"), + path.join(flat.legacyDir, "sessions"), DIR_LINK_TYPE, ); - fs.symlinkSync(path.join(targetDir, "agent"), path.join(legacyDir, "agent"), DIR_LINK_TYPE); - - const result = await runStateDirMigration(root); - expectUnmigratedWithoutWarnings(result); - }); - - it("warns when legacy state dir is empty and target already exists", async () => { - const root = await makeTempRoot(); - const { targetDir } = ensureLegacyAndTargetStateDirs(root); - - const result = await runStateDirMigration(root); - expectTargetAlreadyExistsWarning(result, targetDir); - }); - - it("warns when legacy state dir contains non-symlink entries and target already exists", async () => { - const root = await makeTempRoot(); - const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root); - fs.writeFileSync(path.join(legacyDir, "sessions.json"), "{}", "utf-8"); - - const result = await runStateDirMigration(root); - expectTargetAlreadyExistsWarning(result, targetDir); - }); - - it("does not warn when legacy state dir contains nested symlink mirrors", async () => { - const root = await makeTempRoot(); - const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root); - fs.mkdirSync(path.join(targetDir, "agents", "main"), { recursive: true }); - fs.mkdirSync(path.join(legacyDir, "agents"), { recursive: true }); - fs.symlinkSync( - path.join(targetDir, "agents", "main"), - path.join(legacyDir, "agents", "main"), + path.join(flat.targetDir, "agent"), + path.join(flat.legacyDir, "agent"), DIR_LINK_TYPE, ); + expectUnmigratedWithoutWarnings(await runFreshStateDirMigration(flatRoot)); - const result = await runStateDirMigration(root); - expectUnmigratedWithoutWarnings(result); + const nestedRoot = await makeTempRoot(); + const nested = ensureLegacyAndTargetStateDirs(nestedRoot); + fs.mkdirSync(path.join(nested.targetDir, "agents", "main"), { recursive: true }); + fs.mkdirSync(path.join(nested.legacyDir, "agents"), { recursive: true }); + fs.symlinkSync( + path.join(nested.targetDir, "agents", "main"), + path.join(nested.legacyDir, "agents", "main"), + DIR_LINK_TYPE, + ); + expectUnmigratedWithoutWarnings(await runFreshStateDirMigration(nestedRoot)); }); - it("warns when legacy state dir symlink points outside the target tree", async () => { - const root = await makeTempRoot(); - const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root); - const outsideDir = path.join(root, ".outside-state"); - fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true }); + it("warns when target exists and legacy state is not a safe mirror", async () => { + const emptyRoot = await makeTempRoot(); + const empty = ensureLegacyAndTargetStateDirs(emptyRoot); + expectTargetAlreadyExistsWarning(await runFreshStateDirMigration(emptyRoot), empty.targetDir); + + const fileRoot = await makeTempRoot(); + const file = ensureLegacyAndTargetStateDirs(fileRoot); + fs.writeFileSync(path.join(file.legacyDir, "sessions.json"), "{}", "utf-8"); + expectTargetAlreadyExistsWarning(await runFreshStateDirMigration(fileRoot), file.targetDir); + + const outsideRoot = await makeTempRoot(); + const outside = ensureLegacyAndTargetStateDirs(outsideRoot); + const outsideDir = path.join(outsideRoot, ".outside-state"); + fs.mkdirSync(path.join(outside.targetDir, "sessions"), { recursive: true }); fs.mkdirSync(outsideDir, { recursive: true }); + fs.symlinkSync(outsideDir, path.join(outside.legacyDir, "sessions"), DIR_LINK_TYPE); + expectTargetAlreadyExistsWarning( + await runFreshStateDirMigration(outsideRoot), + outside.targetDir, + ); - fs.symlinkSync(path.join(outsideDir), path.join(legacyDir, "sessions"), DIR_LINK_TYPE); - - const result = await runStateDirMigration(root); - expectTargetAlreadyExistsWarning(result, targetDir); - }); - - it("warns when legacy state dir contains a broken symlink target", async () => { - const root = await makeTempRoot(); - const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root); - fs.mkdirSync(path.join(targetDir, "sessions"), { recursive: true }); - - const targetSessionDir = path.join(targetDir, "sessions"); - fs.symlinkSync(targetSessionDir, path.join(legacyDir, "sessions"), DIR_LINK_TYPE); + const brokenRoot = await makeTempRoot(); + const broken = ensureLegacyAndTargetStateDirs(brokenRoot); + const targetSessionDir = path.join(broken.targetDir, "sessions"); + fs.mkdirSync(targetSessionDir, { recursive: true }); + fs.symlinkSync(targetSessionDir, path.join(broken.legacyDir, "sessions"), DIR_LINK_TYPE); fs.rmSync(targetSessionDir, { recursive: true, force: true }); + expectTargetAlreadyExistsWarning(await runFreshStateDirMigration(brokenRoot), broken.targetDir); - const result = await runStateDirMigration(root); - expectTargetAlreadyExistsWarning(result, targetDir); - }); - - it("warns when legacy symlink escapes target tree through second-hop symlink", async () => { - const root = await makeTempRoot(); - const { targetDir, legacyDir } = ensureLegacyAndTargetStateDirs(root); - const outsideDir = path.join(root, ".outside-state"); - fs.mkdirSync(outsideDir, { recursive: true }); - - const targetHop = path.join(targetDir, "hop"); - fs.symlinkSync(outsideDir, targetHop, DIR_LINK_TYPE); - fs.symlinkSync(targetHop, path.join(legacyDir, "sessions"), DIR_LINK_TYPE); - - const result = await runStateDirMigration(root); - expectTargetAlreadyExistsWarning(result, targetDir); + const secondHopRoot = await makeTempRoot(); + const secondHop = ensureLegacyAndTargetStateDirs(secondHopRoot); + const secondHopOutsideDir = path.join(secondHopRoot, ".outside-state"); + fs.mkdirSync(secondHopOutsideDir, { recursive: true }); + const targetHop = path.join(secondHop.targetDir, "hop"); + fs.symlinkSync(secondHopOutsideDir, targetHop, DIR_LINK_TYPE); + fs.symlinkSync(targetHop, path.join(secondHop.legacyDir, "sessions"), DIR_LINK_TYPE); + expectTargetAlreadyExistsWarning( + await runFreshStateDirMigration(secondHopRoot), + secondHop.targetDir, + ); }); }); diff --git a/src/commands/doctor/shared/legacy-config-write-ownership.test.ts b/src/commands/doctor/shared/legacy-config-write-ownership.test.ts index 0d719cbdcd7..5900260eee6 100644 --- a/src/commands/doctor/shared/legacy-config-write-ownership.test.ts +++ b/src/commands/doctor/shared/legacy-config-write-ownership.test.ts @@ -4,6 +4,14 @@ import { describe, expect, it } from "vitest"; const REPO_ROOT = path.resolve(import.meta.dirname, "../../../.."); const SRC_ROOT = path.join(REPO_ROOT, "src"); +const DOCTOR_ROOT = path.join(SRC_ROOT, "commands", "doctor"); +const LEGACY_REPAIR_FLAG = "migrateLegacyConfig"; +const LEGACY_MIGRATION_MODULE = "legacy-config-migrate"; +const LEGACY_REPAIR_FLAG_BYTES = Buffer.from(LEGACY_REPAIR_FLAG); +const LEGACY_MIGRATION_MODULE_BYTES = Buffer.from(LEGACY_MIGRATION_MODULE); +const LEGACY_REPAIR_FLAG_RE = /migrateLegacyConfig\s*:\s*true/; +const LEGACY_MIGRATION_MODULE_RE = + /legacy-config-migrate(?:\.js)?|legacy-config-migrations(?:\.[\w-]+)?(?:\.js)?/; function collectSourceFiles(dir: string, acc: string[] = []): string[] { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -12,6 +20,9 @@ function collectSourceFiles(dir: string, acc: string[] = []): string[] { } const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { + if (fullPath === DOCTOR_ROOT) { + continue; + } collectSourceFiles(fullPath, acc); continue; } @@ -23,27 +34,33 @@ function collectSourceFiles(dir: string, acc: string[] = []): string[] { return acc; } +function collectViolations(files: string[]): string[] { + const violations: string[] = []; + for (const file of files) { + const rel = path.relative(REPO_ROOT, file).replaceAll(path.sep, "/"); + const sourceBytes = fs.readFileSync(file); + const hasRepairFlag = sourceBytes.includes(LEGACY_REPAIR_FLAG_BYTES); + const hasMigrationModule = sourceBytes.includes(LEGACY_MIGRATION_MODULE_BYTES); + if (!hasRepairFlag && !hasMigrationModule) { + continue; + } + const source = sourceBytes.toString("utf8"); + + if (hasRepairFlag && LEGACY_REPAIR_FLAG_RE.test(source)) { + violations.push(`${rel}: migrateLegacyConfig:true outside doctor`); + } + + if (hasMigrationModule && LEGACY_MIGRATION_MODULE_RE.test(source)) { + violations.push(`${rel}: doctor legacy migration module referenced outside doctor`); + } + } + return violations; +} + describe("legacy config write ownership", () => { it("keeps legacy config repair flags and migration modules under doctor", () => { const files = collectSourceFiles(SRC_ROOT); - const violations: string[] = []; - - for (const file of files) { - const rel = path.relative(REPO_ROOT, file).replaceAll(path.sep, "/"); - const source = fs.readFileSync(file, "utf8"); - const isDoctorFile = rel.startsWith("src/commands/doctor/"); - - if (!isDoctorFile && /migrateLegacyConfig\s*:\s*true/.test(source)) { - violations.push(`${rel}: migrateLegacyConfig:true outside doctor`); - } - - if ( - !isDoctorFile && - /legacy-config-migrate(?:\.js)?|legacy-config-migrations(?:\.[\w-]+)?(?:\.js)?/.test(source) - ) { - violations.push(`${rel}: doctor legacy migration module referenced outside doctor`); - } - } + const violations = collectViolations(files); expect(violations).toEqual([]); }); diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts index 72f8036995f..d7a092ce463 100644 --- a/src/commands/doctor/shared/preview-warnings.test.ts +++ b/src/commands/doctor/shared/preview-warnings.test.ts @@ -1,10 +1,23 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as bundledSources from "../../../plugins/bundled-sources.js"; -import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js"; -import * as manifestRegistry from "../../../plugins/manifest-registry.js"; import { collectDoctorPreviewWarnings } from "./preview-warnings.js"; +type TestManifestRecord = { + id: string; + channels: string[]; +}; + +const manifestState = vi.hoisted( + () => + ({ + plugins: [] as TestManifestRecord[], + diagnostics: [] as Array<{ level: string; message: string; source: string }>, + }) satisfies { + plugins: TestManifestRecord[]; + diagnostics: Array<{ level: string; message: string; source: string }>; + }, +); + vi.mock("../channel-capabilities.js", () => { const fallback = { dmAllowFromMode: "topOnly", @@ -40,22 +53,98 @@ vi.mock("./channel-doctor.js", () => ({ shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning: vi.fn(() => false), })); -function manifest(id: string): PluginManifestRecord { +vi.mock("./channel-plugin-blockers.js", () => ({ + scanConfiguredChannelPluginBlockers: (cfg: { + channels?: Record; + plugins?: { enabled?: boolean; entries?: Record }; + }) => { + const configuredChannels = new Set(Object.keys(cfg.channels ?? {})); + return manifestState.plugins.flatMap((plugin) => { + const disabledByEntry = cfg.plugins?.entries?.[plugin.id]?.enabled === false; + const pluginsDisabled = cfg.plugins?.enabled === false; + if (!disabledByEntry && !pluginsDisabled) { + return []; + } + return plugin.channels + .filter((channelId) => configuredChannels.has(channelId)) + .map((channelId) => ({ + channelId, + pluginId: plugin.id, + reason: disabledByEntry ? "disabled in config" : "plugins disabled", + })); + }); + }, + collectConfiguredChannelPluginBlockerWarnings: ( + hits: Array<{ channelId: string; pluginId: string; reason: string }>, + ) => + hits.map((hit) => { + const reason = + hit.reason === "disabled in config" + ? `plugin "${hit.pluginId}" is disabled by plugins.entries.${hit.pluginId}.enabled=false.` + : "plugins.enabled=false blocks channel plugins globally."; + return `- channels.${hit.channelId}: channel is configured, but ${reason}`; + }), + isWarningBlockedByChannelPlugin: (warning: string, hits: Array<{ channelId: string }>) => + hits.some( + (hit) => + warning.includes(`channels.${hit.channelId}:`) || + warning.includes(`channels.${hit.channelId}.`), + ), +})); + +vi.mock("./stale-plugin-config.js", () => ({ + scanStalePluginConfig: (cfg: { + plugins?: { allow?: string[]; entries?: Record }; + }) => { + const knownIds = new Set(manifestState.plugins.map((plugin) => plugin.id)); + const ids = [...(cfg.plugins?.allow ?? []), ...Object.keys(cfg.plugins?.entries ?? {})]; + return [...new Set(ids)].filter((id) => !knownIds.has(id)).map((id) => ({ id })); + }, + isStalePluginAutoRepairBlocked: () => + manifestState.diagnostics.some((diagnostic) => diagnostic.level === "error"), + collectStalePluginConfigWarnings: ({ + autoRepairBlocked, + doctorFixCommand, + hits, + }: { + autoRepairBlocked: boolean; + doctorFixCommand: string; + hits: Array<{ id: string }>; + }) => + hits.map( + (hit) => + `plugins.allow: stale plugin reference "${hit.id}". plugins.entries.${hit.id} is unused. ${ + autoRepairBlocked + ? `Auto-removal is paused; rerun "${doctorFixCommand}".` + : `Run "${doctorFixCommand}".` + }`, + ), +})); + +vi.mock("./bundled-plugin-load-paths.js", () => ({ + scanBundledPluginLoadPathMigrations: (cfg: { plugins?: { load?: { paths?: string[] } } }) => + (cfg.plugins?.load?.paths ?? []).map((legacyPath) => ({ legacyPath })), + collectBundledPluginLoadPathWarnings: ({ + doctorFixCommand, + hits, + }: { + doctorFixCommand: string; + hits: Array<{ legacyPath: string }>; + }) => + hits.map( + (hit) => + `plugins.load.paths: legacy bundled plugin path "${hit.legacyPath}". Run "${doctorFixCommand}".`, + ), +})); + +function manifest(id: string): TestManifestRecord { return { id, channels: [], - providers: [], - cliBackends: [], - skills: [], - hooks: [], - origin: "bundled", - rootDir: `/plugins/${id}`, - source: `/plugins/${id}`, - manifestPath: `/plugins/${id}/openclaw.plugin.json`, }; } -function channelManifest(id: string, channelId: string): PluginManifestRecord { +function channelManifest(id: string, channelId: string): TestManifestRecord { return { ...manifest(id), channels: [channelId], @@ -64,10 +153,8 @@ function channelManifest(id: string, channelId: string): PluginManifestRecord { describe("doctor preview warnings", () => { beforeEach(() => { - vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ - plugins: [manifest("discord")], - diagnostics: [], - }); + manifestState.plugins = [manifest("discord")]; + manifestState.diagnostics = []; }); afterEach(() => { @@ -147,23 +234,7 @@ describe("doctor preview warnings", () => { it("includes bundled plugin load path migration warnings", async () => { const packageRoot = path.resolve("app-node-modules", "openclaw"); const legacyPath = path.join(packageRoot, "extensions", "feishu"); - const bundledPath = path.join(packageRoot, "dist", "extensions", "feishu"); - vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ - plugins: [manifest("feishu")], - diagnostics: [], - }); - vi.spyOn(bundledSources, "resolveBundledPluginSources").mockReturnValue( - new Map([ - [ - "feishu", - { - pluginId: "feishu", - localPath: bundledPath, - npmSpec: "@openclaw/feishu", - }, - ], - ]), - ); + manifestState.plugins = [manifest("feishu")]; const warnings = await collectDoctorPreviewWarnings({ cfg: { @@ -183,12 +254,10 @@ describe("doctor preview warnings", () => { }); it("warns but skips auto-removal when plugin discovery has errors", async () => { - vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ - plugins: [], - diagnostics: [ - { level: "error", message: "plugin path not found: /missing", source: "/missing" }, - ], - }); + manifestState.plugins = []; + manifestState.diagnostics = [ + { level: "error", message: "plugin path not found: /missing", source: "/missing" }, + ]; const warnings = await collectDoctorPreviewWarnings({ cfg: { @@ -210,10 +279,7 @@ describe("doctor preview warnings", () => { }); it("warns when a configured channel plugin is disabled explicitly", async () => { - vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ - plugins: [channelManifest("telegram", "telegram")], - diagnostics: [], - }); + manifestState.plugins = [channelManifest("telegram", "telegram")]; const warnings = await collectDoctorPreviewWarnings({ cfg: { @@ -243,10 +309,7 @@ describe("doctor preview warnings", () => { }); it("warns when channel plugins are blocked globally", async () => { - vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ - plugins: [channelManifest("telegram", "telegram")], - diagnostics: [], - }); + manifestState.plugins = [channelManifest("telegram", "telegram")]; const warnings = await collectDoctorPreviewWarnings({ cfg: { diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index 7f03de90fee..6e4a2dd15a1 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -1,122 +1,204 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { sanitizeForLog } from "../../../terminal/ansi.js"; -import { - collectBundledPluginLoadPathWarnings, - scanBundledPluginLoadPathMigrations, -} from "./bundled-plugin-load-paths.js"; -import { - collectChannelDoctorEmptyAllowlistExtraWarnings, - collectChannelDoctorPreviewWarnings, -} from "./channel-doctor.js"; -import { - collectConfiguredChannelPluginBlockerWarnings, - isWarningBlockedByChannelPlugin, - scanConfiguredChannelPluginBlockers, -} from "./channel-plugin-blockers.js"; -import { scanEmptyAllowlistPolicyWarnings } from "./empty-allowlist-scan.js"; -import { - collectExecSafeBinCoverageWarnings, - collectExecSafeBinTrustedDirHintWarnings, - scanExecSafeBinCoverage, - scanExecSafeBinTrustedDirHints, -} from "./exec-safe-bins.js"; -import { - collectLegacyToolsBySenderWarnings, - scanLegacyToolsBySenderKeys, -} from "./legacy-tools-by-sender.js"; -import { - collectOpenPolicyAllowFromWarnings, - maybeRepairOpenPolicyAllowFrom, -} from "./open-policy-allowfrom.js"; -import { - collectStalePluginConfigWarnings, - isStalePluginAutoRepairBlocked, - scanStalePluginConfig, -} from "./stale-plugin-config.js"; + +function hasRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function hasChannels(cfg: OpenClawConfig): boolean { + return hasRecord(cfg.channels); +} + +function hasPlugins(cfg: OpenClawConfig): boolean { + return hasRecord(cfg.plugins); +} + +function hasPluginLoadPaths(cfg: OpenClawConfig): boolean { + const plugins = cfg.plugins; + if (!hasRecord(plugins)) { + return false; + } + const load = plugins.load; + return hasRecord(load) && Array.isArray(load.paths) && load.paths.length > 0; +} + +function hasExplicitChannelPluginBlockerConfig(cfg: OpenClawConfig): boolean { + if (cfg.plugins?.enabled === false) { + return true; + } + const entries = cfg.plugins?.entries; + if (!hasRecord(entries)) { + return false; + } + return Object.values(entries).some( + (entry) => hasRecord(entry) && "enabled" in entry && entry.enabled === false, + ); +} + +function hasToolsBySenderKey(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some(hasToolsBySenderKey); + } + if (!hasRecord(value)) { + return false; + } + if (hasRecord(value.toolsBySender)) { + return true; + } + return Object.entries(value).some( + ([key, nested]) => key !== "toolsBySender" && hasToolsBySenderKey(nested), + ); +} + +function hasConfiguredSafeBins(cfg: OpenClawConfig): boolean { + const globalExec = cfg.tools?.exec; + if ( + hasRecord(globalExec) && + Array.isArray(globalExec.safeBins) && + globalExec.safeBins.length > 0 + ) { + return true; + } + return (cfg.agents?.list ?? []).some((agent) => { + const agentExec = hasRecord(agent) && hasRecord(agent.tools) ? agent.tools.exec : undefined; + return ( + hasRecord(agentExec) && Array.isArray(agentExec.safeBins) && agentExec.safeBins.length > 0 + ); + }); +} export async function collectDoctorPreviewWarnings(params: { cfg: OpenClawConfig; doctorFixCommand: string; }): Promise { const warnings: string[] = []; + const hasChannelConfig = hasChannels(params.cfg); + const hasPluginConfig = hasPlugins(params.cfg); - const channelPluginBlockerHits = scanConfiguredChannelPluginBlockers(params.cfg, process.env); - if (channelPluginBlockerHits.length > 0) { + const channelPluginRuntime = + hasChannelConfig && hasExplicitChannelPluginBlockerConfig(params.cfg) + ? await import("./channel-plugin-blockers.js") + : undefined; + const channelPluginBlockerHits = + channelPluginRuntime?.scanConfiguredChannelPluginBlockers(params.cfg, process.env) ?? []; + if (channelPluginRuntime && channelPluginBlockerHits.length > 0) { warnings.push( - collectConfiguredChannelPluginBlockerWarnings(channelPluginBlockerHits).join("\n"), + channelPluginRuntime + .collectConfiguredChannelPluginBlockerWarnings(channelPluginBlockerHits) + .join("\n"), ); } - const channelDoctorWarnings = await collectChannelDoctorPreviewWarnings({ - cfg: params.cfg, - doctorFixCommand: params.doctorFixCommand, - }); - if (channelDoctorWarnings.length > 0) { - warnings.push(...channelDoctorWarnings); + if (hasChannelConfig) { + const { collectChannelDoctorPreviewWarnings } = await import("./channel-doctor.js"); + const channelDoctorWarnings = await collectChannelDoctorPreviewWarnings({ + cfg: params.cfg, + doctorFixCommand: params.doctorFixCommand, + }); + if (channelDoctorWarnings.length > 0) { + warnings.push(...channelDoctorWarnings); + } + + const { collectOpenPolicyAllowFromWarnings, maybeRepairOpenPolicyAllowFrom } = + await import("./open-policy-allowfrom.js"); + const allowFromScan = maybeRepairOpenPolicyAllowFrom(params.cfg); + if (allowFromScan.changes.length > 0) { + warnings.push( + collectOpenPolicyAllowFromWarnings({ + changes: allowFromScan.changes, + doctorFixCommand: params.doctorFixCommand, + }).join("\n"), + ); + } } - const allowFromScan = maybeRepairOpenPolicyAllowFrom(params.cfg); - if (allowFromScan.changes.length > 0) { - warnings.push( - collectOpenPolicyAllowFromWarnings({ - changes: allowFromScan.changes, - doctorFixCommand: params.doctorFixCommand, - }).join("\n"), + if (hasPluginConfig) { + const { + collectStalePluginConfigWarnings, + isStalePluginAutoRepairBlocked, + scanStalePluginConfig, + } = await import("./stale-plugin-config.js"); + const stalePluginHits = scanStalePluginConfig(params.cfg, process.env); + if (stalePluginHits.length > 0) { + warnings.push( + collectStalePluginConfigWarnings({ + hits: stalePluginHits, + doctorFixCommand: params.doctorFixCommand, + autoRepairBlocked: isStalePluginAutoRepairBlocked(params.cfg, process.env), + }).join("\n"), + ); + } + } + + if (hasPluginLoadPaths(params.cfg)) { + const { collectBundledPluginLoadPathWarnings, scanBundledPluginLoadPathMigrations } = + await import("./bundled-plugin-load-paths.js"); + const bundledPluginLoadPathHits = scanBundledPluginLoadPathMigrations(params.cfg, process.env); + if (bundledPluginLoadPathHits.length > 0) { + warnings.push( + collectBundledPluginLoadPathWarnings({ + hits: bundledPluginLoadPathHits, + doctorFixCommand: params.doctorFixCommand, + }).join("\n"), + ); + } + } + + if (hasChannelConfig) { + const { collectChannelDoctorEmptyAllowlistExtraWarnings } = await import("./channel-doctor.js"); + const { scanEmptyAllowlistPolicyWarnings } = await import("./empty-allowlist-scan.js"); + const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(params.cfg, { + doctorFixCommand: params.doctorFixCommand, + extraWarningsForAccount: collectChannelDoctorEmptyAllowlistExtraWarnings, + }).filter( + (warning) => + !( + channelPluginRuntime?.isWarningBlockedByChannelPlugin( + warning, + channelPluginBlockerHits, + ) ?? false + ), ); + if (emptyAllowlistWarnings.length > 0) { + const { sanitizeForLog } = await import("../../../terminal/ansi.js"); + warnings.push(emptyAllowlistWarnings.map((line) => sanitizeForLog(line)).join("\n")); + } } - const stalePluginHits = scanStalePluginConfig(params.cfg, process.env); - if (stalePluginHits.length > 0) { - warnings.push( - collectStalePluginConfigWarnings({ - hits: stalePluginHits, - doctorFixCommand: params.doctorFixCommand, - autoRepairBlocked: isStalePluginAutoRepairBlocked(params.cfg, process.env), - }).join("\n"), - ); + if (hasToolsBySenderKey(params.cfg)) { + const { collectLegacyToolsBySenderWarnings, scanLegacyToolsBySenderKeys } = + await import("./legacy-tools-by-sender.js"); + const toolsBySenderHits = scanLegacyToolsBySenderKeys(params.cfg); + if (toolsBySenderHits.length > 0) { + warnings.push( + collectLegacyToolsBySenderWarnings({ + hits: toolsBySenderHits, + doctorFixCommand: params.doctorFixCommand, + }).join("\n"), + ); + } } - const bundledPluginLoadPathHits = scanBundledPluginLoadPathMigrations(params.cfg, process.env); - if (bundledPluginLoadPathHits.length > 0) { - warnings.push( - collectBundledPluginLoadPathWarnings({ - hits: bundledPluginLoadPathHits, - doctorFixCommand: params.doctorFixCommand, - }).join("\n"), - ); - } + if (hasConfiguredSafeBins(params.cfg)) { + const { + collectExecSafeBinCoverageWarnings, + collectExecSafeBinTrustedDirHintWarnings, + scanExecSafeBinCoverage, + scanExecSafeBinTrustedDirHints, + } = await import("./exec-safe-bins.js"); + const safeBinCoverage = scanExecSafeBinCoverage(params.cfg); + if (safeBinCoverage.length > 0) { + warnings.push( + collectExecSafeBinCoverageWarnings({ + hits: safeBinCoverage, + doctorFixCommand: params.doctorFixCommand, + }).join("\n"), + ); + } - const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(params.cfg, { - doctorFixCommand: params.doctorFixCommand, - extraWarningsForAccount: collectChannelDoctorEmptyAllowlistExtraWarnings, - }).filter((warning) => !isWarningBlockedByChannelPlugin(warning, channelPluginBlockerHits)); - if (emptyAllowlistWarnings.length > 0) { - warnings.push(emptyAllowlistWarnings.map((line) => sanitizeForLog(line)).join("\n")); - } - - const toolsBySenderHits = scanLegacyToolsBySenderKeys(params.cfg); - if (toolsBySenderHits.length > 0) { - warnings.push( - collectLegacyToolsBySenderWarnings({ - hits: toolsBySenderHits, - doctorFixCommand: params.doctorFixCommand, - }).join("\n"), - ); - } - - const safeBinCoverage = scanExecSafeBinCoverage(params.cfg); - if (safeBinCoverage.length > 0) { - warnings.push( - collectExecSafeBinCoverageWarnings({ - hits: safeBinCoverage, - doctorFixCommand: params.doctorFixCommand, - }).join("\n"), - ); - } - - const safeBinTrustedDirHints = scanExecSafeBinTrustedDirHints(params.cfg); - if (safeBinTrustedDirHints.length > 0) { - warnings.push(collectExecSafeBinTrustedDirHintWarnings(safeBinTrustedDirHints).join("\n")); + const safeBinTrustedDirHints = scanExecSafeBinTrustedDirHints(params.cfg); + if (safeBinTrustedDirHints.length > 0) { + warnings.push(collectExecSafeBinTrustedDirHintWarnings(safeBinTrustedDirHints).join("\n")); + } } return warnings; diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 9ec14bce45e..aec27fa9858 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -142,28 +142,51 @@ vi.mock("../config/config.js", () => ({ resolveGatewayPort: mocks.resolveGatewayPort, })); -vi.mock("../infra/bonjour-discovery.js", async () => { - const actual = await vi.importActual( - "../infra/bonjour-discovery.js", - ); - return { - ...actual, - discoverGatewayBeacons: mocks.discoverGatewayBeacons, - }; -}); +vi.mock("../infra/bonjour-discovery.js", () => ({ + discoverGatewayBeacons: mocks.discoverGatewayBeacons, + resolveGatewayDiscoveryEndpoint: (beacon: GatewayBonjourBeacon) => { + const host = beacon.host?.trim(); + const port = beacon.port; + if (!host || typeof port !== "number" || !Number.isFinite(port) || port <= 0) { + return null; + } + const scheme = beacon.gatewayTls === true ? "wss" : "ws"; + return { + host, + port, + gatewayTls: beacon.gatewayTls === true, + gatewayTlsFingerprintSha256: beacon.gatewayTlsFingerprintSha256, + scheme, + wsUrl: `${scheme}://${host}:${port}`, + }; + }, +})); vi.mock("../infra/tailnet.js", () => ({ pickPrimaryTailnetIPv4: mocks.pickPrimaryTailnetIPv4, })); -vi.mock("../infra/ssh-tunnel.js", async () => { - const actual = - await vi.importActual("../infra/ssh-tunnel.js"); - return { - ...actual, - startSshPortForward: mocks.startSshPortForward, - }; -}); +vi.mock("../infra/ssh-tunnel.js", () => ({ + parseSshTarget: (rawTarget: string) => { + const trimmed = rawTarget.trim(); + if (!trimmed || trimmed.startsWith("-")) { + return null; + } + const [userHost, rawPort] = trimmed.split(":"); + const [maybeUser, maybeHost] = userHost.includes("@") + ? userHost.split("@", 2) + : [undefined, userHost]; + if (!maybeHost) { + return null; + } + return { + user: maybeUser, + host: maybeHost, + port: rawPort ? Number(rawPort) : 22, + }; + }, + startSshPortForward: mocks.startSshPortForward, +})); vi.mock("../infra/ssh-config.js", () => ({ resolveSshConfig: mocks.resolveSshConfig, diff --git a/src/commands/health-format.ts b/src/commands/health-format.ts index 790ae293e4b..3050cc05022 100644 --- a/src/commands/health-format.ts +++ b/src/commands/health-format.ts @@ -1,4 +1,7 @@ +import { getChannelPlugin } from "../channels/plugins/index.js"; +import { asNullableRecord } from "../shared/record-coerce.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; +import type { ChannelAccountHealthSummary, HealthSummary } from "./health.types.js"; const formatKv = (line: string, rich: boolean) => { const idx = line.indexOf(": "); @@ -47,3 +50,176 @@ export function formatHealthCheckFailure(err: unknown, opts: { rich?: boolean } } return out.join("\n"); } + +const formatProbeLine = (probe: unknown, opts: { botUsernames?: string[] } = {}): string | null => { + const record = asNullableRecord(probe); + if (!record) { + return null; + } + const ok = typeof record.ok === "boolean" ? record.ok : undefined; + if (ok === undefined) { + return null; + } + const elapsedMs = typeof record.elapsedMs === "number" ? record.elapsedMs : null; + const status = typeof record.status === "number" ? record.status : null; + const error = typeof record.error === "string" ? record.error : null; + const bot = asNullableRecord(record.bot); + const botUsername = bot && typeof bot.username === "string" ? bot.username : null; + const webhook = asNullableRecord(record.webhook); + const webhookUrl = webhook && typeof webhook.url === "string" ? webhook.url : null; + + const usernames = new Set(); + if (botUsername) { + usernames.add(botUsername); + } + for (const extra of opts.botUsernames ?? []) { + if (extra) { + usernames.add(extra); + } + } + + if (ok) { + let label = "ok"; + if (usernames.size > 0) { + label += ` (@${Array.from(usernames).join(", @")})`; + } + if (elapsedMs != null) { + label += ` (${elapsedMs}ms)`; + } + if (webhookUrl) { + label += ` - webhook ${webhookUrl}`; + } + return label; + } + let label = `failed (${status ?? "unknown"})`; + if (error) { + label += ` - ${error}`; + } + return label; +}; + +const formatAccountProbeTiming = (summary: ChannelAccountHealthSummary): string | null => { + const probe = asNullableRecord(summary.probe); + if (!probe) { + return null; + } + const elapsedMs = typeof probe.elapsedMs === "number" ? Math.round(probe.elapsedMs) : null; + const ok = typeof probe.ok === "boolean" ? probe.ok : null; + if (elapsedMs == null && ok !== true) { + return null; + } + + const accountId = summary.accountId || "default"; + const botRecord = asNullableRecord(probe.bot); + const botUsername = + botRecord && typeof botRecord.username === "string" ? botRecord.username : null; + const handle = botUsername ? `@${botUsername}` : accountId; + const timing = elapsedMs != null ? `${elapsedMs}ms` : "ok"; + + return `${handle}:${accountId}:${timing}`; +}; + +const isProbeFailure = (summary: ChannelAccountHealthSummary): boolean => { + const probe = asNullableRecord(summary.probe); + if (!probe) { + return false; + } + const ok = typeof probe.ok === "boolean" ? probe.ok : null; + return ok === false; +}; + +export const formatHealthChannelLines = ( + summary: HealthSummary, + opts: { + accountMode?: "default" | "all"; + accountIdsByChannel?: Record; + } = {}, +): string[] => { + const channels = summary.channels ?? {}; + const channelOrder = + summary.channelOrder?.length > 0 ? summary.channelOrder : Object.keys(channels); + const accountMode = opts.accountMode ?? "default"; + + const lines: string[] = []; + for (const channelId of channelOrder) { + const channelSummary = channels[channelId]; + if (!channelSummary) { + continue; + } + const plugin = getChannelPlugin(channelId as never); + const label = summary.channelLabels?.[channelId] ?? plugin?.meta.label ?? channelId; + const accountSummaries = channelSummary.accounts ?? {}; + const accountIds = opts.accountIdsByChannel?.[channelId]; + const filteredSummaries = + accountIds && accountIds.length > 0 + ? accountIds + .map((accountId) => accountSummaries[accountId]) + .filter((entry): entry is ChannelAccountHealthSummary => Boolean(entry)) + : undefined; + const listSummaries = + accountMode === "all" + ? Object.values(accountSummaries) + : (filteredSummaries ?? (channelSummary.accounts ? Object.values(accountSummaries) : [])); + const baseSummary = + filteredSummaries && filteredSummaries.length > 0 ? filteredSummaries[0] : channelSummary; + const botUsernames = listSummaries + ? listSummaries + .map((account) => { + const probeRecord = asNullableRecord(account.probe); + const bot = probeRecord ? asNullableRecord(probeRecord.bot) : null; + return bot && typeof bot.username === "string" ? bot.username : null; + }) + .filter((value): value is string => Boolean(value)) + : []; + const linked = typeof baseSummary.linked === "boolean" ? baseSummary.linked : null; + if (linked !== null) { + if (linked) { + const authAgeMs = typeof baseSummary.authAgeMs === "number" ? baseSummary.authAgeMs : null; + const authLabel = authAgeMs != null ? ` (auth age ${Math.round(authAgeMs / 60000)}m)` : ""; + lines.push(`${label}: linked${authLabel}`); + } else { + lines.push(`${label}: not linked`); + } + continue; + } + + const configured = typeof baseSummary.configured === "boolean" ? baseSummary.configured : null; + if (configured === false) { + lines.push(`${label}: not configured`); + continue; + } + + const accountTimings = + accountMode === "all" + ? listSummaries + .map((account) => formatAccountProbeTiming(account)) + .filter((value): value is string => Boolean(value)) + : []; + const failedSummary = listSummaries.find((summary) => isProbeFailure(summary)); + if (failedSummary) { + const failureLine = formatProbeLine(failedSummary.probe, { botUsernames }); + if (failureLine) { + lines.push(`${label}: ${failureLine}`); + continue; + } + } + + if (accountTimings.length > 0) { + lines.push(`${label}: ok (${accountTimings.join(", ")})`); + continue; + } + + const probeLine = formatProbeLine(baseSummary.probe, { botUsernames }); + if (probeLine) { + lines.push(`${label}: ${probeLine}`); + continue; + } + + if (configured === true) { + lines.push(`${label}: configured`); + continue; + } + lines.push(`${label}: unknown`); + } + return lines; +}; diff --git a/src/commands/health.command.coverage.test.ts b/src/commands/health.command.coverage.test.ts index 02cbae14789..cd40e7bf4d7 100644 --- a/src/commands/health.command.coverage.test.ts +++ b/src/commands/health.command.coverage.test.ts @@ -67,7 +67,7 @@ describe("healthCommand (coverage)", () => { }); }); - it("prints the rich text summary when linked and configured", async () => { + it("prints the rich text summary and verbose gateway details", async () => { const recent = createRecentSessionRows(); callGatewayMock.mockResolvedValueOnce({ ok: true, @@ -128,40 +128,17 @@ describe("healthCommand (coverage)", () => { }, } satisfies HealthSummary); - await healthCommand({ json: false, timeoutMs: 1000 }, runtime as never); - - expect(runtime.exit).not.toHaveBeenCalled(); - expect(stripAnsi(runtime.log.mock.calls.map((c) => String(c[0])).join("\n"))).toMatch( - /WhatsApp: linked/i, - ); - expect(logWebSelfIdMock).toHaveBeenCalled(); - }); - - it("prints gateway connection details in verbose mode", async () => { - callGatewayMock.mockResolvedValueOnce({ - ok: true, - ts: Date.now(), - durationMs: 5, - channels: {}, - channelOrder: [], - channelLabels: {}, - heartbeatSeconds: 60, - defaultAgentId: "main", - agents: [], - sessions: { - path: "/tmp/sessions.json", - count: 0, - recent: [], - }, - } satisfies HealthSummary); - await healthCommand({ json: false, verbose: true, timeoutMs: 1000 }, runtime as never); + expect(runtime.exit).not.toHaveBeenCalled(); + const output = stripAnsi(runtime.log.mock.calls.map((c) => String(c[0])).join("\n")); + expect(output).toMatch(/WhatsApp: linked/i); expect(runtime.log.mock.calls.slice(0, 3)).toEqual([ ["Gateway connection:"], [" Gateway mode: local"], [" Gateway target: ws://127.0.0.1:18789"], ]); expect(buildGatewayConnectionDetailsMock).toHaveBeenCalled(); + expect(logWebSelfIdMock).toHaveBeenCalled(); }); }); diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index f14c397c8fb..3a3a341e1e6 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -353,24 +353,24 @@ describe("getHealthSnapshot", () => { expect(telegram.probe?.webhook?.url).toMatch(/^https:/); expect(calls.some((c) => c.includes("/getMe"))).toBe(true); expect(calls.some((c) => c.includes("/getWebhookInfo"))).toBe(true); - }); - it("treats telegram.tokenFile as configured", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-health-")); const tokenFile = path.join(tmpDir, "telegram-token"); - fs.writeFileSync(tokenFile, "t-file\n", "utf-8"); - const { calls, telegram } = await runSuccessfulTelegramProbe( - { channels: { telegram: { tokenFile } } }, - { clearTokenEnv: true }, - ); - expect(telegram.configured).toBe(true); - expect(telegram.probe?.ok).toBe(true); - expect(calls.some((c) => c.includes("bott-file/getMe"))).toBe(true); - - fs.rmSync(tmpDir, { recursive: true, force: true }); + try { + fs.writeFileSync(tokenFile, "t-file\n", "utf-8"); + const tokenFileProbe = await runSuccessfulTelegramProbe( + { channels: { telegram: { tokenFile } } }, + { clearTokenEnv: true }, + ); + expect(tokenFileProbe.telegram.configured).toBe(true); + expect(tokenFileProbe.telegram.probe?.ok).toBe(true); + expect(tokenFileProbe.calls.some((c) => c.includes("bott-file/getMe"))).toBe(true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); - it("returns a structured telegram probe error when getMe fails", async () => { + it("returns structured telegram probe errors", async () => { testConfig = { channels: { telegram: { botToken: "bad-token" } } }; testStore = {}; vi.stubEnv("DISCORD_BOT_TOKEN", ""); @@ -398,9 +398,7 @@ describe("getHealthSnapshot", () => { expect(telegram.probe?.ok).toBe(false); expect(telegram.probe?.status).toBe(401); expect(telegram.probe?.error).toMatch(/unauthorized/i); - }); - it("captures unexpected probe exceptions as errors", async () => { testConfig = { channels: { telegram: { botToken: "t-err" } } }; testStore = {}; vi.stubEnv("DISCORD_BOT_TOKEN", ""); @@ -412,14 +410,14 @@ describe("getHealthSnapshot", () => { }), ); - const snap = await getHealthSnapshot({ timeoutMs: 25 }); - const telegram = snap.channels.telegram as { + const exceptionSnap = await getHealthSnapshot({ timeoutMs: 25 }); + const exceptionTelegram = exceptionSnap.channels.telegram as { configured?: boolean; probe?: { ok?: boolean; error?: string }; }; - expect(telegram.configured).toBe(true); - expect(telegram.probe?.ok).toBe(false); - expect(telegram.probe?.error).toMatch(/network down/i); + expect(exceptionTelegram.configured).toBe(true); + expect(exceptionTelegram.probe?.ok).toBe(false); + expect(exceptionTelegram.probe?.error).toMatch(/network down/i); }); it("disables heartbeat for agents without heartbeat blocks", async () => { diff --git a/src/commands/health.ts b/src/commands/health.ts index 5f51d8c51d6..c60f6bcc5e1 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,6 +1,6 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; -import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; +import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js"; import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; @@ -18,6 +18,7 @@ import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { asNullableRecord } from "../shared/record-coerce.js"; import { styleHealthChannelLine } from "../terminal/health-style.js"; import { isRich } from "../terminal/theme.js"; +import { formatHealthChannelLines } from "./health-format.js"; import type { AgentHealthSummary, ChannelAccountHealthSummary, @@ -25,6 +26,7 @@ import type { HealthSummary, } from "./health.types.js"; import { logGatewayConnectionDetails } from "./status.gateway-connection.js"; +export { formatHealthChannelLines } from "./health-format.js"; export type { AgentHealthSummary, ChannelAccountHealthSummary, @@ -201,179 +203,6 @@ async function resolveHealthAccountContext(params: { return { account, enabled, configured, diagnostics }; } -const formatProbeLine = (probe: unknown, opts: { botUsernames?: string[] } = {}): string | null => { - const record = asNullableRecord(probe); - if (!record) { - return null; - } - const ok = typeof record.ok === "boolean" ? record.ok : undefined; - if (ok === undefined) { - return null; - } - const elapsedMs = typeof record.elapsedMs === "number" ? record.elapsedMs : null; - const status = typeof record.status === "number" ? record.status : null; - const error = typeof record.error === "string" ? record.error : null; - const bot = asNullableRecord(record.bot); - const botUsername = bot && typeof bot.username === "string" ? bot.username : null; - const webhook = asNullableRecord(record.webhook); - const webhookUrl = webhook && typeof webhook.url === "string" ? webhook.url : null; - - const usernames = new Set(); - if (botUsername) { - usernames.add(botUsername); - } - for (const extra of opts.botUsernames ?? []) { - if (extra) { - usernames.add(extra); - } - } - - if (ok) { - let label = "ok"; - if (usernames.size > 0) { - label += ` (@${Array.from(usernames).join(", @")})`; - } - if (elapsedMs != null) { - label += ` (${elapsedMs}ms)`; - } - if (webhookUrl) { - label += ` - webhook ${webhookUrl}`; - } - return label; - } - let label = `failed (${status ?? "unknown"})`; - if (error) { - label += ` - ${error}`; - } - return label; -}; - -const formatAccountProbeTiming = (summary: ChannelAccountHealthSummary): string | null => { - const probe = asNullableRecord(summary.probe); - if (!probe) { - return null; - } - const elapsedMs = typeof probe.elapsedMs === "number" ? Math.round(probe.elapsedMs) : null; - const ok = typeof probe.ok === "boolean" ? probe.ok : null; - if (elapsedMs == null && ok !== true) { - return null; - } - - const accountId = summary.accountId || "default"; - const botRecord = asNullableRecord(probe.bot); - const botUsername = - botRecord && typeof botRecord.username === "string" ? botRecord.username : null; - const handle = botUsername ? `@${botUsername}` : accountId; - const timing = elapsedMs != null ? `${elapsedMs}ms` : "ok"; - - return `${handle}:${accountId}:${timing}`; -}; - -const isProbeFailure = (summary: ChannelAccountHealthSummary): boolean => { - const probe = asNullableRecord(summary.probe); - if (!probe) { - return false; - } - const ok = typeof probe.ok === "boolean" ? probe.ok : null; - return ok === false; -}; - -export const formatHealthChannelLines = ( - summary: HealthSummary, - opts: { - accountMode?: "default" | "all"; - accountIdsByChannel?: Record; - } = {}, -): string[] => { - const channels = summary.channels ?? {}; - const channelOrder = - summary.channelOrder?.length > 0 ? summary.channelOrder : Object.keys(channels); - const accountMode = opts.accountMode ?? "default"; - - const lines: string[] = []; - for (const channelId of channelOrder) { - const channelSummary = channels[channelId]; - if (!channelSummary) { - continue; - } - const plugin = getChannelPlugin(channelId as never); - const label = summary.channelLabels?.[channelId] ?? plugin?.meta.label ?? channelId; - const accountSummaries = channelSummary.accounts ?? {}; - const accountIds = opts.accountIdsByChannel?.[channelId]; - const filteredSummaries = - accountIds && accountIds.length > 0 - ? accountIds - .map((accountId) => accountSummaries[accountId]) - .filter((entry): entry is ChannelAccountHealthSummary => Boolean(entry)) - : undefined; - const listSummaries = - accountMode === "all" - ? Object.values(accountSummaries) - : (filteredSummaries ?? (channelSummary.accounts ? Object.values(accountSummaries) : [])); - const baseSummary = - filteredSummaries && filteredSummaries.length > 0 ? filteredSummaries[0] : channelSummary; - const botUsernames = listSummaries - ? listSummaries - .map((account) => { - const probeRecord = asNullableRecord(account.probe); - const bot = probeRecord ? asNullableRecord(probeRecord.bot) : null; - return bot && typeof bot.username === "string" ? bot.username : null; - }) - .filter((value): value is string => Boolean(value)) - : []; - const linked = typeof baseSummary.linked === "boolean" ? baseSummary.linked : null; - if (linked !== null) { - if (linked) { - const authAgeMs = typeof baseSummary.authAgeMs === "number" ? baseSummary.authAgeMs : null; - const authLabel = authAgeMs != null ? ` (auth age ${Math.round(authAgeMs / 60000)}m)` : ""; - lines.push(`${label}: linked${authLabel}`); - } else { - lines.push(`${label}: not linked`); - } - continue; - } - - const configured = typeof baseSummary.configured === "boolean" ? baseSummary.configured : null; - if (configured === false) { - lines.push(`${label}: not configured`); - continue; - } - - const accountTimings = - accountMode === "all" - ? listSummaries - .map((account) => formatAccountProbeTiming(account)) - .filter((value): value is string => Boolean(value)) - : []; - const failedSummary = listSummaries.find((summary) => isProbeFailure(summary)); - if (failedSummary) { - const failureLine = formatProbeLine(failedSummary.probe, { botUsernames }); - if (failureLine) { - lines.push(`${label}: ${failureLine}`); - continue; - } - } - - if (accountTimings.length > 0) { - lines.push(`${label}: ok (${accountTimings.join(", ")})`); - continue; - } - - const probeLine = formatProbeLine(baseSummary.probe, { botUsernames }); - if (probeLine) { - lines.push(`${label}: ${probeLine}`); - continue; - } - - if (configured === true) { - lines.push(`${label}: configured`); - continue; - } - lines.push(`${label}: unknown`); - } - return lines; -}; - export async function getHealthSnapshot(params?: { timeoutMs?: number; probe?: boolean; diff --git a/src/commands/models.list.auth-sync.test.ts b/src/commands/models.list.auth-sync.test.ts deleted file mode 100644 index 9e27bce0774..00000000000 --- a/src/commands/models.list.auth-sync.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { - type AuthProfileStore, - resolveApiKeyForProfile, - saveAuthProfileStore, -} from "../agents/auth-profiles.js"; -import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; -import { withEnvAsync } from "../test-utils/env.js"; -import { toModelRow } from "./models/list.registry.js"; - -const OPENROUTER_MODEL = { - provider: "openrouter", - id: "openai/gpt-5.4", - name: "GPT-5.4 via OpenRouter", - api: "openai-chat-completions", - baseUrl: "https://openrouter.ai/api/v1", - input: ["text"], - contextWindow: 1_000_000, - maxTokens: 128_000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, -} as const; - -async function pathExists(pathname: string): Promise { - try { - await fs.stat(pathname); - return true; - } catch { - return false; - } -} - -type AuthSyncFixture = { - root: string; - stateDir: string; - agentDir: string; - configPath: string; - authPath: string; -}; - -async function withAuthSyncFixture(run: (fixture: AuthSyncFixture) => Promise) { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-models-list-auth-sync-")); - try { - const stateDir = path.join(root, "state"); - const agentDir = path.join(stateDir, "agents", "main", "agent"); - const configPath = path.join(stateDir, "openclaw.json"); - const authPath = path.join(agentDir, "auth.json"); - - await fs.mkdir(agentDir, { recursive: true }); - await fs.writeFile(configPath, "{}\n", "utf8"); - - await withEnvAsync( - { - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_AGENT_DIR: agentDir, - PI_CODING_AGENT_DIR: agentDir, - OPENCLAW_CONFIG_PATH: configPath, - OPENROUTER_API_KEY: undefined, - }, - async () => { - clearRuntimeConfigSnapshot(); - clearConfigCache(); - await run({ root, stateDir, agentDir, configPath, authPath }); - }, - ); - } finally { - clearRuntimeConfigSnapshot(); - clearConfigCache(); - await fs.rm(root, { recursive: true, force: true }); - } -} - -describe("models list auth-profile sync", () => { - it("marks models available when auth exists only in auth-profiles.json", async () => { - await withAuthSyncFixture(async ({ agentDir, authPath }) => { - const authStore: AuthProfileStore = { - version: 1, - profiles: { - "openrouter:default": { - type: "api_key", - provider: "openrouter", - key: "sk-or-v1-regression-test", - }, - }, - }; - saveAuthProfileStore(authStore, agentDir); - - expect(await pathExists(authPath)).toBe(false); - - const row = toModelRow({ - model: OPENROUTER_MODEL as never, - key: "openrouter/openai/gpt-5.4", - tags: [], - cfg: {}, - authStore, - }); - expect(row.available).toBe(true); - expect(await pathExists(authPath)).toBe(false); - }); - }); - - it("does not persist blank auth-profile credentials", async () => { - await withAuthSyncFixture(async ({ agentDir, authPath }) => { - const authStore: AuthProfileStore = { - version: 1, - profiles: { - "openrouter:default": { - type: "api_key", - provider: "openrouter", - key: " ", - }, - }, - }; - saveAuthProfileStore(authStore, agentDir); - - await expect( - resolveApiKeyForProfile({ - cfg: {}, - store: authStore, - profileId: "openrouter:default", - agentDir, - }), - ).resolves.toBeNull(); - if (await pathExists(authPath)) { - const parsed = JSON.parse(await fs.readFile(authPath, "utf8")) as Record< - string, - { type?: string; key?: string } - >; - const openrouterKey = parsed.openrouter?.key; - if (openrouterKey !== undefined) { - expect(openrouterKey.trim().length).toBeGreaterThan(0); - } - } - }); - }); -}); diff --git a/src/commands/models/alias-name.ts b/src/commands/models/alias-name.ts new file mode 100644 index 00000000000..11c39c6c8d5 --- /dev/null +++ b/src/commands/models/alias-name.ts @@ -0,0 +1,10 @@ +export function normalizeAlias(alias: string): string { + const trimmed = alias.trim(); + if (!trimmed) { + throw new Error("Alias cannot be empty."); + } + if (!/^[A-Za-z0-9_.:-]+$/.test(trimmed)) { + throw new Error("Alias must use letters, numbers, dots, underscores, colons, or dashes."); + } + return trimmed; +} diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index 3768b51f1ea..fe6a52de59c 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -26,13 +26,46 @@ const mocks = vi.hoisted(() => ({ clearAuthProfileCooldown: vi.fn(), })); -vi.mock("../../agents/auth-profiles.js", () => ({ - loadAuthProfileStoreForRuntime: mocks.loadAuthProfileStoreForRuntime, +vi.mock("../../agents/auth-profiles/profiles.js", () => ({ listProfilesForProvider: mocks.listProfilesForProvider, - clearAuthProfileCooldown: mocks.clearAuthProfileCooldown, upsertAuthProfile: mocks.upsertAuthProfile, })); +vi.mock("../../agents/auth-profiles/store.js", () => ({ + loadAuthProfileStoreForRuntime: mocks.loadAuthProfileStoreForRuntime, +})); + +vi.mock("../../agents/auth-profiles/usage.js", () => ({ + clearAuthProfileCooldown: mocks.clearAuthProfileCooldown, +})); + +vi.mock("../../plugins/provider-auth-helpers.js", () => ({ + applyAuthProfileConfig: ( + cfg: OpenClawConfig, + params: { + profileId: string; + provider: string; + mode: "api_key" | "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, @@ -59,14 +92,10 @@ vi.mock("../../wizard/clack-prompter.js", () => ({ createClackPrompter: mocks.createClackPrompter, })); -vi.mock("./shared.js", async (importActual) => { - const actual = await importActual(); - return { - ...actual, - loadValidConfigOrThrow: mocks.loadValidConfigOrThrow, - updateConfig: mocks.updateConfig, - }; -}); +vi.mock("./shared.js", () => ({ + loadValidConfigOrThrow: mocks.loadValidConfigOrThrow, + updateConfig: mocks.updateConfig, +})); vi.mock("../../config/logging.js", () => ({ logConfigUpdated: mocks.logConfigUpdated, @@ -80,6 +109,91 @@ vi.mock("../oauth-env.js", () => ({ isRemoteEnvironment: mocks.isRemoteEnvironment, })); +vi.mock("../oauth-flow.js", () => ({ + createVpsAwareOAuthHandlers: vi.fn(() => ({ + onAuth: vi.fn(), + onPrompt: vi.fn(), + })), +})); + +vi.mock("../auth-token.js", () => ({ + validateAnthropicSetupToken: vi.fn(() => undefined), +})); + +vi.mock("../provider-auth-helpers.js", () => { + const normalize = (value: string | undefined) => value?.trim().toLowerCase() ?? ""; + const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + 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 { + 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) => { + const merged = mergePatch(cfg, patch); + 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 { modelsAuthLoginCommand, modelsAuthPasteTokenCommand, modelsAuthSetupTokenCommand } = await import("./auth.js"); @@ -200,9 +314,35 @@ describe("modelsAuthLoginCommand", () => { it("runs plugin-owned openai-codex login", async () => { const runtime = createRuntime(); + const fakeStore = { + profiles: { + "openai-codex:user@example.com": { + type: "oauth", + provider: "openai-codex", + }, + }, + usageStats: { + "openai-codex:user@example.com": { + disabledUntil: Date.now() + 3_600_000, + disabledReason: "auth_permanent", + errorCount: 3, + }, + }, + }; + mocks.loadAuthProfileStoreForRuntime.mockReturnValue(fakeStore); + mocks.listProfilesForProvider.mockReturnValue(["openai-codex:user@example.com"]); await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); + expect(mocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith("/tmp/openclaw/agents/main"); + expect(mocks.clearAuthProfileCooldown).toHaveBeenCalledWith({ + store: fakeStore, + profileId: "openai-codex:user@example.com", + agentDir: "/tmp/openclaw/agents/main", + }); + expect(mocks.clearAuthProfileCooldown.mock.invocationCallOrder[0]).toBeLessThan( + runProviderAuth.mock.invocationCallOrder[0], + ); expect(runProviderAuth).toHaveBeenCalledOnce(); expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({ profileId: "openai-codex:user@example.com", @@ -227,63 +367,6 @@ describe("modelsAuthLoginCommand", () => { ); }); - it("applies openai-codex default model when --set-default is used", async () => { - const runtime = createRuntime(); - - await modelsAuthLoginCommand({ provider: "openai-codex", setDefault: true }, runtime); - - expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({ - primary: "openai-codex/gpt-5.4", - }); - expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.4"); - }); - - it("supports provider-owned Claude CLI migration without writing auth profiles", 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.resolvePluginProviders.mockReturnValue([ - { - id: "anthropic", - label: "Anthropic", - auth: [ - { - id: "cli", - label: "Claude CLI", - kind: "custom", - run: runClaudeCliMigration, - }, - ], - }, - ]); - - await modelsAuthLoginCommand( - { provider: "anthropic", method: "cli", setDefault: true }, - runtime, - ); - - expect(runClaudeCliMigration).toHaveBeenCalledOnce(); - expect(mocks.upsertAuthProfile).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("loads the owning plugin for an explicit provider even in a clean config", async () => { const runtime = createRuntime(); const runClaudeCliMigration = vi.fn().mockResolvedValue({ @@ -335,6 +418,14 @@ describe("modelsAuthLoginCommand", () => { }), ); expect(runClaudeCliMigration).toHaveBeenCalledOnce(); + expect(mocks.upsertAuthProfile).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 () => { @@ -482,39 +573,6 @@ describe("modelsAuthLoginCommand", () => { expect(runtime.log).toHaveBeenCalledWith("Default model set to claude-cli/claude-sonnet-4-6"); }); - it("clears stale auth lockouts before attempting openai-codex login", async () => { - const runtime = createRuntime(); - const fakeStore = { - profiles: { - "openai-codex:user@example.com": { - type: "oauth", - provider: "openai-codex", - }, - }, - usageStats: { - "openai-codex:user@example.com": { - disabledUntil: Date.now() + 3_600_000, - disabledReason: "auth_permanent", - errorCount: 3, - }, - }, - }; - mocks.loadAuthProfileStoreForRuntime.mockReturnValue(fakeStore); - mocks.listProfilesForProvider.mockReturnValue(["openai-codex:user@example.com"]); - - await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); - - expect(mocks.clearAuthProfileCooldown).toHaveBeenCalledWith({ - store: fakeStore, - profileId: "openai-codex:user@example.com", - agentDir: "/tmp/openclaw/agents/main", - }); - // Verify clearing happens before login attempt - const clearOrder = mocks.clearAuthProfileCooldown.mock.invocationCallOrder[0]; - const loginOrder = runProviderAuth.mock.invocationCallOrder[0]; - expect(clearOrder).toBeLessThan(loginOrder); - }); - it("survives lockout clearing failure without blocking login", async () => { const runtime = createRuntime(); mocks.loadAuthProfileStoreForRuntime.mockImplementation(() => { @@ -526,16 +584,6 @@ describe("modelsAuthLoginCommand", () => { expect(runProviderAuth).toHaveBeenCalledOnce(); }); - it("loads lockout state from the agent-scoped store", async () => { - const runtime = createRuntime(); - mocks.loadAuthProfileStoreForRuntime.mockReturnValue({ profiles: {}, usageStats: {} }); - mocks.listProfilesForProvider.mockReturnValue([]); - - await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); - - expect(mocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith("/tmp/openclaw/agents/main"); - }); - it("reports loaded plugin providers when requested provider is unavailable", async () => { const runtime = createRuntime(); @@ -568,23 +616,6 @@ describe("modelsAuthLoginCommand", () => { } }); - it("writes pasted tokens to the resolved agent store", async () => { - const runtime = createRuntime(); - mocks.clackText.mockResolvedValue("tok-fresh"); - - await modelsAuthPasteTokenCommand({ provider: "openai" }, runtime); - - expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({ - profileId: "openai:manual", - credential: { - type: "token", - provider: "openai", - token: "tok-fresh", - }, - agentDir: "/tmp/openclaw/agents/main", - }); - }); - it("writes pasted Anthropic setup-tokens and logs the preference note", async () => { const runtime = createRuntime(); mocks.clackText.mockResolvedValue(`sk-ant-oat01-${"a".repeat(80)}`); @@ -653,48 +684,4 @@ describe("modelsAuthLoginCommand", () => { agentDir: "/tmp/openclaw/agents/main", }); }); - - it("runs setup-token for Anthropic when the provider exposes the method", async () => { - const runtime = createRuntime(); - const runTokenAuth = vi.fn().mockResolvedValue({ - profiles: [ - { - profileId: "anthropic:default", - credential: { - type: "token", - provider: "anthropic", - token: `sk-ant-oat01-${"b".repeat(80)}`, - }, - }, - ], - defaultModel: "anthropic/claude-sonnet-4-6", - }); - mocks.resolvePluginProviders.mockReturnValue([ - { - id: "anthropic", - label: "Anthropic", - auth: [ - { - id: "setup-token", - label: "setup-token", - kind: "token", - run: runTokenAuth, - }, - ], - }, - ]); - - await modelsAuthSetupTokenCommand({ provider: "anthropic", yes: true }, runtime); - - expect(runTokenAuth).toHaveBeenCalledOnce(); - expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({ - profileId: "anthropic:default", - credential: { - type: "token", - provider: "anthropic", - token: `sk-ant-oat01-${"b".repeat(80)}`, - }, - agentDir: "/tmp/openclaw/agents/main", - }); - }); }); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 10fa7247711..136090bd607 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -10,14 +10,11 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../../agents/agent-scope.js"; -import { - clearAuthProfileCooldown, - listProfilesForProvider, - loadAuthProfileStoreForRuntime, - upsertAuthProfile, -} from "../../agents/auth-profiles.js"; +import { listProfilesForProvider, upsertAuthProfile } from "../../agents/auth-profiles/profiles.js"; +import { loadAuthProfileStoreForRuntime } from "../../agents/auth-profiles/store.js"; import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; -import { normalizeProviderId } from "../../agents/model-selection.js"; +import { clearAuthProfileCooldown } from "../../agents/auth-profiles/usage.js"; +import { normalizeProviderId } from "../../agents/model-selection-normalize.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; @@ -40,7 +37,6 @@ import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { validateAnthropicSetupToken } from "../auth-token.js"; import { isRemoteEnvironment } from "../oauth-env.js"; import { createVpsAwareOAuthHandlers } from "../oauth-flow.js"; -import { openUrl } from "../onboard-helpers.js"; import { applyProviderAuthConfigPatch, applyDefaultModel, @@ -131,6 +127,11 @@ async function resolveModelsAuthContext(params?: { }; } +async function resolveModelsAuthAgentDir(): Promise { + const config = await loadValidConfigOrThrow(); + return resolveAgentDir(config, resolveDefaultAgentId(config)); +} + function resolveRequestedProviderOrThrow( providers: ProviderPlugin[], rawProvider?: string, @@ -300,6 +301,7 @@ async function runProviderAuthMethod(params: { allowSecretRefPrompt: false, isRemote: isRemoteEnvironment(), openUrl: async (url) => { + const { openUrl } = await import("../onboard-helpers.js"); await openUrl(url); }, oauth: { @@ -375,7 +377,7 @@ export async function modelsAuthPasteTokenCommand( }, runtime: RuntimeEnv, ) { - const { agentDir } = await resolveModelsAuthContext(); + const agentDir = await resolveModelsAuthAgentDir(); const rawProvider = normalizeOptionalString(opts.provider); if (!rawProvider) { throw new Error("Missing --provider."); diff --git a/src/commands/models/list.auth-overview.test.ts b/src/commands/models/list.auth-overview.test.ts index 65c324d4b42..c8307cd055d 100644 --- a/src/commands/models/list.auth-overview.test.ts +++ b/src/commands/models/list.auth-overview.test.ts @@ -1,8 +1,66 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { NON_ENV_SECRETREF_MARKER } from "../../agents/model-auth-markers.js"; import { withEnv } from "../../test-utils/env.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; +vi.mock("../../agents/auth-profiles/display.js", () => ({ + resolveAuthProfileDisplayLabel: vi.fn(({ profileId }: { profileId: string }) => profileId), +})); + +vi.mock("../../agents/auth-profiles/paths.js", () => ({ + resolveAuthStorePathForDisplay: vi.fn(() => "/tmp/auth-profiles.json"), +})); + +vi.mock("../../agents/auth-profiles/profiles.js", () => ({ + listProfilesForProvider: vi.fn( + (store: { profiles?: Record }, provider: string) => + Object.keys(store.profiles ?? {}).filter( + (profileId) => store.profiles?.[profileId]?.provider === provider, + ), + ), +})); + +vi.mock("../../agents/auth-profiles/usage.js", () => ({ + resolveProfileUnusableUntilForDisplay: vi.fn(() => undefined), +})); + +vi.mock("../../agents/model-auth.js", () => { + const resolveConfigKey = ( + cfg: { models?: { providers?: Record } } | undefined, + provider: string, + ) => cfg?.models?.providers?.[provider]?.apiKey; + + return { + getCustomProviderApiKey: vi.fn(resolveConfigKey), + resolveEnvApiKey: vi.fn((provider: string) => { + if (provider !== "openai" || !process.env.OPENAI_API_KEY?.trim()) { + return null; + } + return { + apiKey: process.env.OPENAI_API_KEY, + source: "env: OPENAI_API_KEY", + }; + }), + resolveUsableCustomProviderApiKey: vi.fn( + (params: { + cfg?: { models?: { providers?: Record } }; + provider: string; + }) => { + const apiKey = resolveConfigKey(params.cfg, params.provider); + if (!apiKey || apiKey === "secretref-managed") { + return null; + } + if (apiKey === "OPENAI_API_KEY") { + return process.env.OPENAI_API_KEY?.trim() + ? { apiKey: process.env.OPENAI_API_KEY, source: "env: OPENAI_API_KEY" } + : null; + } + return { apiKey, source: "models.json" }; + }, + ), + }; +}); + function resolveOpenAiOverview(apiKey: string) { return resolveProviderAuthOverview({ provider: "openai", diff --git a/src/commands/models/list.auth-overview.ts b/src/commands/models/list.auth-overview.ts index d849a26fb10..50afd510213 100644 --- a/src/commands/models/list.auth-overview.ts +++ b/src/commands/models/list.auth-overview.ts @@ -1,11 +1,9 @@ import { formatRemainingShort } from "../../agents/auth-health.js"; -import { - type AuthProfileStore, - listProfilesForProvider, - resolveAuthProfileDisplayLabel, - resolveAuthStorePathForDisplay, - resolveProfileUnusableUntilForDisplay, -} from "../../agents/auth-profiles.js"; +import { resolveAuthProfileDisplayLabel } from "../../agents/auth-profiles/display.js"; +import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles/paths.js"; +import { listProfilesForProvider } from "../../agents/auth-profiles/profiles.js"; +import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; +import { resolveProfileUnusableUntilForDisplay } from "../../agents/auth-profiles/usage.js"; import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; import { getCustomProviderApiKey, diff --git a/src/commands/models/list.local-url.ts b/src/commands/models/list.local-url.ts new file mode 100644 index 00000000000..0f2e6e7bec6 --- /dev/null +++ b/src/commands/models/list.local-url.ts @@ -0,0 +1,17 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; + +export const isLocalBaseUrl = (baseUrl: string) => { + try { + const url = new URL(baseUrl); + const host = normalizeLowercaseStringOrEmpty(url.hostname); + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "0.0.0.0" || + host === "::1" || + host.endsWith(".local") + ); + } catch { + return false; + } +}; diff --git a/src/commands/models/list.model-row.test.ts b/src/commands/models/list.model-row.test.ts new file mode 100644 index 00000000000..26aa9f09fb0 --- /dev/null +++ b/src/commands/models/list.model-row.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; +import { toModelRow } from "./list.model-row.js"; + +const OPENROUTER_MODEL = { + provider: "openrouter", + id: "openai/gpt-5.4", + name: "GPT-5.4 via OpenRouter", + api: "openai-chat-completions", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text"], + contextWindow: 1_000_000, + maxTokens: 128_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, +} as const; + +describe("toModelRow", () => { + it("marks models available from auth profiles without loading model discovery", () => { + const authStore: AuthProfileStore = { + version: 1, + profiles: { + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "sk-or-v1-regression-test", + }, + }, + }; + + const row = toModelRow({ + model: OPENROUTER_MODEL as never, + key: "openrouter/openai/gpt-5.4", + tags: [], + cfg: {}, + authStore, + }); + + expect(row.available).toBe(true); + }); +}); diff --git a/src/commands/models/list.model-row.ts b/src/commands/models/list.model-row.ts new file mode 100644 index 00000000000..e06d1cf2411 --- /dev/null +++ b/src/commands/models/list.model-row.ts @@ -0,0 +1,97 @@ +import type { Api, Model } from "@mariozechner/pi-ai"; +import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; +import { modelKey } from "../../agents/model-ref-shared.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { isLocalBaseUrl } from "./list.local-url.js"; +import type { ModelRow } from "./list.types.js"; + +export type ModelAuthAvailabilityResolver = (params: { + provider: string; + cfg: OpenClawConfig; + authStore: AuthProfileStore; +}) => boolean; + +function authStoreHasProviderProfile(authStore: AuthProfileStore, provider: string): boolean { + return Object.values(authStore.profiles ?? {}).some( + (credential) => credential.provider === provider, + ); +} + +export function toModelRow(params: { + model?: Model; + key: string; + tags: string[]; + aliases?: string[]; + availableKeys?: Set; + cfg?: OpenClawConfig; + authStore?: AuthProfileStore; + allowProviderAvailabilityFallback?: boolean; + hasAuthForProvider?: ModelAuthAvailabilityResolver; +}): ModelRow { + const { + model, + key, + tags, + aliases = [], + availableKeys, + cfg, + authStore, + allowProviderAvailabilityFallback = false, + } = params; + if (!model) { + return { + key, + name: key, + input: "-", + contextWindow: null, + local: null, + available: null, + tags: [...tags, "missing"], + missing: true, + }; + } + + const input = model.input.join("+") || "text"; + const local = isLocalBaseUrl(model.baseUrl); + const modelIsAvailable = availableKeys?.has(modelKey(model.provider, model.id)) ?? false; + // Prefer model-level registry availability when present. + // Fall back to provider-level auth heuristics only if registry availability isn't available, + // or if the caller marks this as a synthetic/forward-compat model that won't appear in getAvailable(). + const available = + availableKeys !== undefined && !allowProviderAvailabilityFallback + ? modelIsAvailable + : modelIsAvailable || + (cfg && authStore + ? ( + params.hasAuthForProvider ?? + ((input) => authStoreHasProviderProfile(input.authStore, input.provider)) + )({ + provider: model.provider, + cfg, + authStore, + }) + : false); + const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : []; + const mergedTags = new Set(tags); + if (aliasTags.length > 0) { + for (const tag of mergedTags) { + if (tag === "alias" || tag.startsWith("alias:")) { + mergedTags.delete(tag); + } + } + for (const tag of aliasTags) { + mergedTags.add(tag); + } + } + + return { + key, + name: model.name || model.id, + input, + contextWindow: model.contextWindow ?? null, + local, + available, + tags: Array.from(mergedTags), + missing: false, + }; +} diff --git a/src/commands/models/list.probe.targets.test.ts b/src/commands/models/list.probe.targets.test.ts index 093bbe12b6e..81385f6a116 100644 --- a/src/commands/models/list.probe.targets.test.ts +++ b/src/commands/models/list.probe.targets.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../../agents/auth-profiles.js"; -import { OLLAMA_LOCAL_AUTH_MARKER } from "../../agents/model-auth-markers.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -18,29 +17,62 @@ const resolveSecretRefStringMock = vi.fn(async () => "resolved-secret"); vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: loadModelCatalogMock, })); +vi.mock("../../agents/model-auth.js", () => ({ + hasUsableCustomProviderApiKey: (cfg: OpenClawConfig, provider: string) => { + const raw = cfg.models?.providers?.[provider]?.apiKey; + return typeof raw === "string" && raw.trim().length > 0 && raw !== "ollama-local"; + }, + resolveEnvApiKey: (provider: string) => { + const keys = + provider === "anthropic" + ? ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] + : provider === "zai" + ? ["ZAI_API_KEY", "Z_AI_API_KEY"] + : []; + const source = keys.find((key) => process.env[key]?.trim()); + return source ? { source, value: process.env[source] } : null; + }, +})); +vi.mock("../../agents/model-selection.js", () => { + const normalizeProviderId = (value: string) => + value.trim().toLowerCase() === "z.ai" || value.trim().toLowerCase() === "z-ai" + ? "zai" + : value.trim().toLowerCase(); + return { + normalizeProviderId, + findNormalizedProviderValue: (record: Record | undefined, provider: string) => + Object.entries(record ?? {}).find(([key]) => normalizeProviderId(key) === provider)?.[1], + parseModelRef: (raw: string, defaultProvider: string) => { + const [provider, ...modelParts] = raw.includes("/") ? raw.split("/") : [defaultProvider, raw]; + const model = modelParts.join("/"); + return provider && model ? { provider: normalizeProviderId(provider), model } : null; + }, + }; +}); vi.mock("../../secrets/resolve.js", () => ({ resolveSecretRefString: resolveSecretRefStringMock, })); +vi.mock("../status-all/format.js", () => ({ + redactSecrets: (value: string) => value, +})); +vi.mock("./shared.js", () => ({ + DEFAULT_PROVIDER: "openai", + formatMs: (ms: number) => `${ms}ms`, +})); -vi.mock("../../agents/auth-profiles.js", async () => { - const actual = await vi.importActual( - "../../agents/auth-profiles.js", - ); - return { - ...actual, - ensureAuthProfileStore: () => mockStore, - listProfilesForProvider: (_store: AuthProfileStore, provider: string) => - Object.entries(mockStore.profiles) - .filter( - ([, profile]) => - typeof profile.provider === "string" && profile.provider.toLowerCase() === provider, - ) - .map(([profileId]) => profileId), - resolveAuthProfileDisplayLabel: ({ profileId }: { profileId: string }) => profileId, - resolveAuthProfileOrder: resolveAuthProfileOrderMock, - resolveAuthProfileEligibility: resolveAuthProfileEligibilityMock, - }; -}); +vi.mock("../../agents/auth-profiles.js", () => ({ + ensureAuthProfileStore: () => mockStore, + listProfilesForProvider: (_store: AuthProfileStore, provider: string) => + Object.entries(mockStore.profiles) + .filter( + ([, profile]) => + typeof profile.provider === "string" && profile.provider.toLowerCase() === provider, + ) + .map(([profileId]) => profileId), + resolveAuthProfileDisplayLabel: ({ profileId }: { profileId: string }) => profileId, + resolveAuthProfileOrder: resolveAuthProfileOrderMock, + resolveAuthProfileEligibility: resolveAuthProfileEligibilityMock, +})); const { buildProbeTargets } = await import("./list.probe.js"); @@ -219,7 +251,7 @@ describe("buildProbeTargets reason codes", () => { order: {}, }; await withClearedAnthropicEnv(async () => { - const plan = await buildAnthropicPlanFromModelsJsonApiKey(OLLAMA_LOCAL_AUTH_MARKER); + const plan = await buildAnthropicPlanFromModelsJsonApiKey("ollama-local"); expect(plan.targets).toEqual([]); expect(plan.results).toEqual([]); }); diff --git a/src/commands/models/list.probe.test.ts b/src/commands/models/list.probe.test.ts index 932908572dc..e474bb1eddb 100644 --- a/src/commands/models/list.probe.test.ts +++ b/src/commands/models/list.probe.test.ts @@ -1,14 +1,15 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import { importFreshModule } from "../../../test/helpers/import-fresh.js"; -import { mapFailoverReasonToProbeStatus } from "./list.probe.js"; + +let probeModule: typeof import("./list.probe.js"); describe("mapFailoverReasonToProbeStatus", () => { - it("does not import the embedded runner on module load", async () => { + beforeAll(async () => { vi.doMock("../../agents/pi-embedded.js", () => { throw new Error("pi-embedded should stay lazy for probe imports"); }); try { - await importFreshModule( + probeModule = await importFreshModule( import.meta.url, `./list.probe.js?scope=${Math.random().toString(36).slice(2)}`, ); @@ -17,11 +18,13 @@ describe("mapFailoverReasonToProbeStatus", () => { } }); - it("maps auth_permanent to auth", () => { - expect(mapFailoverReasonToProbeStatus("auth_permanent")).toBe("auth"); + it("does not import the embedded runner on module load", async () => { + expect(probeModule.mapFailoverReasonToProbeStatus).toBeTypeOf("function"); }); - it("keeps existing failover reason mappings", () => { + it("maps failover reasons to probe statuses", () => { + const { mapFailoverReasonToProbeStatus } = probeModule; + expect(mapFailoverReasonToProbeStatus("auth_permanent")).toBe("auth"); expect(mapFailoverReasonToProbeStatus("auth")).toBe("auth"); expect(mapFailoverReasonToProbeStatus("rate_limit")).toBe("rate_limit"); expect(mapFailoverReasonToProbeStatus("overloaded")).toBe("rate_limit"); @@ -29,9 +32,7 @@ describe("mapFailoverReasonToProbeStatus", () => { expect(mapFailoverReasonToProbeStatus("timeout")).toBe("timeout"); expect(mapFailoverReasonToProbeStatus("model_not_found")).toBe("format"); expect(mapFailoverReasonToProbeStatus("format")).toBe("format"); - }); - it("falls back to unknown for unrecognized values", () => { expect(mapFailoverReasonToProbeStatus(undefined)).toBe("unknown"); expect(mapFailoverReasonToProbeStatus(null)).toBe("unknown"); expect(mapFailoverReasonToProbeStatus("something_else")).toBe("unknown"); diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index 2bb377a8620..3cb364bff4e 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -8,6 +8,7 @@ import { MODEL_AVAILABILITY_UNAVAILABLE_CODE, shouldFallbackToAuthHeuristics, } from "./list.errors.js"; +import { toModelRow as toModelRowBase } from "./list.model-row.js"; import { discoverAuthStorage, discoverModels, @@ -18,7 +19,7 @@ import { resolveOpenClawAgentDir, } from "./list.runtime.js"; import type { ModelRow } from "./list.types.js"; -import { isLocalBaseUrl, modelKey } from "./shared.js"; +import { modelKey } from "./shared.js"; const hasAuthForProvider = ( provider: string, @@ -140,71 +141,10 @@ export async function loadModelRegistry( return { registry, models, availableKeys, availabilityErrorMessage }; } -export function toModelRow(params: { - model?: Model; - key: string; - tags: string[]; - aliases?: string[]; - availableKeys?: Set; - cfg?: OpenClawConfig; - authStore?: AuthProfileStore; - allowProviderAvailabilityFallback?: boolean; -}): ModelRow { - const { - model, - key, - tags, - aliases = [], - availableKeys, - cfg, - authStore, - allowProviderAvailabilityFallback = false, - } = params; - if (!model) { - return { - key, - name: key, - input: "-", - contextWindow: null, - local: null, - available: null, - tags: [...tags, "missing"], - missing: true, - }; - } - - const input = model.input.join("+") || "text"; - const local = isLocalBaseUrl(model.baseUrl); - const modelIsAvailable = availableKeys?.has(modelKey(model.provider, model.id)) ?? false; - // Prefer model-level registry availability when present. - // Fall back to provider-level auth heuristics only if registry availability isn't available, - // or if the caller marks this as a synthetic/forward-compat model that won't appear in getAvailable(). - const available = - availableKeys !== undefined && !allowProviderAvailabilityFallback - ? modelIsAvailable - : modelIsAvailable || - (cfg && authStore ? hasAuthForProvider(model.provider, cfg, authStore) : false); - const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : []; - const mergedTags = new Set(tags); - if (aliasTags.length > 0) { - for (const tag of mergedTags) { - if (tag === "alias" || tag.startsWith("alias:")) { - mergedTags.delete(tag); - } - } - for (const tag of aliasTags) { - mergedTags.add(tag); - } - } - - return { - key, - name: model.name || model.id, - input, - contextWindow: model.contextWindow ?? null, - local, - available, - tags: Array.from(mergedTags), - missing: false, - }; +export function toModelRow(params: Parameters[0]): ModelRow { + return toModelRowBase({ + ...params, + hasAuthForProvider: ({ provider, cfg, authStore }) => + hasAuthForProvider(provider, cfg, authStore), + }); } diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index f367511e7fb..d7bbed2d16f 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -10,11 +10,9 @@ import { DEFAULT_OAUTH_WARN_MS, formatRemainingShort, } from "../../agents/auth-health.js"; -import { - ensureAuthProfileStore, - resolveAuthStorePathForDisplay, - resolveProfileUnusableUntilForDisplay, -} from "../../agents/auth-profiles.js"; +import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles/paths.js"; +import { ensureAuthProfileStore } from "../../agents/auth-profiles/store.js"; +import { resolveProfileUnusableUntilForDisplay } from "../../agents/auth-profiles/usage.js"; import { resolveProviderEnvApiKeyCandidates } from "../../agents/model-auth-env-vars.js"; import { resolveEnvApiKey } from "../../agents/model-auth.js"; import { @@ -26,34 +24,19 @@ import { resolveDefaultModelForAgent, resolveModelRefFromString, } from "../../agents/model-selection.js"; -import { withProgressTotals } from "../../cli/progress.js"; import { createConfigIO } from "../../config/config.js"; import { resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue, } from "../../config/model-input.js"; -import { - formatUsageWindowSummary, - loadProviderUsageSummary, - resolveUsageProviderId, - type UsageProviderId, -} from "../../infra/provider-usage.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; -import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { colorize, theme } from "../../terminal/theme.js"; import { shortenHomePath } from "../../utils.js"; -import { buildProviderAuthRecoveryHint } from "../provider-auth-guidance.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; import { isRich } from "./list.format.js"; -import { - describeProbeSummary, - formatProbeLatency, - runAuthProbes, - sortProbeResults, - type AuthProbeSummary, -} from "./list.probe.js"; +import { type AuthProbeSummary } from "./list.probe.js"; import { loadModelsConfig } from "./load-config.js"; import { DEFAULT_MODEL, @@ -62,6 +45,15 @@ import { resolveKnownAgentId, } from "./shared.js"; +type ProviderUsageRuntime = typeof import("../../infra/provider-usage.js"); + +let providerUsageRuntimePromise: Promise | undefined; + +function loadProviderUsageRuntime(): Promise { + providerUsageRuntimePromise ??= import("../../infra/provider-usage.js"); + return providerUsageRuntimePromise; +} + export async function modelsStatusCommand( opts: { json?: boolean; @@ -227,6 +219,10 @@ export async function modelsStatusCommand( let probeSummary: AuthProbeSummary | undefined; if (opts.probe) { + const [{ withProgressTotals }, { runAuthProbes }] = await Promise.all([ + import("../../cli/progress.js"), + import("./list.probe.js"), + ]); probeSummary = await withProgressTotals( { label: "Probing auth profiles…", total: 1 }, async (update) => { @@ -517,6 +513,7 @@ export async function modelsStatusCommand( } if (missingProvidersInUse.length > 0) { + const { buildProviderAuthRecoveryHint } = await import("../provider-auth-guidance.js"); runtime.log(""); runtime.log(colorize(rich, theme.heading, "Missing auth")); for (const provider of missingProvidersInUse) { @@ -534,12 +531,14 @@ export async function modelsStatusCommand( if (oauthProfiles.length === 0) { runtime.log(colorize(rich, theme.muted, "- none")); } else { + const { formatUsageWindowSummary, loadProviderUsageSummary, resolveUsageProviderId } = + await loadProviderUsageRuntime(); const usageByProvider = new Map(); const usageProviders = Array.from( new Set( oauthProfiles .map((profile) => resolveUsageProviderId(profile.provider)) - .filter((provider): provider is UsageProviderId => Boolean(provider)), + .filter((provider): provider is NonNullable => Boolean(provider)), ), ); if (usageProviders.length > 0) { @@ -611,6 +610,10 @@ export async function modelsStatusCommand( } if (probeSummary) { + const [ + { getTerminalTableWidth, renderTable }, + { describeProbeSummary, formatProbeLatency, sortProbeResults }, + ] = await Promise.all([import("../../terminal/table.js"), import("./list.probe.js")]); runtime.log(""); runtime.log(colorize(rich, theme.heading, "Auth probes")); if (probeSummary.results.length === 0) { diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index a7f41564297..6b00bb47abd 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it, type Mock, vi } from "vitest"; +import { describe, expect, it, type Mock, vi } from "vitest"; const mocks = vi.hoisted(() => { type MockAuthProfile = { provider: string; [key: string]: unknown }; @@ -25,6 +25,11 @@ const mocks = vi.hoisted(() => { refresh: "oai-refresh-1234567890", expires: Date.now() + 60_000, }, + "openai:default": { + type: "api_key", + provider: "openai", + key: "abc123", // pragma: allowlist secret + }, } as Record, }; @@ -61,6 +66,18 @@ const mocks = vi.hoisted(() => { source: "env: ANTHROPIC_OAUTH_TOKEN", }; } + if (provider === "minimax") { + return { + apiKey: "sk-minimax-0123456789abcdefghijklmnopqrstuvwxyz", // pragma: allowlist secret + source: "env: MINIMAX_API_KEY", + }; + } + if (provider === "fal") { + return { + apiKey: "fal_test_0123456789abcdefghijklmnopqrstuvwxyz", // pragma: allowlist secret + source: "env: FAL_KEY", + }; + } return null; }), resolveProviderEnvApiKeyCandidates: vi.fn().mockReturnValue({ @@ -106,61 +123,91 @@ const mocks = vi.hoisted(() => { }; }); -let modelsStatusCommand: typeof import("./list.status-command.js").modelsStatusCommand; +vi.mock("../../agents/agent-paths.js", () => ({ + resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir, +})); +vi.mock("../../agents/agent-scope.js", () => ({ + resolveAgentDir: mocks.resolveAgentDir, + resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, + resolveAgentExplicitModelPrimary: mocks.resolveAgentExplicitModelPrimary, + resolveAgentEffectiveModelPrimary: mocks.resolveAgentEffectiveModelPrimary, + resolveAgentModelFallbacksOverride: mocks.resolveAgentModelFallbacksOverride, + listAgentIds: mocks.listAgentIds, +})); +vi.mock("../../agents/auth-profiles/display.js", () => ({ + resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, +})); +vi.mock("../../agents/auth-profiles/paths.js", () => ({ + resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay, +})); +vi.mock("../../agents/auth-profiles/profiles.js", () => ({ + listProfilesForProvider: mocks.listProfilesForProvider, +})); +vi.mock("../../agents/auth-profiles/store.js", () => ({ + ensureAuthProfileStore: mocks.ensureAuthProfileStore, +})); +vi.mock("../../agents/auth-profiles/usage.js", () => ({ + resolveProfileUnusableUntilForDisplay: mocks.resolveProfileUnusableUntilForDisplay, +})); +vi.mock("../../agents/auth-health.js", () => ({ + DEFAULT_OAUTH_WARN_MS: 86_400_000, + buildAuthHealthSummary: vi.fn( + ({ store, warnAfterMs }: { store: typeof mocks.store; warnAfterMs: number }) => { + const profiles = Object.entries(store.profiles).map(([profileId, profile]) => ({ + profileId, + provider: profile.provider, + type: profile.type ?? "api_key", + status: profile.type === "api_key" ? "static" : "ok", + source: "store", + label: profileId, + })); + return { + now: Date.now(), + warnAfterMs, + profiles, + providers: profiles.map((profile) => ({ + provider: profile.provider, + status: profile.status, + profiles: [profile], + })), + }; + }, + ), + formatRemainingShort: vi.fn(() => "1h"), +})); +vi.mock("../../agents/model-auth.js", () => ({ + resolveEnvApiKey: mocks.resolveEnvApiKey, + hasUsableCustomProviderApiKey: mocks.hasUsableCustomProviderApiKey, + resolveUsableCustomProviderApiKey: mocks.resolveUsableCustomProviderApiKey, + getCustomProviderApiKey: mocks.getCustomProviderApiKey, +})); +vi.mock("../../agents/model-auth-env-vars.js", () => ({ + resolveProviderEnvApiKeyCandidates: mocks.resolveProviderEnvApiKeyCandidates, + listKnownProviderEnvApiKeyNames: mocks.listKnownProviderEnvApiKeyNames, +})); +vi.mock("../../agents/model-selection-cli.js", () => ({ + isCliProvider: vi.fn( + (provider: string, cfg?: { agents?: { defaults?: { cliBackends?: object } } }) => + Object.prototype.hasOwnProperty.call(cfg?.agents?.defaults?.cliBackends ?? {}, provider), + ), +})); +vi.mock("../../infra/shell-env.js", () => ({ + getShellEnvAppliedKeys: mocks.getShellEnvAppliedKeys, + shouldEnableShellEnvFallback: mocks.shouldEnableShellEnvFallback, +})); +vi.mock("../../config/config.js", () => ({ + createConfigIO: mocks.createConfigIO, +})); +vi.mock("./load-config.js", () => ({ + loadModelsConfig: vi.fn(async () => mocks.loadConfig()), +})); +vi.mock("../../infra/provider-usage.js", () => ({ + formatUsageWindowSummary: vi.fn().mockReturnValue("-"), + loadProviderUsageSummary: mocks.loadProviderUsageSummary, + resolveUsageProviderId: vi.fn((providerId: string) => providerId), +})); -async function loadFreshModelsStatusCommandModuleForTest() { - vi.resetModules(); - vi.doMock("../../agents/agent-paths.js", () => ({ - resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir, - })); - vi.doMock("../../agents/agent-scope.js", () => ({ - resolveAgentDir: mocks.resolveAgentDir, - resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, - resolveAgentExplicitModelPrimary: mocks.resolveAgentExplicitModelPrimary, - resolveAgentEffectiveModelPrimary: mocks.resolveAgentEffectiveModelPrimary, - resolveAgentModelFallbacksOverride: mocks.resolveAgentModelFallbacksOverride, - listAgentIds: mocks.listAgentIds, - })); - vi.doMock("../../agents/auth-profiles.js", () => ({ - ensureAuthProfileStore: mocks.ensureAuthProfileStore, - listProfilesForProvider: mocks.listProfilesForProvider, - resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, - resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay, - resolveProfileUnusableUntilForDisplay: mocks.resolveProfileUnusableUntilForDisplay, - })); - vi.doMock("../../agents/model-auth.js", () => ({ - resolveEnvApiKey: mocks.resolveEnvApiKey, - hasUsableCustomProviderApiKey: mocks.hasUsableCustomProviderApiKey, - resolveUsableCustomProviderApiKey: mocks.resolveUsableCustomProviderApiKey, - getCustomProviderApiKey: mocks.getCustomProviderApiKey, - })); - vi.doMock("../../agents/model-auth-env-vars.js", () => ({ - resolveProviderEnvApiKeyCandidates: mocks.resolveProviderEnvApiKeyCandidates, - listKnownProviderEnvApiKeyNames: mocks.listKnownProviderEnvApiKeyNames, - })); - vi.doMock("../../infra/shell-env.js", () => ({ - getShellEnvAppliedKeys: mocks.getShellEnvAppliedKeys, - shouldEnableShellEnvFallback: mocks.shouldEnableShellEnvFallback, - })); - vi.doMock("../../config/config.js", async () => { - const actual = - await vi.importActual("../../config/config.js"); - return { - ...actual, - createConfigIO: mocks.createConfigIO, - loadConfig: mocks.loadConfig, - }; - }); - vi.doMock("./load-config.js", () => ({ - loadModelsConfig: vi.fn(async () => mocks.loadConfig()), - })); - vi.doMock("../../infra/provider-usage.js", () => ({ - formatUsageWindowSummary: vi.fn().mockReturnValue("-"), - loadProviderUsageSummary: mocks.loadProviderUsageSummary, - resolveUsageProviderId: vi.fn((providerId: string) => providerId), - })); - ({ modelsStatusCommand } = await import("./list.status-command.js")); -} +import { modelsStatusCommand } from "./list.status-command.js"; const defaultResolveEnvApiKeyImpl: | ((provider: string) => { apiKey: string; source: string } | null) @@ -227,23 +274,6 @@ async function withAgentScopeOverrides( } describe("modelsStatusCommand auth overview", () => { - beforeAll(async () => { - await loadFreshModelsStatusCommandModuleForTest(); - }); - - afterAll(() => { - vi.doUnmock("../../agents/agent-paths.js"); - vi.doUnmock("../../agents/agent-scope.js"); - vi.doUnmock("../../agents/auth-profiles.js"); - vi.doUnmock("../../agents/model-auth.js"); - vi.doUnmock("../../agents/model-auth-env-vars.js"); - vi.doUnmock("../../infra/shell-env.js"); - vi.doUnmock("../../config/config.js"); - vi.doUnmock("./load-config.js"); - vi.doUnmock("../../infra/provider-usage.js"); - vi.resetModules(); - }); - it("includes masked auth sources in JSON output", async () => { await modelsStatusCommand({ json: true }, runtime as never); const payload = JSON.parse(String((runtime.log as Mock).mock.calls[0]?.[0])); @@ -271,11 +301,25 @@ describe("modelsStatusCommand auth overview", () => { const openai = providers.find((p) => p.provider === "openai"); expect(openai?.env?.source).toContain("OPENAI_API_KEY"); expect(openai?.env?.value).toContain("..."); + expect(openai?.profiles.labels.join(" ")).toContain("..."); + expect(openai?.profiles.labels.join(" ")).not.toContain("abc123"); expect( - (payload.auth.oauth.providers as Array<{ provider: string }>).some( - (provider) => provider.provider === "openai", + (payload.auth.providersWithOAuth as string[]).some((provider) => + provider.startsWith("openai "), ), ).toBe(false); + expect(providers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + provider: "minimax", + effective: expect.objectContaining({ kind: "env" }), + }), + expect.objectContaining({ + provider: "fal", + effective: expect.objectContaining({ kind: "env" }), + }), + ]), + ); expect( (payload.auth.providersWithOAuth as string[]).some((e) => e.startsWith("anthropic")), @@ -285,97 +329,6 @@ describe("modelsStatusCommand auth overview", () => { ).toBe(true); }); - it("does not emit raw short api-key values in JSON labels", async () => { - const localRuntime = createRuntime(); - const shortSecret = "abc123"; // pragma: allowlist secret - const originalProfiles = { ...mocks.store.profiles }; - mocks.store.profiles = { - ...mocks.store.profiles, - "openai:default": { - type: "api_key", - provider: "openai", - key: shortSecret, - }, - }; - - try { - await modelsStatusCommand({ json: true }, localRuntime as never); - const payload = JSON.parse(String((localRuntime.log as Mock).mock.calls[0]?.[0])); - const providers = payload.auth.providers as Array<{ - provider: string; - profiles: { labels: string[] }; - }>; - const openai = providers.find((p) => p.provider === "openai"); - const labels = openai?.profiles.labels ?? []; - expect(labels.join(" ")).toContain("..."); - expect(labels.join(" ")).not.toContain(shortSecret); - } finally { - mocks.store.profiles = originalProfiles; - } - }); - - it("includes env-backed image-generation providers in effective auth output", async () => { - const localRuntime = createRuntime(); - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - - mocks.resolveEnvApiKey.mockImplementation((provider: string) => { - if (provider === "openai") { - return { - apiKey: "sk-openai-0123456789abcdefghijklmnopqrstuvwxyz", // pragma: allowlist secret - source: "shell env: OPENAI_API_KEY", - }; - } - if (provider === "anthropic") { - return { - apiKey: "sk-ant-oat01-ACCESS-TOKEN-1234567890", // pragma: allowlist secret - source: "env: ANTHROPIC_OAUTH_TOKEN", - }; - } - if (provider === "minimax") { - return { - apiKey: "sk-minimax-0123456789abcdefghijklmnopqrstuvwxyz", // pragma: allowlist secret - source: "env: MINIMAX_API_KEY", - }; - } - if (provider === "fal") { - return { - apiKey: "fal_test_0123456789abcdefghijklmnopqrstuvwxyz", // pragma: allowlist secret - source: "env: FAL_KEY", - }; - } - return null; - }); - - try { - await modelsStatusCommand({ json: true }, localRuntime as never); - const payload = JSON.parse(String((localRuntime.log as Mock).mock.calls[0]?.[0])); - const providers = payload.auth.providers as Array<{ - provider: string; - effective: { kind: string }; - }>; - expect(providers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - provider: "minimax", - effective: expect.objectContaining({ kind: "env" }), - }), - expect.objectContaining({ - provider: "fal", - effective: expect.objectContaining({ kind: "env" }), - }), - ]), - ); - } finally { - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - } - }); - it("uses agent overrides and reports sources", async () => { const localRuntime = createRuntime(); await withAgentScopeOverrides( @@ -400,7 +353,7 @@ describe("modelsStatusCommand auth overview", () => { ); }); - it("does not report cli backends as missing auth", async () => { + it("handles cli backend and aliased provider auth summaries", async () => { const localRuntime = createRuntime(); const originalLoadConfig = mocks.loadConfig.getMockImplementation(); const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); @@ -422,6 +375,32 @@ describe("modelsStatusCommand auth overview", () => { const payload = JSON.parse(String((localRuntime.log as Mock).mock.calls[0]?.[0])); expect(payload.defaultModel).toBe("claude-cli/claude-sonnet-4-6"); expect(payload.auth.missingProvidersInUse).toEqual([]); + + const aliasRuntime = createRuntime(); + mocks.loadConfig.mockReturnValue({ + agents: { + defaults: { + model: { primary: "z.ai/glm-4.7", fallbacks: [] }, + models: { "z.ai/glm-4.7": {} }, + }, + }, + models: { providers: { "z.ai": {} } }, + env: { shellEnv: { enabled: true } }, + }); + mocks.resolveEnvApiKey.mockImplementation((provider: string) => { + if (provider === "zai" || provider === "z.ai" || provider === "z-ai") { + return { + apiKey: "sk-zai-0123456789abcdefghijklmnopqrstuvwxyz", // pragma: allowlist secret + source: "shell env: ZAI_API_KEY", + }; + } + return null; + }); + await modelsStatusCommand({ json: true }, aliasRuntime as never); + const aliasPayload = JSON.parse(String((aliasRuntime.log as Mock).mock.calls[0]?.[0])); + const providers = aliasPayload.auth.providers as Array<{ provider: string }>; + expect(providers.filter((provider) => provider.provider === "zai")).toHaveLength(1); + expect(providers.some((provider) => provider.provider === "z.ai")).toBe(false); } finally { if (originalLoadConfig) { mocks.loadConfig.mockImplementation(originalLoadConfig); @@ -436,79 +415,24 @@ describe("modelsStatusCommand auth overview", () => { } }); - it("dedupes alias and canonical provider ids in auth provider summaries", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalResolveEnvApiKey = mocks.resolveEnvApiKey.getMockImplementation(); - - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "z.ai/glm-4.7", fallbacks: [] }, - models: { "z.ai/glm-4.7": {} }, - }, - }, - models: { providers: { "z.ai": {} } }, - env: { shellEnv: { enabled: true } }, - }); - mocks.resolveEnvApiKey.mockImplementation((provider: string) => { - if (provider === "zai" || provider === "z.ai" || provider === "z-ai") { - return { - apiKey: "sk-zai-0123456789abcdefghijklmnopqrstuvwxyz", // pragma: allowlist secret - source: "shell env: ZAI_API_KEY", - }; - } - return null; - }); - - try { - await modelsStatusCommand({ json: true }, localRuntime as never); - const payload = JSON.parse(String((localRuntime.log as Mock).mock.calls[0]?.[0])); - const providers = payload.auth.providers as Array<{ provider: string }>; - expect(providers.filter((provider) => provider.provider === "zai")).toHaveLength(1); - expect(providers.some((provider) => provider.provider === "z.ai")).toBe(false); - } finally { - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalResolveEnvApiKey) { - mocks.resolveEnvApiKey.mockImplementation(originalResolveEnvApiKey); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - } - }); - - it("labels defaults when --agent has no overrides", async () => { - const localRuntime = createRuntime(); + it("reports defaults source when --agent has no overrides", async () => { await withAgentScopeOverrides( { primary: undefined, fallbacks: undefined, }, async () => { - await modelsStatusCommand({ agent: "main" }, localRuntime as never); - const output = (localRuntime.log as Mock).mock.calls + const textRuntime = createRuntime(); + await modelsStatusCommand({ agent: "main" }, textRuntime as never); + const output = (textRuntime.log as Mock).mock.calls .map((call: unknown[]) => String(call[0])) .join("\n"); expect(output).toContain("Default (defaults)"); expect(output).toContain("Fallbacks (0) (defaults)"); - }, - ); - }); - it("reports defaults source in JSON when --agent has no overrides", async () => { - const localRuntime = createRuntime(); - await withAgentScopeOverrides( - { - primary: undefined, - fallbacks: undefined, - }, - async () => { - await modelsStatusCommand({ json: true, agent: "main" }, localRuntime as never); - const payload = JSON.parse(String((localRuntime.log as Mock).mock.calls[0]?.[0])); + const jsonRuntime = createRuntime(); + await modelsStatusCommand({ json: true, agent: "main" }, jsonRuntime as never); + const payload = JSON.parse(String((jsonRuntime.log as Mock).mock.calls[0]?.[0])); expect(payload.modelConfig).toEqual({ defaultSource: "defaults", fallbacksSource: "defaults", diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index e5e6d2e2a7a..ec25cd4d230 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -18,7 +18,8 @@ import { toAgentModelListLike } from "../../config/model-input.js"; import type { AgentModelEntryConfig } from "../../config/types.agent-defaults.js"; import type { AgentModelConfig } from "../../config/types.agents-shared.js"; import { normalizeAgentId } from "../../routing/session-key.js"; -import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; +export { normalizeAlias } from "./alias-name.js"; +export { isLocalBaseUrl } from "./list.local-url.js"; export const ensureFlagCompatibility = (opts: { json?: boolean; plain?: boolean }) => { if (opts.json && opts.plain) { @@ -49,22 +50,6 @@ export const formatMs = (value?: number | null) => { return `${Math.round(value / 100) / 10}s`; }; -export const isLocalBaseUrl = (baseUrl: string) => { - try { - const url = new URL(baseUrl); - const host = normalizeLowercaseStringOrEmpty(url.hostname); - return ( - host === "localhost" || - host === "127.0.0.1" || - host === "0.0.0.0" || - host === "::1" || - host.endsWith(".local") - ); - } catch { - return false; - } -}; - export async function loadValidConfigOrThrow(): Promise { const snapshot = await readConfigFileSnapshot(); if (!snapshot.valid) { @@ -142,17 +127,6 @@ export function buildAllowlistSet(cfg: OpenClawConfig): Set { return allowed; } -export function normalizeAlias(alias: string): string { - const trimmed = alias.trim(); - if (!trimmed) { - throw new Error("Alias cannot be empty."); - } - if (!/^[A-Za-z0-9_.:-]+$/.test(trimmed)) { - throw new Error("Alias must use letters, numbers, dots, underscores, colons, or dashes."); - } - return trimmed; -} - export function resolveKnownAgentId(params: { cfg: OpenClawConfig; rawAgentId?: string | null; diff --git a/src/commands/onboard-auth.credentials.test.ts b/src/commands/onboard-auth.credentials.test.ts deleted file mode 100644 index 087af95a005..00000000000 --- a/src/commands/onboard-auth.credentials.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { upsertApiKeyProfile } from "../plugins/provider-auth-helpers.js"; -import { captureEnv } from "../test-utils/env.js"; - -const providerEnvVarsById: Record = { - "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], - byteplus: ["BYTEPLUS_API_KEY"], - moonshot: ["MOONSHOT_API_KEY"], - openai: ["OPENAI_API_KEY"], - opencode: ["OPENCODE_API_KEY"], - "opencode-go": ["OPENCODE_API_KEY"], - volcengine: ["VOLCANO_ENGINE_API_KEY"], -}; - -vi.mock("../secrets/provider-env-vars.js", () => ({ - getProviderEnvVars: vi.fn((provider: string) => providerEnvVarsById[provider] ?? []), -})); - -type AuthTestEnv = { - stateDir: string; - agentDir: string; -}; - -async function setupAuthTestEnv(prefix: string): Promise { - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - const agentDir = path.join(stateDir, "agent"); - process.env.OPENCLAW_STATE_DIR = stateDir; - process.env.OPENCLAW_AGENT_DIR = agentDir; - process.env.PI_CODING_AGENT_DIR = agentDir; - await fs.mkdir(agentDir, { recursive: true }); - return { stateDir, agentDir }; -} - -function createAuthTestLifecycle(envKeys: string[]) { - const envSnapshot = captureEnv(envKeys); - let stateDir: string | null = null; - return { - setStateDir(nextStateDir: string) { - stateDir = nextStateDir; - }, - async cleanup() { - if (stateDir) { - await fs.rm(stateDir, { recursive: true, force: true }); - stateDir = null; - } - envSnapshot.restore(); - }, - }; -} - -async function readAuthProfilesForAgent(agentDir: string): Promise { - const raw = await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8"); - return JSON.parse(raw) as T; -} - -describe("onboard auth credentials secret refs", () => { - const lifecycle = createAuthTestLifecycle([ - "OPENCLAW_STATE_DIR", - "OPENCLAW_AGENT_DIR", - "PI_CODING_AGENT_DIR", - "MOONSHOT_API_KEY", - "OPENAI_API_KEY", - "CLOUDFLARE_AI_GATEWAY_API_KEY", - "VOLCANO_ENGINE_API_KEY", - "BYTEPLUS_API_KEY", - "OPENCODE_API_KEY", - ]); - - afterEach(async () => { - await lifecycle.cleanup(); - }); - - type AuthProfileEntry = { key?: string; keyRef?: unknown; metadata?: unknown }; - - async function withAuthEnv( - prefix: string, - run: (env: Awaited>) => Promise, - ) { - const env = await setupAuthTestEnv(prefix); - lifecycle.setStateDir(env.stateDir); - await run(env); - } - - async function readProfile( - agentDir: string, - profileId: string, - ): Promise { - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - return parsed.profiles?.[profileId]; - } - - async function expectStoredAuthKey(params: { - prefix: string; - envVar?: string; - envValue?: string; - profileId: string; - apply: (agentDir: string) => Promise; - expected: AuthProfileEntry; - absent?: Array; - }) { - await withAuthEnv(params.prefix, async (env) => { - if (params.envVar && params.envValue !== undefined) { - process.env[params.envVar] = params.envValue; - } - await params.apply(env.agentDir); - const profile = await readProfile(env.agentDir, params.profileId); - expect(profile).toMatchObject(params.expected); - for (const key of params.absent ?? []) { - expect(profile?.[key]).toBeUndefined(); - } - }); - } - - it("keeps env-backed provider keys as plaintext by default", async () => { - await withAuthEnv("openclaw-onboard-auth-credentials-", async (env) => { - process.env.MOONSHOT_API_KEY = "sk-moonshot-env"; - process.env.OPENAI_API_KEY = "sk-openai-env"; - - upsertApiKeyProfile({ - provider: "moonshot", - input: "sk-moonshot-env", - agentDir: env.agentDir, - }); - upsertApiKeyProfile({ provider: "openai", input: "sk-openai-env", agentDir: env.agentDir }); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(env.agentDir); - expect(parsed.profiles?.["moonshot:default"]).toMatchObject({ key: "sk-moonshot-env" }); - expect(parsed.profiles?.["moonshot:default"]?.keyRef).toBeUndefined(); - expect(parsed.profiles?.["openai:default"]).toMatchObject({ key: "sk-openai-env" }); - expect(parsed.profiles?.["openai:default"]?.keyRef).toBeUndefined(); - }); - }); - - it("stores env-backed provider keys as keyRef in ref mode", async () => { - await withAuthEnv("openclaw-onboard-auth-credentials-ref-", async (env) => { - process.env.MOONSHOT_API_KEY = "sk-moonshot-env"; - process.env.OPENAI_API_KEY = "sk-openai-env"; - - upsertApiKeyProfile({ - provider: "moonshot", - input: "sk-moonshot-env", - agentDir: env.agentDir, - options: { secretInputMode: "ref" }, // pragma: allowlist secret - }); - upsertApiKeyProfile({ - provider: "openai", - input: "sk-openai-env", - agentDir: env.agentDir, - options: { secretInputMode: "ref" }, // pragma: allowlist secret - }); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(env.agentDir); - expect(parsed.profiles?.["moonshot:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" }, - }); - expect(parsed.profiles?.["moonshot:default"]?.key).toBeUndefined(); - expect(parsed.profiles?.["openai:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, - }); - expect(parsed.profiles?.["openai:default"]?.key).toBeUndefined(); - }); - }); - - it("stores ${ENV} moonshot input as keyRef even when env value is unset", async () => { - await expectStoredAuthKey({ - prefix: "openclaw-onboard-auth-credentials-inline-ref-", - profileId: "moonshot:default", - apply: async () => { - upsertApiKeyProfile({ provider: "moonshot", input: "${MOONSHOT_API_KEY}" }); - }, - expected: { - keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" }, - }, - absent: ["key"], - }); - }); - - it("keeps plaintext moonshot key when no env ref applies", async () => { - await expectStoredAuthKey({ - prefix: "openclaw-onboard-auth-credentials-plaintext-", - envVar: "MOONSHOT_API_KEY", - envValue: "sk-moonshot-other", - profileId: "moonshot:default", - apply: async () => { - upsertApiKeyProfile({ provider: "moonshot", input: "sk-moonshot-plaintext" }); - }, - expected: { - key: "sk-moonshot-plaintext", - }, - absent: ["keyRef"], - }); - }); - - it("preserves cloudflare metadata when storing keyRef", async () => { - const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-cloudflare-"); - lifecycle.setStateDir(env.stateDir); - process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = "cf-secret"; // pragma: allowlist secret - - upsertApiKeyProfile({ - provider: "cloudflare-ai-gateway", - input: "cf-secret", - agentDir: env.agentDir, - options: { secretInputMode: "ref" }, // pragma: allowlist secret - metadata: { - accountId: "account-1", - gatewayId: "gateway-1", - }, - }); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(env.agentDir); - expect(parsed.profiles?.["cloudflare-ai-gateway:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "CLOUDFLARE_AI_GATEWAY_API_KEY" }, - metadata: { accountId: "account-1", gatewayId: "gateway-1" }, - }); - expect(parsed.profiles?.["cloudflare-ai-gateway:default"]?.key).toBeUndefined(); - }); - - it("stores env-backed volcengine and byteplus keys as keyRef in ref mode", async () => { - const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-volc-byte-"); - lifecycle.setStateDir(env.stateDir); - process.env.VOLCANO_ENGINE_API_KEY = "volcengine-secret"; // pragma: allowlist secret - process.env.BYTEPLUS_API_KEY = "byteplus-secret"; // pragma: allowlist secret - - upsertApiKeyProfile({ - provider: "volcengine", - input: "volcengine-secret", - agentDir: env.agentDir, - options: { secretInputMode: "ref" }, // pragma: allowlist secret - }); - upsertApiKeyProfile({ - provider: "byteplus", - input: "byteplus-secret", - agentDir: env.agentDir, - options: { secretInputMode: "ref" }, // pragma: allowlist secret - }); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(env.agentDir); - - expect(parsed.profiles?.["volcengine:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "VOLCANO_ENGINE_API_KEY" }, - }); - expect(parsed.profiles?.["volcengine:default"]?.key).toBeUndefined(); - - expect(parsed.profiles?.["byteplus:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "BYTEPLUS_API_KEY" }, - }); - expect(parsed.profiles?.["byteplus:default"]?.key).toBeUndefined(); - }); - - it("stores shared OpenCode credentials for both runtime providers", async () => { - const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-opencode-"); - lifecycle.setStateDir(env.stateDir); - process.env.OPENCODE_API_KEY = "sk-opencode-env"; // pragma: allowlist secret - - for (const provider of ["opencode", "opencode-go"] as const) { - upsertApiKeyProfile({ - provider, - input: "sk-opencode-env", - agentDir: env.agentDir, - options: { secretInputMode: "ref" }, // pragma: allowlist secret - }); - } - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(env.agentDir); - - expect(parsed.profiles?.["opencode:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "OPENCODE_API_KEY" }, - }); - expect(parsed.profiles?.["opencode-go:default"]).toMatchObject({ - keyRef: { source: "env", provider: "default", id: "OPENCODE_API_KEY" }, - }); - }); -}); diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 521d986c239..2db248408be 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -14,6 +14,62 @@ import { setupAuthTestEnv, } from "./test-wizard-helpers.js"; +const providerEnvVarsById = vi.hoisted( + (): Record => ({ + "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], + byteplus: ["BYTEPLUS_API_KEY"], + moonshot: ["MOONSHOT_API_KEY"], + openai: ["OPENAI_API_KEY"], + opencode: ["OPENCODE_API_KEY"], + "opencode-go": ["OPENCODE_API_KEY"], + volcengine: ["VOLCANO_ENGINE_API_KEY"], + }), +); + +vi.mock("../agents/agent-paths.js", () => ({ + resolveOpenClawAgentDir: () => process.env.OPENCLAW_AGENT_DIR ?? "/tmp/openclaw-agent", +})); + +vi.mock("../config/paths.js", () => ({ + resolveStateDir: () => process.env.OPENCLAW_STATE_DIR ?? "/tmp/openclaw-state", +})); + +vi.mock("../agents/auth-profiles/profiles.js", async () => { + const fs = await import("node:fs"); + const path = await import("node:path"); + return { + upsertAuthProfile: (params: { profileId: string; credential: unknown; agentDir?: string }) => { + const agentDir = params.agentDir ?? process.env.OPENCLAW_AGENT_DIR ?? "/tmp/openclaw-agent"; + const file = path.join(agentDir, "auth-profiles.json"); + fs.mkdirSync(agentDir, { recursive: true }); + const existing = (() => { + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as { + version?: number; + profiles?: Record; + }; + } catch { + return { version: 1, profiles: {} }; + } + })(); + fs.writeFileSync( + file, + `${JSON.stringify( + { + version: existing.version ?? 1, + profiles: { + ...existing.profiles, + [params.profileId]: params.credential, + }, + }, + null, + 2, + )}\n`, + ); + }, + }; +}); + vi.mock("../agents/provider-auth-aliases.js", () => ({ resolveProviderIdForAuth: (provider: string) => { const normalized = provider.trim().toLowerCase(); @@ -24,6 +80,10 @@ vi.mock("../agents/provider-auth-aliases.js", () => ({ }, })); +vi.mock("../secrets/provider-env-vars.js", () => ({ + getProviderEnvVars: vi.fn((provider: string) => providerEnvVarsById[provider] ?? []), +})); + describe("writeOAuthCredentials", () => { const lifecycle = createAuthTestLifecycle([ "OPENCLAW_STATE_DIR", @@ -176,6 +236,152 @@ describe("writeOAuthCredentials", () => { }); }); +describe("upsertApiKeyProfile secret refs", () => { + const lifecycle = createAuthTestLifecycle([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "MOONSHOT_API_KEY", + "OPENAI_API_KEY", + "CLOUDFLARE_AI_GATEWAY_API_KEY", + "VOLCANO_ENGINE_API_KEY", + "BYTEPLUS_API_KEY", + "OPENCODE_API_KEY", + ]); + + type AuthProfileEntry = { key?: string; keyRef?: unknown; metadata?: unknown }; + + afterEach(async () => { + await lifecycle.cleanup(); + }); + + async function readProfile( + agentDir: string, + profileId: string, + ): Promise { + const parsed = await readAuthProfilesForAgent<{ + profiles?: Record; + }>(agentDir); + return parsed.profiles?.[profileId]; + } + + it("handles plaintext, ref mode, and inline env-ref provider keys", async () => { + const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-"); + lifecycle.setStateDir(env.stateDir); + process.env.MOONSHOT_API_KEY = "sk-moonshot-env"; // pragma: allowlist secret + process.env.OPENAI_API_KEY = "sk-openai-env"; // pragma: allowlist secret + + upsertApiKeyProfile({ + provider: "moonshot", + input: "sk-moonshot-env", + agentDir: env.agentDir, + }); + upsertApiKeyProfile({ provider: "openai", input: "sk-openai-env", agentDir: env.agentDir }); + + expect(await readProfile(env.agentDir, "moonshot:default")).toMatchObject({ + key: "sk-moonshot-env", + }); + expect((await readProfile(env.agentDir, "moonshot:default"))?.keyRef).toBeUndefined(); + expect(await readProfile(env.agentDir, "openai:default")).toMatchObject({ + key: "sk-openai-env", + }); + expect((await readProfile(env.agentDir, "openai:default"))?.keyRef).toBeUndefined(); + + upsertApiKeyProfile({ + provider: "moonshot", + input: "sk-moonshot-env", + agentDir: env.agentDir, + options: { secretInputMode: "ref" }, // pragma: allowlist secret + }); + upsertApiKeyProfile({ + provider: "openai", + input: "sk-openai-env", + agentDir: env.agentDir, + options: { secretInputMode: "ref" }, // pragma: allowlist secret + }); + upsertApiKeyProfile({ + provider: "moonshot", + input: "${MOONSHOT_API_KEY}", + agentDir: env.agentDir, + profileId: "moonshot:inline", + }); + process.env.MOONSHOT_API_KEY = "sk-moonshot-other"; // pragma: allowlist secret + upsertApiKeyProfile({ + provider: "moonshot", + input: "sk-moonshot-plaintext", + agentDir: env.agentDir, + profileId: "moonshot:plain", + }); + + expect(await readProfile(env.agentDir, "moonshot:default")).toMatchObject({ + keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" }, + }); + expect((await readProfile(env.agentDir, "moonshot:default"))?.key).toBeUndefined(); + expect(await readProfile(env.agentDir, "openai:default")).toMatchObject({ + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }); + expect((await readProfile(env.agentDir, "openai:default"))?.key).toBeUndefined(); + expect(await readProfile(env.agentDir, "moonshot:inline")).toMatchObject({ + keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" }, + }); + expect(await readProfile(env.agentDir, "moonshot:plain")).toMatchObject({ + key: "sk-moonshot-plaintext", + }); + expect((await readProfile(env.agentDir, "moonshot:plain"))?.keyRef).toBeUndefined(); + }); + + it("stores provider-specific env refs and metadata in ref mode", async () => { + const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-provider-ref-"); + lifecycle.setStateDir(env.stateDir); + process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = "cf-secret"; // pragma: allowlist secret + process.env.VOLCANO_ENGINE_API_KEY = "volcengine-secret"; // pragma: allowlist secret + process.env.BYTEPLUS_API_KEY = "byteplus-secret"; // pragma: allowlist secret + process.env.OPENCODE_API_KEY = "sk-opencode-env"; // pragma: allowlist secret + + upsertApiKeyProfile({ + provider: "cloudflare-ai-gateway", + input: "cf-secret", + agentDir: env.agentDir, + options: { secretInputMode: "ref" }, // pragma: allowlist secret + metadata: { + accountId: "account-1", + gatewayId: "gateway-1", + }, + }); + for (const [provider, input] of [ + ["volcengine", "volcengine-secret"], + ["byteplus", "byteplus-secret"], + ["opencode", "sk-opencode-env"], + ["opencode-go", "sk-opencode-env"], + ] as const) { + upsertApiKeyProfile({ + provider, + input, + agentDir: env.agentDir, + options: { secretInputMode: "ref" }, // pragma: allowlist secret + }); + } + + expect(await readProfile(env.agentDir, "cloudflare-ai-gateway:default")).toMatchObject({ + keyRef: { source: "env", provider: "default", id: "CLOUDFLARE_AI_GATEWAY_API_KEY" }, + metadata: { accountId: "account-1", gatewayId: "gateway-1" }, + }); + expect((await readProfile(env.agentDir, "cloudflare-ai-gateway:default"))?.key).toBeUndefined(); + expect(await readProfile(env.agentDir, "volcengine:default")).toMatchObject({ + keyRef: { source: "env", provider: "default", id: "VOLCANO_ENGINE_API_KEY" }, + }); + expect(await readProfile(env.agentDir, "byteplus:default")).toMatchObject({ + keyRef: { source: "env", provider: "default", id: "BYTEPLUS_API_KEY" }, + }); + expect(await readProfile(env.agentDir, "opencode:default")).toMatchObject({ + keyRef: { source: "env", provider: "default", id: "OPENCODE_API_KEY" }, + }); + expect(await readProfile(env.agentDir, "opencode-go:default")).toMatchObject({ + keyRef: { source: "env", provider: "default", id: "OPENCODE_API_KEY" }, + }); + }); +}); + describe("upsertApiKeyProfile", () => { const lifecycle = createAuthTestLifecycle([ "OPENCLAW_STATE_DIR", diff --git a/src/commands/onboard-custom-config.test.ts b/src/commands/onboard-custom-config.test.ts new file mode 100644 index 00000000000..bbea0ee2bac --- /dev/null +++ b/src/commands/onboard-custom-config.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, it } from "vitest"; +import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { + applyCustomApiConfig, + buildAnthropicVerificationProbeRequest, + buildOpenAiVerificationProbeRequest, + parseNonInteractiveCustomApiFlags, +} from "./onboard-custom-config.js"; + +function buildCustomProviderConfig(contextWindow?: number) { + if (contextWindow === undefined) { + return {} as OpenClawConfig; + } + return { + models: { + providers: { + custom: { + api: "openai-completions" as const, + baseUrl: "https://llm.example.com/v1", + models: [ + { + id: "foo-large", + name: "foo-large", + contextWindow, + maxTokens: contextWindow > CONTEXT_WINDOW_HARD_MIN_TOKENS ? 4096 : 1024, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, + }, + ], + }, + }, + }, + } as OpenClawConfig; +} + +function applyCustomModelConfigWithContextWindow(contextWindow?: number) { + return applyCustomApiConfig({ + config: buildCustomProviderConfig(contextWindow), + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + providerId: "custom", + }); +} + +it("uses expanded max_tokens for openai verification probes", async () => { + const request = buildOpenAiVerificationProbeRequest({ + baseUrl: "https://example.com/v1", + apiKey: "test-key", + modelId: "detected-model", + }); + + expect(request.body).toMatchObject({ max_tokens: 16 }); +}); +it("uses azure responses-specific headers and body for openai verification probes", () => { + const request = buildOpenAiVerificationProbeRequest({ + baseUrl: "https://my-resource.openai.azure.com", + apiKey: "azure-test-key", + modelId: "gpt-4.1", + }); + + expect(request.endpoint).toBe("https://my-resource.openai.azure.com/openai/v1/responses"); + expect(request.headers["api-key"]).toBe("azure-test-key"); + expect(request.headers.Authorization).toBeUndefined(); + expect(request.body).toEqual({ + model: "gpt-4.1", + input: "Hi", + max_output_tokens: 16, + stream: false, + }); +}); +it("uses Azure Foundry chat-completions probes for services.ai URLs", () => { + const request = buildOpenAiVerificationProbeRequest({ + baseUrl: "https://my-resource.services.ai.azure.com", + apiKey: "azure-test-key", + modelId: "deepseek-v3-0324", + }); + + expect(request.endpoint).toBe( + "https://my-resource.services.ai.azure.com/openai/deployments/deepseek-v3-0324/chat/completions?api-version=2024-10-21", + ); + expect(request.headers["api-key"]).toBe("azure-test-key"); + expect(request.headers.Authorization).toBeUndefined(); + expect(request.body).toEqual({ + model: "deepseek-v3-0324", + messages: [{ role: "user", content: "Hi" }], + max_tokens: 16, + stream: false, + }); +}); +it("uses expanded max_tokens for anthropic verification probes", () => { + const request = buildAnthropicVerificationProbeRequest({ + baseUrl: "https://example.com", + apiKey: "test-key", + modelId: "detected-model", + }); + + expect(request.endpoint).toBe("https://example.com/v1/messages"); + expect(request.body).toMatchObject({ max_tokens: 1 }); +}); + +describe("applyCustomApiConfig", () => { + it.each([ + { + name: "uses hard-min context window for newly added custom models", + existingContextWindow: undefined, + expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS, + }, + { + name: "upgrades existing custom model context window when below hard minimum", + existingContextWindow: 4096, + expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS, + }, + { + name: "preserves existing custom model context window when already above minimum", + existingContextWindow: 131072, + expectedContextWindow: 131072, + }, + ])("$name", ({ existingContextWindow, expectedContextWindow }) => { + const result = applyCustomModelConfigWithContextWindow(existingContextWindow); + const model = result.config.models?.providers?.custom?.models?.find( + (entry) => entry.id === "foo-large", + ); + expect(model?.contextWindow).toBe(expectedContextWindow); + }); + + it.each([ + { + name: "invalid compatibility values at runtime", + params: { + config: {}, + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "invalid" as unknown as "openai", + }, + expectedMessage: 'Custom provider compatibility must be "openai" or "anthropic".', + }, + { + name: "explicit provider ids that normalize to empty", + params: { + config: {}, + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai" as const, + providerId: "!!!", + }, + expectedMessage: "Custom provider ID must include letters, numbers, or hyphens.", + }, + ])("rejects $name", ({ params, expectedMessage }) => { + expect(() => applyCustomApiConfig(params)).toThrow(expectedMessage); + }); + + it("produces azure-specific config for Azure OpenAI URLs with reasoning model", () => { + const result = applyCustomApiConfig({ + config: {}, + baseUrl: "https://user123-resource.openai.azure.com", + modelId: "o4-mini", + compatibility: "openai", + apiKey: "abcd1234", + }); + const providerId = result.providerId!; + const provider = result.config.models?.providers?.[providerId]; + + expect(provider?.baseUrl).toBe("https://user123-resource.openai.azure.com/openai/v1"); + expect(provider?.api).toBe("azure-openai-responses"); + expect(provider?.authHeader).toBe(false); + expect(provider?.headers).toEqual({ "api-key": "abcd1234" }); + + const model = provider?.models?.find((m) => m.id === "o4-mini"); + expect(model?.input).toEqual(["text", "image"]); + expect(model?.reasoning).toBe(true); + expect(model?.compat).toEqual({ supportsStore: false }); + + const modelRef = `${providerId}/${result.modelId}`; + expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBe("medium"); + }); + + it("keeps selected compatibility for Azure AI Foundry URLs", () => { + const result = applyCustomApiConfig({ + config: {}, + baseUrl: "https://my-resource.services.ai.azure.com", + modelId: "gpt-4.1", + compatibility: "openai", + apiKey: "key123", + }); + const providerId = result.providerId!; + const provider = result.config.models?.providers?.[providerId]; + + expect(provider?.baseUrl).toBe("https://my-resource.services.ai.azure.com/openai/v1"); + expect(provider?.api).toBe("openai-completions"); + expect(provider?.authHeader).toBe(false); + expect(provider?.headers).toEqual({ "api-key": "key123" }); + + const model = provider?.models?.find((m) => m.id === "gpt-4.1"); + expect(model?.reasoning).toBe(false); + expect(model?.input).toEqual(["text"]); + expect(model?.compat).toEqual({ supportsStore: false }); + + const modelRef = `${providerId}/gpt-4.1`; + expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBeUndefined(); + }); + + it("strips pre-existing deployment path from Azure URL in stored config", () => { + const result = applyCustomApiConfig({ + config: {}, + baseUrl: "https://my-resource.openai.azure.com/openai/deployments/gpt-4", + modelId: "gpt-4", + compatibility: "openai", + apiKey: "key456", + }); + const providerId = result.providerId!; + const provider = result.config.models?.providers?.[providerId]; + + expect(provider?.baseUrl).toBe("https://my-resource.openai.azure.com/openai/v1"); + }); + + it("re-onboard updates existing Azure provider instead of creating a duplicate", () => { + const oldProviderId = "custom-my-resource-openai-azure-com"; + const result = applyCustomApiConfig({ + config: { + models: { + providers: { + [oldProviderId]: { + baseUrl: "https://my-resource.openai.azure.com/openai/deployments/gpt-4", + api: "openai-completions", + models: [ + { + id: "gpt-4", + name: "gpt-4", + contextWindow: 1, + maxTokens: 1, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, + }, + ], + }, + }, + }, + }, + baseUrl: "https://my-resource.openai.azure.com", + modelId: "gpt-4", + compatibility: "openai", + apiKey: "key789", + }); + + expect(result.providerId).toBe(oldProviderId); + expect(result.providerIdRenamedFrom).toBeUndefined(); + const provider = result.config.models?.providers?.[oldProviderId]; + expect(provider?.baseUrl).toBe("https://my-resource.openai.azure.com/openai/v1"); + expect(provider?.api).toBe("azure-openai-responses"); + expect(provider?.authHeader).toBe(false); + expect(provider?.headers).toEqual({ "api-key": "key789" }); + }); + + it("renames provider id when a non-azure baseUrl differs", () => { + const result = applyCustomApiConfig({ + config: { + models: { + providers: { + custom: { + baseUrl: "http://old.example.com/v1", + api: "openai-completions", + models: [ + { + id: "old-model", + name: "Old", + contextWindow: 1, + maxTokens: 1, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, + }, + ], + }, + }, + }, + }, + baseUrl: "http://localhost:11434/v1", + modelId: "llama3", + compatibility: "openai", + providerId: "custom", + }); + + expect(result.providerId).toBe("custom-2"); + expect(result.config.models?.providers?.custom).toBeDefined(); + expect(result.config.models?.providers?.["custom-2"]).toBeDefined(); + }); + + it("does not add azure fields for non-azure URLs", () => { + const result = applyCustomApiConfig({ + config: {}, + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + apiKey: "key123", + providerId: "custom", + }); + const provider = result.config.models?.providers?.custom; + + expect(provider?.api).toBe("openai-completions"); + expect(provider?.authHeader).toBeUndefined(); + expect(provider?.headers).toBeUndefined(); + expect(provider?.models?.[0]?.reasoning).toBe(false); + expect(provider?.models?.[0]?.input).toEqual(["text"]); + expect(provider?.models?.[0]?.compat).toBeUndefined(); + expect( + result.config.agents?.defaults?.models?.["custom/foo-large"]?.params?.thinking, + ).toBeUndefined(); + }); + + it("re-onboard preserves user-customized fields for non-azure models", () => { + const result = applyCustomApiConfig({ + config: { + models: { + providers: { + custom: { + baseUrl: "https://llm.example.com/v1", + api: "openai-completions", + models: [ + { + id: "foo-large", + name: "My Custom Model", + reasoning: true, + input: ["text", "image"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 16384, + }, + ], + }, + }, + }, + } as OpenClawConfig, + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + apiKey: "key", + providerId: "custom", + }); + const model = result.config.models?.providers?.custom?.models?.find( + (m) => m.id === "foo-large", + ); + expect(model?.name).toBe("My Custom Model"); + expect(model?.reasoning).toBe(true); + expect(model?.input).toEqual(["text", "image"]); + expect(model?.cost).toEqual({ input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }); + expect(model?.maxTokens).toBe(16384); + expect(model?.contextWindow).toBe(131072); + }); + + it("preserves existing per-model thinking when already set for azure reasoning model", () => { + const providerId = "custom-my-resource-openai-azure-com"; + const modelRef = `${providerId}/o3-mini`; + const result = applyCustomApiConfig({ + config: { + agents: { + defaults: { + models: { + [modelRef]: { params: { thinking: "high" } }, + }, + }, + }, + } as OpenClawConfig, + baseUrl: "https://my-resource.openai.azure.com", + modelId: "o3-mini", + compatibility: "openai", + apiKey: "key", + }); + expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBe("high"); + }); +}); + +describe("parseNonInteractiveCustomApiFlags", () => { + it("parses required flags and defaults compatibility to openai", () => { + const result = parseNonInteractiveCustomApiFlags({ + baseUrl: " https://llm.example.com/v1 ", + modelId: " foo-large ", + apiKey: " custom-test-key ", + providerId: " my-custom ", + }); + + expect(result).toEqual({ + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + apiKey: "custom-test-key", // pragma: allowlist secret + providerId: "my-custom", + }); + }); + + it.each([ + { + name: "missing required flags", + flags: { baseUrl: "https://llm.example.com/v1" }, + expectedMessage: 'Auth choice "custom-api-key" requires a base URL and model ID.', + }, + { + name: "invalid compatibility values", + flags: { + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "xmlrpc", + }, + expectedMessage: 'Invalid --custom-compatibility (use "openai" or "anthropic").', + }, + { + name: "invalid explicit provider ids", + flags: { + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + providerId: "!!!", + }, + expectedMessage: "Custom provider ID must include letters, numbers, or hyphens.", + }, + ])("rejects $name", ({ flags, expectedMessage }) => { + expect(() => parseNonInteractiveCustomApiFlags(flags)).toThrow(expectedMessage); + }); +}); diff --git a/src/commands/onboard-custom-config.ts b/src/commands/onboard-custom-config.ts new file mode 100644 index 00000000000..f907309fd71 --- /dev/null +++ b/src/commands/onboard-custom-config.ts @@ -0,0 +1,608 @@ +import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js"; +import { DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { buildModelAliasIndex, modelKey } from "../agents/model-selection.js"; +import type { ModelProviderConfig } from "../config/types.models.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isSecretRef, type SecretInput } from "../config/types.secrets.js"; +import { applyPrimaryModel } from "../plugins/provider-model-primary.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "../shared/string-coerce.js"; +import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; +import { normalizeAlias } from "./models/alias-name.js"; + +const DEFAULT_CONTEXT_WINDOW = CONTEXT_WINDOW_HARD_MIN_TOKENS; +const DEFAULT_MAX_TOKENS = 4096; +// Azure OpenAI uses the Responses API which supports larger defaults +const AZURE_DEFAULT_CONTEXT_WINDOW = 400_000; +const AZURE_DEFAULT_MAX_TOKENS = 16_384; + +function normalizeContextWindowForCustomModel(value: unknown): number { + const parsed = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : 0; + return parsed >= CONTEXT_WINDOW_HARD_MIN_TOKENS ? parsed : CONTEXT_WINDOW_HARD_MIN_TOKENS; +} + +function isAzureFoundryUrl(baseUrl: string): boolean { + try { + const url = new URL(baseUrl); + const host = normalizeLowercaseStringOrEmpty(url.hostname); + return host.endsWith(".services.ai.azure.com"); + } catch { + return false; + } +} + +function isAzureOpenAiUrl(baseUrl: string): boolean { + try { + const url = new URL(baseUrl); + const host = normalizeLowercaseStringOrEmpty(url.hostname); + return host.endsWith(".openai.azure.com"); + } catch { + return false; + } +} + +function isAzureUrl(baseUrl: string): boolean { + return isAzureFoundryUrl(baseUrl) || isAzureOpenAiUrl(baseUrl); +} + +/** + * Transforms an Azure AI Foundry/OpenAI URL to include the deployment path. + * Azure requires: https://host/openai/deployments//chat/completions?api-version=2024-xx-xx-preview + * But we can't add query params here, so we just add the path prefix. + * The api-version will be handled by the Azure OpenAI client or as a query param. + * + * Example: + * https://my-resource.services.ai.azure.com + gpt-5.4-nano + * => https://my-resource.services.ai.azure.com/openai/deployments/gpt-5.4-nano + */ +function transformAzureUrl(baseUrl: string, modelId: string): string { + const normalizedUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + // Check if the URL already includes the deployment path + if (normalizedUrl.includes("/openai/deployments/")) { + return normalizedUrl; + } + return `${normalizedUrl}/openai/deployments/${modelId}`; +} + +/** + * Transforms an Azure URL into the base URL stored in config. + * + * Example: + * https://my-resource.openai.azure.com + * => https://my-resource.openai.azure.com/openai/v1 + */ +function transformAzureConfigUrl(baseUrl: string): string { + const normalizedUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + if (normalizedUrl.endsWith("/openai/v1")) { + return normalizedUrl; + } + // Strip a full deployment path back to the base origin + const deploymentIdx = normalizedUrl.indexOf("/openai/deployments/"); + const base = deploymentIdx !== -1 ? normalizedUrl.slice(0, deploymentIdx) : normalizedUrl; + return `${base}/openai/v1`; +} + +function hasSameHost(a: string, b: string): boolean { + try { + return ( + normalizeLowercaseStringOrEmpty(new URL(a).hostname) === + normalizeLowercaseStringOrEmpty(new URL(b).hostname) + ); + } catch { + return false; + } +} + +export type CustomApiCompatibility = "openai" | "anthropic"; +export type CustomApiResult = { + config: OpenClawConfig; + providerId?: string; + modelId?: string; + providerIdRenamedFrom?: string; +}; + +export type ApplyCustomApiConfigParams = { + config: OpenClawConfig; + baseUrl: string; + modelId: string; + compatibility: CustomApiCompatibility; + apiKey?: SecretInput; + providerId?: string; + alias?: string; +}; + +export type ParseNonInteractiveCustomApiFlagsParams = { + baseUrl?: string; + modelId?: string; + compatibility?: string; + apiKey?: string; + providerId?: string; +}; + +export type ParsedNonInteractiveCustomApiFlags = { + baseUrl: string; + modelId: string; + compatibility: CustomApiCompatibility; + apiKey?: string; + providerId?: string; +}; + +export type CustomApiErrorCode = + | "missing_required" + | "invalid_compatibility" + | "invalid_base_url" + | "invalid_model_id" + | "invalid_provider_id" + | "invalid_alias"; + +export class CustomApiError extends Error { + readonly code: CustomApiErrorCode; + + constructor(code: CustomApiErrorCode, message: string) { + super(message); + this.name = "CustomApiError"; + this.code = code; + } +} + +export type ResolveCustomProviderIdParams = { + config: OpenClawConfig; + baseUrl: string; + providerId?: string; +}; + +export type ResolvedCustomProviderId = { + providerId: string; + providerIdRenamedFrom?: string; +}; + +export function normalizeEndpointId(raw: string): string { + const trimmed = normalizeOptionalLowercaseString(raw); + if (!trimmed) { + return ""; + } + return trimmed.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, ""); +} + +export function buildEndpointIdFromUrl(baseUrl: string): string { + try { + const url = new URL(baseUrl); + const host = normalizeLowercaseStringOrEmpty(url.hostname.replace(/[^a-z0-9]+/gi, "-")); + const port = url.port ? `-${url.port}` : ""; + const candidate = `custom-${host}${port}`; + return normalizeEndpointId(candidate) || "custom"; + } catch { + return "custom"; + } +} + +function resolveUniqueEndpointId(params: { + requestedId: string; + baseUrl: string; + providers: Record; +}) { + const normalized = normalizeEndpointId(params.requestedId) || "custom"; + const existing = params.providers[normalized]; + if ( + !existing?.baseUrl || + existing.baseUrl === params.baseUrl || + (isAzureUrl(params.baseUrl) && hasSameHost(existing.baseUrl, params.baseUrl)) + ) { + return { providerId: normalized, renamed: false }; + } + let suffix = 2; + let candidate = `${normalized}-${suffix}`; + while (params.providers[candidate]) { + suffix += 1; + candidate = `${normalized}-${suffix}`; + } + return { providerId: candidate, renamed: true }; +} + +export function resolveCustomModelAliasError(params: { + raw: string; + cfg: OpenClawConfig; + modelRef: string; +}): string | undefined { + const trimmed = params.raw.trim(); + if (!trimmed) { + return undefined; + } + let normalized: string; + try { + normalized = normalizeAlias(trimmed); + } catch (err) { + return err instanceof Error ? err.message : "Alias is invalid."; + } + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: DEFAULT_PROVIDER, + }); + const aliasKey = normalizeLowercaseStringOrEmpty(normalized); + const existing = aliasIndex.byAlias.get(aliasKey); + if (!existing) { + return undefined; + } + const existingKey = modelKey(existing.ref.provider, existing.ref.model); + if (existingKey === params.modelRef) { + return undefined; + } + return `Alias ${normalized} already points to ${existingKey}.`; +} + +function buildAzureOpenAiHeaders(apiKey: string) { + const headers: Record = {}; + if (apiKey) { + headers["api-key"] = apiKey; + } + return headers; +} + +function buildOpenAiHeaders(apiKey: string) { + const headers: Record = {}; + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + return headers; +} + +function buildAnthropicHeaders(apiKey: string) { + const headers: Record = { + "anthropic-version": "2023-06-01", + }; + if (apiKey) { + headers["x-api-key"] = apiKey; + } + return headers; +} + +export type VerificationRequest = { + endpoint: string; + headers: Record; + body: Record; +}; + +export function normalizeOptionalProviderApiKey(value: unknown): SecretInput | undefined { + if (isSecretRef(value)) { + return value; + } + return normalizeOptionalSecretInput(value); +} + +function resolveVerificationEndpoint(params: { + baseUrl: string; + modelId: string; + endpointPath: "chat/completions" | "messages"; +}) { + const resolvedUrl = isAzureUrl(params.baseUrl) + ? transformAzureUrl(params.baseUrl, params.modelId) + : params.baseUrl; + const endpointUrl = new URL( + params.endpointPath, + resolvedUrl.endsWith("/") ? resolvedUrl : `${resolvedUrl}/`, + ); + if (isAzureUrl(params.baseUrl)) { + endpointUrl.searchParams.set("api-version", "2024-10-21"); + } + return endpointUrl.href; +} + +export function buildOpenAiVerificationProbeRequest(params: { + baseUrl: string; + apiKey: string; + modelId: string; +}): VerificationRequest { + const isBaseUrlAzureUrl = isAzureUrl(params.baseUrl); + const headers = isBaseUrlAzureUrl + ? buildAzureOpenAiHeaders(params.apiKey) + : buildOpenAiHeaders(params.apiKey); + if (isAzureOpenAiUrl(params.baseUrl)) { + const endpoint = new URL( + "responses", + transformAzureConfigUrl(params.baseUrl).replace(/\/?$/, "/"), + ).href; + return { + endpoint, + headers, + body: { + model: params.modelId, + input: "Hi", + max_output_tokens: 16, + stream: false, + }, + }; + } + const endpoint = resolveVerificationEndpoint({ + baseUrl: params.baseUrl, + modelId: params.modelId, + endpointPath: "chat/completions", + }); + return { + endpoint, + headers, + body: { + model: params.modelId, + messages: [{ role: "user", content: "Hi" }], + // Recent OpenAI-family endpoints reject probes below 16 tokens. + max_tokens: 16, + stream: false, + }, + }; +} + +export function buildAnthropicVerificationProbeRequest(params: { + baseUrl: string; + apiKey: string; + modelId: string; +}): VerificationRequest { + // Use a base URL with /v1 injected for this raw fetch only. The rest of the app uses the + // Anthropic client, which appends /v1 itself; config should store the base URL + // without /v1 to avoid /v1/v1/messages at runtime. See docs/gateway/configuration-reference.md. + const baseUrlForRequest = /\/v1\/?$/.test(params.baseUrl.trim()) + ? params.baseUrl.trim() + : params.baseUrl.trim().replace(/\/?$/, "") + "/v1"; + const endpoint = resolveVerificationEndpoint({ + baseUrl: baseUrlForRequest, + modelId: params.modelId, + endpointPath: "messages", + }); + return { + endpoint, + headers: buildAnthropicHeaders(params.apiKey), + body: { + model: params.modelId, + max_tokens: 1, + messages: [{ role: "user", content: "Hi" }], + stream: false, + }, + }; +} + +function resolveProviderApi( + compatibility: CustomApiCompatibility, +): "openai-completions" | "anthropic-messages" { + return compatibility === "anthropic" ? "anthropic-messages" : "openai-completions"; +} + +function parseCustomApiCompatibility(raw?: string): CustomApiCompatibility { + const compatibilityRaw = normalizeOptionalLowercaseString(raw); + if (!compatibilityRaw) { + return "openai"; + } + if (compatibilityRaw !== "openai" && compatibilityRaw !== "anthropic") { + throw new CustomApiError( + "invalid_compatibility", + 'Invalid --custom-compatibility (use "openai" or "anthropic").', + ); + } + return compatibilityRaw; +} + +export function resolveCustomProviderId( + params: ResolveCustomProviderIdParams, +): ResolvedCustomProviderId { + const providers = params.config.models?.providers ?? {}; + const baseUrl = params.baseUrl.trim(); + const explicitProviderId = params.providerId?.trim(); + if (explicitProviderId && !normalizeEndpointId(explicitProviderId)) { + throw new CustomApiError( + "invalid_provider_id", + "Custom provider ID must include letters, numbers, or hyphens.", + ); + } + const requestedProviderId = explicitProviderId || buildEndpointIdFromUrl(baseUrl); + const providerIdResult = resolveUniqueEndpointId({ + requestedId: requestedProviderId, + baseUrl, + providers, + }); + + return { + providerId: providerIdResult.providerId, + ...(providerIdResult.renamed + ? { + providerIdRenamedFrom: normalizeEndpointId(requestedProviderId) || "custom", + } + : {}), + }; +} + +export function parseNonInteractiveCustomApiFlags( + params: ParseNonInteractiveCustomApiFlagsParams, +): ParsedNonInteractiveCustomApiFlags { + const baseUrl = normalizeOptionalString(params.baseUrl) ?? ""; + const modelId = normalizeOptionalString(params.modelId) ?? ""; + if (!baseUrl || !modelId) { + throw new CustomApiError( + "missing_required", + [ + 'Auth choice "custom-api-key" requires a base URL and model ID.', + "Use --custom-base-url and --custom-model-id.", + ].join("\n"), + ); + } + + const apiKey = normalizeOptionalString(params.apiKey); + const providerId = normalizeOptionalString(params.providerId); + if (providerId && !normalizeEndpointId(providerId)) { + throw new CustomApiError( + "invalid_provider_id", + "Custom provider ID must include letters, numbers, or hyphens.", + ); + } + return { + baseUrl, + modelId, + compatibility: parseCustomApiCompatibility(params.compatibility), + ...(apiKey ? { apiKey } : {}), + ...(providerId ? { providerId } : {}), + }; +} + +export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): CustomApiResult { + const baseUrl = normalizeOptionalString(params.baseUrl) ?? ""; + if (!URL.canParse(baseUrl)) { + throw new CustomApiError("invalid_base_url", "Custom provider base URL must be a valid URL."); + } + + if (params.compatibility !== "openai" && params.compatibility !== "anthropic") { + throw new CustomApiError( + "invalid_compatibility", + 'Custom provider compatibility must be "openai" or "anthropic".', + ); + } + + const modelId = normalizeOptionalString(params.modelId) ?? ""; + if (!modelId) { + throw new CustomApiError("invalid_model_id", "Custom provider model ID is required."); + } + + const isAzure = isAzureUrl(baseUrl); + const isAzureOpenAi = isAzureOpenAiUrl(baseUrl); + const resolvedBaseUrl = isAzure ? transformAzureConfigUrl(baseUrl) : baseUrl; + + const providerIdResult = resolveCustomProviderId({ + config: params.config, + baseUrl: resolvedBaseUrl, + providerId: params.providerId, + }); + const providerId = providerIdResult.providerId; + const providers = params.config.models?.providers ?? {}; + + const modelRef = modelKey(providerId, modelId); + const alias = normalizeOptionalString(params.alias) ?? ""; + const aliasError = resolveCustomModelAliasError({ + raw: alias, + cfg: params.config, + modelRef, + }); + if (aliasError) { + throw new CustomApiError("invalid_alias", aliasError); + } + + const existingProvider = providers[providerId]; + const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; + const hasModel = existingModels.some((model) => model.id === modelId); + const isLikelyReasoningModel = isAzure && /\b(o[134]|gpt-([5-9]|\d{2,}))\b/i.test(modelId); + const nextModel = isAzure + ? { + id: modelId, + name: `${modelId} (Custom Provider)`, + contextWindow: AZURE_DEFAULT_CONTEXT_WINDOW, + maxTokens: AZURE_DEFAULT_MAX_TOKENS, + input: isLikelyReasoningModel + ? (["text", "image"] as Array<"text" | "image">) + : (["text"] as ["text"]), + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: isLikelyReasoningModel, + compat: { supportsStore: false }, + } + : { + id: modelId, + name: `${modelId} (Custom Provider)`, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + input: ["text"] as ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, + }; + const mergedModels = hasModel + ? existingModels.map((model) => + model.id === modelId + ? { + ...model, + ...(isAzure ? nextModel : {}), + name: model.name ?? nextModel.name, + cost: model.cost ?? nextModel.cost, + contextWindow: normalizeContextWindowForCustomModel(model.contextWindow), + maxTokens: model.maxTokens ?? nextModel.maxTokens, + } + : model, + ) + : [...existingModels, nextModel]; + const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? {}; + const normalizedApiKey = + normalizeOptionalProviderApiKey(params.apiKey) ?? + normalizeOptionalProviderApiKey(existingApiKey); + + const providerApi = isAzureOpenAi + ? ("azure-openai-responses" as const) + : resolveProviderApi(params.compatibility); + const azureHeaders = isAzure && normalizedApiKey ? { "api-key": normalizedApiKey } : undefined; + + let config: OpenClawConfig = { + ...params.config, + models: { + ...params.config.models, + mode: params.config.models?.mode ?? "merge", + providers: { + ...providers, + [providerId]: { + ...existingProviderRest, + baseUrl: resolvedBaseUrl, + api: providerApi, + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + ...(isAzure ? { authHeader: false } : {}), + ...(azureHeaders ? { headers: azureHeaders } : {}), + models: mergedModels.length > 0 ? mergedModels : [nextModel], + }, + }, + }, + }; + + config = applyPrimaryModel(config, modelRef); + if (isAzure && isLikelyReasoningModel) { + const existingPerModelThinking = config.agents?.defaults?.models?.[modelRef]?.params?.thinking; + if (!existingPerModelThinking) { + config = { + ...config, + agents: { + ...config.agents, + defaults: { + ...config.agents?.defaults, + models: { + ...config.agents?.defaults?.models, + [modelRef]: { + ...config.agents?.defaults?.models?.[modelRef], + params: { + ...config.agents?.defaults?.models?.[modelRef]?.params, + thinking: "medium", + }, + }, + }, + }, + }, + }; + } + } + if (alias) { + config = { + ...config, + agents: { + ...config.agents, + defaults: { + ...config.agents?.defaults, + models: { + ...config.agents?.defaults?.models, + [modelRef]: { + ...config.agents?.defaults?.models?.[modelRef], + alias, + }, + }, + }, + }, + }; + } + + return { + config, + providerId, + modelId, + ...(providerIdResult.providerIdRenamedFrom + ? { providerIdRenamedFrom: providerIdResult.providerIdRenamedFrom } + : {}), + }; +} diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index c5f6efa57d5..c7d5a2379e2 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -1,18 +1,22 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { defaultRuntime } from "../runtime.js"; -import { - applyCustomApiConfig, - parseNonInteractiveCustomApiFlags, - promptCustomApiConfig, -} from "./onboard-custom.js"; +import type { ensureApiKeyFromEnvOrPrompt } from "../plugins/provider-auth-input.js"; +import { promptCustomApiConfig } from "./onboard-custom.js"; const OLLAMA_DEFAULT_BASE_URL_FOR_TEST = "http://127.0.0.1:11434"; -// Mock dependencies -vi.mock("./model-picker.js", () => ({ - applyPrimaryModel: vi.fn((cfg) => cfg), +vi.mock("../plugins/provider-auth-input.js", () => ({ + ensureApiKeyFromEnvOrPrompt: vi.fn( + async (params: Parameters[0]) => { + await params.prompter.select({ message: "Secret input mode", options: [] }); + const input = await params.prompter.text({ + message: params.promptMessage, + validate: params.validate, + }); + const apiKey = params.normalize(input ?? ""); + await params.setCredential(apiKey); + return apiKey; + }, + ), })); function createTestPrompter(params: { text: string[]; select?: string[] }): { @@ -63,7 +67,7 @@ async function runPromptCustomApi( ) { return promptCustomApiConfig({ prompter: prompter as unknown as Parameters[0]["prompter"], - runtime: { ...defaultRuntime, log: vi.fn() }, + runtime: { log: vi.fn() } as unknown as Parameters[0]["runtime"], config, }); } @@ -79,59 +83,6 @@ function expectOpenAiCompatResult(params: { expect(params.result.config.models?.providers?.custom?.api).toBe("openai-completions"); } -function getFirstFetchVerificationCall(fetchMock: ReturnType) { - const firstCall = fetchMock.mock.calls[0]; - const firstUrl = firstCall?.[0]; - const firstInit = firstCall?.[1] as - | { body?: string; headers?: Record } - | undefined; - if (typeof firstUrl !== "string") { - throw new Error("Expected first verification call URL"); - } - return { - url: firstUrl, - init: firstInit, - body: JSON.parse(firstInit?.body ?? "{}"), - }; -} - -function buildCustomProviderConfig(contextWindow?: number) { - if (contextWindow === undefined) { - return {} as OpenClawConfig; - } - return { - models: { - providers: { - custom: { - api: "openai-completions" as const, - baseUrl: "https://llm.example.com/v1", - models: [ - { - id: "foo-large", - name: "foo-large", - contextWindow, - maxTokens: contextWindow > CONTEXT_WINDOW_HARD_MIN_TOKENS ? 4096 : 1024, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - reasoning: false, - }, - ], - }, - }, - }, - } as OpenClawConfig; -} - -function applyCustomModelConfigWithContextWindow(contextWindow?: number) { - return applyCustomApiConfig({ - config: buildCustomProviderConfig(contextWindow), - baseUrl: "https://llm.example.com/v1", - modelId: "foo-large", - compatibility: "openai", - providerId: "custom", - }); -} - describe("promptCustomApiConfig", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -191,94 +142,6 @@ describe("promptCustomApiConfig", () => { expectOpenAiCompatResult({ prompter, textCalls: 5, selectCalls: 2, result }); }); - it("uses expanded max_tokens for openai verification probes", async () => { - const prompter = createTestPrompter({ - text: ["https://example.com/v1", "test-key", "detected-model", "custom", "alias"], - select: ["plaintext", "openai"], - }); - const fetchMock = stubFetchSequence([{ ok: true }]); - - await runPromptCustomApi(prompter); - - const firstCall = fetchMock.mock.calls[0]?.[1] as { body?: string } | undefined; - expect(firstCall?.body).toBeDefined(); - expect(JSON.parse(firstCall?.body ?? "{}")).toMatchObject({ max_tokens: 16 }); - }); - - it("uses azure responses-specific headers and body for openai verification probes", async () => { - const prompter = createTestPrompter({ - text: [ - "https://my-resource.openai.azure.com", - "azure-test-key", - "gpt-4.1", - "custom", - "alias", - ], - select: ["plaintext", "openai"], - }); - const fetchMock = stubFetchSequence([{ ok: true }]); - - await runPromptCustomApi(prompter); - - const { url, init, body } = getFirstFetchVerificationCall(fetchMock); - - expect(url).toBe("https://my-resource.openai.azure.com/openai/v1/responses"); - expect(init?.headers?.["api-key"]).toBe("azure-test-key"); - expect(init?.headers?.Authorization).toBeUndefined(); - expect(init?.body).toBeDefined(); - expect(body).toEqual({ - model: "gpt-4.1", - input: "Hi", - max_output_tokens: 16, - stream: false, - }); - }); - - it("uses Azure Foundry chat-completions probes for services.ai URLs", async () => { - const prompter = createTestPrompter({ - text: [ - "https://my-resource.services.ai.azure.com", - "azure-test-key", - "deepseek-v3-0324", - "custom", - "alias", - ], - select: ["plaintext", "openai"], - }); - const fetchMock = stubFetchSequence([{ ok: true }]); - - await runPromptCustomApi(prompter); - - const { url, init, body } = getFirstFetchVerificationCall(fetchMock); - - expect(url).toBe( - "https://my-resource.services.ai.azure.com/openai/deployments/deepseek-v3-0324/chat/completions?api-version=2024-10-21", - ); - expect(init?.headers?.["api-key"]).toBe("azure-test-key"); - expect(init?.headers?.Authorization).toBeUndefined(); - expect(body).toEqual({ - model: "deepseek-v3-0324", - messages: [{ role: "user", content: "Hi" }], - max_tokens: 16, - stream: false, - }); - }); - - it("uses expanded max_tokens for anthropic verification probes", async () => { - const prompter = createTestPrompter({ - text: ["https://example.com", "test-key", "detected-model", "custom", "alias"], - select: ["plaintext", "unknown"], - }); - const fetchMock = stubFetchSequence([{ ok: false, status: 404 }, { ok: true }]); - - await runPromptCustomApi(prompter); - - expect(fetchMock).toHaveBeenCalledTimes(2); - const secondCall = fetchMock.mock.calls[1]?.[1] as { body?: string } | undefined; - expect(secondCall?.body).toBeDefined(); - expect(JSON.parse(secondCall?.body ?? "{}")).toMatchObject({ max_tokens: 1 }); - }); - it("re-prompts base url when unknown detection fails", async () => { const prompter = createTestPrompter({ text: [ @@ -301,39 +164,6 @@ describe("promptCustomApiConfig", () => { ); }); - it("renames provider id when baseUrl differs", async () => { - const prompter = createTestPrompter({ - text: ["http://localhost:11434/v1", "", "llama3", "custom", ""], - select: ["plaintext", "openai"], - }); - stubFetchSequence([{ ok: true }]); - const result = await runPromptCustomApi(prompter, { - models: { - providers: { - custom: { - baseUrl: "http://old.example.com/v1", - api: "openai-completions", - models: [ - { - id: "old-model", - name: "Old", - contextWindow: 1, - maxTokens: 1, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - reasoning: false, - }, - ], - }, - }, - }, - }); - - expect(result.providerId).toBe("custom-2"); - expect(result.config.models?.providers?.custom).toBeDefined(); - expect(result.config.models?.providers?.["custom-2"]).toBeDefined(); - }); - it("aborts verification after timeout", async () => { vi.useFakeTimers(); const prompter = createTestPrompter({ @@ -358,348 +188,4 @@ describe("promptCustomApiConfig", () => { expect(prompter.text).toHaveBeenCalledTimes(6); }); - - it("stores env SecretRef for custom provider when selected", async () => { - vi.stubEnv("CUSTOM_PROVIDER_API_KEY", "test-env-key"); - const prompter = createTestPrompter({ - text: ["https://example.com/v1", "CUSTOM_PROVIDER_API_KEY", "detected-model", "custom", ""], - select: ["ref", "env", "openai"], - }); - const fetchMock = stubFetchSequence([{ ok: true }]); - - const result = await runPromptCustomApi(prompter); - - expect(result.config.models?.providers?.custom?.apiKey).toEqual({ - source: "env", - provider: "default", - id: "CUSTOM_PROVIDER_API_KEY", - }); - const firstCall = fetchMock.mock.calls[0]?.[1] as - | { headers?: Record } - | undefined; - expect(firstCall?.headers?.Authorization).toBe("Bearer test-env-key"); - }); - - it("re-prompts source after provider ref preflight fails and succeeds with env ref", async () => { - vi.stubEnv("CUSTOM_PROVIDER_API_KEY", "test-env-key"); - const prompter = createTestPrompter({ - text: [ - "https://example.com/v1", - "/providers/custom/apiKey", - "CUSTOM_PROVIDER_API_KEY", - "detected-model", - "custom", - "", - ], - select: ["ref", "provider", "filemain", "env", "openai"], - }); - stubFetchSequence([{ ok: true }]); - - const result = await runPromptCustomApi(prompter, { - secrets: { - providers: { - filemain: { - source: "file", - path: "/tmp/openclaw-missing-provider.json", - mode: "json", - }, - }, - }, - }); - - expect(prompter.note).toHaveBeenCalledWith( - expect.stringContaining("Could not validate provider reference"), - "Reference check failed", - ); - expect(result.config.models?.providers?.custom?.apiKey).toEqual({ - source: "env", - provider: "default", - id: "CUSTOM_PROVIDER_API_KEY", - }); - }); -}); - -describe("applyCustomApiConfig", () => { - it.each([ - { - name: "uses hard-min context window for newly added custom models", - existingContextWindow: undefined, - expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS, - }, - { - name: "upgrades existing custom model context window when below hard minimum", - existingContextWindow: 4096, - expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS, - }, - { - name: "preserves existing custom model context window when already above minimum", - existingContextWindow: 131072, - expectedContextWindow: 131072, - }, - ])("$name", ({ existingContextWindow, expectedContextWindow }) => { - const result = applyCustomModelConfigWithContextWindow(existingContextWindow); - const model = result.config.models?.providers?.custom?.models?.find( - (entry) => entry.id === "foo-large", - ); - expect(model?.contextWindow).toBe(expectedContextWindow); - }); - - it.each([ - { - name: "invalid compatibility values at runtime", - params: { - config: {}, - baseUrl: "https://llm.example.com/v1", - modelId: "foo-large", - compatibility: "invalid" as unknown as "openai", - }, - expectedMessage: 'Custom provider compatibility must be "openai" or "anthropic".', - }, - { - name: "explicit provider ids that normalize to empty", - params: { - config: {}, - baseUrl: "https://llm.example.com/v1", - modelId: "foo-large", - compatibility: "openai" as const, - providerId: "!!!", - }, - expectedMessage: "Custom provider ID must include letters, numbers, or hyphens.", - }, - ])("rejects $name", ({ params, expectedMessage }) => { - expect(() => applyCustomApiConfig(params)).toThrow(expectedMessage); - }); - - it("produces azure-specific config for Azure OpenAI URLs with reasoning model", () => { - const result = applyCustomApiConfig({ - config: {}, - baseUrl: "https://user123-resource.openai.azure.com", - modelId: "o4-mini", - compatibility: "openai", - apiKey: "abcd1234", - }); - const providerId = result.providerId!; - const provider = result.config.models?.providers?.[providerId]; - - expect(provider?.baseUrl).toBe("https://user123-resource.openai.azure.com/openai/v1"); - expect(provider?.api).toBe("azure-openai-responses"); - expect(provider?.authHeader).toBe(false); - expect(provider?.headers).toEqual({ "api-key": "abcd1234" }); - - const model = provider?.models?.find((m) => m.id === "o4-mini"); - expect(model?.input).toEqual(["text", "image"]); - expect(model?.reasoning).toBe(true); - expect(model?.compat).toEqual({ supportsStore: false }); - - const modelRef = `${providerId}/${result.modelId}`; - expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBe("medium"); - }); - - it("keeps selected compatibility for Azure AI Foundry URLs", () => { - const result = applyCustomApiConfig({ - config: {}, - baseUrl: "https://my-resource.services.ai.azure.com", - modelId: "gpt-4.1", - compatibility: "openai", - apiKey: "key123", - }); - const providerId = result.providerId!; - const provider = result.config.models?.providers?.[providerId]; - - expect(provider?.baseUrl).toBe("https://my-resource.services.ai.azure.com/openai/v1"); - expect(provider?.api).toBe("openai-completions"); - expect(provider?.authHeader).toBe(false); - expect(provider?.headers).toEqual({ "api-key": "key123" }); - - const model = provider?.models?.find((m) => m.id === "gpt-4.1"); - expect(model?.reasoning).toBe(false); - expect(model?.input).toEqual(["text"]); - expect(model?.compat).toEqual({ supportsStore: false }); - - const modelRef = `${providerId}/gpt-4.1`; - expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBeUndefined(); - }); - - it("strips pre-existing deployment path from Azure URL in stored config", () => { - const result = applyCustomApiConfig({ - config: {}, - baseUrl: "https://my-resource.openai.azure.com/openai/deployments/gpt-4", - modelId: "gpt-4", - compatibility: "openai", - apiKey: "key456", - }); - const providerId = result.providerId!; - const provider = result.config.models?.providers?.[providerId]; - - expect(provider?.baseUrl).toBe("https://my-resource.openai.azure.com/openai/v1"); - }); - - it("re-onboard updates existing Azure provider instead of creating a duplicate", () => { - const oldProviderId = "custom-my-resource-openai-azure-com"; - const result = applyCustomApiConfig({ - config: { - models: { - providers: { - [oldProviderId]: { - baseUrl: "https://my-resource.openai.azure.com/openai/deployments/gpt-4", - api: "openai-completions", - models: [ - { - id: "gpt-4", - name: "gpt-4", - contextWindow: 1, - maxTokens: 1, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - reasoning: false, - }, - ], - }, - }, - }, - }, - baseUrl: "https://my-resource.openai.azure.com", - modelId: "gpt-4", - compatibility: "openai", - apiKey: "key789", - }); - - expect(result.providerId).toBe(oldProviderId); - expect(result.providerIdRenamedFrom).toBeUndefined(); - const provider = result.config.models?.providers?.[oldProviderId]; - expect(provider?.baseUrl).toBe("https://my-resource.openai.azure.com/openai/v1"); - expect(provider?.api).toBe("azure-openai-responses"); - expect(provider?.authHeader).toBe(false); - expect(provider?.headers).toEqual({ "api-key": "key789" }); - }); - - it("does not add azure fields for non-azure URLs", () => { - const result = applyCustomApiConfig({ - config: {}, - baseUrl: "https://llm.example.com/v1", - modelId: "foo-large", - compatibility: "openai", - apiKey: "key123", - providerId: "custom", - }); - const provider = result.config.models?.providers?.custom; - - expect(provider?.api).toBe("openai-completions"); - expect(provider?.authHeader).toBeUndefined(); - expect(provider?.headers).toBeUndefined(); - expect(provider?.models?.[0]?.reasoning).toBe(false); - expect(provider?.models?.[0]?.input).toEqual(["text"]); - expect(provider?.models?.[0]?.compat).toBeUndefined(); - expect( - result.config.agents?.defaults?.models?.["custom/foo-large"]?.params?.thinking, - ).toBeUndefined(); - }); - - it("re-onboard preserves user-customized fields for non-azure models", () => { - const result = applyCustomApiConfig({ - config: { - models: { - providers: { - custom: { - baseUrl: "https://llm.example.com/v1", - api: "openai-completions", - models: [ - { - id: "foo-large", - name: "My Custom Model", - reasoning: true, - input: ["text", "image"], - cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 131072, - maxTokens: 16384, - }, - ], - }, - }, - }, - } as OpenClawConfig, - baseUrl: "https://llm.example.com/v1", - modelId: "foo-large", - compatibility: "openai", - apiKey: "key", - providerId: "custom", - }); - const model = result.config.models?.providers?.custom?.models?.find( - (m) => m.id === "foo-large", - ); - expect(model?.name).toBe("My Custom Model"); - expect(model?.reasoning).toBe(true); - expect(model?.input).toEqual(["text", "image"]); - expect(model?.cost).toEqual({ input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }); - expect(model?.maxTokens).toBe(16384); - expect(model?.contextWindow).toBe(131072); - }); - - it("preserves existing per-model thinking when already set for azure reasoning model", () => { - const providerId = "custom-my-resource-openai-azure-com"; - const modelRef = `${providerId}/o3-mini`; - const result = applyCustomApiConfig({ - config: { - agents: { - defaults: { - models: { - [modelRef]: { params: { thinking: "high" } }, - }, - }, - }, - } as OpenClawConfig, - baseUrl: "https://my-resource.openai.azure.com", - modelId: "o3-mini", - compatibility: "openai", - apiKey: "key", - }); - expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBe("high"); - }); -}); - -describe("parseNonInteractiveCustomApiFlags", () => { - it("parses required flags and defaults compatibility to openai", () => { - const result = parseNonInteractiveCustomApiFlags({ - baseUrl: " https://llm.example.com/v1 ", - modelId: " foo-large ", - apiKey: " custom-test-key ", - providerId: " my-custom ", - }); - - expect(result).toEqual({ - baseUrl: "https://llm.example.com/v1", - modelId: "foo-large", - compatibility: "openai", - apiKey: "custom-test-key", // pragma: allowlist secret - providerId: "my-custom", - }); - }); - - it.each([ - { - name: "missing required flags", - flags: { baseUrl: "https://llm.example.com/v1" }, - expectedMessage: 'Auth choice "custom-api-key" requires a base URL and model ID.', - }, - { - name: "invalid compatibility values", - flags: { - baseUrl: "https://llm.example.com/v1", - modelId: "foo-large", - compatibility: "xmlrpc", - }, - expectedMessage: 'Invalid --custom-compatibility (use "openai" or "anthropic").', - }, - { - name: "invalid explicit provider ids", - flags: { - baseUrl: "https://llm.example.com/v1", - modelId: "foo-large", - providerId: "!!!", - }, - expectedMessage: "Custom provider ID must include letters, numbers, or hyphens.", - }, - ])("rejects $name", ({ flags, expectedMessage }) => { - expect(() => parseNonInteractiveCustomApiFlags(flags)).toThrow(expectedMessage); - }); }); diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index d3d11f6f684..f19dcd94169 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -1,174 +1,44 @@ -import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js"; -import { DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { buildModelAliasIndex, modelKey } from "../agents/model-selection.js"; -import type { ModelProviderConfig } from "../config/types.models.js"; +import { modelKey } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { isSecretRef, type SecretInput } from "../config/types.secrets.js"; +import type { SecretInput } from "../config/types.secrets.js"; +import { ensureApiKeyFromEnvOrPrompt } from "../plugins/provider-auth-input.js"; import { OLLAMA_DEFAULT_BASE_URL } from "../plugins/provider-model-defaults.js"; import type { RuntimeEnv } from "../runtime.js"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, - normalizeOptionalString, -} from "../shared/string-coerce.js"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; -import { - normalizeSecretInput, - normalizeOptionalSecretInput, -} from "../utils/normalize-secret-input.js"; +import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -import { ensureApiKeyFromEnvOrPrompt } from "./auth-choice.apply-helpers.js"; -import { applyPrimaryModel } from "./model-picker.js"; -import { normalizeAlias } from "./models/shared.js"; +import { + applyCustomApiConfig, + buildAnthropicVerificationProbeRequest, + buildEndpointIdFromUrl, + buildOpenAiVerificationProbeRequest, + normalizeEndpointId, + normalizeOptionalProviderApiKey, + resolveCustomModelAliasError, + resolveCustomProviderId, + type CustomApiCompatibility, + type CustomApiResult, +} from "./onboard-custom-config.js"; +export { + applyCustomApiConfig, + buildAnthropicVerificationProbeRequest, + buildOpenAiVerificationProbeRequest, + CustomApiError, + parseNonInteractiveCustomApiFlags, + resolveCustomProviderId, + type ApplyCustomApiConfigParams, + type CustomApiCompatibility, + type CustomApiErrorCode, + type CustomApiResult, + type ParseNonInteractiveCustomApiFlagsParams, + type ParsedNonInteractiveCustomApiFlags, + type ResolveCustomProviderIdParams, + type ResolvedCustomProviderId, +} from "./onboard-custom-config.js"; import type { SecretInputMode } from "./onboard-types.js"; -const DEFAULT_CONTEXT_WINDOW = CONTEXT_WINDOW_HARD_MIN_TOKENS; -const DEFAULT_MAX_TOKENS = 4096; -// Azure OpenAI uses the Responses API which supports larger defaults -const AZURE_DEFAULT_CONTEXT_WINDOW = 400_000; -const AZURE_DEFAULT_MAX_TOKENS = 16_384; const VERIFY_TIMEOUT_MS = 30_000; - -function normalizeContextWindowForCustomModel(value: unknown): number { - const parsed = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : 0; - return parsed >= CONTEXT_WINDOW_HARD_MIN_TOKENS ? parsed : CONTEXT_WINDOW_HARD_MIN_TOKENS; -} - -function isAzureFoundryUrl(baseUrl: string): boolean { - try { - const url = new URL(baseUrl); - const host = normalizeLowercaseStringOrEmpty(url.hostname); - return host.endsWith(".services.ai.azure.com"); - } catch { - return false; - } -} - -function isAzureOpenAiUrl(baseUrl: string): boolean { - try { - const url = new URL(baseUrl); - const host = normalizeLowercaseStringOrEmpty(url.hostname); - return host.endsWith(".openai.azure.com"); - } catch { - return false; - } -} - -function isAzureUrl(baseUrl: string): boolean { - return isAzureFoundryUrl(baseUrl) || isAzureOpenAiUrl(baseUrl); -} - -/** - * Transforms an Azure AI Foundry/OpenAI URL to include the deployment path. - * Azure requires: https://host/openai/deployments//chat/completions?api-version=2024-xx-xx-preview - * But we can't add query params here, so we just add the path prefix. - * The api-version will be handled by the Azure OpenAI client or as a query param. - * - * Example: - * https://my-resource.services.ai.azure.com + gpt-5.4-nano - * => https://my-resource.services.ai.azure.com/openai/deployments/gpt-5.4-nano - */ -function transformAzureUrl(baseUrl: string, modelId: string): string { - const normalizedUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; - // Check if the URL already includes the deployment path - if (normalizedUrl.includes("/openai/deployments/")) { - return normalizedUrl; - } - return `${normalizedUrl}/openai/deployments/${modelId}`; -} - -/** - * Transforms an Azure URL into the base URL stored in config. - * - * Example: - * https://my-resource.openai.azure.com - * => https://my-resource.openai.azure.com/openai/v1 - */ -function transformAzureConfigUrl(baseUrl: string): string { - const normalizedUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; - if (normalizedUrl.endsWith("/openai/v1")) { - return normalizedUrl; - } - // Strip a full deployment path back to the base origin - const deploymentIdx = normalizedUrl.indexOf("/openai/deployments/"); - const base = deploymentIdx !== -1 ? normalizedUrl.slice(0, deploymentIdx) : normalizedUrl; - return `${base}/openai/v1`; -} - -function hasSameHost(a: string, b: string): boolean { - try { - return ( - normalizeLowercaseStringOrEmpty(new URL(a).hostname) === - normalizeLowercaseStringOrEmpty(new URL(b).hostname) - ); - } catch { - return false; - } -} - -export type CustomApiCompatibility = "openai" | "anthropic"; type CustomApiCompatibilityChoice = CustomApiCompatibility | "unknown"; -export type CustomApiResult = { - config: OpenClawConfig; - providerId?: string; - modelId?: string; - providerIdRenamedFrom?: string; -}; - -export type ApplyCustomApiConfigParams = { - config: OpenClawConfig; - baseUrl: string; - modelId: string; - compatibility: CustomApiCompatibility; - apiKey?: SecretInput; - providerId?: string; - alias?: string; -}; - -export type ParseNonInteractiveCustomApiFlagsParams = { - baseUrl?: string; - modelId?: string; - compatibility?: string; - apiKey?: string; - providerId?: string; -}; - -export type ParsedNonInteractiveCustomApiFlags = { - baseUrl: string; - modelId: string; - compatibility: CustomApiCompatibility; - apiKey?: string; - providerId?: string; -}; - -export type CustomApiErrorCode = - | "missing_required" - | "invalid_compatibility" - | "invalid_base_url" - | "invalid_model_id" - | "invalid_provider_id" - | "invalid_alias"; - -export class CustomApiError extends Error { - readonly code: CustomApiErrorCode; - - constructor(code: CustomApiErrorCode, message: string) { - super(message); - this.name = "CustomApiError"; - this.code = code; - } -} - -export type ResolveCustomProviderIdParams = { - config: OpenClawConfig; - baseUrl: string; - providerId?: string; -}; - -export type ResolvedCustomProviderId = { - providerId: string; - providerIdRenamedFrom?: string; -}; const COMPATIBILITY_OPTIONS: Array<{ value: CustomApiCompatibilityChoice; @@ -192,106 +62,6 @@ const COMPATIBILITY_OPTIONS: Array<{ }, ]; -function normalizeEndpointId(raw: string): string { - const trimmed = normalizeOptionalLowercaseString(raw); - if (!trimmed) { - return ""; - } - return trimmed.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, ""); -} - -function buildEndpointIdFromUrl(baseUrl: string): string { - try { - const url = new URL(baseUrl); - const host = normalizeLowercaseStringOrEmpty(url.hostname.replace(/[^a-z0-9]+/gi, "-")); - const port = url.port ? `-${url.port}` : ""; - const candidate = `custom-${host}${port}`; - return normalizeEndpointId(candidate) || "custom"; - } catch { - return "custom"; - } -} - -function resolveUniqueEndpointId(params: { - requestedId: string; - baseUrl: string; - providers: Record; -}) { - const normalized = normalizeEndpointId(params.requestedId) || "custom"; - const existing = params.providers[normalized]; - if ( - !existing?.baseUrl || - existing.baseUrl === params.baseUrl || - (isAzureUrl(params.baseUrl) && hasSameHost(existing.baseUrl, params.baseUrl)) - ) { - return { providerId: normalized, renamed: false }; - } - let suffix = 2; - let candidate = `${normalized}-${suffix}`; - while (params.providers[candidate]) { - suffix += 1; - candidate = `${normalized}-${suffix}`; - } - return { providerId: candidate, renamed: true }; -} - -function resolveAliasError(params: { - raw: string; - cfg: OpenClawConfig; - modelRef: string; -}): string | undefined { - const trimmed = params.raw.trim(); - if (!trimmed) { - return undefined; - } - let normalized: string; - try { - normalized = normalizeAlias(trimmed); - } catch (err) { - return err instanceof Error ? err.message : "Alias is invalid."; - } - const aliasIndex = buildModelAliasIndex({ - cfg: params.cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - const aliasKey = normalizeLowercaseStringOrEmpty(normalized); - const existing = aliasIndex.byAlias.get(aliasKey); - if (!existing) { - return undefined; - } - const existingKey = modelKey(existing.ref.provider, existing.ref.model); - if (existingKey === params.modelRef) { - return undefined; - } - return `Alias ${normalized} already points to ${existingKey}.`; -} - -function buildAzureOpenAiHeaders(apiKey: string) { - const headers: Record = {}; - if (apiKey) { - headers["api-key"] = apiKey; - } - return headers; -} - -function buildOpenAiHeaders(apiKey: string) { - const headers: Record = {}; - if (apiKey) { - headers.Authorization = `Bearer ${apiKey}`; - } - return headers; -} - -function buildAnthropicHeaders(apiKey: string) { - const headers: Record = { - "anthropic-version": "2023-06-01", - }; - if (apiKey) { - headers["x-api-key"] = apiKey; - } - return headers; -} - function formatVerificationError(error: unknown): string { if (!error) { return "unknown error"; @@ -315,31 +85,6 @@ type VerificationResult = { error?: unknown; }; -function normalizeOptionalProviderApiKey(value: unknown): SecretInput | undefined { - if (isSecretRef(value)) { - return value; - } - return normalizeOptionalSecretInput(value); -} - -function resolveVerificationEndpoint(params: { - baseUrl: string; - modelId: string; - endpointPath: "chat/completions" | "messages"; -}) { - const resolvedUrl = isAzureUrl(params.baseUrl) - ? transformAzureUrl(params.baseUrl, params.modelId) - : params.baseUrl; - const endpointUrl = new URL( - params.endpointPath, - resolvedUrl.endsWith("/") ? resolvedUrl : `${resolvedUrl}/`, - ); - if (isAzureUrl(params.baseUrl)) { - endpointUrl.searchParams.set("api-version", "2024-10-21"); - } - return endpointUrl.href; -} - async function requestVerification(params: { endpoint: string; headers: Record; @@ -369,43 +114,7 @@ async function requestOpenAiVerification(params: { apiKey: string; modelId: string; }): Promise { - const isBaseUrlAzureUrl = isAzureUrl(params.baseUrl); - const headers = isBaseUrlAzureUrl - ? buildAzureOpenAiHeaders(params.apiKey) - : buildOpenAiHeaders(params.apiKey); - if (isAzureOpenAiUrl(params.baseUrl)) { - const endpoint = new URL( - "responses", - transformAzureConfigUrl(params.baseUrl).replace(/\/?$/, "/"), - ).href; - return await requestVerification({ - endpoint, - headers, - body: { - model: params.modelId, - input: "Hi", - max_output_tokens: 16, - stream: false, - }, - }); - } else { - const endpoint = resolveVerificationEndpoint({ - baseUrl: params.baseUrl, - modelId: params.modelId, - endpointPath: "chat/completions", - }); - return await requestVerification({ - endpoint, - headers, - body: { - model: params.modelId, - messages: [{ role: "user", content: "Hi" }], - // Recent OpenAI-family endpoints reject probes below 16 tokens. - max_tokens: 16, - stream: false, - }, - }); - } + return await requestVerification(buildOpenAiVerificationProbeRequest(params)); } async function requestAnthropicVerification(params: { @@ -413,27 +122,7 @@ async function requestAnthropicVerification(params: { apiKey: string; modelId: string; }): Promise { - // Use a base URL with /v1 injected for this raw fetch only. The rest of the app uses the - // Anthropic client, which appends /v1 itself; config should store the base URL - // without /v1 to avoid /v1/v1/messages at runtime. See docs/gateway/configuration-reference.md. - const baseUrlForRequest = /\/v1\/?$/.test(params.baseUrl.trim()) - ? params.baseUrl.trim() - : params.baseUrl.trim().replace(/\/?$/, "") + "/v1"; - const endpoint = resolveVerificationEndpoint({ - baseUrl: baseUrlForRequest, - modelId: params.modelId, - endpointPath: "messages", - }); - return await requestVerification({ - endpoint, - headers: buildAnthropicHeaders(params.apiKey), - body: { - model: params.modelId, - max_tokens: 1, - messages: [{ role: "user", content: "Hi" }], - stream: false, - }, - }); + return await requestVerification(buildAnthropicVerificationProbeRequest(params)); } async function promptBaseUrlAndKey(params: { @@ -521,252 +210,6 @@ async function applyCustomApiRetryChoice(params: { return { baseUrl, apiKey, resolvedApiKey, modelId }; } -function resolveProviderApi( - compatibility: CustomApiCompatibility, -): "openai-completions" | "anthropic-messages" { - return compatibility === "anthropic" ? "anthropic-messages" : "openai-completions"; -} - -function parseCustomApiCompatibility(raw?: string): CustomApiCompatibility { - const compatibilityRaw = normalizeOptionalLowercaseString(raw); - if (!compatibilityRaw) { - return "openai"; - } - if (compatibilityRaw !== "openai" && compatibilityRaw !== "anthropic") { - throw new CustomApiError( - "invalid_compatibility", - 'Invalid --custom-compatibility (use "openai" or "anthropic").', - ); - } - return compatibilityRaw; -} - -export function resolveCustomProviderId( - params: ResolveCustomProviderIdParams, -): ResolvedCustomProviderId { - const providers = params.config.models?.providers ?? {}; - const baseUrl = params.baseUrl.trim(); - const explicitProviderId = params.providerId?.trim(); - if (explicitProviderId && !normalizeEndpointId(explicitProviderId)) { - throw new CustomApiError( - "invalid_provider_id", - "Custom provider ID must include letters, numbers, or hyphens.", - ); - } - const requestedProviderId = explicitProviderId || buildEndpointIdFromUrl(baseUrl); - const providerIdResult = resolveUniqueEndpointId({ - requestedId: requestedProviderId, - baseUrl, - providers, - }); - - return { - providerId: providerIdResult.providerId, - ...(providerIdResult.renamed - ? { - providerIdRenamedFrom: normalizeEndpointId(requestedProviderId) || "custom", - } - : {}), - }; -} - -export function parseNonInteractiveCustomApiFlags( - params: ParseNonInteractiveCustomApiFlagsParams, -): ParsedNonInteractiveCustomApiFlags { - const baseUrl = normalizeOptionalString(params.baseUrl) ?? ""; - const modelId = normalizeOptionalString(params.modelId) ?? ""; - if (!baseUrl || !modelId) { - throw new CustomApiError( - "missing_required", - [ - 'Auth choice "custom-api-key" requires a base URL and model ID.', - "Use --custom-base-url and --custom-model-id.", - ].join("\n"), - ); - } - - const apiKey = normalizeOptionalString(params.apiKey); - const providerId = normalizeOptionalString(params.providerId); - if (providerId && !normalizeEndpointId(providerId)) { - throw new CustomApiError( - "invalid_provider_id", - "Custom provider ID must include letters, numbers, or hyphens.", - ); - } - return { - baseUrl, - modelId, - compatibility: parseCustomApiCompatibility(params.compatibility), - ...(apiKey ? { apiKey } : {}), - ...(providerId ? { providerId } : {}), - }; -} - -export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): CustomApiResult { - const baseUrl = normalizeOptionalString(params.baseUrl) ?? ""; - if (!URL.canParse(baseUrl)) { - throw new CustomApiError("invalid_base_url", "Custom provider base URL must be a valid URL."); - } - - if (params.compatibility !== "openai" && params.compatibility !== "anthropic") { - throw new CustomApiError( - "invalid_compatibility", - 'Custom provider compatibility must be "openai" or "anthropic".', - ); - } - - const modelId = normalizeOptionalString(params.modelId) ?? ""; - if (!modelId) { - throw new CustomApiError("invalid_model_id", "Custom provider model ID is required."); - } - - const isAzure = isAzureUrl(baseUrl); - const isAzureOpenAi = isAzureOpenAiUrl(baseUrl); - const resolvedBaseUrl = isAzure ? transformAzureConfigUrl(baseUrl) : baseUrl; - - const providerIdResult = resolveCustomProviderId({ - config: params.config, - baseUrl: resolvedBaseUrl, - providerId: params.providerId, - }); - const providerId = providerIdResult.providerId; - const providers = params.config.models?.providers ?? {}; - - const modelRef = modelKey(providerId, modelId); - const alias = normalizeOptionalString(params.alias) ?? ""; - const aliasError = resolveAliasError({ - raw: alias, - cfg: params.config, - modelRef, - }); - if (aliasError) { - throw new CustomApiError("invalid_alias", aliasError); - } - - const existingProvider = providers[providerId]; - const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; - const hasModel = existingModels.some((model) => model.id === modelId); - const isLikelyReasoningModel = isAzure && /\b(o[134]|gpt-([5-9]|\d{2,}))\b/i.test(modelId); - const nextModel = isAzure - ? { - id: modelId, - name: `${modelId} (Custom Provider)`, - contextWindow: AZURE_DEFAULT_CONTEXT_WINDOW, - maxTokens: AZURE_DEFAULT_MAX_TOKENS, - input: isLikelyReasoningModel - ? (["text", "image"] as Array<"text" | "image">) - : (["text"] as ["text"]), - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - reasoning: isLikelyReasoningModel, - compat: { supportsStore: false }, - } - : { - id: modelId, - name: `${modelId} (Custom Provider)`, - contextWindow: DEFAULT_CONTEXT_WINDOW, - maxTokens: DEFAULT_MAX_TOKENS, - input: ["text"] as ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - reasoning: false, - }; - const mergedModels = hasModel - ? existingModels.map((model) => - model.id === modelId - ? { - ...model, - ...(isAzure ? nextModel : {}), - name: model.name ?? nextModel.name, - cost: model.cost ?? nextModel.cost, - contextWindow: normalizeContextWindowForCustomModel(model.contextWindow), - maxTokens: model.maxTokens ?? nextModel.maxTokens, - } - : model, - ) - : [...existingModels, nextModel]; - const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? {}; - const normalizedApiKey = - normalizeOptionalProviderApiKey(params.apiKey) ?? - normalizeOptionalProviderApiKey(existingApiKey); - - const providerApi = isAzureOpenAi - ? ("azure-openai-responses" as const) - : resolveProviderApi(params.compatibility); - const azureHeaders = isAzure && normalizedApiKey ? { "api-key": normalizedApiKey } : undefined; - - let config: OpenClawConfig = { - ...params.config, - models: { - ...params.config.models, - mode: params.config.models?.mode ?? "merge", - providers: { - ...providers, - [providerId]: { - ...existingProviderRest, - baseUrl: resolvedBaseUrl, - api: providerApi, - ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), - ...(isAzure ? { authHeader: false } : {}), - ...(azureHeaders ? { headers: azureHeaders } : {}), - models: mergedModels.length > 0 ? mergedModels : [nextModel], - }, - }, - }, - }; - - config = applyPrimaryModel(config, modelRef); - if (isAzure && isLikelyReasoningModel) { - const existingPerModelThinking = config.agents?.defaults?.models?.[modelRef]?.params?.thinking; - if (!existingPerModelThinking) { - config = { - ...config, - agents: { - ...config.agents, - defaults: { - ...config.agents?.defaults, - models: { - ...config.agents?.defaults?.models, - [modelRef]: { - ...config.agents?.defaults?.models?.[modelRef], - params: { - ...config.agents?.defaults?.models?.[modelRef]?.params, - thinking: "medium", - }, - }, - }, - }, - }, - }; - } - } - if (alias) { - config = { - ...config, - agents: { - ...config.agents, - defaults: { - ...config.agents?.defaults, - models: { - ...config.agents?.defaults?.models, - [modelRef]: { - ...config.agents?.defaults?.models?.[modelRef], - alias, - }, - }, - }, - }, - }; - } - - return { - config, - providerId, - modelId, - ...(providerIdResult.providerIdRenamedFrom - ? { providerIdRenamedFrom: providerIdResult.providerIdRenamedFrom } - : {}), - }; -} - export async function promptCustomApiConfig(params: { prompter: WizardPrompter; runtime: RuntimeEnv; @@ -871,7 +314,6 @@ export async function promptCustomApiConfig(params: { } } - const providers = config.models?.providers ?? {}; const suggestedId = buildEndpointIdFromUrl(baseUrl); const providerIdInput = await prompter.text({ message: "Endpoint ID", @@ -890,14 +332,13 @@ export async function promptCustomApiConfig(params: { placeholder: "e.g. local, ollama", initialValue: "", validate: (value) => { - const requestedId = normalizeEndpointId(providerIdInput) || "custom"; - const providerIdResult = resolveUniqueEndpointId({ - requestedId, + const resolvedProvider = resolveCustomProviderId({ + config, baseUrl, - providers, + providerId: providerIdInput, }); - const modelRef = modelKey(providerIdResult.providerId, modelId); - return resolveAliasError({ raw: value, cfg: config, modelRef }); + const modelRef = modelKey(resolvedProvider.providerId, modelId); + return resolveCustomModelAliasError({ raw: value, cfg: config, modelRef }); }, }); const resolvedCompatibility = compatibility ?? "openai"; diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index e5155d6f2e8..0bedc8923b7 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -1,22 +1,15 @@ -import nodeFs from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { captureEnv } from "../test-utils/env.js"; -import { createThrowingRuntime, readJsonFile } from "./onboard-non-interactive.test-helpers.js"; +import { createThrowingRuntime } from "./onboard-non-interactive.test-helpers.js"; import type { installGatewayDaemonNonInteractive } from "./onboard-non-interactive/local/daemon-install.js"; -const gatewayClientCalls: Array<{ - url?: string; - token?: string; - password?: string; - onHelloOk?: (hello: { features?: { methods?: string[] } }) => void; - onClose?: (code: number, reason: string) => void; -}> = []; const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); +const testConfigStore = new Map(); type InstallGatewayDaemonResult = Awaited>; const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => vi.fn(async (): Promise => ({ installed: true })), @@ -60,25 +53,20 @@ function resolveTestConfigPath() { return path.join(stateDir, "openclaw.json"); } +function readTestConfig(): T { + return (testConfigStore.get(resolveTestConfigPath()) ?? {}) as T; +} + vi.mock("../config/io.js", () => ({ createConfigIO: () => ({ configPath: resolveTestConfigPath(), }), - loadConfig: () => { - try { - return JSON.parse(nodeFs.readFileSync(resolveTestConfigPath(), "utf-8")); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return {}; - } - throw err; - } - }, + loadConfig: () => testConfigStore.get(resolveTestConfigPath()) ?? {}, readConfigFileSnapshot: async () => { const configPath = resolveTestConfigPath(); - try { - const raw = await fs.readFile(configPath, "utf-8"); - const config = JSON.parse(raw); + const config = testConfigStore.get(configPath); + if (config) { + const raw = `${JSON.stringify(config, null, 2)}\n`; return { exists: true, valid: true, @@ -87,58 +75,25 @@ vi.mock("../config/io.js", () => ({ raw, hash: "test-config-hash", }; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err; - } - return { - exists: false, - valid: true, - config: {}, - sourceConfig: {}, - raw: null, - hash: undefined, - }; } + return { + exists: false, + valid: true, + config: {}, + sourceConfig: {}, + raw: null, + hash: undefined, + }; }, })); vi.mock("../config/config.js", () => ({ replaceConfigFile: async ({ nextConfig }: { nextConfig: OpenClawConfig }) => { - const configPath = resolveTestConfigPath(); - await fs.mkdir(path.dirname(configPath), { recursive: true }); - await fs.writeFile(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf-8"); + testConfigStore.set(resolveTestConfigPath(), nextConfig); }, resolveGatewayPort: (cfg: OpenClawConfig) => cfg.gateway?.port ?? 18789, })); -vi.mock("../gateway/client.js", () => ({ - GatewayClient: class { - params: { - url?: string; - token?: string; - password?: string; - onHelloOk?: (hello: { features?: { methods?: string[] } }) => void; - }; - constructor(params: { - url?: string; - token?: string; - password?: string; - onHelloOk?: (hello: { features?: { methods?: string[] } }) => void; - }) { - this.params = params; - gatewayClientCalls.push(params); - } - async request() { - return { ok: true }; - } - start() { - queueMicrotask(() => this.params.onHelloOk?.({ features: { methods: ["health"] } })); - } - stop() {} - }, -})); - vi.mock("./onboard-helpers.js", () => { const normalizeGatewayTokenInput = (value: unknown): string => { if (typeof value !== "string") { @@ -184,18 +139,13 @@ vi.mock("../daemon/diagnostics.js", () => ({ })); let runNonInteractiveSetup: typeof import("./onboard-non-interactive.js").runNonInteractiveSetup; -let resolveStateConfigPath: typeof import("../config/paths.js").resolveConfigPath; -let callGateway: typeof import("../gateway/call.js").callGateway | undefined; +let resolveInstallDaemonGatewayHealthTiming: typeof import("./onboard-non-interactive/local.js").resolveInstallDaemonGatewayHealthTiming; async function loadGatewayOnboardModules(): Promise { vi.resetModules(); ({ runNonInteractiveSetup } = await import("./onboard-non-interactive.js")); - ({ resolveConfigPath: resolveStateConfigPath } = await import("../config/paths.js")); -} - -async function loadCallGateway(): Promise { - callGateway ??= (await import("../gateway/call.js")).callGateway; - return callGateway; + ({ resolveInstallDaemonGatewayHealthTiming } = + await import("./onboard-non-interactive/local.js")); } function getPseudoPort(base: number): number { @@ -273,15 +223,6 @@ async function runLocalDaemonSetup(stateDir: string, runtimeEnv: RuntimeEnv = ru await runNonInteractiveSetup(createLocalDaemonSetupOptions(stateDir), runtimeEnv); } -async function withMockedPlatform(platform: NodeJS.Platform, run: () => Promise): Promise { - const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform); - try { - return await run(); - } finally { - platformSpy.mockRestore(); - } -} - function mockGatewayReachableWithCapturedTimeouts() { let capturedDeadlineMs: number | undefined; let capturedProbeTimeoutMs: number | undefined; @@ -359,10 +300,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => { await loadGatewayOnboardModules(); }); - beforeEach(() => { - gatewayClientCalls.length = 0; - }); - afterAll(async () => { if (tempHome) { await fs.rm(tempHome, { recursive: true, force: true }); @@ -372,12 +309,12 @@ describe("onboard (non-interactive): gateway and remote auth", () => { afterEach(() => { waitForGatewayReachableMock = undefined; + testConfigStore.clear(); installGatewayDaemonNonInteractiveMock.mockClear(); healthCommandMock.mockClear(); gatewayServiceMock.isLoaded.mockClear(); gatewayServiceMock.readRuntime.mockClear(); readLastGatewayErrorLineMock.mockClear(); - gatewayClientCalls.length = 0; }); it("writes gateway token auth into config", async () => { @@ -401,12 +338,11 @@ describe("onboard (non-interactive): gateway and remote auth", () => { runtime, ); - const configPath = resolveStateConfigPath(process.env, stateDir); - const cfg = await readJsonFile<{ + const cfg = readTestConfig<{ gateway?: { mode?: string; auth?: { mode?: string; token?: string } }; agents?: { defaults?: { workspace?: string } }; tools?: { profile?: string }; - }>(configPath); + }>(); expect(cfg?.agents?.defaults?.workspace).toBe(workspace); expect(cfg?.gateway?.mode).toBe("local"); @@ -416,37 +352,8 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); - it("keeps gateway.mode=local on the install-daemon onboarding path", async () => { - await withStateDir("state-install-daemon-local-mode-", async (stateDir) => { - const workspace = path.join(stateDir, "openclaw"); - - await runNonInteractiveSetup( - { - nonInteractive: true, - mode: "local", - workspace, - authChoice: "skip", - skipSkills: true, - skipHealth: true, - installDaemon: true, - gatewayBind: "loopback", - }, - runtime, - ); - - const configPath = resolveStateConfigPath(process.env, stateDir); - const cfg = await readJsonFile<{ - gateway?: { mode?: string; bind?: string }; - }>(configPath); - - expect(cfg?.gateway?.mode).toBe("local"); - expect(cfg?.gateway?.bind).toBe("loopback"); - expect(installGatewayDaemonNonInteractiveMock).toHaveBeenCalledTimes(1); - }); - }, 60_000); - - it("writes gateway.remote url/token and callGateway uses them", async () => { - await withStateDir("state-remote-", async (stateDir) => { + it("writes gateway.remote url/token", async () => { + await withStateDir("state-remote-", async (_stateDir) => { const port = getPseudoPort(30_000); const token = "tok_remote_123"; await runNonInteractiveSetup( @@ -461,20 +368,13 @@ describe("onboard (non-interactive): gateway and remote auth", () => { runtime, ); - const cfg = await readJsonFile<{ + const cfg = readTestConfig<{ gateway?: { mode?: string; remote?: { url?: string; token?: string } }; - }>(resolveStateConfigPath(process.env, stateDir)); + }>(); expect(cfg.gateway?.mode).toBe("remote"); expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`); expect(cfg.gateway?.remote?.token).toBe(token); - - gatewayClientCalls.length = 0; - const health = await (await loadCallGateway())({ method: "health" }); - expect(health?.ok).toBe(true); - const lastCall = gatewayClientCalls[gatewayClientCalls.length - 1]; - expect(lastCall?.url).toBe(`ws://127.0.0.1:${port}`); - expect(lastCall?.token).toBe(token); }); }, 60_000); @@ -511,33 +411,25 @@ describe("onboard (non-interactive): gateway and remote auth", () => { await runLocalDaemonSetup(stateDir); + const cfg = readTestConfig<{ + gateway?: { mode?: string; bind?: string }; + }>(); + + expect(cfg?.gateway?.mode).toBe("local"); + expect(cfg?.gateway?.bind).toBe("loopback"); expect(installGatewayDaemonNonInteractiveMock).toHaveBeenCalledTimes(1); expect(captured.deadlineMs).toBe(45_000); expect(captured.probeTimeoutMs).toBe(10_000); }); }, 60_000); - it("uses longer Windows health timeouts when daemon install was requested", async () => { - await withStateDir("state-local-daemon-health-win-", async (stateDir) => { - const captured = mockGatewayReachableWithCapturedTimeouts(); - - await withMockedPlatform("win32", async () => { - await runLocalDaemonSetup(stateDir); - }); - - expect(installGatewayDaemonNonInteractiveMock).toHaveBeenCalledTimes(1); - expect(captured.deadlineMs).toBe(90_000); - expect(captured.probeTimeoutMs).toBe(15_000); - expect(healthCommandMock).toHaveBeenCalledTimes(1); - expect(healthCommandMock).toHaveBeenCalledWith( - expect.objectContaining({ - json: false, - timeoutMs: 90_000, - }), - runtime, - ); + it("uses longer Windows health timings for daemon install probes", () => { + expect(resolveInstallDaemonGatewayHealthTiming("win32")).toEqual({ + deadlineMs: 90_000, + probeTimeoutMs: 15_000, + healthCommandTimeoutMs: 90_000, }); - }, 60_000); + }); it("emits a daemon-install failure when Linux user systemd is unavailable", async () => { await withStateDir("state-local-daemon-install-json-fail-", async (stateDir) => { @@ -654,14 +546,13 @@ describe("onboard (non-interactive): gateway and remote auth", () => { runtime, ); - const configPath = resolveStateConfigPath(process.env, stateDir); - const cfg = await readJsonFile<{ + const cfg = readTestConfig<{ gateway?: { bind?: string; port?: number; auth?: { mode?: string; token?: string }; }; - }>(configPath); + }>(); expect(cfg.gateway?.bind).toBe("lan"); expect(cfg.gateway?.port).toBe(port); diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts deleted file mode 100644 index 79dd9b505ab..00000000000 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ /dev/null @@ -1,1102 +0,0 @@ -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { setTimeout as delay } from "node:timers/promises"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { makeTempWorkspace } from "../test-helpers/workspace.js"; -import { withEnvAsync } from "../test-utils/env.js"; -import { - createThrowingRuntime, - type NonInteractiveRuntime, -} from "./onboard-non-interactive.test-helpers.js"; - -type OnboardEnv = { - configPath: string; - runtime: NonInteractiveRuntime; -}; -type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; - -const MINIMAX_API_BASE_URL = "https://api.minimax.chat/v1"; -const MINIMAX_CN_API_BASE_URL = "https://api.minimax.chat/v1"; -const OPENAI_DEFAULT_MODEL = "openai/gpt-5.4"; -const ZAI_CODING_GLOBAL_BASE_URL = "https://api.z.ai/api/coding/paas/v4"; -const ZAI_CODING_CN_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; -const ZAI_GLOBAL_BASE_URL = "https://api.z.ai/api/paas/v4"; -const TEST_AUTH_STORE_VERSION = 1; -const TEST_MAIN_AUTH_STORE_KEY = "__main__"; - -const ensureWorkspaceAndSessionsMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => {})); -const testConfigFile = vi.hoisted(() => ({ raw: null as string | null })); -const readConfigFileSnapshotMock = vi.hoisted(() => - vi.fn(async () => { - const configPath = process.env.OPENCLAW_CONFIG_PATH; - if (!configPath) { - throw new Error("OPENCLAW_CONFIG_PATH must be set for provider auth onboarding tests"); - } - const raw = testConfigFile.raw; - const parsed = raw ? (JSON.parse(raw) as Record) : {}; - const hash = raw === null ? undefined : crypto.createHash("sha256").update(raw).digest("hex"); - return { - path: path.resolve(configPath), - exists: raw !== null, - valid: true, - raw, - hash, - config: structuredClone(parsed), - sourceConfig: structuredClone(parsed), - runtimeConfig: structuredClone(parsed), - }; - }), -); -const replaceConfigFileMock = vi.hoisted(() => - vi.fn(async (params: { nextConfig: unknown }) => { - const configPath = process.env.OPENCLAW_CONFIG_PATH; - if (!configPath) { - throw new Error("OPENCLAW_CONFIG_PATH must be set for provider auth onboarding tests"); - } - testConfigFile.raw = `${JSON.stringify(params.nextConfig, null, 2)}\n`; - return { - path: configPath, - previousHash: null, - snapshot: {}, - nextConfig: params.nextConfig, - }; - }), -); -const testAuthProfileStores = vi.hoisted( - () => new Map> }>(), -); -const upsertAuthProfileWithLockMock = vi.hoisted(() => - vi.fn( - async (params: { - profileId: string; - credential: Record; - agentDir?: string; - }) => { - upsertAuthProfile(params); - }, - ), -); - -function normalizeStoredSecret(value: unknown): string { - return typeof value === "string" ? value.replaceAll("\r", "").replaceAll("\n", "").trim() : ""; -} - -function cloneTestAuthStore(store: { - version: number; - profiles: Record>; -}) { - return structuredClone(store); -} - -function writeRuntimeAuthSnapshots() { - if (!replaceRuntimeAuthProfileStoreSnapshots) { - return; - } - replaceRuntimeAuthProfileStoreSnapshots( - Array.from(testAuthProfileStores.entries()).map(([key, store]) => - key === TEST_MAIN_AUTH_STORE_KEY - ? { store: cloneTestAuthStore(store) as never } - : { agentDir: key, store: cloneTestAuthStore(store) as never }, - ), - ); -} - -function getOrCreateTestAuthStore(agentDir?: string) { - const key = agentDir?.trim() || TEST_MAIN_AUTH_STORE_KEY; - let store = testAuthProfileStores.get(key); - if (!store) { - store = { version: TEST_AUTH_STORE_VERSION, profiles: {} }; - testAuthProfileStores.set(key, store); - } - return store; -} - -function upsertAuthProfile(params: { - profileId: string; - credential: Record; - agentDir?: string; -}) { - const credential = - params.credential.type === "api_key" && typeof params.credential.key === "string" - ? { - ...params.credential, - key: normalizeStoredSecret(params.credential.key), - } - : params.credential.type === "token" && typeof params.credential.token === "string" - ? { - ...params.credential, - token: normalizeStoredSecret(params.credential.token), - } - : params.credential; - for (const targetAgentDir of new Set([undefined, params.agentDir])) { - const store = getOrCreateTestAuthStore(targetAgentDir); - store.profiles[params.profileId] = credential; - } - writeRuntimeAuthSnapshots(); -} - -vi.mock("../config/io.js", () => ({ - createConfigIO: () => ({ configPath: process.env.OPENCLAW_CONFIG_PATH ?? "openclaw.json" }), - readConfigFileSnapshot: readConfigFileSnapshotMock, -})); - -vi.mock("../config/config.js", () => ({ - readConfigFileSnapshot: readConfigFileSnapshotMock, - replaceConfigFile: replaceConfigFileMock, - resolveGatewayPort: (cfg?: { gateway?: { port?: unknown } }) => - typeof cfg?.gateway?.port === "number" ? cfg.gateway.port : 18789, -})); - -vi.mock("../agents/auth-profiles/upsert-with-lock.js", () => ({ - upsertAuthProfileWithLock: upsertAuthProfileWithLockMock, -})); - -vi.mock("./onboard-non-interactive/local/auth-choice.plugin-providers.js", async () => { - function resolveDefaultAgentId(config: { - agents?: { list?: Array<{ id?: string; default?: boolean }> }; - }): string { - return config.agents?.list?.find((agent) => agent.default)?.id?.trim() || "main"; - } - - function resolveAgentDir(_config: unknown, agentId: string): string { - return path.join(process.env.OPENCLAW_STATE_DIR || "/tmp/openclaw-test", "agents", agentId); - } - - function resolveAgentWorkspaceDir(): string | undefined { - return undefined; - } - - function resolveDefaultAgentWorkspaceDir(): string { - return "/tmp/openclaw-workspace"; - } - - function enablePluginInConfig(config: Record): { - enabled: true; - config: Record; - } { - return { enabled: true, config }; - } - - async function detectZaiEndpoint(params: { - apiKey: string; - endpoint?: "coding-global" | "coding-cn"; - }): Promise<{ baseUrl: string; modelId: string } | null> { - const baseUrl = - params.endpoint === "coding-global" - ? ZAI_CODING_GLOBAL_BASE_URL - : params.endpoint === "coding-cn" - ? ZAI_CODING_CN_BASE_URL - : ZAI_GLOBAL_BASE_URL; - const modelIds = params.endpoint === "coding-cn" ? ["glm-5.1", "glm-4.7"] : ["glm-5.1"]; - for (const modelId of modelIds) { - const response = await fetch(`${baseUrl}/chat/completions`, { - method: "POST", - headers: { authorization: `Bearer ${params.apiKey}` }, - body: JSON.stringify({ model: modelId }), - }); - if (response.status === 200) { - return { baseUrl, modelId }; - } - } - return null; - } - - const ZAI_FALLBACKS = { - "zai-api-key": { - baseUrl: ZAI_GLOBAL_BASE_URL, - modelId: "glm-5.1", - }, - "zai-coding-cn": { - baseUrl: ZAI_CODING_CN_BASE_URL, - modelId: "glm-4.7", - }, - "zai-coding-global": { - baseUrl: ZAI_CODING_GLOBAL_BASE_URL, - modelId: "glm-5.1", - }, - } as const; - - type HandlerContext = { - authChoice: string; - config: Record; - baseConfig: Record; - opts: Record; - runtime: { - error: (message: string) => void; - exit: (code: number) => void; - log: (s: string) => void; - }; - agentDir?: string; - workspaceDir?: string; - resolveApiKey: (input: { - provider: string; - flagValue?: string; - flagName: `--${string}`; - envVar: string; - envVarName?: string; - allowProfile?: boolean; - required?: boolean; - }) => Promise<{ - key: string; - source: "profile" | "env" | "flag"; - envVarName?: string; - } | null>; - toApiKeyCredential: (input: { - provider: string; - resolved: { - key: string; - source: "profile" | "env" | "flag"; - envVarName?: string; - }; - email?: string; - metadata?: Record; - }) => Record | null; - }; - - type ChoiceHandler = { - providerId: string; - label: string; - pluginId?: string; - runNonInteractive: (ctx: HandlerContext) => Promise; - }; - - function normalizeText(value: unknown): string { - return typeof value === "string" ? value.replaceAll("\r", "").replaceAll("\n", "").trim() : ""; - } - - function withProviderConfig( - cfg: Record, - providerId: string, - patch: Record, - ): Record { - const models = - cfg.models && typeof cfg.models === "object" ? (cfg.models as Record) : {}; - const providers = - models.providers && typeof models.providers === "object" - ? (models.providers as Record) - : {}; - const existing = - providers[providerId] && typeof providers[providerId] === "object" - ? (providers[providerId] as Record) - : {}; - return { - ...cfg, - models: { - ...models, - providers: { - ...providers, - [providerId]: { - ...existing, - ...patch, - }, - }, - }, - }; - } - - function buildTestProviderModel( - id: string, - params?: { - reasoning?: boolean; - input?: Array<"text" | "image">; - contextWindow?: number; - maxTokens?: number; - }, - ): Record { - return { - id, - name: id, - reasoning: params?.reasoning ?? false, - input: params?.input ?? ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: params?.contextWindow ?? 131072, - maxTokens: params?.maxTokens ?? 16384, - }; - } - - function applyAuthProfileConfig( - cfg: Record, - params: { - profileId: string; - provider: string; - mode: "api_key" | "oauth" | "token"; - email?: string; - displayName?: string; - }, - ): Record { - const auth = - cfg.auth && typeof cfg.auth === "object" ? (cfg.auth as Record) : {}; - const profiles = - auth.profiles && typeof auth.profiles === "object" - ? (auth.profiles as Record) - : {}; - return { - ...cfg, - auth: { - ...auth, - profiles: { - ...profiles, - [params.profileId]: { - provider: params.provider, - mode: params.mode, - ...(params.email ? { email: params.email } : {}), - ...(params.displayName ? { displayName: params.displayName } : {}), - }, - }, - }, - }; - } - - function applyPrimaryModel(cfg: Record, model: string): Record { - const agents = - cfg.agents && typeof cfg.agents === "object" ? (cfg.agents as Record) : {}; - const defaults = - agents.defaults && typeof agents.defaults === "object" - ? (agents.defaults as Record) - : {}; - const models = - defaults.models && typeof defaults.models === "object" - ? (defaults.models as Record) - : {}; - return { - ...cfg, - agents: { - ...agents, - defaults: { - ...defaults, - model: { - primary: model, - }, - models: { - ...models, - [model]: models[model] ?? {}, - }, - }, - }, - }; - } - - function createApiKeyChoice(params: { - providerId: string; - label: string; - optionKey: string; - flagName: `--${string}`; - envVar: string; - choiceId: string; - pluginId?: string; - defaultModel?: string; - profileId?: string; - profileIds?: string[]; - applyConfig?: (cfg: Record) => Record; - }): ChoiceHandler { - const profileIds = - params.profileIds?.map((value) => value.trim()).filter(Boolean) ?? - (params.profileId ? [params.profileId] : [`${params.providerId}:default`]); - return { - providerId: params.providerId, - label: params.label, - ...(params.pluginId ? { pluginId: params.pluginId } : {}), - runNonInteractive: async (ctx) => { - const resolved = await ctx.resolveApiKey({ - provider: params.providerId, - flagValue: normalizeText(ctx.opts[params.optionKey]), - flagName: params.flagName, - envVar: params.envVar, - }); - if (!resolved) { - return null; - } - if (resolved.source !== "profile") { - for (const profileId of profileIds) { - const credential = ctx.toApiKeyCredential({ - provider: profileId.split(":", 1)[0]?.trim() || params.providerId, - resolved, - }); - if (!credential) { - return null; - } - upsertAuthProfile({ - profileId, - credential, - agentDir: ctx.agentDir, - }); - } - } - let next = ctx.config; - for (const profileId of profileIds) { - next = applyAuthProfileConfig(next, { - profileId, - provider: profileId.split(":", 1)[0]?.trim() || params.providerId, - mode: "api_key", - }); - } - if (params.applyConfig) { - next = params.applyConfig(next); - } - return params.defaultModel ? applyPrimaryModel(next, params.defaultModel) : next; - }, - }; - } - - function createZaiChoice( - choiceId: "zai-api-key" | "zai-coding-cn" | "zai-coding-global", - ): ChoiceHandler { - return { - providerId: "zai", - label: "Z.AI", - runNonInteractive: async (ctx) => { - const resolved = await ctx.resolveApiKey({ - provider: "zai", - flagValue: normalizeText(ctx.opts.zaiApiKey), - flagName: "--zai-api-key", - envVar: "ZAI_API_KEY", - }); - if (!resolved) { - return null; - } - if (resolved.source !== "profile") { - const credential = ctx.toApiKeyCredential({ - provider: "zai", - resolved, - }); - if (!credential) { - return null; - } - upsertAuthProfile({ - profileId: "zai:default", - credential: credential as never, - agentDir: ctx.agentDir, - }); - } - const detected = await detectZaiEndpoint({ - apiKey: resolved.key, - ...(choiceId === "zai-coding-global" - ? { endpoint: "coding-global" as const } - : choiceId === "zai-coding-cn" - ? { endpoint: "coding-cn" as const } - : {}), - }); - const fallback = ZAI_FALLBACKS[choiceId]; - let next = applyAuthProfileConfig(ctx.config as never, { - profileId: "zai:default", - provider: "zai", - mode: "api_key", - }); - next = withProviderConfig(next, "zai", { - baseUrl: detected?.baseUrl ?? fallback.baseUrl, - api: "openai-completions", - models: [ - buildTestProviderModel(detected?.modelId ?? fallback.modelId, { - input: ["text"], - }), - ], - }); - return applyPrimaryModel(next as never, `zai/${detected?.modelId ?? fallback.modelId}`); - }, - }; - } - - const choiceMap = new Map([ - [ - "setup-token", - { - providerId: "anthropic", - label: "Anthropic setup-token", - async runNonInteractive(ctx) { - const token = normalizeText(ctx.opts.token); - if (!token) { - ctx.runtime.error("Anthropic setup-token auth requires --token."); - ctx.runtime.exit(1); - return null; - } - upsertAuthProfile({ - profileId: (ctx.opts.tokenProfileId as string | undefined) ?? "anthropic:default", - credential: { - type: "token", - provider: "anthropic", - token, - } as never, - agentDir: ctx.agentDir, - }); - const withProfile = applyAuthProfileConfig(ctx.config as never, { - profileId: (ctx.opts.tokenProfileId as string | undefined) ?? "anthropic:default", - provider: "anthropic", - mode: "token", - }); - return applyPrimaryModel(withProfile, "anthropic/claude-sonnet-4-6"); - }, - }, - ], - [ - "apiKey", - createApiKeyChoice({ - providerId: "anthropic", - label: "Anthropic", - choiceId: "apiKey", - optionKey: "anthropicApiKey", - flagName: "--anthropic-api-key", - envVar: "ANTHROPIC_API_KEY", - }), - ], - [ - "minimax-global-api", - createApiKeyChoice({ - providerId: "minimax", - label: "MiniMax", - choiceId: "minimax-global-api", - optionKey: "minimaxApiKey", - flagName: "--minimax-api-key", - envVar: "MINIMAX_API_KEY", - profileId: "minimax:global", - defaultModel: "minimax/MiniMax-M2.7", - applyConfig: (cfg) => - withProviderConfig(cfg, "minimax", { - baseUrl: MINIMAX_API_BASE_URL, - api: "anthropic-messages", - models: [buildTestProviderModel("MiniMax-M2.7")], - }), - }), - ], - [ - "minimax-cn-api", - createApiKeyChoice({ - providerId: "minimax", - label: "MiniMax", - choiceId: "minimax-cn-api", - optionKey: "minimaxApiKey", - flagName: "--minimax-api-key", - envVar: "MINIMAX_API_KEY", - profileId: "minimax:cn", - defaultModel: "minimax/MiniMax-M2.7", - applyConfig: (cfg) => - withProviderConfig(cfg, "minimax", { - baseUrl: MINIMAX_CN_API_BASE_URL, - api: "anthropic-messages", - models: [buildTestProviderModel("MiniMax-M2.7")], - }), - }), - ], - ["zai-api-key", createZaiChoice("zai-api-key")], - ["zai-coding-cn", createZaiChoice("zai-coding-cn")], - ["zai-coding-global", createZaiChoice("zai-coding-global")], - [ - "xai-api-key", - createApiKeyChoice({ - providerId: "xai", - label: "xAI", - choiceId: "xai-api-key", - optionKey: "xaiApiKey", - flagName: "--xai-api-key", - envVar: "XAI_API_KEY", - defaultModel: "xai/grok-4", - }), - ], - [ - "openai-api-key", - createApiKeyChoice({ - providerId: "openai", - label: "OpenAI", - choiceId: "openai-api-key", - optionKey: "openaiApiKey", - flagName: "--openai-api-key", - envVar: "OPENAI_API_KEY", - defaultModel: OPENAI_DEFAULT_MODEL, - }), - ], - [ - "opencode-zen", - createApiKeyChoice({ - providerId: "opencode", - label: "OpenCode", - choiceId: "opencode-zen", - optionKey: "opencodeApiKey", - flagName: "--opencode-api-key", - envVar: "OPENCODE_ZEN_API_KEY", - profileIds: ["opencode:default", "opencode-go:default"], - defaultModel: "opencode/claude-opus-4-6", - }), - ], - [ - "qwen-api-key", - createApiKeyChoice({ - providerId: "qwen", - label: "Qwen Cloud", - choiceId: "qwen-api-key", - optionKey: "modelstudioApiKey", - flagName: "--modelstudio-api-key", - envVar: "QWEN_API_KEY", - defaultModel: "qwen/qwen3.5-plus", - applyConfig: (cfg) => - withProviderConfig(cfg, "qwen", { - baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", - api: "openai-completions", - models: [buildTestProviderModel("qwen3.5-plus")], - }), - }), - ], - ]); - - return { - applyNonInteractivePluginProviderChoice: async (params: { - nextConfig: Record; - authChoice: string; - opts: Record; - runtime: HandlerContext["runtime"]; - baseConfig: Record; - resolveApiKey: HandlerContext["resolveApiKey"]; - toApiKeyCredential: HandlerContext["toApiKeyCredential"]; - }) => { - const handler = choiceMap.get(params.authChoice); - if (!handler) { - return undefined; - } - - const enableResult = enablePluginInConfig( - params.nextConfig as never, - handler.pluginId ?? handler.providerId, - ); - if (!enableResult.enabled) { - params.runtime.error( - `${handler.label} plugin is disabled (${enableResult.reason ?? "blocked"}).`, - ); - params.runtime.exit(1); - return null; - } - - const agentId = resolveDefaultAgentId(enableResult.config); - const agentDir = resolveAgentDir(enableResult.config, agentId); - const workspaceDir = - resolveAgentWorkspaceDir(enableResult.config, agentId) ?? resolveDefaultAgentWorkspaceDir(); - - return await handler.runNonInteractive({ - authChoice: params.authChoice, - config: enableResult.config, - baseConfig: params.baseConfig, - opts: params.opts, - runtime: params.runtime, - agentDir, - workspaceDir, - resolveApiKey: params.resolveApiKey, - toApiKeyCredential: params.toApiKeyCredential, - }); - }, - }; -}); - -vi.mock("./onboard-helpers.js", () => { - const normalizeGatewayTokenInput = (value: unknown): string => { - if (typeof value !== "string") { - return ""; - } - const trimmed = value.trim(); - return trimmed === "undefined" || trimmed === "null" ? "" : trimmed; - }; - return { - DEFAULT_WORKSPACE: "/tmp/openclaw-workspace", - applyWizardMetadata: (cfg: unknown) => cfg, - ensureWorkspaceAndSessions: ensureWorkspaceAndSessionsMock, - normalizeGatewayTokenInput, - randomToken: () => "tok_generated_provider_auth_test_token", - resolveControlUiLinks: ({ port }: { port: number }) => ({ - httpUrl: `http://127.0.0.1:${port}`, - wsUrl: `ws://127.0.0.1:${port}`, - }), - waitForGatewayReachable: async () => ({ ok: true }), - }; -}); - -const NON_INTERACTIVE_DEFAULT_OPTIONS = { - nonInteractive: true, - skipHealth: true, - skipChannels: true, - json: true, -} as const; - -let runNonInteractiveSetup: typeof import("./onboard-non-interactive.js").runNonInteractiveSetup; -let clearRuntimeAuthProfileStoreSnapshots: typeof import("../agents/auth-profiles.js").clearRuntimeAuthProfileStoreSnapshots; -let replaceRuntimeAuthProfileStoreSnapshots: typeof import("../agents/auth-profiles.js").replaceRuntimeAuthProfileStoreSnapshots; -let resetFileLockStateForTest: typeof import("../infra/file-lock.js").resetFileLockStateForTest; -let clearPluginDiscoveryCache: typeof import("../plugins/discovery.js").clearPluginDiscoveryCache; -let clearPluginManifestRegistryCache: typeof import("../plugins/manifest-registry.js").clearPluginManifestRegistryCache; - -type ProviderAuthConfigSnapshot = { - auth?: { profiles?: Record }; - agents?: { defaults?: { model?: { primary?: string } } }; - models?: { - providers?: Record< - string, - { - baseUrl?: string; - api?: string; - apiKey?: string | { source?: string; id?: string }; - models?: Array<{ id?: string }>; - } - >; - }; -}; - -function createZaiFetchMock(responses: Record): FetchLike { - return vi.fn(async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : ""; - const parsedBody = - typeof init?.body === "string" ? (JSON.parse(init.body) as { model?: string }) : {}; - const key = `${url}::${parsedBody.model ?? ""}`; - const status = responses[key] ?? 404; - return new Response( - JSON.stringify( - status === 200 ? { ok: true } : { error: { code: "unsupported", message: "unsupported" } }, - ), - { - status, - headers: { "content-type": "application/json" }, - }, - ); - }); -} - -async function withZaiProbeFetch( - responses: Record, - run: (fetchMock: FetchLike) => Promise, -): Promise { - const originalVitest = process.env.VITEST; - delete process.env.VITEST; - const fetchMock = createZaiFetchMock(responses); - vi.stubGlobal("fetch", fetchMock); - try { - return await run(fetchMock); - } finally { - vi.unstubAllGlobals(); - if (originalVitest === undefined) { - delete process.env.VITEST; - } else { - process.env.VITEST = originalVitest; - } - } -} - -function expectZaiProbeCalls( - fetchMock: FetchLike, - expected: Array<{ url: string; modelId: string }>, -): void { - const calls = ( - fetchMock as unknown as { - mock: { calls: Array<[RequestInfo | URL, RequestInit?]> }; - } - ).mock.calls; - - expect(calls).toHaveLength(expected.length); - for (const [index, probe] of expected.entries()) { - const [input, init] = calls[index] ?? []; - const requestUrl = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input && typeof input === "object" && "url" in input && typeof input.url === "string" - ? input.url - : undefined; - expect(requestUrl).toBe(probe.url); - expect(init?.method).toBe("POST"); - const body = - typeof init?.body === "string" ? (JSON.parse(init.body) as { model?: string }) : {}; - expect(body.model).toBe(probe.modelId); - } -} - -async function removeDirWithRetry(dir: string): Promise { - for (let attempt = 0; attempt < 5; attempt += 1) { - try { - await fs.rm(dir, { recursive: true, force: true }); - return; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - const isTransient = code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM"; - if (!isTransient || attempt === 4) { - throw error; - } - await delay(10 * (attempt + 1)); - } - } -} - -async function withOnboardEnv( - prefix: string, - run: (ctx: OnboardEnv) => Promise, -): Promise { - const tempHome = await makeTempWorkspace(prefix); - const configPath = path.join(tempHome, "openclaw.json"); - const runtime = createThrowingRuntime(); - - try { - clearTestConfigFile(); - await withEnvAsync( - { - HOME: tempHome, - OPENCLAW_STATE_DIR: tempHome, - OPENCLAW_CONFIG_PATH: configPath, - OPENCLAW_SKIP_CHANNELS: "1", - OPENCLAW_SKIP_GMAIL_WATCHER: "1", - OPENCLAW_SKIP_CRON: "1", - OPENCLAW_SKIP_CANVAS_HOST: "1", - OPENCLAW_GATEWAY_TOKEN: undefined, - OPENCLAW_GATEWAY_PASSWORD: undefined, - CUSTOM_API_KEY: undefined, - OPENCLAW_DISABLE_CONFIG_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", - }, - async () => { - await run({ configPath, runtime }); - }, - ); - } finally { - await removeDirWithRetry(tempHome); - } -} - -function clearTestConfigFile(): void { - testConfigFile.raw = null; -} - -function readTestConfig(): T { - return (testConfigFile.raw ? JSON.parse(testConfigFile.raw) : {}) as T; -} - -async function runNonInteractiveSetupWithDefaults( - runtime: NonInteractiveRuntime, - options: Record, -): Promise { - await runNonInteractiveSetup( - { - ...NON_INTERACTIVE_DEFAULT_OPTIONS, - ...options, - }, - runtime, - ); -} - -async function runOnboardingAndReadConfig( - env: OnboardEnv, - options: Record, -): Promise { - await runNonInteractiveSetupWithDefaults(env.runtime, { - skipSkills: true, - ...options, - }); - return readTestConfig(); -} - -async function expectApiKeyProfile(params: { - profileId: string; - provider: string; - key: string; - metadata?: Record; -}): Promise { - const store = getOrCreateTestAuthStore(); - const profile = store.profiles[params.profileId]; - expect(profile?.type).toBe("api_key"); - if (profile?.type === "api_key") { - expect(profile.provider).toBe(params.provider); - expect(profile.key).toBe(params.key); - if (params.metadata) { - expect(profile.metadata).toEqual(params.metadata); - } - } -} - -async function loadProviderAuthOnboardModules(): Promise { - ({ runNonInteractiveSetup } = await import("./onboard-non-interactive.js")); - ({ clearRuntimeAuthProfileStoreSnapshots, replaceRuntimeAuthProfileStoreSnapshots } = - await import("../agents/auth-profiles.js")); - ({ resetFileLockStateForTest } = await import("../infra/file-lock.js")); - ({ clearPluginDiscoveryCache } = await import("../plugins/discovery.js")); - ({ clearPluginManifestRegistryCache } = await import("../plugins/manifest-registry.js")); -} - -describe("onboard (non-interactive): provider auth", () => { - beforeAll(async () => { - await loadProviderAuthOnboardModules(); - }); - - function resetProviderAuthTestState() { - testAuthProfileStores.clear(); - clearRuntimeAuthProfileStoreSnapshots(); - resetFileLockStateForTest(); - clearPluginDiscoveryCache(); - clearPluginManifestRegistryCache(); - ensureWorkspaceAndSessionsMock.mockClear(); - } - - beforeEach(() => { - resetProviderAuthTestState(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - testAuthProfileStores.clear(); - clearRuntimeAuthProfileStoreSnapshots(); - resetFileLockStateForTest(); - clearPluginDiscoveryCache(); - clearPluginManifestRegistryCache(); - }); - - it("stores MiniMax API keys for the CN endpoint choice", async () => { - await withOnboardEnv("openclaw-onboard-minimax-", async (env) => { - const cfg = await runOnboardingAndReadConfig(env, { - authChoice: "minimax-cn-api", - minimaxApiKey: "sk-minimax-\r\ntest", // pragma: allowlist secret - }); - expect(cfg.auth?.profiles?.["minimax:cn"]?.provider).toBe("minimax"); - expect(cfg.auth?.profiles?.["minimax:cn"]?.mode).toBe("api_key"); - await expectApiKeyProfile({ - profileId: "minimax:cn", - provider: "minimax", - key: "sk-minimax-test", - }); - }); - }); - - it("stores Z.AI API keys across global and CN coding endpoint choices", async () => { - const scenarios = [ - { - authChoice: "zai-api-key", - responses: { [`${ZAI_GLOBAL_BASE_URL}/chat/completions::glm-5.1`]: 200 }, - expectedCalls: [{ url: `${ZAI_GLOBAL_BASE_URL}/chat/completions`, modelId: "glm-5.1" }], - }, - { - authChoice: "zai-coding-cn", - responses: { - [`${ZAI_CODING_CN_BASE_URL}/chat/completions::glm-5.1`]: 404, - [`${ZAI_CODING_CN_BASE_URL}/chat/completions::glm-4.7`]: 200, - }, - expectedCalls: [ - { url: `${ZAI_CODING_CN_BASE_URL}/chat/completions`, modelId: "glm-5.1" }, - { url: `${ZAI_CODING_CN_BASE_URL}/chat/completions`, modelId: "glm-4.7" }, - ], - }, - ] as const; - - await withOnboardEnv("openclaw-onboard-zai-", async (env) => { - for (const scenario of scenarios) { - clearTestConfigFile(); - resetProviderAuthTestState(); - await withZaiProbeFetch(scenario.responses, async (fetchMock) => { - const cfg = await runOnboardingAndReadConfig(env, { - authChoice: scenario.authChoice, - zaiApiKey: "zai-test-key", // pragma: allowlist secret - }); - - expect(cfg.auth?.profiles?.["zai:default"]?.provider).toBe("zai"); - expect(cfg.auth?.profiles?.["zai:default"]?.mode).toBe("api_key"); - expectZaiProbeCalls(fetchMock, scenario.expectedCalls); - await expectApiKeyProfile({ - profileId: "zai:default", - provider: "zai", - key: "zai-test-key", - }); - }); - } - }); - }); - - it("handles Qwen API key onboarding from inferred flags", async () => { - await withOnboardEnv("openclaw-onboard-provider-api-keys-", async (env) => { - const cfg = await runOnboardingAndReadConfig(env, { - modelstudioApiKey: "modelstudio-test-key", // pragma: allowlist secret - }); - - expect(cfg.auth?.profiles?.["qwen:default"]?.provider).toBe("qwen"); - expect(cfg.auth?.profiles?.["qwen:default"]?.mode).toBe("api_key"); - expect(cfg.agents?.defaults?.model?.primary).toBe("qwen/qwen3.5-plus"); - expect(cfg.models?.providers?.qwen?.baseUrl).toBe( - "https://coding-intl.dashscope.aliyuncs.com/v1", - ); - await expectApiKeyProfile({ - profileId: "qwen:default", - provider: "qwen", - key: "modelstudio-test-key", - }); - }); - }); - - it("stores legacy Anthropic setup-token onboarding again when explicitly selected", async () => { - await withOnboardEnv("openclaw-onboard-token-", async ({ runtime }) => { - const cleanToken = `sk-ant-oat01-${"a".repeat(80)}`; - const token = `${cleanToken.slice(0, 30)}\r${cleanToken.slice(30)}`; - - await runNonInteractiveSetupWithDefaults(runtime, { - authChoice: "setup-token", - token, - tokenProfileId: "anthropic:default", - }); - - const cfg = readTestConfig(); - expect(cfg.auth?.profiles?.["anthropic:default"]?.provider).toBe("anthropic"); - expect(cfg.auth?.profiles?.["anthropic:default"]?.mode).toBe("token"); - expect(cfg.agents?.defaults?.model?.primary).toBe("anthropic/claude-sonnet-4-6"); - expect(getOrCreateTestAuthStore().profiles["anthropic:default"]).toMatchObject({ - provider: "anthropic", - type: "token", - token: cleanToken, - }); - }); - }); - - it("stores the detected env alias as keyRef for both OpenCode runtime providers", async () => { - await withOnboardEnv("openclaw-onboard-ref-opencode-alias-", async ({ runtime }) => { - await withEnvAsync( - { - OPENCODE_API_KEY: undefined, - OPENCODE_ZEN_API_KEY: "opencode-zen-env-key", // pragma: allowlist secret - }, - async () => { - await runNonInteractiveSetupWithDefaults(runtime, { - authChoice: "opencode-zen", - secretInputMode: "ref", // pragma: allowlist secret - skipSkills: true, - }); - - const store = getOrCreateTestAuthStore(); - for (const profileId of ["opencode:default", "opencode-go:default"]) { - const profile = store.profiles[profileId]; - expect(profile?.type).toBe("api_key"); - if (profile?.type === "api_key") { - expect(profile.key).toBeUndefined(); - expect(profile.keyRef).toEqual({ - source: "env", - provider: "default", - id: "OPENCODE_ZEN_API_KEY", - }); - } - } - }, - ); - }); - }); - - it("configures custom providers from explicit non-interactive flags", async () => { - await withOnboardEnv("openclaw-onboard-custom-provider-", async ({ runtime }) => { - await runNonInteractiveSetupWithDefaults(runtime, { - authChoice: "custom-api-key", - customBaseUrl: "https://llm.example.com/v1", - customApiKey: "custom-test-key", // pragma: allowlist secret - customModelId: "foo-large", - customCompatibility: "anthropic", - skipSkills: true, - }); - const cfg = readTestConfig(); - const provider = cfg.models?.providers?.["custom-llm-example-com"]; - expect(provider?.baseUrl).toBe("https://llm.example.com/v1"); - expect(provider?.api).toBe("anthropic-messages"); - expect(provider?.apiKey).toBe("custom-test-key"); - expect(provider?.models?.some((model) => model.id === "foo-large")).toBe(true); - expect(cfg.agents?.defaults?.model?.primary).toBe("custom-llm-example-com/foo-large"); - }); - }); -}); diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 9d848a43aeb..fca9ed7ad97 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -14,7 +14,6 @@ import { waitForGatewayReachable, } from "../onboard-helpers.js"; import type { OnboardOptions } from "../onboard-types.js"; -import { inferAuthChoiceFromFlags } from "./local/auth-choice-inference.js"; import { applyNonInteractiveGatewayConfig } from "./local/gateway-config.js"; import { type GatewayHealthFailureDiagnostics, @@ -32,12 +31,14 @@ const WINDOWS_INSTALL_DAEMON_HEALTH_PROBE_TIMEOUT_MS = 15_000; const INSTALL_DAEMON_HEALTH_COMMAND_TIMEOUT_MS = 10_000; const WINDOWS_INSTALL_DAEMON_HEALTH_COMMAND_TIMEOUT_MS = 90_000; -function resolveInstallDaemonGatewayHealthTiming(): { +export function resolveInstallDaemonGatewayHealthTiming( + platform: NodeJS.Platform = process.platform, +): { deadlineMs: number; probeTimeoutMs: number; healthCommandTimeoutMs: number; } { - if (process.platform === "win32") { + if (platform === "win32") { return { deadlineMs: WINDOWS_INSTALL_DAEMON_HEALTH_DEADLINE_MS, probeTimeoutMs: WINDOWS_INSTALL_DAEMON_HEALTH_PROBE_TIMEOUT_MS, @@ -136,12 +137,14 @@ export async function runNonInteractiveLocalSetup(params: { let nextConfig: OpenClawConfig = applyLocalSetupWorkspaceConfig(baseConfig, workspaceDir); - const inferredAuthChoice = inferAuthChoiceFromFlags(opts, { - config: nextConfig, - workspaceDir, - env: process.env, - }); - if (!opts.authChoice && inferredAuthChoice.matches.length > 1) { + const inferredAuthChoice = opts.authChoice + ? undefined + : (await import("./local/auth-choice-inference.js")).inferAuthChoiceFromFlags(opts, { + config: nextConfig, + workspaceDir, + env: process.env, + }); + if (!opts.authChoice && inferredAuthChoice && inferredAuthChoice.matches.length > 1) { runtime.error( [ "Multiple API key flags were provided for non-interactive setup.", @@ -152,7 +155,7 @@ export async function runNonInteractiveLocalSetup(params: { runtime.exit(1); return; } - const authChoice = opts.authChoice ?? inferredAuthChoice.choice ?? "skip"; + const authChoice = opts.authChoice ?? inferredAuthChoice?.choice ?? "skip"; if (authChoice !== "skip") { const { applyNonInteractiveAuthChoice } = await import("./local/auth-choice.js"); const nextConfigAfterAuth = await applyNonInteractiveAuthChoice({ diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 2e8b43fdcad..3bbdbb0809b 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -16,7 +16,7 @@ import { CustomApiError, parseNonInteractiveCustomApiFlags, resolveCustomProviderId, -} from "../../onboard-custom.js"; +} from "../../onboard-custom-config.js"; import type { AuthChoice, OnboardOptions } from "../../onboard-types.js"; import { resolveNonInteractiveApiKey } from "../api-keys.js"; import { applyNonInteractivePluginProviderChoice } from "./auth-choice.plugin-providers.js"; diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 7f976c5b7dd..6c8012c4897 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -295,121 +295,68 @@ describe("setupSearch", () => { expect(result).toBe(cfg); }); - it("sets provider and key for perplexity", async () => { - const cfg: OpenClawConfig = {}; - const { prompter } = createPrompter({ - selectValue: "perplexity", - textValue: "pplx-test-key", - }); - const result = await setupSearch(cfg, runtime, prompter); - expect(result.tools?.web?.search?.provider).toBe("perplexity"); - expect(pluginWebSearchApiKey(result, "perplexity")).toBe("pplx-test-key"); - expect(result.tools?.web?.search?.enabled).toBe(true); - expect(result.plugins?.entries?.perplexity?.enabled).toBe(true); - }); + it("sets provider keys and enables plugin entries", async () => { + const cases = [ + { provider: "perplexity", pluginId: "perplexity", key: "pplx-test-key" }, + { provider: "brave", pluginId: "brave", key: "BSA-test-key" }, + { provider: "firecrawl", pluginId: "firecrawl", key: "fc-test-key" }, + { provider: "grok", pluginId: "xai", key: "xai-test" }, + { provider: "tavily", pluginId: "tavily", key: "tvly-test-key" }, + { + provider: "gemini", + pluginId: "google", + key: "AIza-test", + textMessage: "Google Gemini API key", + }, + ]; - it("sets provider and key for brave", async () => { - const cfg: OpenClawConfig = {}; - const { prompter } = createPrompter({ - selectValue: "brave", - textValue: "BSA-test-key", - }); - const result = await setupSearch(cfg, runtime, prompter); - expect(result.tools?.web?.search?.provider).toBe("brave"); - expect(result.tools?.web?.search?.enabled).toBe(true); - expect(pluginWebSearchApiKey(result, "brave")).toBe("BSA-test-key"); - expect(result.plugins?.entries?.brave?.enabled).toBe(true); - }); + for (const entry of cases) { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: entry.provider, + textValue: entry.key, + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe(entry.provider); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(pluginWebSearchApiKey(result, entry.pluginId)).toBe(entry.key); + expect(result.plugins?.entries?.[entry.pluginId]?.enabled).toBe(true); + if (entry.textMessage) { + expect(prompter.text).toHaveBeenCalledWith( + expect.objectContaining({ message: entry.textMessage }), + ); + } + } - it("sets provider and key for gemini", async () => { - const cfg: OpenClawConfig = {}; - const { prompter } = createPrompter({ - selectValue: "gemini", - textValue: "AIza-test", - }); - const result = await setupSearch(cfg, runtime, prompter); - expect(result.tools?.web?.search?.provider).toBe("gemini"); - expect(result.tools?.web?.search?.enabled).toBe(true); - expect(pluginWebSearchApiKey(result, "google")).toBe("AIza-test"); - expect(result.plugins?.entries?.google?.enabled).toBe(true); - expect(prompter.text).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Google Gemini API key", - }), - ); - }); - - it("sets provider and key for firecrawl and enables the plugin", async () => { - const cfg: OpenClawConfig = {}; - const { prompter } = createPrompter({ - selectValue: "firecrawl", - textValue: "fc-test-key", - }); - const result = await setupSearch(cfg, runtime, prompter); - expect(result.tools?.web?.search?.provider).toBe("firecrawl"); - expect(result.tools?.web?.search?.enabled).toBe(true); - expect(pluginWebSearchApiKey(result, "firecrawl")).toBe("fc-test-key"); - expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true); - }); - - it("re-enables firecrawl and persists its plugin config when selected from disabled state", async () => { - const cfg = createDisabledFirecrawlConfig(); - const { prompter } = createPrompter({ - selectValue: "firecrawl", - textValue: "fc-disabled-key", - }); - const result = await setupSearch(cfg, runtime, prompter); - expect(result.tools?.web?.search?.provider).toBe("firecrawl"); - expect(result.tools?.web?.search?.enabled).toBe(true); - expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true); - expect(readFirecrawlPluginApiKey(result)).toBe("fc-disabled-key"); - }); - - it("sets provider and key for grok", async () => { - const cfg: OpenClawConfig = {}; - const { prompter } = createPrompter({ - selectValue: "grok", - textValue: "xai-test", - }); - const result = await setupSearch(cfg, runtime, prompter); - expect(result.tools?.web?.search?.provider).toBe("grok"); - expect(result.tools?.web?.search?.enabled).toBe(true); - expect(pluginWebSearchApiKey(result, "xai")).toBe("xai-test"); - expect(result.plugins?.entries?.xai?.enabled).toBe(true); - }); - - it("sets provider and key for kimi", async () => { - const cfg: OpenClawConfig = {}; - const { prompter } = createPrompter({ + const kimiCfg: OpenClawConfig = {}; + const { prompter: kimiPrompter } = createPrompter({ selectValues: ["kimi", "https://api.moonshot.ai/v1", "__keep__"], textValue: "sk-moonshot", }); - const result = await setupSearch(cfg, runtime, prompter); - const kimiWebSearchConfig = result.plugins?.entries?.moonshot?.config?.webSearch as + const kimiResult = await setupSearch(kimiCfg, runtime, kimiPrompter); + const kimiWebSearchConfig = kimiResult.plugins?.entries?.moonshot?.config?.webSearch as | { baseUrl?: string; model?: string; } | undefined; - expect(result.tools?.web?.search?.provider).toBe("kimi"); - expect(result.tools?.web?.search?.enabled).toBe(true); - expect(pluginWebSearchApiKey(result, "moonshot")).toBe("sk-moonshot"); - expect(result.plugins?.entries?.moonshot?.enabled).toBe(true); + expect(kimiResult.tools?.web?.search?.provider).toBe("kimi"); + expect(kimiResult.tools?.web?.search?.enabled).toBe(true); + expect(pluginWebSearchApiKey(kimiResult, "moonshot")).toBe("sk-moonshot"); + expect(kimiResult.plugins?.entries?.moonshot?.enabled).toBe(true); expect(kimiWebSearchConfig?.baseUrl).toBe("https://api.moonshot.ai/v1"); expect(kimiWebSearchConfig?.model).toBe("kimi-k2.5"); - }); - it("sets provider and key for tavily and enables the plugin", async () => { - const cfg: OpenClawConfig = {}; - const { prompter } = createPrompter({ - selectValue: "tavily", - textValue: "tvly-test-key", + const disabledCfg = createDisabledFirecrawlConfig(); + const { prompter: disabledPrompter } = createPrompter({ + selectValue: "firecrawl", + textValue: "fc-disabled-key", }); - const result = await setupSearch(cfg, runtime, prompter); - expect(result.tools?.web?.search?.provider).toBe("tavily"); - expect(result.tools?.web?.search?.enabled).toBe(true); - expect(pluginWebSearchApiKey(result, "tavily")).toBe("tvly-test-key"); - expect(result.plugins?.entries?.tavily?.enabled).toBe(true); + const disabledResult = await setupSearch(disabledCfg, runtime, disabledPrompter); + expect(disabledResult.tools?.web?.search?.provider).toBe("firecrawl"); + expect(disabledResult.tools?.web?.search?.enabled).toBe(true); + expect(disabledResult.plugins?.entries?.firecrawl?.enabled).toBe(true); + expect(readFirecrawlPluginApiKey(disabledResult)).toBe("fc-disabled-key"); }); it("shows missing-key note when no key is provided and no env var", async () => { @@ -441,15 +388,13 @@ describe("setupSearch", () => { ); expect(pluginWebSearchApiKey(result, "perplexity")).toBe("existing-key"); expect(result.tools?.web?.search?.enabled).toBe(true); - }); - it("advanced preserves enabled:false when keeping existing key", async () => { - const result = await runBlankPerplexityKeyEntry( + const disabledResult = await runBlankPerplexityKeyEntry( "existing-key", // pragma: allowlist secret false, ); - expect(pluginWebSearchApiKey(result, "perplexity")).toBe("existing-key"); - expect(result.tools?.web?.search?.enabled).toBe(false); + expect(pluginWebSearchApiKey(disabledResult, "perplexity")).toBe("existing-key"); + expect(disabledResult.tools?.web?.search?.enabled).toBe(false); }); it("quickstart skips key prompt when config key exists", async () => { @@ -460,17 +405,16 @@ describe("setupSearch", () => { expect(pluginWebSearchApiKey(result, "perplexity")).toBe("stored-pplx-key"); expect(result.tools?.web?.search?.enabled).toBe(true); expect(prompter.text).not.toHaveBeenCalled(); - }); - it("quickstart preserves enabled:false when search was intentionally disabled", async () => { - const { result, prompter } = await runQuickstartPerplexitySetup( - "stored-pplx-key", // pragma: allowlist secret - false, - ); - expect(result.tools?.web?.search?.provider).toBe("perplexity"); - expect(pluginWebSearchApiKey(result, "perplexity")).toBe("stored-pplx-key"); - expect(result.tools?.web?.search?.enabled).toBe(false); - expect(prompter.text).not.toHaveBeenCalled(); + const { result: disabledResult, prompter: disabledPrompter } = + await runQuickstartPerplexitySetup( + "stored-pplx-key", // pragma: allowlist secret + false, + ); + expect(disabledResult.tools?.web?.search?.provider).toBe("perplexity"); + expect(pluginWebSearchApiKey(disabledResult, "perplexity")).toBe("stored-pplx-key"); + expect(disabledResult.tools?.web?.search?.enabled).toBe(false); + expect(disabledPrompter.text).not.toHaveBeenCalled(); }); it("quickstart skips key prompt when canonical plugin config key exists", async () => { @@ -614,15 +558,14 @@ describe("setupSearch", () => { } }); - it("stores env-backed SecretRef when secretInputMode=ref for perplexity", async () => { + it("stores env-backed SecretRef for perplexity ref mode", async () => { const originalPerplexity = process.env.PERPLEXITY_API_KEY; const originalOpenRouter = process.env.OPENROUTER_API_KEY; delete process.env.PERPLEXITY_API_KEY; delete process.env.OPENROUTER_API_KEY; - const cfg: OpenClawConfig = {}; try { const { prompter } = createPrompter({ selectValue: "perplexity" }); - const result = await setupSearch(cfg, runtime, prompter, { + const result = await setupSearch({}, runtime, prompter, { secretInputMode: "ref", // pragma: allowlist secret }); expect(result.tools?.web?.search?.provider).toBe("perplexity"); @@ -632,37 +575,18 @@ describe("setupSearch", () => { id: "PERPLEXITY_API_KEY", // pragma: allowlist secret }); expect(prompter.text).not.toHaveBeenCalled(); - } finally { - if (originalPerplexity === undefined) { - delete process.env.PERPLEXITY_API_KEY; - } else { - process.env.PERPLEXITY_API_KEY = originalPerplexity; - } - if (originalOpenRouter === undefined) { - delete process.env.OPENROUTER_API_KEY; - } else { - process.env.OPENROUTER_API_KEY = originalOpenRouter; - } - } - }); - it("prefers detected OPENROUTER_API_KEY SecretRef for perplexity ref mode", async () => { - const originalPerplexity = process.env.PERPLEXITY_API_KEY; - const originalOpenRouter = process.env.OPENROUTER_API_KEY; - delete process.env.PERPLEXITY_API_KEY; - process.env.OPENROUTER_API_KEY = "sk-or-test"; - const cfg: OpenClawConfig = {}; - try { - const { prompter } = createPrompter({ selectValue: "perplexity" }); - const result = await setupSearch(cfg, runtime, prompter, { + process.env.OPENROUTER_API_KEY = "sk-or-test"; + const { prompter: openRouterPrompter } = createPrompter({ selectValue: "perplexity" }); + const openRouterResult = await setupSearch({}, runtime, openRouterPrompter, { secretInputMode: "ref", // pragma: allowlist secret }); - expect(pluginWebSearchApiKey(result, "perplexity")).toEqual({ + expect(pluginWebSearchApiKey(openRouterResult, "perplexity")).toEqual({ source: "env", provider: "default", id: "OPENROUTER_API_KEY", // pragma: allowlist secret }); - expect(prompter.text).not.toHaveBeenCalled(); + expect(openRouterPrompter.text).not.toHaveBeenCalled(); } finally { if (originalPerplexity === undefined) { delete process.env.PERPLEXITY_API_KEY; @@ -677,39 +601,27 @@ describe("setupSearch", () => { } }); - it("stores env-backed SecretRef when secretInputMode=ref for brave", async () => { - const cfg: OpenClawConfig = {}; - const { prompter } = createPrompter({ selectValue: "brave" }); - const result = await setupSearch(cfg, runtime, prompter, { - secretInputMode: "ref", // pragma: allowlist secret - }); - expect(result.tools?.web?.search?.provider).toBe("brave"); - expect(pluginWebSearchApiKey(result, "brave")).toEqual({ - source: "env", - provider: "default", - id: "BRAVE_API_KEY", - }); - expect(result.plugins?.entries?.brave?.enabled).toBe(true); - expect(prompter.text).not.toHaveBeenCalled(); - }); - - it("stores env-backed SecretRef when secretInputMode=ref for tavily", async () => { + it("stores env-backed SecretRefs for simple providers", async () => { const original = process.env.TAVILY_API_KEY; delete process.env.TAVILY_API_KEY; - const cfg: OpenClawConfig = {}; try { - const { prompter } = createPrompter({ selectValue: "tavily" }); - const result = await setupSearch(cfg, runtime, prompter, { - secretInputMode: "ref", // pragma: allowlist secret - }); - expect(result.tools?.web?.search?.provider).toBe("tavily"); - expect(pluginWebSearchApiKey(result, "tavily")).toEqual({ - source: "env", - provider: "default", - id: "TAVILY_API_KEY", - }); - expect(result.plugins?.entries?.tavily?.enabled).toBe(true); - expect(prompter.text).not.toHaveBeenCalled(); + for (const entry of [ + { provider: "brave", pluginId: "brave", env: "BRAVE_API_KEY" }, + { provider: "tavily", pluginId: "tavily", env: "TAVILY_API_KEY" }, + ]) { + const { prompter } = createPrompter({ selectValue: entry.provider }); + const result = await setupSearch({}, runtime, prompter, { + secretInputMode: "ref", // pragma: allowlist secret + }); + expect(result.tools?.web?.search?.provider).toBe(entry.provider); + expect(pluginWebSearchApiKey(result, entry.pluginId)).toEqual({ + source: "env", + provider: "default", + id: entry.env, + }); + expect(result.plugins?.entries?.[entry.pluginId]?.enabled).toBe(true); + expect(prompter.text).not.toHaveBeenCalled(); + } } finally { if (original === undefined) { delete process.env.TAVILY_API_KEY; diff --git a/src/commands/status.command.text-runtime.ts b/src/commands/status.command.text-runtime.ts index eeaa097d653..a8ce202e6b5 100644 --- a/src/commands/status.command.text-runtime.ts +++ b/src/commands/status.command.text-runtime.ts @@ -13,7 +13,7 @@ export { } from "../plugins/status.js"; export { getTerminalTableWidth, renderTable } from "../terminal/table.js"; export { theme } from "../terminal/theme.js"; -export { formatHealthChannelLines } from "./health.js"; +export { formatHealthChannelLines } from "./health-format.js"; export { groupChannelIssuesByChannel } from "./status-all/channel-issues.js"; export { buildStatusChannelsTableRows, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index ec6e6137cb1..0b265513007 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -56,7 +56,7 @@ function loadStatusNodeModeModule() { return statusNodeModeModulePromise; } -function resolvePairingRecoveryContext(params: { +export function resolvePairingRecoveryContext(params: { error?: string | null; closeReason?: string | null; }): { requestId: string | null } | null { diff --git a/src/commands/status.scan.fast-json.test.ts b/src/commands/status.scan.fast-json.test.ts index 3d977c15940..268872367df 100644 --- a/src/commands/status.scan.fast-json.test.ts +++ b/src/commands/status.scan.fast-json.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { applyStatusScanDefaults, createStatusMemorySearchConfig, @@ -19,8 +19,7 @@ let originalForceStderr: boolean; let loggingStateRef: typeof import("../logging/state.js").loggingState; let scanStatusJsonFast: typeof import("./status.scan.fast-json.js").scanStatusJsonFast; -beforeEach(async () => { - vi.clearAllMocks(); +function configureFastJsonStatus() { applyStatusScanDefaults(mocks, { sourceConfig: createStatusMemorySearchConfig(), resolvedConfig: createStatusMemorySearchConfig(), @@ -31,8 +30,17 @@ beforeEach(async () => { mocks.resolveMemorySearchConfig.mockReturnValue({ store: { path: "/tmp/main.sqlite" }, }); +} + +beforeAll(async () => { + configureFastJsonStatus(); ({ scanStatusJsonFast } = await loadStatusScanModuleForTest(mocks, { fastJson: true })); ({ loggingState: loggingStateRef } = await import("../logging/state.js")); +}); + +beforeEach(() => { + vi.clearAllMocks(); + configureFastJsonStatus(); originalForceStderr = loggingStateRef.forceConsoleToStderr; loggingStateRef.forceConsoleToStderr = false; }); diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index ed03f41a775..ed613fee804 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { applyStatusScanDefaults, createStatusMemorySearchConfig, @@ -20,11 +20,15 @@ let originalForceStderr: boolean; let loggingStateRef: typeof import("../logging/state.js").loggingState; let scanStatus: typeof import("./status.scan.js").scanStatus; -beforeEach(async () => { - vi.clearAllMocks(); +beforeAll(async () => { configureScanStatus(); ({ scanStatus } = await loadStatusScanModuleForTest(mocks)); ({ loggingState: loggingStateRef } = await import("../logging/state.js")); +}); + +beforeEach(() => { + vi.clearAllMocks(); + configureScanStatus(); originalForceStderr = loggingStateRef.forceConsoleToStderr; loggingStateRef.forceConsoleToStderr = false; }); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index b595e8e19ed..b9d9d2318dc 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -1,6 +1,5 @@ import type { Mock } from "vitest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { GatewaySecretRefUnavailableError } from "../gateway/credentials.js"; import type { PluginCompatibilityNotice } from "../plugins/status.js"; import { createCompatibilityNotice } from "../plugins/status.test-helpers.js"; import { captureEnv } from "../test-utils/env.js"; @@ -77,15 +76,8 @@ function createErrorChannelPlugin(params: { id: string; label: string; docsPath: } async function withUnknownUsageStore(run: () => Promise) { - const originalLoadSessionStore = mocks.loadSessionStore.getMockImplementation(); mocks.loadSessionStore.mockReturnValue(createUnknownUsageSessionStore()); - try { - await run(); - } finally { - if (originalLoadSessionStore) { - mocks.loadSessionStore.mockImplementation(originalLoadSessionStore); - } - } + await run(); } function getRuntimeLogs() { @@ -640,7 +632,10 @@ vi.mock("../gateway/call.js", () => ({ }) => { const token = params.config?.gateway?.auth?.token; if (token && typeof token === "object" && "source" in token) { - throw new GatewaySecretRefUnavailableError("gateway.auth.token"); + throw Object.assign(new Error("gateway.auth.token unavailable"), { + name: "GatewaySecretRefUnavailableError", + path: "gateway.auth.token", + }); } const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim(); return envToken ? { token: envToken } : {}; @@ -776,7 +771,7 @@ vi.mock("./status-runtime-shared.ts", () => ({ ), })); -import { statusCommand } from "./status.command.js"; +import { resolvePairingRecoveryContext, statusCommand } from "./status.command.js"; const runtime = { log: vi.fn(), @@ -917,8 +912,6 @@ describe("statusCommand", () => { inconsistent_timestamps: 0, }, }); - mocks.hasPotentialConfiguredChannels.mockReset(); - mocks.hasPotentialConfiguredChannels.mockReturnValue(true); mocks.runSecurityAudit.mockReset(); mocks.runSecurityAudit.mockResolvedValue(createDefaultSecurityAuditResult()); mocks.resolveGatewayService.mockReset(); @@ -959,7 +952,7 @@ describe("statusCommand", () => { (runtime.error as Mock<(...args: unknown[]) => void>).mockClear(); }); - it("prints JSON when requested", async () => { + it("prints JSON and includes security audit only when all is requested", async () => { mocks.hasPotentialConfiguredChannels.mockReturnValue(false); mocks.buildPluginCompatibilityNotices.mockReturnValue([ createCompatibilityNotice({ pluginId: "legacy-plugin", code: "legacy-before-agent-start" }), @@ -995,16 +988,13 @@ describe("statusCommand", () => { }), ); expect(mocks.runSecurityAudit).not.toHaveBeenCalled(); - }); - - it("includes security audit in JSON when all is requested", async () => { - mocks.hasPotentialConfiguredChannels.mockReturnValue(false); + runtimeLogMock.mockClear(); await statusCommand({ json: true, all: true }, runtime as never); - const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0])); - expect(payload.securityAudit.summary.critical).toBe(1); - expect(payload.securityAudit.summary.warn).toBe(1); + const allPayload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0])); + expect(allPayload.securityAudit.summary.critical).toBe(1); + expect(allPayload.securityAudit.summary.warn).toBe(1); expect(mocks.runSecurityAudit).toHaveBeenCalledWith( expect.objectContaining({ includeFilesystem: true, @@ -1025,18 +1015,11 @@ describe("statusCommand", () => { }); }); - it("prints unknown usage in formatted output when totalTokens is missing", async () => { - await withUnknownUsageStore(async () => { - const logs = await runStatusAndGetLogs(); - expect(logs.some((line) => line.includes("unknown/") && line.includes("(?%)"))).toBe(true); - }); - }); - - it("prints formatted lines otherwise", async () => { + it("prints formatted lines with verbose cache details", async () => { mocks.buildPluginCompatibilityNotices.mockReturnValue([ createCompatibilityNotice({ pluginId: "legacy-plugin", code: "legacy-before-agent-start" }), ]); - const logs = await runStatusAndGetLogs(); + const logs = await runStatusAndGetLogs({ verbose: true }); for (const token of [ "OpenClaw status", "Overview", @@ -1072,10 +1055,6 @@ describe("statusCommand", () => { line.includes("openclaw --profile isolated status --all"), ), ).toBe(true); - }); - - it("shows explicit cache details in verbose session output", async () => { - const logs = await runStatusAndGetLogs({ verbose: true }); expect(logs.some((line) => line.includes("Cache"))).toBe(true); expect(logs.some((line) => line.includes("40% hit"))).toBe(true); expect(logs.some((line) => line.includes("read 2.0k"))).toBe(true); @@ -1122,8 +1101,7 @@ describe("statusCommand", () => { expect(joined).toContain("tasks maintenance --apply"); }); - it("caps cached percentage at the prompt-token denominator for legacy session totals", async () => { - const originalLoadSessionStore = mocks.loadSessionStore.getMockImplementation(); + it("uses prompt-side denominator for cached percentages", async () => { mocks.loadSessionStore.mockReturnValue({ "+1000": { ...createDefaultSessionStoreEntry(), @@ -1133,19 +1111,10 @@ describe("statusCommand", () => { totalTokens: 1_000, }, }); - try { - const logs = await runStatusAndGetLogs(); - expect(logs.some((line) => line.includes("100% cached"))).toBe(true); - expect(logs.some((line) => line.includes("120% cached"))).toBe(false); - } finally { - if (originalLoadSessionStore) { - mocks.loadSessionStore.mockImplementation(originalLoadSessionStore); - } - } - }); + const logs = await runStatusAndGetLogs(); + expect(logs.some((line) => line.includes("100% cached"))).toBe(true); + expect(logs.some((line) => line.includes("120% cached"))).toBe(false); - it("uses prompt-side tokens for cached percentage when they differ from totalTokens", async () => { - const originalLoadSessionStore = mocks.loadSessionStore.getMockImplementation(); mocks.loadSessionStore.mockReturnValue({ "+1000": { ...createDefaultSessionStoreEntry(), @@ -1155,15 +1124,9 @@ describe("statusCommand", () => { totalTokens: 5_000, }, }); - try { - const logs = await runStatusAndGetLogs(); - expect(logs.some((line) => line.includes("67% cached"))).toBe(true); - expect(logs.some((line) => line.includes("40% cached"))).toBe(false); - } finally { - if (originalLoadSessionStore) { - mocks.loadSessionStore.mockImplementation(originalLoadSessionStore); - } - } + const promptSideLogs = await runStatusAndGetLogs(); + expect(promptSideLogs.some((line) => line.includes("67% cached"))).toBe(true); + expect(promptSideLogs.some((line) => line.includes("40% cached"))).toBe(false); }); it("shows node-only gateway info when no local gateway service is installed", async () => { @@ -1283,60 +1246,45 @@ describe("statusCommand", () => { expect(joined).toMatch(/WARN/); }); - it.each([ - { - name: "prints requestId-aware recovery guidance when gateway pairing is required", - error: "connect failed: pairing required (requestId: req-123)", - closeReason: "pairing required (requestId: req-123)", - includes: ["devices approve req-123"], - excludes: [], - }, - { - name: "prints fallback recovery guidance when pairing requestId is unavailable", - error: "connect failed: pairing required", - closeReason: "connect failed", - includes: [], - excludes: ["devices approve req-"], - }, - { - name: "does not render unsafe requestId content into approval command hints", - error: "connect failed: pairing required (requestId: req-123;rm -rf /)", - closeReason: "pairing required (requestId: req-123;rm -rf /)", - includes: [], - excludes: ["devices approve req-123;rm -rf /"], - }, - ])("$name", async ({ error, closeReason, includes, excludes }) => { + it("prints safe gateway pairing recovery guidance", async () => { + expect( + resolvePairingRecoveryContext({ + error: "connect failed: pairing required (requestId: req-123)", + closeReason: "pairing required (requestId: req-123)", + }), + ).toEqual({ requestId: "req-123" }); + expect( + resolvePairingRecoveryContext({ + error: "connect failed: pairing required", + closeReason: "connect failed", + }), + ).toEqual({ requestId: null }); + expect( + resolvePairingRecoveryContext({ + error: "connect failed: pairing required (requestId: req-123;rm -rf /)", + closeReason: "pairing required (requestId: req-123;rm -rf /)", + }), + ).toEqual({ requestId: null }); + expect( + resolvePairingRecoveryContext({ + error: "connect failed: pairing required", + closeReason: "pairing required (requestId: req-close-456)", + }), + ).toEqual({ requestId: "req-close-456" }); + mocks.loadConfig.mockReturnValue({ session: {}, channels: { whatsapp: { allowFrom: ["*"] } }, }); mockProbeGatewayResult({ - error, - close: { code: 1008, reason: closeReason }, + error: "connect failed: pairing required (requestId: req-123)", + close: { code: 1008, reason: "pairing required (requestId: req-123)" }, }); const joined = await runStatusAndGetJoinedLogs(); expect(joined).toContain("Gateway pairing approval required."); + expect(joined).toContain("devices approve req-123"); expect(joined).toContain("devices approve --latest"); expect(joined).toContain("devices list"); - for (const expected of includes) { - expect(joined).toContain(expected); - } - for (const blocked of excludes) { - expect(joined).not.toContain(blocked); - } - }); - - it("extracts requestId from close reason when error text omits it", async () => { - mocks.loadConfig.mockReturnValue({ - session: {}, - channels: { whatsapp: { allowFrom: ["*"] } }, - }); - mockProbeGatewayResult({ - error: "connect failed: pairing required", - close: { code: 1008, reason: "pairing required (requestId: req-close-456)" }, - }); - const joined = await runStatusAndGetJoinedLogs(); - expect(joined).toContain("devices approve req-close-456"); }); it("includes sessions across agents in JSON output", async () => { diff --git a/src/commands/tasks.test.ts b/src/commands/tasks.test.ts index 1467e19244a..68e5d50f8f9 100644 --- a/src/commands/tasks.test.ts +++ b/src/commands/tasks.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; -import { createRunningTaskRun } from "../tasks/task-executor.js"; import { createManagedTaskFlow, resetTaskFlowRegistryForTests, } from "../tasks/task-flow-registry.js"; import { + createTaskRecord, resetTaskRegistryDeliveryRuntimeForTests, resetTaskRegistryForTests, } from "../tasks/task-registry.js"; @@ -55,16 +55,17 @@ describe("tasks commands", () => { resetTaskFlowRegistryForTests({ persist: false }); }); - it("keeps tasks audit JSON stable while adding TaskFlow summary fields", async () => { + it("keeps audit JSON stable and sorts combined findings before limiting", async () => { await withTaskCommandStateDir(async () => { const now = Date.now(); vi.useFakeTimers(); vi.setSystemTime(now - 40 * 60_000); - createRunningTaskRun({ + createTaskRecord({ runtime: "cli", ownerKey: "agent:main:main", scopeKind: "session", runId: "task-stale-queued", + status: "running", task: "Inspect issue backlog", }); vi.setSystemTime(now); @@ -95,22 +96,7 @@ describe("tasks commands", () => { expect(payload.summary.taskFlows.byCode.stale_waiting).toBe(1); expect(payload.summary.taskFlows.byCode.missing_linked_tasks).toBe(1); expect(payload.summary.combined.total).toBe(3); - }); - }); - it("sorts combined audit findings before applying the limit", async () => { - await withTaskCommandStateDir(async () => { - const now = Date.now(); - vi.useFakeTimers(); - vi.setSystemTime(now - 40 * 60_000); - createRunningTaskRun({ - runtime: "cli", - ownerKey: "agent:main:main", - scopeKind: "session", - runId: "task-stale-queued", - task: "Queue audit", - }); - vi.setSystemTime(now); const runningFlow = createManagedTaskFlow({ ownerKey: "agent:main:main", controllerId: "tests/tasks-command", @@ -120,15 +106,17 @@ describe("tasks commands", () => { updatedAt: now - 45 * 60_000, }); - const runtime = createRuntime(); - await tasksAuditCommand({ json: true, limit: 1 }, runtime); + const limitedRuntime = createRuntime(); + await tasksAuditCommand({ json: true, limit: 1 }, limitedRuntime); - const payload = JSON.parse(String(vi.mocked(runtime.log).mock.calls[0]?.[0])) as { + const limitedPayload = JSON.parse( + String(vi.mocked(limitedRuntime.log).mock.calls[0]?.[0]), + ) as { findings: Array<{ kind: string; code: string; token?: string }>; }; - expect(payload.findings).toHaveLength(1); - expect(payload.findings[0]).toMatchObject({ + expect(limitedPayload.findings).toHaveLength(1); + expect(limitedPayload.findings[0]).toMatchObject({ kind: "task_flow", code: "stale_running", token: runningFlow.flowId, diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 229c013e346..6ff85a7a5f3 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -1,5 +1,3 @@ -import { loadConfig } from "../config/config.js"; -import { info } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { @@ -45,6 +43,13 @@ const DELIVERY_PAD = 14; const ID_PAD = 10; const RUN_PAD = 10; +const info = theme.info; + +async function loadTaskCancelConfig() { + const { loadConfig } = await import("../config/config.js"); + return loadConfig(); +} + function truncate(value: string, maxChars: number) { if (value.length <= maxChars) { return value; @@ -387,7 +392,7 @@ export async function tasksCancelCommand(opts: { lookup: string }, runtime: Runt return; } const result = await cancelTaskById({ - cfg: loadConfig(), + cfg: await loadTaskCancelConfig(), taskId: task.taskId, }); if (!result.found) {