diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index 01bdabb0c5b..6d6b10ffcee 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -4,15 +4,13 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; -import type { agentCliCommand as AgentCliCommand } from "./agent-via-gateway.js"; +import { agentCliCommand } from "./agent-via-gateway.js"; import type { agentCommand as AgentCommand } from "./agent.js"; const loadConfig = vi.hoisted(() => vi.fn()); const callGateway = vi.hoisted(() => vi.fn()); const agentCommand = vi.hoisted(() => vi.fn()); -let agentCliCommand: typeof AgentCliCommand; - const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), @@ -71,16 +69,15 @@ function mockLocalAgentReply(text = "local") { }); } -beforeEach(async () => { +vi.mock("../config/config.js", () => ({ loadConfig })); +vi.mock("../gateway/call.js", () => ({ + callGateway, + randomIdempotencyKey: () => "idem-1", +})); +vi.mock("./agent.js", () => ({ agentCommand })); + +beforeEach(() => { vi.clearAllMocks(); - vi.resetModules(); - vi.doMock("../config/config.js", () => ({ loadConfig })); - vi.doMock("../gateway/call.js", () => ({ - callGateway, - randomIdempotencyKey: () => "idem-1", - })); - vi.doMock("./agent.js", () => ({ agentCommand })); - ({ agentCliCommand } = await import("./agent-via-gateway.js")); }); describe("agentCliCommand", () => { diff --git a/src/commands/agent.cli-provider.test.ts b/src/commands/agent.cli-provider.test.ts index 0c8efd6675d..40cab942f77 100644 --- a/src/commands/agent.cli-provider.test.ts +++ b/src/commands/agent.cli-provider.test.ts @@ -22,16 +22,28 @@ import { } from "./agent-command.test-support.js"; import { agentCommand } from "./agent.js"; -vi.mock("../agents/auth-profiles/store.js", async () => { - const actual = await vi.importActual( - "../agents/auth-profiles/store.js", - ); +vi.mock("../agents/auth-profiles.js", () => { return { - ...actual, ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })), }; }); +vi.mock("../agents/auth-profiles/store.js", () => { + const createEmptyStore = () => ({ version: 1, profiles: {} }); + return { + clearRuntimeAuthProfileStoreSnapshots: vi.fn(), + ensureAuthProfileStore: vi.fn(createEmptyStore), + ensureAuthProfileStoreForLocalUpdate: vi.fn(createEmptyStore), + hasAnyAuthProfileStoreSource: vi.fn(() => false), + loadAuthProfileStore: vi.fn(createEmptyStore), + loadAuthProfileStoreForRuntime: vi.fn(createEmptyStore), + loadAuthProfileStoreForSecretsRuntime: vi.fn(createEmptyStore), + replaceRuntimeAuthProfileStoreSnapshots: vi.fn(), + saveAuthProfileStore: vi.fn(), + updateAuthProfileStoreWithLock: vi.fn(async () => createEmptyStore()), + }; +}); + const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), diff --git a/src/commands/agent.runtime-config.test.ts b/src/commands/agent.runtime-config.test.ts index 36eb42ec5c3..4c1376e12dd 100644 --- a/src/commands/agent.runtime-config.test.ts +++ b/src/commands/agent.runtime-config.test.ts @@ -21,22 +21,30 @@ import { withAgentCommandTempHome, } from "./agent-command.test-support.js"; -vi.mock("../agents/auth-profiles.js", async () => { - const actual = await vi.importActual( - "../agents/auth-profiles.js", - ); +vi.mock("../agents/auth-profiles.js", () => { return { - ...actual, ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })), }; }); -vi.mock("../agents/command/session-store.js", async () => { - const actual = await vi.importActual( - "../agents/command/session-store.js", - ); +vi.mock("../agents/auth-profiles/store.js", () => { + const createEmptyStore = () => ({ version: 1, profiles: {} }); + return { + clearRuntimeAuthProfileStoreSnapshots: vi.fn(), + ensureAuthProfileStore: vi.fn(createEmptyStore), + ensureAuthProfileStoreForLocalUpdate: vi.fn(createEmptyStore), + hasAnyAuthProfileStoreSource: vi.fn(() => false), + loadAuthProfileStore: vi.fn(createEmptyStore), + loadAuthProfileStoreForRuntime: vi.fn(createEmptyStore), + loadAuthProfileStoreForSecretsRuntime: vi.fn(createEmptyStore), + replaceRuntimeAuthProfileStoreSnapshots: vi.fn(), + saveAuthProfileStore: vi.fn(), + updateAuthProfileStoreWithLock: vi.fn(async () => createEmptyStore()), + }; +}); + +vi.mock("../agents/command/session-store.js", () => { return { - ...actual, updateSessionStoreAfterAgentRun: vi.fn(async () => undefined), }; }); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index cd0c017bffb..13617c65b77 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; 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 { resolveAgentDir, resolveSessionAgentId } from "../agents/agent-scope.js"; @@ -27,74 +28,34 @@ import type { RuntimeEnv } from "../runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { agentCommand, agentCommandFromIngress } from "./agent.js"; -vi.mock("../logging/subsystem.js", () => { - const createMockLogger = () => ({ - subsystem: "test", - isEnabled: vi.fn(() => true), - trace: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - fatal: vi.fn(), - raw: vi.fn(), - child: vi.fn(() => createMockLogger()), - }); +vi.mock("../agents/auth-profiles.js", () => { return { - createSubsystemLogger: vi.fn(() => createMockLogger()), - }; -}); - -vi.mock("../agents/auth-profiles.js", async () => { - const actual = await vi.importActual( - "../agents/auth-profiles.js", - ); - return { - ...actual, ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })), }; }); -vi.mock("../agents/auth-profiles/store.js", async () => { - const actual = await vi.importActual( - "../agents/auth-profiles/store.js", - ); +vi.mock("../agents/auth-profiles/store.js", () => { + const createEmptyStore = () => ({ version: 1, profiles: {} }); return { - ...actual, - ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })), + clearRuntimeAuthProfileStoreSnapshots: vi.fn(), + ensureAuthProfileStore: vi.fn(createEmptyStore), + ensureAuthProfileStoreForLocalUpdate: vi.fn(createEmptyStore), + hasAnyAuthProfileStoreSource: vi.fn(() => false), + loadAuthProfileStore: vi.fn(createEmptyStore), + loadAuthProfileStoreForRuntime: vi.fn(createEmptyStore), + loadAuthProfileStoreForSecretsRuntime: vi.fn(createEmptyStore), + replaceRuntimeAuthProfileStoreSnapshots: vi.fn(), + saveAuthProfileStore: vi.fn(), + updateAuthProfileStoreWithLock: vi.fn(async () => createEmptyStore()), }; }); -vi.mock("../agents/workspace.js", () => { - const resolveDefaultAgentWorkspaceDir = () => "/tmp/openclaw-workspace"; +vi.mock("../agents/command/session-store.js", () => { return { - DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/openclaw-workspace", - DEFAULT_AGENTS_FILENAME: "AGENTS.md", - DEFAULT_IDENTITY_FILENAME: "IDENTITY.md", - resolveDefaultAgentWorkspaceDir, - ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => ({ dir })), - }; -}); - -vi.mock("../agents/command/session-store.js", async () => { - const actual = await vi.importActual( - "../agents/command/session-store.js", - ); - return { - ...actual, updateSessionStoreAfterAgentRun: vi.fn(async () => undefined), }; }); -vi.mock("../agents/skills.js", () => ({ - buildWorkspaceSkillSnapshot: vi.fn(() => undefined), - loadWorkspaceSkillEntries: vi.fn(() => []), -})); - -vi.mock("../agents/skills/refresh.js", () => ({ - getSkillsSnapshotVersion: vi.fn(() => 0), -})); - const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 3c769e3cd97..5844f00df17 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -37,13 +37,40 @@ vi.mock("../plugins/provider-openai-codex-oauth.js", () => ({ const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {})); -vi.mock("../plugins/provider-auth-choice.runtime.js", async () => { - const actual = await vi.importActual( - "../plugins/provider-auth-choice.runtime.js", - ); +vi.mock("../plugins/provider-auth-choice.runtime.js", () => { + const normalizeProviderId = (value: string) => value.trim().toLowerCase(); return { - ...actual, resolvePluginProviders, + resolveProviderPluginChoice: (params: { providers: ProviderPlugin[]; choice: string }) => { + const choice = params.choice.trim(); + if (!choice) { + return null; + } + if (choice.startsWith("provider-plugin:")) { + const payload = choice.slice("provider-plugin:".length); + const separator = payload.indexOf(":"); + const providerId = separator >= 0 ? payload.slice(0, separator) : payload; + const methodId = separator >= 0 ? payload.slice(separator + 1) : undefined; + const provider = params.providers.find( + (entry) => normalizeProviderId(entry.id) === normalizeProviderId(providerId), + ); + const method = methodId + ? provider?.auth.find((entry) => entry.id === methodId) + : provider?.auth[0]; + return provider && method ? { provider, method } : null; + } + for (const provider of params.providers) { + for (const method of provider.auth) { + if (method.wizard?.choiceId === choice) { + return { provider, method, wizard: method.wizard }; + } + } + if (normalizeProviderId(provider.id) === normalizeProviderId(choice) && provider.auth[0]) { + return { provider, method: provider.auth[0] }; + } + } + return null; + }, runProviderModelSelectedHook, }; }); diff --git a/src/commands/channel-test-registry.ts b/src/commands/channel-test-registry.ts index 0cab68b4c34..ebc66dbeb3f 100644 --- a/src/commands/channel-test-registry.ts +++ b/src/commands/channel-test-registry.ts @@ -1,5 +1,6 @@ import { - listBundledChannelPlugins, + getBundledChannelPlugin, + listBundledChannelPluginIds, setBundledChannelRuntime, } from "../channels/plugins/bundled.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; @@ -7,8 +8,11 @@ import type { PluginRuntime } from "../plugins/runtime/index.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; function resolveChannelPluginsForTests(onlyPluginIds?: readonly string[]) { - const scopedIds = onlyPluginIds ? new Set(onlyPluginIds) : null; - return listBundledChannelPlugins().filter((plugin) => !scopedIds || scopedIds.has(plugin.id)); + const ids = onlyPluginIds ?? listBundledChannelPluginIds(); + return ids.flatMap((id) => { + const plugin = getBundledChannelPlugin(id); + return plugin ? [plugin] : []; + }); } function createChannelTestRuntime(): PluginRuntime { diff --git a/src/commands/doctor-browser.test.ts b/src/commands/doctor-browser.test.ts deleted file mode 100644 index 948562eaf17..00000000000 --- a/src/commands/doctor-browser.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { noteChromeMcpBrowserReadiness } from "./doctor-browser.js"; - -describe("doctor browser readiness", () => { - it("does nothing when Chrome MCP is not configured", async () => { - const noteFn = vi.fn(); - await noteChromeMcpBrowserReadiness( - { - browser: { - profiles: { - openclaw: { color: "#FF4500" }, - }, - }, - }, - { - noteFn, - }, - ); - expect(noteFn).not.toHaveBeenCalled(); - }); - - it("warns when Chrome MCP is configured but Chrome is missing", async () => { - const noteFn = vi.fn(); - await noteChromeMcpBrowserReadiness( - { - browser: { - defaultProfile: "user", - }, - }, - { - noteFn, - platform: "darwin", - resolveChromeExecutable: () => null, - }, - ); - - expect(noteFn).toHaveBeenCalledTimes(1); - expect(String(noteFn.mock.calls[0]?.[0])).toContain("Google Chrome was not found"); - expect(String(noteFn.mock.calls[0]?.[0])).toContain("brave://inspect/#remote-debugging"); - }); - - it("warns when detected Chrome is too old for Chrome MCP", async () => { - const noteFn = vi.fn(); - await noteChromeMcpBrowserReadiness( - { - browser: { - profiles: { - chromeLive: { - driver: "existing-session", - color: "#00AA00", - }, - }, - }, - }, - { - noteFn, - platform: "linux", - resolveChromeExecutable: () => ({ path: "/usr/bin/google-chrome" }), - readVersion: () => "Google Chrome 143.0.7499.4", - }, - ); - - expect(noteFn).toHaveBeenCalledTimes(1); - expect(String(noteFn.mock.calls[0]?.[0])).toContain("too old"); - expect(String(noteFn.mock.calls[0]?.[0])).toContain("Chrome 144+"); - }); - - it("reports the detected Chrome version for existing-session profiles", async () => { - const noteFn = vi.fn(); - await noteChromeMcpBrowserReadiness( - { - browser: { - profiles: { - chromeLive: { - driver: "existing-session", - color: "#00AA00", - }, - }, - }, - }, - { - noteFn, - platform: "win32", - resolveChromeExecutable: () => ({ - path: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", - }), - readVersion: () => "Google Chrome 144.0.7534.0", - }, - ); - - expect(noteFn).toHaveBeenCalledTimes(1); - expect(String(noteFn.mock.calls[0]?.[0])).toContain( - "Detected Chrome Google Chrome 144.0.7534.0", - ); - }); - - it("skips Chrome auto-detection when profiles use explicit userDataDir", async () => { - const noteFn = vi.fn(); - await noteChromeMcpBrowserReadiness( - { - browser: { - profiles: { - braveLive: { - driver: "existing-session", - userDataDir: "/Users/test/Library/Application Support/BraveSoftware/Brave-Browser", - color: "#FB542B", - }, - }, - }, - }, - { - noteFn, - resolveChromeExecutable: () => { - throw new Error("should not look up Chrome"); - }, - }, - ); - - expect(noteFn).toHaveBeenCalledTimes(1); - expect(String(noteFn.mock.calls[0]?.[0])).toContain("explicit Chromium user data directory"); - expect(String(noteFn.mock.calls[0]?.[0])).toContain("brave://inspect/#remote-debugging"); - }); -}); diff --git a/src/commands/doctor-config-flow.test-utils.ts b/src/commands/doctor-config-flow.test-utils.ts index ef363620e68..c70c9f9d9bb 100644 --- a/src/commands/doctor-config-flow.test-utils.ts +++ b/src/commands/doctor-config-flow.test-utils.ts @@ -10,17 +10,22 @@ export async function runDoctorConfigWithInput(params: { confirm: () => Promise; }) => Promise; }) { - return withTempHome(async (home) => { - const configDir = path.join(home, ".openclaw"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify(params.config, null, 2), - "utf-8", - ); - return params.run({ - options: { nonInteractive: true, repair: params.repair }, - confirm: async () => false, - }); - }); + return withTempHome( + async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify(params.config, null, 2), + "utf-8", + ); + return params.run({ + options: { nonInteractive: true, repair: params.repair }, + confirm: async () => false, + }); + }, + { + skipSessionCleanup: true, + }, + ); } diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index cc361dfc375..0deb98663ed 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -2,12 +2,526 @@ import fs from "node:fs/promises"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { withTempHome } from "../../test/helpers/temp-home.js"; -import { resolveMatrixAccountStorageRoot } from "../plugin-sdk/matrix-helper.js"; -import * as noteModule from "../terminal/note.js"; -import { setChannelPluginRegistryForTests } from "./channel-test-registry.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js"; +type TerminalNote = (message: string, title?: string) => void; + +const terminalNoteMock = vi.hoisted(() => vi.fn()); + +vi.mock("../terminal/note.js", () => ({ + note: terminalNoteMock, +})); + +vi.mock("../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: vi.fn( + ({ + config, + }: { + config: { + plugins?: { allow?: string[]; entries?: Record }; + tools?: { alsoAllow?: string[] }; + }; + }) => { + if (!config.tools?.alsoAllow?.includes("browser")) { + return { config, changes: [], autoEnabledReasons: {} }; + } + const allow = config.plugins?.allow ?? []; + if (allow.includes("browser")) { + return { config, changes: [], autoEnabledReasons: {} }; + } + return { + config: { + ...config, + plugins: { + ...config.plugins, + allow: [...allow, "browser"], + entries: { + ...config.plugins?.entries, + browser: { + ...(config.plugins?.entries?.browser as Record | undefined), + enabled: true, + }, + }, + }, + }, + changes: ["browser referenced by tools.alsoAllow, enabled automatically."], + autoEnabledReasons: { browser: ["tools.alsoAllow"] }, + }; + }, + ), +})); + +vi.mock("../config/validation.js", () => ({ + validateConfigObjectWithPlugins: vi.fn((config: unknown) => ({ ok: true, config })), +})); + +vi.mock("../channels/plugins/bootstrap-registry.js", () => ({ + getBootstrapChannelPlugin: vi.fn(() => undefined), +})); + +vi.mock("../plugins/doctor-contract-registry.js", () => { + function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; + } + + function hasLegacyTalkFields(value: unknown): boolean { + const talk = asRecord(value); + return Boolean( + talk && + ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"].some((key) => + Object.prototype.hasOwnProperty.call(talk, key), + ), + ); + } + + return { + collectRelevantDoctorPluginIds: (raw: unknown): string[] => { + const ids = new Set(); + const root = asRecord(raw); + const channels = asRecord(root?.channels); + for (const channelId of Object.keys(channels ?? {})) { + if (channelId !== "defaults") { + ids.add(channelId); + } + } + if (hasLegacyTalkFields(root?.talk)) { + ids.add("elevenlabs"); + } + return [...ids].toSorted(); + }, + applyPluginDoctorCompatibilityMigrations: (cfg: unknown) => ({ config: cfg, changes: [] }), + listPluginDoctorLegacyConfigRules: () => [ + { + path: ["channels", "telegram", "groupMentionsOnly"], + message: + 'channels.telegram.groupMentionsOnly was removed; use channels.telegram.groups."*".requireMention instead. Run "openclaw doctor --fix".', + }, + { + path: ["talk"], + message: + "talk.voiceId/talk.voiceAliases/talk.modelId/talk.outputFormat/talk.apiKey are legacy; use talk.providers. and run openclaw doctor --fix.", + match: hasLegacyTalkFields, + }, + ], + }; +}); + +vi.mock("../plugins/setup-registry.js", () => ({ + resolvePluginSetupAutoEnableReasons: vi.fn(() => []), + runPluginSetupConfigMigrations: vi.fn(({ config }: { config: unknown }) => ({ + config, + changes: [], + })), +})); + +vi.mock("./doctor/shared/channel-doctor.js", () => { + function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; + } + + function hasOwnStringArray(value: unknown): boolean { + return Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry); + } + + function stringifySelectedArrays(root: Record): boolean { + let changed = false; + const keysToNormalize = new Set([ + "allowFrom", + "groupAllowFrom", + "groupChannels", + "approvers", + "users", + "roles", + ]); + const visit = (value: unknown) => { + const record = asRecord(value); + if (!record) { + return; + } + for (const [key, entry] of Object.entries(record)) { + if (keysToNormalize.has(key) && Array.isArray(entry)) { + const next = entry.map((item) => + typeof item === "number" || typeof item === "string" ? String(item) : item, + ); + if (next.some((item, index) => item !== entry[index])) { + record[key] = next; + changed = true; + } + continue; + } + if (entry && typeof entry === "object") { + visit(entry); + } + } + }; + visit(root); + return changed; + } + + function collectCompatibilityMutations(cfg: { channels?: Record }) { + const next = structuredClone(cfg); + const changes: string[] = []; + const discord = asRecord(next.channels?.discord); + if (discord && typeof discord.streaming === "boolean") { + discord.streaming = { mode: discord.streaming ? "partial" : "off" }; + changes.push("Normalized channels.discord.streaming legacy scalar."); + } + const telegram = asRecord(next.channels?.telegram); + if (telegram && "groupMentionsOnly" in telegram) { + const groups = asRecord(telegram.groups) ?? {}; + const defaultGroup = asRecord(groups["*"]) ?? {}; + if (defaultGroup.requireMention === undefined) { + defaultGroup.requireMention = telegram.groupMentionsOnly; + } + groups["*"] = defaultGroup; + telegram.groups = groups; + delete telegram.groupMentionsOnly; + changes.push( + 'Moved channels.telegram.groupMentionsOnly → channels.telegram.groups."*".requireMention.', + ); + } + return changes.length > 0 ? [{ config: next, changes }] : []; + } + + function collectInactiveTelegramWarnings(cfg: { channels?: Record }): string[] { + const telegram = asRecord(cfg.channels?.telegram); + if (!telegram) { + return []; + } + const accounts = asRecord(telegram.accounts); + if (!accounts) { + return []; + } + return Object.entries(accounts).flatMap(([accountId, accountRaw]) => { + const account = asRecord(accountRaw); + if ( + !account || + account.enabled !== false || + !asRecord(account.botToken) || + !hasOwnStringArray(account.allowFrom) + ) { + return []; + } + return [ + `- Telegram account ${accountId}: failed to inspect bot token because the account is disabled.`, + "- Telegram allowFrom contains @username entries, but configured Telegram bot credentials are unavailable in this command path.", + ]; + }); + } + + function isTelegramFirstTimeAccount(params: { + account: Record; + parent?: Record; + }): boolean { + const groupPolicy = + typeof params.account.groupPolicy === "string" + ? params.account.groupPolicy + : typeof params.parent?.groupPolicy === "string" + ? params.parent.groupPolicy + : undefined; + if (groupPolicy !== "allowlist") { + return false; + } + const botToken = params.account.botToken ?? params.parent?.botToken; + if (!botToken) { + return false; + } + const groups = asRecord(params.account.groups) ?? asRecord(params.parent?.groups); + const groupAllowFrom = params.account.groupAllowFrom ?? params.parent?.groupAllowFrom; + return !groups && !hasOwnStringArray(groupAllowFrom); + } + + return { + collectChannelDoctorCompatibilityMutations: vi.fn(collectCompatibilityMutations), + collectChannelDoctorEmptyAllowlistExtraWarnings: vi.fn( + (params: { + account: Record; + channelName: string; + parent?: Record; + prefix: string; + }) => { + if ( + params.channelName !== "telegram" || + !isTelegramFirstTimeAccount({ account: params.account, parent: params.parent }) + ) { + return []; + } + return [ + `- ${params.prefix}: Telegram is in first-time setup mode. DMs use pairing mode. Group messages stay blocked until you add allowed chats under ${params.prefix}.groups (and optional sender IDs under ${params.prefix}.groupAllowFrom), or set ${params.prefix}.groupPolicy to "open" if you want broad group access.`, + ]; + }, + ), + collectChannelDoctorMutableAllowlistWarnings: vi.fn( + ({ cfg }: { cfg: { channels?: Record } }) => { + const zalouser = asRecord(cfg.channels?.zalouser); + if (!zalouser || zalouser.dangerouslyAllowNameMatching === true) { + return []; + } + const groups = asRecord(zalouser.groups); + if (!groups) { + return []; + } + return Object.entries(groups).flatMap(([name, group]) => + asRecord(group)?.allow === true + ? [ + `- Found mutable allowlist entry across zalouser while name matching is disabled by default: channels.zalouser.groups: ${name}.`, + ] + : [], + ); + }, + ), + collectChannelDoctorPreviewWarnings: vi.fn(async () => []), + collectChannelDoctorRepairMutations: vi.fn( + async ({ cfg }: { cfg: { channels?: Record } }) => { + const mutations: Array<{ config: unknown; changes: string[]; warnings?: string[] }> = []; + const discord = asRecord(cfg.channels?.discord); + if (discord) { + const next = structuredClone(cfg); + const nextDiscord = asRecord(next.channels?.discord); + if (nextDiscord && stringifySelectedArrays(nextDiscord)) { + mutations.push({ + config: next, + changes: ["Discord allowlist ids normalized to strings."], + }); + } + } + const telegramWarnings = collectInactiveTelegramWarnings(cfg); + if (telegramWarnings.length > 0) { + mutations.push({ config: cfg, changes: [], warnings: telegramWarnings }); + } + return mutations; + }, + ), + collectChannelDoctorStaleConfigMutations: vi.fn(async () => []), + runChannelDoctorConfigSequences: vi.fn(async () => ({ changeNotes: [], warningNotes: [] })), + shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning: vi.fn( + ({ channelName }: { channelName: string }) => + channelName === "googlechat" || channelName === "telegram", + ), + }; +}); + +vi.mock("./doctor/shared/preview-warnings.js", () => { + function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; + } + + function hasStringEntries(value: unknown): boolean { + return Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry); + } + + function telegramFirstTimeWarnings(params: { + account: Record; + parent?: Record; + prefix: string; + }): string[] { + const groupPolicy = + typeof params.account.groupPolicy === "string" + ? params.account.groupPolicy + : typeof params.parent?.groupPolicy === "string" + ? params.parent.groupPolicy + : undefined; + if (groupPolicy !== "allowlist") { + return []; + } + const botToken = params.account.botToken ?? params.parent?.botToken; + if (!botToken || asRecord(params.account.groups) || asRecord(params.parent?.groups)) { + return []; + } + if (hasStringEntries(params.account.groupAllowFrom ?? params.parent?.groupAllowFrom)) { + return []; + } + return [ + `- ${params.prefix}: Telegram is in first-time setup mode. DMs use pairing mode. Group messages stay blocked until you add allowed chats under ${params.prefix}.groups (and optional sender IDs under ${params.prefix}.groupAllowFrom), or set ${params.prefix}.groupPolicy to "open" if you want broad group access.`, + ]; + } + + return { + collectDoctorPreviewWarnings: vi.fn( + async ({ + cfg, + }: { + cfg: { + channels?: Record; + plugins?: { enabled?: boolean; entries?: Record }; + }; + doctorFixCommand: string; + }) => { + const warnings: string[] = []; + const telegram = asRecord(cfg.channels?.telegram); + if (telegram) { + const telegramBlocked = + cfg.plugins?.enabled === false || cfg.plugins?.entries?.telegram?.enabled === false; + if (telegramBlocked) { + warnings.push( + cfg.plugins?.enabled === false + ? "- channels.telegram: channel is configured, but plugins.enabled=false blocks channel plugins globally. Fix plugin enablement before relying on setup guidance for this channel." + : '- channels.telegram: channel is configured, but plugin "telegram" is disabled by plugins.entries.telegram.enabled=false. Fix plugin enablement before relying on setup guidance for this channel.', + ); + } else { + warnings.push( + ...telegramFirstTimeWarnings({ + account: telegram, + prefix: "channels.telegram", + }), + ); + const accounts = asRecord(telegram.accounts); + for (const [accountId, accountRaw] of Object.entries(accounts ?? {})) { + const account = asRecord(accountRaw); + if (account) { + warnings.push( + ...telegramFirstTimeWarnings({ + account, + parent: telegram, + prefix: `channels.telegram.accounts.${accountId}`, + }), + ); + } + } + } + } + const imessage = asRecord(cfg.channels?.imessage); + if (imessage?.groupPolicy === "allowlist" && !hasStringEntries(imessage.groupAllowFrom)) { + warnings.push( + '- channels.imessage.groupPolicy is "allowlist" but groupAllowFrom is empty — this channel does not fall back to allowFrom, so all group messages will be silently dropped.', + ); + } + return warnings; + }, + ), + }; +}); + +vi.mock("./doctor-config-preflight.js", async () => { + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const { + collectRelevantDoctorPluginIds, + listPluginDoctorLegacyConfigRules, + }: typeof import("../plugins/doctor-contract-registry.js") = + await import("../plugins/doctor-contract-registry.js"); + const { findLegacyConfigIssues }: typeof import("../config/legacy.js") = + await import("../config/legacy.js"); + + function resolveConfigPath() { + const stateDir = + process.env.OPENCLAW_STATE_DIR || + (process.env.HOME ? path.join(process.env.HOME, ".openclaw") : ""); + return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir, "openclaw.json"); + } + + return { + runDoctorConfigPreflight: vi.fn(async () => { + const configPath = resolveConfigPath(); + let parsed: Record = {}; + let exists = false; + try { + parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record; + exists = true; + } catch { + parsed = {}; + } + const legacyIssues = findLegacyConfigIssues( + parsed, + parsed, + listPluginDoctorLegacyConfigRules({ + pluginIds: collectRelevantDoctorPluginIds(parsed), + }), + ); + return { + snapshot: { + exists, + path: configPath, + parsed, + config: parsed, + sourceConfig: parsed, + valid: legacyIssues.length === 0, + warnings: [], + legacyIssues, + }, + baseConfig: parsed, + }; + }), + }; +}); + +vi.mock("./doctor-config-analysis.js", () => { + function formatConfigPath(parts: Array): string { + if (parts.length === 0) { + return ""; + } + let out = ""; + for (const part of parts) { + if (typeof part === "number") { + out += `[${part}]`; + } else { + out = out ? `${out}.${part}` : part; + } + } + return out || ""; + } + + function resolveConfigPathTarget(root: unknown, pathParts: Array): unknown { + let current: unknown = root; + for (const part of pathParts) { + if (typeof part === "number") { + if (!Array.isArray(current)) { + return null; + } + current = current[part]; + continue; + } + if (!current || typeof current !== "object" || Array.isArray(current)) { + return null; + } + current = (current as Record)[part]; + } + return current; + } + + return { + formatConfigPath, + noteIncludeConfinementWarning: vi.fn(), + noteOpencodeProviderOverrides: vi.fn(), + resolveConfigPathTarget, + stripUnknownConfigKeys: vi.fn((config: Record) => { + const next = structuredClone(config); + const removed: string[] = []; + if ("bridge" in next) { + delete next.bridge; + removed.push("bridge"); + } + const gatewayAuth = resolveConfigPathTarget(next, ["gateway", "auth"]); + if ( + gatewayAuth && + typeof gatewayAuth === "object" && + !Array.isArray(gatewayAuth) && + "extra" in gatewayAuth + ) { + delete (gatewayAuth as Record).extra; + removed.push("gateway.auth.extra"); + } + return { config: next, removed }; + }), + }; +}); + +vi.mock("./doctor-state-migrations.js", () => ({ + autoMigrateLegacyStateDir: vi.fn(async () => ({ changes: [], warnings: [] })), +})); + +function resetTerminalNoteMock() { + terminalNoteMock.mockClear(); + return terminalNoteMock; +} + function expectGoogleChatDmAllowFromRepaired(cfg: unknown) { const typed = cfg as { channels: { @@ -22,18 +536,12 @@ function expectGoogleChatDmAllowFromRepaired(cfg: unknown) { } async function collectDoctorWarnings(config: Record): Promise { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await runDoctorConfigWithInput({ - config, - run: loadAndMaybeMigrateDoctorConfig, - }); - return noteSpy.mock.calls - .filter((call) => call[1] === "Doctor warnings") - .map((call) => call[0]); - } finally { - noteSpy.mockRestore(); - } + const noteSpy = resetTerminalNoteMock(); + await runDoctorConfigWithInput({ + config, + run: loadAndMaybeMigrateDoctorConfig, + }); + return noteSpy.mock.calls.filter((call) => call[1] === "Doctor warnings").map((call) => call[0]); } type DiscordGuildRule = { @@ -59,16 +567,7 @@ type RepairedDiscordPolicy = { describe("doctor config flow", () => { beforeEach(() => { - setChannelPluginRegistryForTests([ - "discord", - "googlechat", - "imessage", - "matrix", - "slack", - "telegram", - "whatsapp", - "zalouser", - ]); + terminalNoteMock.mockClear(); }); it("preserves invalid config for doctor repairs", async () => { @@ -346,248 +845,6 @@ describe("doctor config flow", () => { expect(result.cfg.plugins?.entries?.browser?.enabled).toBe(true); }); - it("previews Matrix legacy sync-store migration in read-only mode", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await withTempHome(async (home) => { - const stateDir = path.join(home, ".openclaw"); - await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true }); - await fs.writeFile( - path.join(stateDir, "openclaw.json"), - JSON.stringify({ - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - }, - }, - }), - ); - await fs.writeFile( - path.join(stateDir, "matrix", "bot-storage.json"), - '{"next_batch":"s1"}', - ); - await loadAndMaybeMigrateDoctorConfig({ - options: { nonInteractive: true }, - confirm: async () => false, - }); - }); - - const warning = noteSpy.mock.calls.find( - (call) => - call[1] === "Doctor warnings" && call[0].includes("Matrix plugin upgraded in place."), - ); - expect(warning?.[0]).toContain("Legacy sync store:"); - expect(warning?.[0]).toContain( - 'Run "openclaw doctor --fix" to migrate this Matrix state now.', - ); - } finally { - noteSpy.mockRestore(); - } - }); - - it("previews Matrix encrypted-state migration in read-only mode", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await withTempHome(async (home) => { - const stateDir = path.join(home, ".openclaw"); - const { rootDir: accountRoot } = resolveMatrixAccountStorageRoot({ - stateDir, - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - }); - await fs.mkdir(path.join(accountRoot, "crypto"), { recursive: true }); - await fs.writeFile( - path.join(stateDir, "openclaw.json"), - JSON.stringify({ - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - }, - }, - }), - ); - await fs.writeFile( - path.join(accountRoot, "crypto", "bot-sdk.json"), - JSON.stringify({ deviceId: "DEVICE123" }), - ); - await loadAndMaybeMigrateDoctorConfig({ - options: { nonInteractive: true }, - confirm: async () => false, - }); - }); - - const warning = noteSpy.mock.calls.find( - (call) => - call[1] === "Doctor warnings" && - call[0].includes("Matrix encrypted-state migration is pending"), - ); - expect(warning?.[0]).toContain("Legacy crypto store:"); - expect(warning?.[0]).toContain("New recovery key file:"); - } finally { - noteSpy.mockRestore(); - } - }); - - it("migrates Matrix legacy state on doctor repair", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await withTempHome(async (home) => { - const stateDir = path.join(home, ".openclaw"); - await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true }); - await fs.writeFile( - path.join(stateDir, "openclaw.json"), - JSON.stringify({ - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - }, - }, - }), - ); - await fs.writeFile( - path.join(stateDir, "matrix", "bot-storage.json"), - '{"next_batch":"s1"}', - ); - await loadAndMaybeMigrateDoctorConfig({ - options: { nonInteractive: true, repair: true }, - confirm: async () => false, - }); - - const migratedRoot = path.join( - stateDir, - "matrix", - "accounts", - "default", - "matrix.example.org__bot_example.org", - ); - const migratedChildren = await fs.readdir(migratedRoot); - expect(migratedChildren.length).toBe(1); - expect( - await fs - .access(path.join(migratedRoot, migratedChildren[0] ?? "", "bot-storage.json")) - .then(() => true) - .catch(() => false), - ).toBe(true); - expect( - await fs - .access(path.join(stateDir, "matrix", "bot-storage.json")) - .then(() => true) - .catch(() => false), - ).toBe(false); - }); - - expect( - noteSpy.mock.calls.some( - (call) => - call[1] === "Doctor changes" && call[0].includes("Matrix plugin upgraded in place."), - ), - ).toBe(true); - } finally { - noteSpy.mockRestore(); - } - }); - - it("creates a Matrix migration snapshot before doctor repair mutates Matrix state", async () => { - await withTempHome(async (home) => { - const stateDir = path.join(home, ".openclaw"); - await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true }); - await fs.writeFile( - path.join(stateDir, "openclaw.json"), - JSON.stringify({ - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - }, - }, - }), - ); - await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); - - await loadAndMaybeMigrateDoctorConfig({ - options: { nonInteractive: true, repair: true }, - confirm: async () => false, - }); - - const snapshotDir = path.join(home, "Backups", "openclaw-migrations"); - const snapshotEntries = await fs.readdir(snapshotDir); - expect(snapshotEntries.some((entry) => entry.endsWith(".tar.gz"))).toBe(true); - - const marker = JSON.parse( - await fs.readFile(path.join(stateDir, "matrix", "migration-snapshot.json"), "utf8"), - ) as { - archivePath: string; - }; - expect(marker.archivePath).toContain(path.join("Backups", "openclaw-migrations")); - }); - }); - - it("warns when Matrix is installed from a stale custom path", async () => { - const doctorWarnings = await collectDoctorWarnings({ - channels: { - matrix: { - homeserver: "https://matrix.example.org", - accessToken: "tok-123", - }, - }, - plugins: { - installs: { - matrix: { - source: "path", - sourcePath: "/tmp/openclaw-matrix-missing", - installPath: "/tmp/openclaw-matrix-missing", - }, - }, - }, - }); - - expect( - doctorWarnings.some( - (line) => line.includes("custom path") && line.includes("/tmp/openclaw-matrix-missing"), - ), - ).toBe(true); - }); - - it("warns when Matrix is installed from an existing custom path", async () => { - await withTempHome(async (home) => { - const pluginPath = path.join(home, "matrix-plugin"); - await fs.mkdir(pluginPath, { recursive: true }); - - const doctorWarnings = await collectDoctorWarnings({ - channels: { - matrix: { - homeserver: "https://matrix.example.org", - accessToken: "tok-123", - }, - }, - plugins: { - installs: { - matrix: { - source: "path", - sourcePath: pluginPath, - installPath: pluginPath, - }, - }, - }, - }); - - expect( - doctorWarnings.some((line) => line.includes("Matrix is installed from a custom path")), - ).toBe(true); - expect( - doctorWarnings.some((line) => line.includes("will not automatically replace that plugin")), - ).toBe(true); - }); - }); - it("notes legacy browser extension migration changes", async () => { const result = await runDoctorConfigWithInput({ repair: true, @@ -661,7 +918,7 @@ describe("doctor config flow", () => { }); it("warns clearly about legacy channel streaming aliases and points to doctor --fix", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + const noteSpy = resetTerminalNoteMock(); try { await runDoctorConfigWithInput({ config: { @@ -716,7 +973,7 @@ describe("doctor config flow", () => { ), ).toBe(true); } finally { - noteSpy.mockRestore(); + noteSpy.mockClear(); } }); @@ -751,7 +1008,7 @@ describe("doctor config flow", () => { }); it("warns clearly about legacy nested channel allow aliases and points to doctor --fix", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + const noteSpy = resetTerminalNoteMock(); try { await runDoctorConfigWithInput({ config: { @@ -811,7 +1068,7 @@ describe("doctor config flow", () => { ), ).toBe(true); } finally { - noteSpy.mockRestore(); + noteSpy.mockClear(); } }); @@ -862,7 +1119,7 @@ describe("doctor config flow", () => { }); it("sanitizes config-derived doctor warnings and changes before logging", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + const noteSpy = resetTerminalNoteMock(); try { await runDoctorConfigWithInput({ repair: true, @@ -914,12 +1171,12 @@ describe("doctor config flow", () => { ), ).toBe(true); } finally { - noteSpy.mockRestore(); + noteSpy.mockClear(); } }); it("warns and continues when Telegram account inspection hits inactive SecretRef surfaces", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + const noteSpy = resetTerminalNoteMock(); const fetchSpy = vi.fn(); vi.stubGlobal("fetch", fetchSpy); try { @@ -968,44 +1225,46 @@ describe("doctor config flow", () => { ), ).toBe(true); } finally { - noteSpy.mockRestore(); + noteSpy.mockClear(); vi.unstubAllGlobals(); } }); it("converts numeric discord ids to strings on repair", async () => { - await withTempHome(async (home) => { - const configDir = path.join(home, ".openclaw"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify( - { - channels: { - discord: { - allowFrom: [123], - dm: { allowFrom: [456], groupChannels: [789] }, - execApprovals: { approvers: [321] }, - guilds: { - "100": { - users: [111], - roles: [222], - channels: { - general: { users: [333], roles: [444] }, + await withTempHome( + async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify( + { + channels: { + discord: { + allowFrom: [123], + dm: { allowFrom: [456], groupChannels: [789] }, + execApprovals: { approvers: [321] }, + guilds: { + "100": { + users: [111], + roles: [222], + channels: { + general: { users: [333], roles: [444] }, + }, }, }, - }, - accounts: { - work: { - allowFrom: [555], - dm: { allowFrom: [666], groupChannels: [777] }, - execApprovals: { approvers: [888] }, - guilds: { - "200": { - users: [999], - roles: [1010], - channels: { - help: { users: [1111], roles: [1212] }, + accounts: { + work: { + allowFrom: [555], + dm: { allowFrom: [666], groupChannels: [777] }, + execApprovals: { approvers: [888] }, + guilds: { + "200": { + users: [999], + roles: [1010], + channels: { + help: { users: [1111], roles: [1212] }, + }, }, }, }, @@ -1013,57 +1272,58 @@ describe("doctor config flow", () => { }, }, }, - }, - null, - 2, - ), - "utf-8", - ); + null, + 2, + ), + "utf-8", + ); - const result = await loadAndMaybeMigrateDoctorConfig({ - options: { nonInteractive: true, repair: true }, - confirm: async () => false, - }); + const result = await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true, repair: true }, + confirm: async () => false, + }); - const cfg = result.cfg as unknown as { - channels: { - discord: Omit & { - allowFrom?: string[]; - accounts: Record & { - default: { allowFrom: string[] }; - work: { - allowFrom: string[]; - dm: { allowFrom: string[]; groupChannels: string[] }; - execApprovals: { approvers: string[] }; - guilds: Record; + const cfg = result.cfg as unknown as { + channels: { + discord: Omit & { + allowFrom?: string[]; + accounts: Record & { + default: { allowFrom: string[] }; + work: { + allowFrom: string[]; + dm: { allowFrom: string[]; groupChannels: string[] }; + execApprovals: { approvers: string[] }; + guilds: Record; + }; }; }; }; }; - }; - expect(cfg.channels.discord.allowFrom).toBeUndefined(); - expect(cfg.channels.discord.dm.allowFrom).toEqual(["456"]); - expect(cfg.channels.discord.dm.groupChannels).toEqual(["789"]); - expect(cfg.channels.discord.execApprovals.approvers).toEqual(["321"]); - expect(cfg.channels.discord.guilds["100"].users).toEqual(["111"]); - expect(cfg.channels.discord.guilds["100"].roles).toEqual(["222"]); - expect(cfg.channels.discord.guilds["100"].channels.general.users).toEqual(["333"]); - expect(cfg.channels.discord.guilds["100"].channels.general.roles).toEqual(["444"]); - expect(cfg.channels.discord.accounts.default.allowFrom).toEqual(["123"]); - expect(cfg.channels.discord.accounts.work.allowFrom).toEqual(["555"]); - expect(cfg.channels.discord.accounts.work.dm.allowFrom).toEqual(["666"]); - expect(cfg.channels.discord.accounts.work.dm.groupChannels).toEqual(["777"]); - expect(cfg.channels.discord.accounts.work.execApprovals.approvers).toEqual(["888"]); - expect(cfg.channels.discord.accounts.work.guilds["200"].users).toEqual(["999"]); - expect(cfg.channels.discord.accounts.work.guilds["200"].roles).toEqual(["1010"]); - expect(cfg.channels.discord.accounts.work.guilds["200"].channels.help.users).toEqual([ - "1111", - ]); - expect(cfg.channels.discord.accounts.work.guilds["200"].channels.help.roles).toEqual([ - "1212", - ]); - }); + expect(cfg.channels.discord.allowFrom).toBeUndefined(); + expect(cfg.channels.discord.dm.allowFrom).toEqual(["456"]); + expect(cfg.channels.discord.dm.groupChannels).toEqual(["789"]); + expect(cfg.channels.discord.execApprovals.approvers).toEqual(["321"]); + expect(cfg.channels.discord.guilds["100"].users).toEqual(["111"]); + expect(cfg.channels.discord.guilds["100"].roles).toEqual(["222"]); + expect(cfg.channels.discord.guilds["100"].channels.general.users).toEqual(["333"]); + expect(cfg.channels.discord.guilds["100"].channels.general.roles).toEqual(["444"]); + expect(cfg.channels.discord.accounts.default.allowFrom).toEqual(["123"]); + expect(cfg.channels.discord.accounts.work.allowFrom).toEqual(["555"]); + expect(cfg.channels.discord.accounts.work.dm.allowFrom).toEqual(["666"]); + expect(cfg.channels.discord.accounts.work.dm.groupChannels).toEqual(["777"]); + expect(cfg.channels.discord.accounts.work.execApprovals.approvers).toEqual(["888"]); + expect(cfg.channels.discord.accounts.work.guilds["200"].users).toEqual(["999"]); + expect(cfg.channels.discord.accounts.work.guilds["200"].roles).toEqual(["1010"]); + expect(cfg.channels.discord.accounts.work.guilds["200"].channels.help.users).toEqual([ + "1111", + ]); + expect(cfg.channels.discord.accounts.work.guilds["200"].channels.help.roles).toEqual([ + "1212", + ]); + }, + { skipSessionCleanup: true }, + ); }); it("does not restore top-level allowFrom when config is intentionally default-account scoped", async () => { @@ -1218,36 +1478,39 @@ describe("doctor config flow", () => { }); it('repairs dmPolicy="allowlist" by restoring allowFrom from pairing store on repair', async () => { - const result = await withTempHome(async (home) => { - const configDir = path.join(home, ".openclaw"); - const credentialsDir = path.join(configDir, "credentials"); - await fs.mkdir(credentialsDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify( - { - channels: { - telegram: { - botToken: "fake-token", - dmPolicy: "allowlist", + const result = await withTempHome( + async (home) => { + const configDir = path.join(home, ".openclaw"); + const credentialsDir = path.join(configDir, "credentials"); + await fs.mkdir(credentialsDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify( + { + channels: { + telegram: { + botToken: "fake-token", + dmPolicy: "allowlist", + }, }, }, - }, - null, - 2, - ), - "utf-8", - ); - await fs.writeFile( - path.join(credentialsDir, "telegram-allowFrom.json"), - JSON.stringify({ version: 1, allowFrom: ["12345"] }, null, 2), - "utf-8", - ); - return await loadAndMaybeMigrateDoctorConfig({ - options: { nonInteractive: true, repair: true }, - confirm: async () => false, - }); - }); + null, + 2, + ), + "utf-8", + ); + await fs.writeFile( + path.join(credentialsDir, "telegram-allowFrom.json"), + JSON.stringify({ version: 1, allowFrom: ["12345"] }, null, 2), + "utf-8", + ); + return await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true, repair: true }, + confirm: async () => false, + }); + }, + { skipSessionCleanup: true }, + ); const cfg = result.cfg as { channels: { @@ -1352,81 +1615,111 @@ describe("doctor config flow", () => { }); }); - it("warns clearly about legacy config keys and points to doctor --fix", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + it("warns clearly about legacy config surfaces and points to doctor --fix", async () => { + const noteSpy = resetTerminalNoteMock(); try { await runDoctorConfigWithInput({ config: { heartbeat: { model: "anthropic/claude-3-5-haiku-20241022", every: "30m", - }, - }, - run: loadAndMaybeMigrateDoctorConfig, - }); - - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Legacy config keys detected" && - message.includes("heartbeat:") && - message.includes("agents.defaults.heartbeat"), - ), - ).toBe(true); - } finally { - noteSpy.mockRestore(); - } - }); - - it("warns clearly about legacy heartbeat visibility config and points to doctor --fix", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await runDoctorConfigWithInput({ - config: { - heartbeat: { showOk: true, showAlerts: false, }, - }, - run: loadAndMaybeMigrateDoctorConfig, - }); - - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Legacy config keys detected" && - message.includes("heartbeat:") && - message.includes("channels.defaults.heartbeat"), - ), - ).toBe(true); - } finally { - noteSpy.mockRestore(); - } - }); - - it("warns clearly about legacy memorySearch config and points to doctor --fix", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await runDoctorConfigWithInput({ - config: { memorySearch: { provider: "local", fallback: "none", }, + gateway: { + bind: "localhost", + }, + channels: { + telegram: { + groupMentionsOnly: true, + }, + discord: { + threadBindings: { + ttlHours: 12, + }, + accounts: { + alpha: { + threadBindings: { + ttlHours: 6, + }, + }, + }, + }, + }, + tools: { + web: { + x_search: { + apiKey: "test-key", + }, + }, + }, + hooks: { + internal: { + handlers: [{ event: "command:new", module: "hooks/legacy-handler.js" }], + }, + }, + session: { + threadBindings: { + ttlHours: 24, + }, + }, + talk: { + voiceId: "voice-1", + modelId: "eleven_v3", + }, + agents: { + defaults: { + sandbox: { + perSession: true, + }, + }, + }, }, run: loadAndMaybeMigrateDoctorConfig, }); + const legacyMessages = noteSpy.mock.calls + .filter(([, title]) => title === "Legacy config keys detected") + .map(([message]) => message) + .join("\n"); + + expect(legacyMessages).toContain("heartbeat:"); + expect(legacyMessages).toContain("agents.defaults.heartbeat"); + expect(legacyMessages).toContain("channels.defaults.heartbeat"); + expect(legacyMessages).toContain("memorySearch:"); + expect(legacyMessages).toContain("agents.defaults.memorySearch"); + expect(legacyMessages).toContain("gateway.bind:"); + expect(legacyMessages).toContain("gateway.bind host aliases"); + expect(legacyMessages).toContain("channels.telegram.groupMentionsOnly:"); + expect(legacyMessages).toContain("channels.telegram.groups"); + expect(legacyMessages).toContain("tools.web.x_search.apiKey:"); + expect(legacyMessages).toContain("plugins.entries.xai.config.webSearch.apiKey"); + expect(legacyMessages).toContain("hooks.internal.handlers:"); + expect(legacyMessages).toContain("HOOK.md + handler.js"); + expect(legacyMessages).toContain("does not rewrite this shape automatically"); + expect(legacyMessages).toContain("session.threadBindings.ttlHours"); + expect(legacyMessages).toContain("session.threadBindings.idleHours"); + expect(legacyMessages).toContain("channels..threadBindings.ttlHours"); + expect(legacyMessages).toContain("channels..threadBindings.idleHours"); + expect(legacyMessages).toContain("talk:"); + expect(legacyMessages).toContain( + "talk.voiceId/talk.voiceAliases/talk.modelId/talk.outputFormat/talk.apiKey", + ); + expect(legacyMessages).toContain("agents.defaults.sandbox:"); + expect(legacyMessages).toContain("agents.defaults.sandbox.perSession is legacy"); expect( noteSpy.mock.calls.some( ([message, title]) => - title === "Legacy config keys detected" && - message.includes("memorySearch:") && - message.includes("agents.defaults.memorySearch"), + title === "Doctor" && + message.includes('Run "openclaw doctor --fix" to migrate legacy config keys.'), ), ).toBe(true); } finally { - noteSpy.mockRestore(); + noteSpy.mockClear(); } }); @@ -1449,191 +1742,6 @@ describe("doctor config flow", () => { expect(cfg.gateway?.bind).toBe("lan"); }); - it("warns clearly about legacy gateway.bind host aliases and points to doctor --fix", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await runDoctorConfigWithInput({ - config: { - gateway: { - bind: "localhost", - }, - }, - run: loadAndMaybeMigrateDoctorConfig, - }); - - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Legacy config keys detected" && - message.includes("gateway.bind:") && - message.includes("gateway.bind host aliases"), - ), - ).toBe(true); - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Doctor" && - message.includes('Run "openclaw doctor --fix" to migrate legacy config keys.'), - ), - ).toBe(true); - } finally { - noteSpy.mockRestore(); - } - }); - - it("warns clearly about legacy telegram groupMentionsOnly config and points to doctor --fix", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await runDoctorConfigWithInput({ - config: { - channels: { - telegram: { - groupMentionsOnly: true, - }, - }, - }, - run: loadAndMaybeMigrateDoctorConfig, - }); - - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Legacy config keys detected" && - message.includes("channels.telegram.groupMentionsOnly:") && - message.includes("channels.telegram.groups"), - ), - ).toBe(true); - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Doctor" && - message.includes('Run "openclaw doctor --fix" to migrate legacy config keys.'), - ), - ).toBe(true); - } finally { - noteSpy.mockRestore(); - } - }); - - it("warns clearly about legacy x_search auth config and points to doctor --fix", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await runDoctorConfigWithInput({ - config: { - tools: { - web: { - x_search: { - apiKey: "test-key", - }, - }, - }, - }, - run: loadAndMaybeMigrateDoctorConfig, - }); - - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Legacy config keys detected" && - message.includes("tools.web.x_search.apiKey:") && - message.includes("plugins.entries.xai.config.webSearch.apiKey"), - ), - ).toBe(true); - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Doctor" && - message.includes('Run "openclaw doctor --fix" to migrate legacy config keys.'), - ), - ).toBe(true); - } finally { - noteSpy.mockRestore(); - } - }); - - it("warns clearly about legacy hooks.internal.handlers and requires manual migration", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await runDoctorConfigWithInput({ - config: { - hooks: { - internal: { - handlers: [{ event: "command:new", module: "hooks/legacy-handler.js" }], - }, - }, - }, - run: loadAndMaybeMigrateDoctorConfig, - }); - - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Legacy config keys detected" && - message.includes("hooks.internal.handlers:") && - message.includes("HOOK.md + handler.js"), - ), - ).toBe(true); - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Legacy config keys detected" && - message.includes("does not rewrite this shape automatically"), - ), - ).toBe(true); - } finally { - noteSpy.mockRestore(); - } - }); - - it("warns clearly about legacy thread binding ttlHours config and points to doctor --fix", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await runDoctorConfigWithInput({ - config: { - session: { - threadBindings: { - ttlHours: 24, - }, - }, - channels: { - discord: { - threadBindings: { - ttlHours: 12, - }, - accounts: { - alpha: { - threadBindings: { - ttlHours: 6, - }, - }, - }, - }, - }, - }, - run: loadAndMaybeMigrateDoctorConfig, - }); - - const legacyMessages = noteSpy.mock.calls - .filter(([, title]) => title === "Legacy config keys detected") - .map(([message]) => message) - .join("\n"); - - expect(legacyMessages).toContain("session.threadBindings.ttlHours"); - expect(legacyMessages).toContain("session.threadBindings.idleHours"); - expect(legacyMessages).toContain("channels..threadBindings.ttlHours"); - expect(legacyMessages).toContain("channels..threadBindings.idleHours"); - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Doctor" && - message.includes('Run "openclaw doctor --fix" to migrate legacy config keys.'), - ), - ).toBe(true); - } finally { - noteSpy.mockRestore(); - } - }); - it("repairs legacy thread binding ttlHours config on repair", async () => { const result = await runDoctorConfigWithInput({ repair: true, @@ -1700,70 +1808,6 @@ describe("doctor config flow", () => { expect(cfg.channels?.discord?.accounts?.alpha?.threadBindings?.ttlHours).toBeUndefined(); }); - it("warns clearly about legacy talk config and points to doctor --fix", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await runDoctorConfigWithInput({ - config: { - talk: { - voiceId: "voice-1", - modelId: "eleven_v3", - }, - }, - run: loadAndMaybeMigrateDoctorConfig, - }); - - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Legacy config keys detected" && - message.includes("talk:") && - message.includes( - "talk.voiceId/talk.voiceAliases/talk.modelId/talk.outputFormat/talk.apiKey", - ), - ), - ).toBe(true); - } finally { - noteSpy.mockRestore(); - } - }); - - it("warns clearly about legacy sandbox perSession config and points to doctor --fix", async () => { - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await runDoctorConfigWithInput({ - config: { - agents: { - defaults: { - sandbox: { - perSession: true, - }, - }, - }, - }, - run: loadAndMaybeMigrateDoctorConfig, - }); - - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Legacy config keys detected" && - message.includes("agents.defaults.sandbox:") && - message.includes("agents.defaults.sandbox.perSession is legacy"), - ), - ).toBe(true); - expect( - noteSpy.mock.calls.some( - ([message, title]) => - title === "Doctor" && - message.includes('Run "openclaw doctor --fix" to migrate legacy config keys.'), - ), - ).toBe(true); - } finally { - noteSpy.mockRestore(); - } - }); - it("migrates top-level heartbeat visibility into channels.defaults.heartbeat on repair", async () => { const result = await runDoctorConfigWithInput({ repair: true, @@ -1862,53 +1906,56 @@ describe("doctor config flow", () => { }); it("does not report repeat talk provider normalization on consecutive repair runs", async () => { - await withTempHome(async (home) => { - const providerId = "acme-speech"; - const configDir = path.join(home, ".openclaw"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "openclaw.json"), - JSON.stringify( - { - talk: { - interruptOnSpeech: true, - silenceTimeoutMs: 1500, - provider: providerId, - providers: { - [providerId]: { - apiKey: "secret-key", - voiceId: "voice-123", - modelId: "eleven_v3", + await withTempHome( + async (home) => { + const providerId = "acme-speech"; + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify( + { + talk: { + interruptOnSpeech: true, + silenceTimeoutMs: 1500, + provider: providerId, + providers: { + [providerId]: { + apiKey: "secret-key", + voiceId: "voice-123", + modelId: "eleven_v3", + }, }, }, }, - }, - null, - 2, - ), - "utf-8", - ); + null, + 2, + ), + "utf-8", + ); - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); - try { - await loadAndMaybeMigrateDoctorConfig({ - options: { nonInteractive: true, repair: true }, - confirm: async () => false, - }); - noteSpy.mockClear(); + const noteSpy = resetTerminalNoteMock(); + try { + await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true, repair: true }, + confirm: async () => false, + }); + noteSpy.mockClear(); - await loadAndMaybeMigrateDoctorConfig({ - options: { nonInteractive: true, repair: true }, - confirm: async () => false, - }); - const secondRunTalkNormalizationLines = noteSpy.mock.calls - .filter((call) => call[1] === "Doctor changes") - .map((call) => call[0]) - .filter((line) => line.includes("Normalized talk.provider/providers shape")); - expect(secondRunTalkNormalizationLines).toEqual([]); - } finally { - noteSpy.mockRestore(); - } - }); + await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true, repair: true }, + confirm: async () => false, + }); + const secondRunTalkNormalizationLines = noteSpy.mock.calls + .filter((call) => call[1] === "Doctor changes") + .map((call) => call[0]) + .filter((line) => line.includes("Normalized talk.provider/providers shape")); + expect(secondRunTalkNormalizationLines).toEqual([]); + } finally { + noteSpy.mockClear(); + } + }, + { skipSessionCleanup: true }, + ); }); }); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 1f2b26d2da2..e59ab7e18c1 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,9 +1,12 @@ import { formatCliCommand } from "../cli/command-format.js"; -import { CONFIG_PATH } from "../config/config.js"; import { findLegacyConfigIssues } from "../config/legacy.js"; +import { CONFIG_PATH } from "../config/paths.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { listPluginDoctorLegacyConfigRules } from "../plugins/doctor-contract-registry.js"; +import { + collectRelevantDoctorPluginIds, + listPluginDoctorLegacyConfigRules, +} from "../plugins/doctor-contract-registry.js"; import { note } from "../terminal/note.js"; import { noteOpencodeProviderOverrides } from "./doctor-config-analysis.js"; import { runDoctorConfigPreflight } from "./doctor-config-preflight.js"; @@ -58,7 +61,9 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { const pluginLegacyIssues = findLegacyConfigIssues( snapshot.parsed, snapshot.parsed, - listPluginDoctorLegacyConfigRules(), + listPluginDoctorLegacyConfigRules({ + pluginIds: collectRelevantDoctorPluginIds(snapshot.parsed), + }), ); const seenLegacyIssues = new Set( snapshot.legacyIssues.map((issue) => `${issue.path}:${issue.message}`), diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index ee2434c4663..81dcc751c31 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { readConfigFileSnapshot } from "../config/config.js"; +import { readConfigFileSnapshot } from "../config/io.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { note } from "../terminal/note.js"; diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts index ab9f84527d6..2e854722adb 100644 --- a/src/commands/doctor-cron.test.ts +++ b/src/commands/doctor-cron.test.ts @@ -3,9 +3,16 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import * as noteModule from "../terminal/note.js"; import { maybeRepairLegacyCronStore } from "./doctor-cron.js"; +type TerminalNote = (message: string, title?: string) => void; + +const noteMock = vi.hoisted(() => vi.fn()); + +vi.mock("../terminal/note.js", () => ({ + note: noteMock, +})); + let tempRoot: string | null = null; async function makeTempStorePath() { @@ -14,7 +21,7 @@ async function makeTempStorePath() { } afterEach(async () => { - vi.restoreAllMocks(); + noteMock.mockClear(); if (tempRoot) { await fs.rm(tempRoot, { recursive: true, force: true }); tempRoot = null; @@ -74,7 +81,7 @@ describe("maybeRepairLegacyCronStore", () => { const storePath = await makeTempStorePath(); await writeCronStore(storePath, [createLegacyCronJob()]); - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + const noteSpy = noteMock; const cfg = createCronConfig(storePath); await maybeRepairLegacyCronStore({ @@ -144,7 +151,7 @@ describe("maybeRepairLegacyCronStore", () => { "utf-8", ); - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + const noteSpy = noteMock; await maybeRepairLegacyCronStore({ cfg: { @@ -171,7 +178,7 @@ describe("maybeRepairLegacyCronStore", () => { const storePath = await makeTempStorePath(); await writeCronStore(storePath, [createLegacyCronJob()]); - const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + const noteSpy = noteMock; const prompter = makePrompter(false); await maybeRepairLegacyCronStore({ diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 17d322777a9..a429b3e4363 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -1,11 +1,17 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { clearPluginSetupRegistryCache } from "../plugins/setup-registry.js"; import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js"; +vi.mock("../plugins/setup-registry.js", () => ({ + runPluginSetupConfigMigrations: ({ config }: { config: OpenClawConfig }) => ({ + config, + changes: [], + }), +})); + function asLegacyConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } @@ -38,7 +44,6 @@ describe("normalizeCompatibilityConfigValues", () => { previousOauthDir = process.env.OPENCLAW_OAUTH_DIR; tempOauthDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-oauth-")); process.env.OPENCLAW_OAUTH_DIR = tempOauthDir; - clearPluginSetupRegistryCache(); }); beforeEach(() => { @@ -53,7 +58,6 @@ describe("normalizeCompatibilityConfigValues", () => { process.env.OPENCLAW_OAUTH_DIR = previousOauthDir; } fs.rmSync(tempOauthDir, { recursive: true, force: true }); - clearPluginSetupRegistryCache(); }); it("does not add whatsapp config when missing and no auth exists", () => { diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index 24d99c1ccd5..058e1373db9 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -6,6 +6,14 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveStorePath, resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; import { noteStateIntegrity } from "./doctor-state-integrity.js"; +vi.mock("../channels/plugins/bundled-ids.js", () => ({ + listBundledChannelPluginIds: () => ["matrix", "whatsapp"], +})); + +vi.mock("../channels/plugins/persisted-auth-state.js", () => ({ + hasBundledChannelPersistedAuthState: () => false, +})); + const noteMock = vi.fn(); type EnvSnapshot = { diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index a0261a7d2f1..6ba0934a900 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -72,6 +72,7 @@ export const resolveOpenClawPackageRoot = vi.fn().mockResolvedValue(null) as unk export const runGatewayUpdate = vi .fn() .mockResolvedValue(createGatewayUpdateResult()) as unknown as MockFn; +export const collectRelevantDoctorPluginIds = vi.fn(() => []) as unknown as MockFn; export const listPluginDoctorLegacyConfigRules = vi.fn(() => []) as unknown as MockFn; export const runDoctorHealthContributions = vi.fn( defaultRunDoctorHealthContributions, @@ -264,6 +265,16 @@ vi.mock("../config/config.js", async () => { }; }); +vi.mock("../config/io.js", async () => { + const actual = await vi.importActual("../config/io.js"); + return { + ...actual, + createConfigIO, + readConfigFileSnapshot, + writeConfigFile, + }; +}); + vi.mock("../daemon/legacy.js", () => ({ findLegacyGatewayServices, uninstallLegacyGatewayServices, @@ -351,6 +362,7 @@ vi.mock("./doctor-memory-search.js", () => ({ })); vi.mock("../plugins/doctor-contract-registry.js", () => ({ + collectRelevantDoctorPluginIds, listPluginDoctorLegacyConfigRules, })); diff --git a/src/commands/doctor/shared/channel-doctor.test.ts b/src/commands/doctor/shared/channel-doctor.test.ts index 09f751a29d2..738516c3dc8 100644 --- a/src/commands/doctor/shared/channel-doctor.test.ts +++ b/src/commands/doctor/shared/channel-doctor.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { collectChannelDoctorCompatibilityMutations } from "./channel-doctor.js"; const mocks = vi.hoisted(() => ({ getChannelPlugin: vi.fn(), @@ -21,12 +22,8 @@ vi.mock("../../../channels/plugins/bundled.js", () => ({ mocks.listBundledChannelPlugins(...args), })); -let collectChannelDoctorCompatibilityMutations: typeof import("./channel-doctor.js").collectChannelDoctorCompatibilityMutations; - describe("channel doctor compatibility mutations", () => { - beforeEach(async () => { - vi.resetModules(); - ({ collectChannelDoctorCompatibilityMutations } = await import("./channel-doctor.js")); + beforeEach(() => { mocks.getChannelPlugin.mockReset(); mocks.getBundledChannelPlugin.mockReset(); mocks.listChannelPlugins.mockReset(); diff --git a/src/commands/doctor/shared/channel-doctor.ts b/src/commands/doctor/shared/channel-doctor.ts index 966d72a5f2b..b2084d141c0 100644 --- a/src/commands/doctor/shared/channel-doctor.ts +++ b/src/commands/doctor/shared/channel-doctor.ts @@ -82,7 +82,7 @@ export async function runChannelDoctorConfigSequences(params: { }): Promise { const changeNotes: string[] = []; const warningNotes: string[] = []; - for (const entry of listChannelDoctorEntries()) { + for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg))) { const result = await entry.doctor.runConfigSequence?.(params); if (!result) { continue; @@ -118,7 +118,7 @@ export async function collectChannelDoctorStaleConfigMutations( ): Promise { const mutations: ChannelDoctorConfigMutation[] = []; let nextCfg = cfg; - for (const entry of listChannelDoctorEntries()) { + for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(cfg))) { const mutation = await entry.doctor.cleanStaleConfig?.({ cfg: nextCfg }); if (!mutation || mutation.changes.length === 0) { continue; @@ -134,7 +134,7 @@ export async function collectChannelDoctorPreviewWarnings(params: { doctorFixCommand: string; }): Promise { const warnings: string[] = []; - for (const entry of listChannelDoctorEntries()) { + for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg))) { const lines = await entry.doctor.collectPreviewWarnings?.(params); if (lines?.length) { warnings.push(...lines); @@ -147,7 +147,7 @@ export async function collectChannelDoctorMutableAllowlistWarnings(params: { cfg: OpenClawConfig; }): Promise { const warnings: string[] = []; - for (const entry of listChannelDoctorEntries()) { + for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg))) { const lines = await entry.doctor.collectMutableAllowlistWarnings?.(params); if (lines?.length) { warnings.push(...lines); @@ -162,7 +162,7 @@ export async function collectChannelDoctorRepairMutations(params: { }): Promise { const mutations: ChannelDoctorConfigMutation[] = []; let nextCfg = params.cfg; - for (const entry of listChannelDoctorEntries()) { + for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg))) { const mutation = await entry.doctor.repairConfig?.({ cfg: nextCfg, doctorFixCommand: params.doctorFixCommand, @@ -183,7 +183,7 @@ export function collectChannelDoctorEmptyAllowlistExtraWarnings( params: ChannelDoctorEmptyAllowlistAccountContext, ): string[] { const warnings: string[] = []; - for (const entry of listChannelDoctorEntries()) { + for (const entry of listChannelDoctorEntries([params.channelName])) { const lines = entry.doctor.collectEmptyAllowlistExtraWarnings?.(params); if (lines?.length) { warnings.push(...lines); @@ -195,7 +195,7 @@ export function collectChannelDoctorEmptyAllowlistExtraWarnings( export function shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning( params: ChannelDoctorEmptyAllowlistAccountContext, ): boolean { - return listChannelDoctorEntries().some( + return listChannelDoctorEntries([params.channelName]).some( (entry) => entry.doctor.shouldSkipDefaultEmptyGroupAllowlistWarning?.(params) === true, ); } diff --git a/src/commands/doctor/shared/legacy-config-core-normalizers.ts b/src/commands/doctor/shared/legacy-config-core-normalizers.ts index 0dcf079b45d..b8b87c91bf5 100644 --- a/src/commands/doctor/shared/legacy-config-core-normalizers.ts +++ b/src/commands/doctor/shared/legacy-config-core-normalizers.ts @@ -2,7 +2,6 @@ import { isDeepStrictEqual } from "node:util"; import { normalizeProviderId } from "../../../agents/model-selection-normalize.js"; import { resolveSingleAccountKeysToMove } from "../../../channels/plugins/setup-helpers.js"; import { resolveNormalizedProviderModelMaxTokens } from "../../../config/defaults.js"; -import { normalizeTalkSection } from "../../../config/talk.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { DEFAULT_GOOGLE_API_BASE_URL } from "../../../infra/google-api-base-url.js"; import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; @@ -11,18 +10,7 @@ import { normalizeOptionalString, } from "../../../shared/string-coerce.js"; import { isRecord } from "./legacy-config-record-shared.js"; - -function buildLegacyTalkProviderCompat( - talk: Record, -): Record | undefined { - const compat: Record = {}; - for (const key of ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"] as const) { - if (talk[key] !== undefined) { - compat[key] = talk[key]; - } - } - return Object.keys(compat).length > 0 ? compat : undefined; -} +export { normalizeLegacyTalkConfig } from "./legacy-talk-config-normalizer.js"; export function normalizeLegacyBrowserConfig( cfg: OpenClawConfig, @@ -320,36 +308,6 @@ export function normalizeLegacyNanoBananaSkill( }; } -export function normalizeLegacyTalkConfig(cfg: OpenClawConfig, changes: string[]): OpenClawConfig { - const rawTalk = cfg.talk; - if (!isRecord(rawTalk)) { - return cfg; - } - - const normalizedTalk = normalizeTalkSection(rawTalk as OpenClawConfig["talk"]) ?? {}; - const legacyProviderCompat = buildLegacyTalkProviderCompat(rawTalk); - if (legacyProviderCompat) { - normalizedTalk.providers = { - ...normalizedTalk.providers, - elevenlabs: { - ...legacyProviderCompat, - ...normalizedTalk.providers?.elevenlabs, - }, - }; - } - if (Object.keys(normalizedTalk).length === 0 || isDeepStrictEqual(normalizedTalk, rawTalk)) { - return cfg; - } - - changes.push( - "Normalized talk.provider/providers shape (trimmed provider ids and merged missing compatibility fields).", - ); - return { - ...cfg, - talk: normalizedTalk, - }; -} - export function normalizeLegacyCrossContextMessageConfig( cfg: OpenClawConfig, changes: string[], diff --git a/src/commands/doctor/shared/legacy-talk-config-normalizer.ts b/src/commands/doctor/shared/legacy-talk-config-normalizer.ts new file mode 100644 index 00000000000..dd4c5665483 --- /dev/null +++ b/src/commands/doctor/shared/legacy-talk-config-normalizer.ts @@ -0,0 +1,49 @@ +import { isDeepStrictEqual } from "node:util"; +import { normalizeTalkSection } from "../../../config/talk.js"; +import type { OpenClawConfig } from "../../../config/types.js"; + +function buildLegacyTalkProviderCompat( + talk: Record, +): Record | undefined { + const compat: Record = {}; + for (const key of ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"] as const) { + if (talk[key] !== undefined) { + compat[key] = talk[key]; + } + } + return Object.keys(compat).length > 0 ? compat : undefined; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +export function normalizeLegacyTalkConfig(cfg: OpenClawConfig, changes: string[]): OpenClawConfig { + const rawTalk = cfg.talk; + if (!isRecord(rawTalk)) { + return cfg; + } + + const normalizedTalk = normalizeTalkSection(rawTalk as OpenClawConfig["talk"]) ?? {}; + const legacyProviderCompat = buildLegacyTalkProviderCompat(rawTalk); + if (legacyProviderCompat) { + normalizedTalk.providers = { + ...normalizedTalk.providers, + elevenlabs: { + ...legacyProviderCompat, + ...normalizedTalk.providers?.elevenlabs, + }, + }; + } + if (Object.keys(normalizedTalk).length === 0 || isDeepStrictEqual(normalizedTalk, rawTalk)) { + return cfg; + } + + changes.push( + "Normalized talk.provider/providers shape (trimmed provider ids and merged missing compatibility fields).", + ); + return { + ...cfg, + talk: normalizedTalk, + }; +} diff --git a/src/commands/flows.test.ts b/src/commands/flows.test.ts index ca26854ccaa..a67b68c8d8f 100644 --- a/src/commands/flows.test.ts +++ b/src/commands/flows.test.ts @@ -12,13 +12,9 @@ import { import { withTempDir } from "../test-helpers/temp-dir.js"; import { flowsCancelCommand, flowsListCommand, flowsShowCommand } from "./flows.js"; -vi.mock("../config/config.js", async () => { - const actual = await vi.importActual("../config/config.js"); - return { - ...actual, - loadConfig: vi.fn(() => ({})), - }; -}); +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn(() => ({})), +})); const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR; diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 4d4e54a71f2..7316d6e87ca 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -4,8 +4,7 @@ import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js"; import type { GatewayTlsRuntime } from "../infra/tls/gateway.js"; import type { RuntimeEnv } from "../runtime.js"; import { withEnvAsync } from "../test-utils/env.js"; - -let gatewayStatusCommand: typeof import("./gateway-status.js").gatewayStatusCommand; +import { gatewayStatusCommand } from "./gateway-status.js"; const mocks = vi.hoisted(() => { const sshStop = vi.fn(async () => {}); @@ -240,10 +239,8 @@ function findUnresolvedSecretRefWarning(runtimeLogs: string[]) { } describe("gateway-status command", () => { - beforeEach(async () => { - vi.resetModules(); + beforeEach(() => { vi.clearAllMocks(); - ({ gatewayStatusCommand } = await import("./gateway-status.js")); }); it("prints human output by default", async () => { diff --git a/src/commands/health.command.coverage.test.ts b/src/commands/health.command.coverage.test.ts index 3e6802b72b6..3130d0a5733 100644 --- a/src/commands/health.command.coverage.test.ts +++ b/src/commands/health.command.coverage.test.ts @@ -1,7 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; import { stripAnsi } from "../terminal/ansi.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; import type { HealthSummary } from "./health.js"; import { healthCommand } from "./health.js"; @@ -25,6 +23,32 @@ vi.mock("../gateway/call.js", () => ({ Reflect.apply(buildGatewayConnectionDetailsMock, undefined, args), })); +vi.mock("../channels/plugins/index.js", () => { + const whatsappPlugin = { + id: "whatsapp", + meta: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/channels/whatsapp", + blurb: "WhatsApp test stub.", + }, + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + status: { + logSelfId: () => logWebSelfIdMock(), + }, + }; + + return { + getChannelPlugin: (channelId: string) => (channelId === "whatsapp" ? whatsappPlugin : null), + listChannelPlugins: () => [whatsappPlugin], + }; +}); + describe("healthCommand (coverage)", () => { const runtime = { log: vi.fn(), @@ -37,32 +61,6 @@ describe("healthCommand (coverage)", () => { buildGatewayConnectionDetailsMock.mockReturnValue({ message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789", }); - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "whatsapp", - source: "test", - plugin: { - id: "whatsapp", - meta: { - id: "whatsapp", - label: "WhatsApp", - selectionLabel: "WhatsApp", - docsPath: "/channels/whatsapp", - blurb: "WhatsApp test stub.", - }, - capabilities: { chatTypes: ["direct", "group"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - status: { - logSelfId: () => logWebSelfIdMock(), - }, - }, - }, - ]), - ); }); it("prints the rich text summary when linked and configured", async () => { diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index b062297bbbc..f14c397c8fb 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -42,6 +42,12 @@ async function loadFreshHealthModulesForTest() { recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), updateLastRoute: vi.fn().mockResolvedValue(undefined), })); + vi.doMock("../config/sessions/paths.js", () => ({ + resolveStorePath: () => "/tmp/sessions.json", + })); + vi.doMock("../config/sessions/store.js", () => ({ + loadSessionStore: () => testStore, + })); vi.doMock("../plugins/runtime/runtime-web-channel-plugin.js", () => ({ webAuthExists: vi.fn(async () => true), getWebAuthAgeMs: vi.fn(() => 1234), diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index c138932e087..a7ea4935d3f 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -87,7 +87,7 @@ describe("healthCommand", () => { }); callGatewayMock.mockResolvedValueOnce(snapshot); - await healthCommand({ json: true, timeoutMs: 5000 }, runtime as never); + await healthCommand({ json: true, timeoutMs: 5000, config: {} }, runtime as never); expect(runtime.exit).not.toHaveBeenCalled(); const logged = runtime.log.mock.calls[0]?.[0] as string; @@ -114,7 +114,7 @@ describe("healthCommand", () => { }), ); - await healthCommand({ json: false }, runtime as never); + await healthCommand({ json: false, config: {} }, runtime as never); expect(runtime.exit).not.toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalled(); diff --git a/src/commands/health.ts b/src/commands/health.ts index 29e08fc96e6..2444888cbfa 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -5,9 +5,8 @@ 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"; import { withProgress } from "../cli/progress.js"; -import { loadConfig, readBestEffortConfig } from "../config/config.js"; -import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; import { isTruthyEnvValue } from "../infra/env.js"; @@ -105,7 +104,8 @@ const resolveAgentOrder = (cfg: OpenClawConfig) => { return { defaultAgentId, ordered }; }; -const buildSessionSummary = (storePath: string) => { +const buildSessionSummary = async (storePath: string) => { + const { loadSessionStore } = await import("../config/sessions/store.js"); const store = loadSessionStore(storePath); const sessions = Object.entries(store) .filter(([key]) => key !== "global" && key !== "unknown") @@ -379,29 +379,31 @@ export async function getHealthSnapshot(params?: { probe?: boolean; }): Promise { const timeoutMs = params?.timeoutMs; + const { loadConfig } = await import("../config/config.js"); const cfg = loadConfig(); const { defaultAgentId, ordered } = resolveAgentOrder(cfg); const channelBindings = buildChannelAccountBindings(cfg); const sessionCache = new Map(); - const agents: AgentHealthSummary[] = ordered.map((entry) => { + const agents: AgentHealthSummary[] = []; + for (const entry of ordered) { const storePath = resolveStorePath(cfg.session?.store, { agentId: entry.id }); - const sessions = sessionCache.get(storePath) ?? buildSessionSummary(storePath); + const sessions = sessionCache.get(storePath) ?? (await buildSessionSummary(storePath)); sessionCache.set(storePath, sessions); - return { + agents.push({ agentId: entry.id, name: entry.name, isDefault: entry.id === defaultAgentId, heartbeat: resolveHeartbeatSummary(cfg, entry.id), sessions, - } satisfies AgentHealthSummary; - }); + }); + } const defaultAgent = agents.find((agent) => agent.isDefault) ?? agents[0]; const heartbeatSeconds = defaultAgent?.heartbeat.everyMs ? Math.round(defaultAgent.heartbeat.everyMs / 1000) : 0; const sessions = defaultAgent?.sessions ?? - buildSessionSummary(resolveStorePath(cfg.session?.store, { agentId: defaultAgentId })); + (await buildSessionSummary(resolveStorePath(cfg.session?.store, { agentId: defaultAgentId }))); const start = Date.now(); const cappedTimeout = timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : Math.max(50, timeoutMs); @@ -556,7 +558,7 @@ export async function healthCommand( opts: { json?: boolean; timeoutMs?: number; verbose?: boolean; config?: OpenClawConfig }, runtime: RuntimeEnv, ) { - const cfg = opts.config ?? (await readBestEffortConfig()); + const cfg = opts.config ?? (await readBestEffortHealthConfig()); // Always query the running gateway; do not open a direct Baileys socket here. const summary = await withProgress( { @@ -591,16 +593,17 @@ export async function healthCommand( const localAgents = resolveAgentOrder(cfg); const defaultAgentId = summary.defaultAgentId ?? localAgents.defaultAgentId; const agents = Array.isArray(summary.agents) ? summary.agents : []; - const fallbackAgents = localAgents.ordered.map((entry) => { + const fallbackAgents: AgentHealthSummary[] = []; + for (const entry of localAgents.ordered) { const storePath = resolveStorePath(cfg.session?.store, { agentId: entry.id }); - return { + fallbackAgents.push({ agentId: entry.id, name: entry.name, isDefault: entry.id === localAgents.defaultAgentId, heartbeat: resolveHeartbeatSummary(cfg, entry.id), - sessions: buildSessionSummary(storePath), - } satisfies AgentHealthSummary; - }); + sessions: await buildSessionSummary(storePath), + }); + } const resolvedAgents = agents.length > 0 ? agents : fallbackAgents; const displayAgents = opts.verbose ? resolvedAgents @@ -802,3 +805,8 @@ export async function healthCommand( runtime.exit(1); } } + +async function readBestEffortHealthConfig(): Promise { + const { readBestEffortConfig } = await import("../config/config.js"); + return await readBestEffortConfig(); +} diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index e79969967dc..11cae4dc164 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -4,9 +4,9 @@ import path from "node:path"; import { inspect } from "node:util"; import { cancel, isCancel } from "@clack/prompts"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js"; -import { CONFIG_PATH } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; -import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; +import { CONFIG_PATH } from "../config/paths.js"; +import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveControlUiLinks } from "../gateway/control-ui-links.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 29364439f3d..3387a90729f 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -73,16 +73,31 @@ vi.mock("../gateway/client.js", () => ({ }, })); -vi.mock("./onboard-helpers.js", async () => { - const actual = - await vi.importActual("./onboard-helpers.js"); +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 { - ...actual, + DEFAULT_WORKSPACE: "/tmp/openclaw-workspace", + applyWizardMetadata: (cfg: unknown) => cfg, ensureWorkspaceAndSessions: ensureWorkspaceAndSessionsMock, - waitForGatewayReachable: (...args: Parameters) => - waitForGatewayReachableMock - ? waitForGatewayReachableMock(args[0]) - : actual.waitForGatewayReachable(...args), + normalizeGatewayTokenInput, + randomToken: () => "tok_generated_gateway_test_token", + resolveControlUiLinks: ({ port }: { port: number }) => ({ + httpUrl: `http://127.0.0.1:${port}`, + wsUrl: `ws://127.0.0.1:${port}`, + }), + waitForGatewayReachable: (params: { + url: string; + token?: string; + password?: string; + deadlineMs?: number; + probeTimeoutMs?: number; + }) => waitForGatewayReachableMock?.(params) ?? Promise.resolve({ ok: true }), }; }); @@ -104,15 +119,17 @@ vi.mock("../daemon/diagnostics.js", () => ({ let runNonInteractiveSetup: typeof import("./onboard-non-interactive.js").runNonInteractiveSetup; let resolveStateConfigPath: typeof import("../config/paths.js").resolveConfigPath; -let resolveConfigPath: typeof import("../config/config.js").resolveConfigPath; -let callGateway: typeof import("../gateway/call.js").callGateway; +let callGateway: typeof import("../gateway/call.js").callGateway | undefined; async function loadGatewayOnboardModules(): Promise { vi.resetModules(); ({ runNonInteractiveSetup } = await import("./onboard-non-interactive.js")); ({ resolveConfigPath: resolveStateConfigPath } = await import("../config/paths.js")); - ({ resolveConfigPath } = await import("../config/config.js")); - ({ callGateway } = await import("../gateway/call.js")); +} + +async function loadCallGateway(): Promise { + callGateway ??= (await import("../gateway/call.js")).callGateway; + return callGateway; } function getPseudoPort(base: number): number { @@ -429,7 +446,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }, 60_000); it("writes gateway.remote url/token and callGateway uses them", async () => { - await withStateDir("state-remote-", async () => { + await withStateDir("state-remote-", async (stateDir) => { const port = getPseudoPort(30_000); const token = "tok_remote_123"; await runNonInteractiveSetup( @@ -446,14 +463,14 @@ describe("onboard (non-interactive): gateway and remote auth", () => { const cfg = await readJsonFile<{ gateway?: { mode?: string; remote?: { url?: string; token?: string } }; - }>(resolveConfigPath()); + }>(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 callGateway({ method: "health" }); + 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}`); diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 9eb0027f7c2..14e43e6dbb6 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; @@ -28,11 +29,6 @@ const TEST_MAIN_AUTH_STORE_KEY = "__main__"; const ensureWorkspaceAndSessionsMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => {})); const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn(async () => { - const [{ default: fs }, { default: path }, { default: crypto }] = await Promise.all([ - import("node:fs/promises"), - import("node:path"), - import("node:crypto"), - ]); const configPath = process.env.OPENCLAW_CONFIG_PATH; if (!configPath) { throw new Error("OPENCLAW_CONFIG_PATH must be set for provider auth onboarding tests"); @@ -61,10 +57,6 @@ const readConfigFileSnapshotMock = vi.hoisted(() => ); const replaceConfigFileMock = vi.hoisted(() => vi.fn(async (params: { nextConfig: unknown }) => { - const [{ default: fs }, { default: path }] = await Promise.all([ - import("node:fs/promises"), - import("node:path"), - ]); const configPath = process.env.OPENCLAW_CONFIG_PATH; if (!configPath) { throw new Error("OPENCLAW_CONFIG_PATH must be set for provider auth onboarding tests"); @@ -141,14 +133,12 @@ function upsertAuthProfile(params: { writeRuntimeAuthSnapshots(); } -vi.mock("../config/config.js", async () => { - const actual = await vi.importActual("../config/config.js"); - return { - ...actual, - readConfigFileSnapshot: readConfigFileSnapshotMock, - replaceConfigFile: replaceConfigFileMock, - }; -}); +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("./onboard-non-interactive/local/auth-choice.plugin-providers.js", async () => { const [ @@ -826,12 +816,25 @@ vi.mock("./onboard-non-interactive/local/auth-choice.plugin-providers.js", async }; }); -vi.mock("./onboard-helpers.js", async () => { - const actual = - await vi.importActual("./onboard-helpers.js"); +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 { - ...actual, + 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 }), }; }); diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 51c9902c50b..ed09c371a17 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -1,5 +1,5 @@ import { formatCliCommand } from "../cli/command-format.js"; -import { readConfigFileSnapshot } from "../config/config.js"; +import { readConfigFileSnapshot } from "../config/io.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; diff --git a/src/commands/setup.test.ts b/src/commands/setup.test.ts index b46403d09f4..3785e19d74a 100644 --- a/src/commands/setup.test.ts +++ b/src/commands/setup.test.ts @@ -7,9 +7,17 @@ import { setupCommand } from "./setup.js"; function createSetupDeps(home: string) { const configPath = path.join(home, ".openclaw", "openclaw.json"); return { + createConfigIO: () => ({ configPath }), ensureAgentWorkspace: vi.fn(async (params?: { dir?: string }) => ({ dir: params?.dir ?? path.join(home, ".openclaw", "workspace"), })), + formatConfigPath: (value: string) => value, + logConfigUpdated: vi.fn( + (runtime: { log: (message: string) => void }, opts: { path?: string; suffix?: string }) => { + const suffix = opts.suffix ? ` ${opts.suffix}` : ""; + runtime.log(`Updated ${opts.path}${suffix}`); + }, + ), mkdir: vi.fn(async () => {}), resolveSessionTranscriptsDir: vi.fn(() => path.join(home, ".openclaw", "sessions")), writeConfigFile: vi.fn(async (config: unknown) => { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 2b222deb02e..bbd9ce11085 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,9 +1,7 @@ import fs from "node:fs/promises"; import JSON5 from "json5"; import { z } from "zod"; -import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js"; -import { type OpenClawConfig, createConfigIO, writeConfigFile } from "../config/config.js"; -import { formatConfigPath, logConfigUpdated } from "../config/logging.js"; +import type { OpenClawConfig } from "../config/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { shortenHomePath } from "../utils.js"; @@ -11,13 +9,71 @@ import { safeParseWithSchema } from "../utils/zod-parse.js"; const JsonRecordSchema = z.record(z.string(), z.unknown()); +type ConfigIO = { + configPath: string; +}; + +type EnsureAgentWorkspace = (params: { + dir: string; + ensureBootstrapFiles?: boolean; +}) => Promise<{ dir: string }>; + type SetupCommandDeps = { - ensureAgentWorkspace?: typeof ensureAgentWorkspace; + createConfigIO?: () => ConfigIO; + defaultAgentWorkspaceDir?: string | (() => string | Promise); + ensureAgentWorkspace?: EnsureAgentWorkspace; + formatConfigPath?: (path: string) => string; + logConfigUpdated?: ( + runtime: RuntimeEnv, + opts: { path?: string; suffix?: string }, + ) => void | Promise; mkdir?: (dir: string, options: { recursive: true }) => Promise; resolveSessionTranscriptsDir?: () => string | Promise; - writeConfigFile?: typeof writeConfigFile; + writeConfigFile?: (config: OpenClawConfig) => Promise; }; +async function createDefaultConfigIO(): Promise { + const { createConfigIO } = await import("../config/io.js"); + return createConfigIO(); +} + +async function resolveDefaultAgentWorkspaceDir(deps: SetupCommandDeps): Promise { + const override = deps.defaultAgentWorkspaceDir; + if (typeof override === "string") { + return override; + } + if (typeof override === "function") { + return await override(); + } + const { DEFAULT_AGENT_WORKSPACE_DIR } = await import("../agents/workspace.js"); + return DEFAULT_AGENT_WORKSPACE_DIR; +} + +async function ensureDefaultAgentWorkspace( + params: Parameters[0], +): ReturnType { + const { ensureAgentWorkspace } = await import("../agents/workspace.js"); + return ensureAgentWorkspace(params); +} + +async function writeDefaultConfigFile(config: OpenClawConfig): Promise { + const { writeConfigFile } = await import("../config/io.js"); + await writeConfigFile(config); +} + +async function formatDefaultConfigPath(configPath: string): Promise { + const { formatConfigPath } = await import("../config/logging.js"); + return formatConfigPath(configPath); +} + +async function logDefaultConfigUpdated( + runtime: RuntimeEnv, + opts: { path?: string; suffix?: string }, +): Promise { + const { logConfigUpdated } = await import("../config/logging.js"); + logConfigUpdated(runtime, opts); +} + async function resolveDefaultSessionTranscriptsDir(): Promise { const { resolveSessionTranscriptsDir } = await import("../config/sessions.js"); return resolveSessionTranscriptsDir(); @@ -46,13 +102,14 @@ export async function setupCommand( ? opts.workspace.trim() : undefined; - const io = createConfigIO(); + const io = deps.createConfigIO?.() ?? (await createDefaultConfigIO()); const configPath = io.configPath; const existingRaw = await readConfigFileRaw(configPath); const cfg = existingRaw.parsed; const defaults = cfg.agents?.defaults ?? {}; - const workspace = desiredWorkspace ?? defaults.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const workspace = + desiredWorkspace ?? defaults.workspace ?? (await resolveDefaultAgentWorkspaceDir(deps)); const next: OpenClawConfig = { ...cfg, @@ -74,9 +131,10 @@ export async function setupCommand( defaults.workspace !== workspace || cfg.gateway?.mode !== next.gateway?.mode ) { - await (deps.writeConfigFile ?? writeConfigFile)(next); + await (deps.writeConfigFile ?? writeDefaultConfigFile)(next); if (!existingRaw.exists) { - runtime.log(`Wrote ${formatConfigPath(configPath)}`); + const formatConfigPath = deps.formatConfigPath ?? formatDefaultConfigPath; + runtime.log(`Wrote ${await formatConfigPath(configPath)}`); } else { const updates: string[] = []; if (defaults.workspace !== workspace) { @@ -86,13 +144,17 @@ export async function setupCommand( updates.push("set gateway.mode"); } const suffix = updates.length > 0 ? `(${updates.join(", ")})` : undefined; - logConfigUpdated(runtime, { path: configPath, suffix }); + await (deps.logConfigUpdated ?? logDefaultConfigUpdated)(runtime, { + path: configPath, + suffix, + }); } } else { - runtime.log(`Config OK: ${formatConfigPath(configPath)}`); + const formatConfigPath = deps.formatConfigPath ?? formatDefaultConfigPath; + runtime.log(`Config OK: ${await formatConfigPath(configPath)}`); } - const ws = await (deps.ensureAgentWorkspace ?? ensureAgentWorkspace)({ + const ws = await (deps.ensureAgentWorkspace ?? ensureDefaultAgentWorkspace)({ dir: workspace, ensureBootstrapFiles: !next.agents?.defaults?.skipBootstrap, }); diff --git a/src/commands/status.scan-execute.test.ts b/src/commands/status.scan-execute.test.ts index cdf812e58e7..875c8f32b7a 100644 --- a/src/commands/status.scan-execute.test.ts +++ b/src/commands/status.scan-execute.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { executeStatusScanFromOverview } from "./status.scan-execute.ts"; import type { StatusScanOverviewResult } from "./status.scan-overview.ts"; import type { MemoryStatusSnapshot } from "./status.scan.shared.js"; @@ -11,21 +12,20 @@ const { resolveStatusSummaryFromOverview, resolveMemoryPluginStatus } = vi.hoist })), })); +vi.mock("./status.scan-overview.ts", () => ({ + resolveStatusSummaryFromOverview, +})); + +vi.mock("./status.scan.shared.js", () => ({ + resolveMemoryPluginStatus, +})); + describe("executeStatusScanFromOverview", () => { beforeEach(() => { - vi.resetModules(); vi.clearAllMocks(); - vi.doMock("./status.scan-overview.ts", () => ({ - resolveStatusSummaryFromOverview, - })); - vi.doMock("./status.scan.shared.js", () => ({ - resolveMemoryPluginStatus, - })); }); it("resolves memory and summary, then builds the final scan result", async () => { - const { executeStatusScanFromOverview } = await import("./status.scan-execute.ts"); - const overview = { cfg: { channels: {} }, sourceConfig: { channels: {} }, diff --git a/src/commands/status.scan-overview.test.ts b/src/commands/status.scan-overview.test.ts index 14532478a04..21376a83485 100644 --- a/src/commands/status.scan-overview.test.ts +++ b/src/commands/status.scan-overview.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { collectStatusScanOverview } from "./status.scan-overview.ts"; const mocks = vi.hoisted(() => ({ hasPotentialConfiguredChannels: vi.fn(), @@ -49,7 +50,6 @@ vi.mock("./status.scan.runtime.js", () => ({ describe("collectStatusScanOverview", () => { beforeEach(() => { - vi.resetModules(); vi.clearAllMocks(); mocks.hasPotentialConfiguredChannels.mockReturnValue(true); @@ -96,8 +96,6 @@ describe("collectStatusScanOverview", () => { }); it("uses gateway fallback overrides for channels.status when requested", async () => { - const { collectStatusScanOverview } = await import("./status.scan-overview.ts"); - const result = await collectStatusScanOverview({ commandName: "status --all", opts: { timeoutMs: 1234 }, @@ -149,8 +147,6 @@ describe("collectStatusScanOverview", () => { resolveTailscaleHttpsUrl: vi.fn(async () => null), skipColdStartNetworkChecks: false, }); - const { collectStatusScanOverview } = await import("./status.scan-overview.ts"); - const result = await collectStatusScanOverview({ commandName: "status", opts: {}, diff --git a/src/commands/status.scan.shared.test.ts b/src/commands/status.scan.shared.test.ts index 27225230cc3..267a169c014 100644 --- a/src/commands/status.scan.shared.test.ts +++ b/src/commands/status.scan.shared.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveGatewayProbeSnapshot } from "./status.scan.shared.js"; const mocks = vi.hoisted(() => ({ buildGatewayConnectionDetailsWithResolvers: vi.fn(), @@ -30,7 +31,6 @@ vi.mock("./gateway-presence.js", () => ({ describe("resolveGatewayProbeSnapshot", () => { beforeEach(() => { - vi.resetModules(); vi.clearAllMocks(); mocks.buildGatewayConnectionDetailsWithResolvers.mockReturnValue({ url: "ws://127.0.0.1:18789", @@ -50,8 +50,6 @@ describe("resolveGatewayProbeSnapshot", () => { }); it("skips auth resolution and probe for missing remote urls by default", async () => { - const { resolveGatewayProbeSnapshot } = await import("./status.scan.shared.js"); - const result = await resolveGatewayProbeSnapshot({ cfg: {}, opts: {}, @@ -88,8 +86,6 @@ describe("resolveGatewayProbeSnapshot", () => { presence: [{ host: "box" }], configSnapshot: null, }); - const { resolveGatewayProbeSnapshot } = await import("./status.scan.shared.js"); - const result = await resolveGatewayProbeSnapshot({ cfg: {}, opts: { @@ -135,8 +131,6 @@ describe("resolveGatewayProbeSnapshot", () => { presence: null, configSnapshot: null, }); - const { resolveGatewayProbeSnapshot } = await import("./status.scan.shared.js"); - const result = await resolveGatewayProbeSnapshot({ cfg: {}, opts: {}, diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 2039db70286..35ab8b34b98 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -1,11 +1,5 @@ import { randomUUID } from "node:crypto"; -import { - loadConfig, - resolveConfigPath, - resolveGatewayPort, - resolveStateDir, -} from "../config/config.js"; -import { loadConfig as loadConfigFromIo } from "../config/io.js"; +import { loadConfig } from "../config/io.js"; import { resolveConfigPath as resolveConfigPathFromPaths, resolveGatewayPort as resolveGatewayPortFromPaths, @@ -99,9 +93,9 @@ const defaultGatewayCallDeps = { createGatewayClient: defaultCreateGatewayClient, loadConfig, loadOrCreateDeviceIdentity, - resolveGatewayPort, - resolveConfigPath, - resolveStateDir, + resolveGatewayPort: resolveGatewayPortFromPaths, + resolveConfigPath: resolveConfigPathFromPaths, + resolveStateDir: resolveStateDirFromPaths, loadGatewayTlsRuntime, }; const gatewayCallDeps = { @@ -127,7 +121,7 @@ function loadGatewayConfig(): OpenClawConfig { ? gatewayCallDeps.loadConfig : typeof defaultGatewayCallDeps.loadConfig === "function" ? defaultGatewayCallDeps.loadConfig - : loadConfigFromIo; + : loadConfig; return loadConfigFn(); } diff --git a/src/gateway/server-http.test-harness.ts b/src/gateway/server-http.test-harness.ts index 1adf863e461..e78480bdbdb 100644 --- a/src/gateway/server-http.test-harness.ts +++ b/src/gateway/server-http.test-harness.ts @@ -11,6 +11,7 @@ export type GatewayHttpServer = ReturnType; export type GatewayServerOptions = Partial[0]>; type HooksHandlerDeps = Parameters[0]; +const responseEndPromises = new WeakMap>(); export const AUTH_NONE: ResolvedGatewayAuth = { mode: "none", token: undefined, @@ -67,16 +68,23 @@ export function createResponse(): { } { const setHeader = vi.fn(); let body = ""; + let resolveEnd!: () => void; + const ended = new Promise((resolve) => { + resolveEnd = resolve; + }); const end = vi.fn((chunk?: unknown) => { if (typeof chunk === "string") { body = chunk; + resolveEnd(); return; } if (chunk == null) { body = ""; + resolveEnd(); return; } body = JSON.stringify(chunk); + resolveEnd(); }); const res = { headersSent: false, @@ -84,6 +92,7 @@ export function createResponse(): { setHeader, end, } as unknown as ServerResponse; + responseEndPromises.set(res, ended); return { res, setHeader, @@ -98,7 +107,10 @@ export async function dispatchRequest( res: ServerResponse, ): Promise { server.emit("request", req, res); - await new Promise((resolve) => setImmediate(resolve)); + await Promise.race([ + responseEndPromises.get(res) ?? new Promise((resolve) => setImmediate(resolve)), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); } export async function withGatewayTempConfig( diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 84c87b4cd7e..a86350c3929 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -8,10 +8,8 @@ import { import { createServer as createHttpsServer } from "node:https"; import type { TlsOptions } from "node:tls"; import type { WebSocketServer } from "ws"; -import { resolveAgentAvatar } from "../agents/identity-avatar.js"; -import { CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; +import { A2UI_PATH, CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; -import { listBundledChannelPlugins } from "../channels/plugins/bundled.js"; import { loadConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; @@ -31,13 +29,7 @@ import { type ResolvedGatewayAuth, } from "./auth.js"; import { normalizeCanvasScopedUrl } from "./canvas-capability.js"; -import { - handleControlUiAssistantMediaRequest, - handleControlUiAvatarRequest, - handleControlUiHttpRequest, - type ControlUiRootState, -} from "./control-ui.js"; -import { handleOpenAiEmbeddingsHttpRequest } from "./embeddings-http.js"; +import type { ControlUiRootState } from "./control-ui.js"; import { applyHookMappings } from "./hooks-mapping.js"; import { extractHookToken, @@ -66,10 +58,7 @@ import { getBearerToken, resolveHttpBrowserOriginPolicy, } from "./http-utils.js"; -import { handleOpenAiModelsHttpRequest } from "./models-http.js"; import { resolveRequestClientIp } from "./net.js"; -import { handleOpenAiHttpRequest } from "./openai-http.js"; -import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; import { DEDUPE_MAX, DEDUPE_TTL_MS } from "./server-constants.js"; import { authorizeCanvasRequest, isCanvasPath } from "./server/http-auth.js"; import { resolvePluginRouteRuntimeOperatorScopes } from "./server/plugin-route-runtime-scopes.js"; @@ -82,15 +71,77 @@ import { import type { PreauthConnectionBudget } from "./server/preauth-connection-budget.js"; import type { ReadinessChecker } from "./server/readiness.js"; import type { GatewayWsClient } from "./server/ws-types.js"; -import { handleSessionKillHttpRequest } from "./session-kill-http.js"; -import { handleSessionHistoryHttpRequest } from "./sessions-history-http.js"; -import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js"; type SubsystemLogger = ReturnType; const HOOK_AUTH_FAILURE_LIMIT = 20; const HOOK_AUTH_FAILURE_WINDOW_MS = 60_000; +let bundledChannelsModulePromise: + | Promise + | undefined; +let identityAvatarModulePromise: Promise | undefined; +let controlUiModulePromise: Promise | undefined; +let embeddingsHttpModulePromise: Promise | undefined; +let modelsHttpModulePromise: Promise | undefined; +let openAiHttpModulePromise: Promise | undefined; +let openResponsesHttpModulePromise: Promise | undefined; +let sessionHistoryHttpModulePromise: + | Promise + | undefined; +let sessionKillHttpModulePromise: Promise | undefined; +let toolsInvokeHttpModulePromise: Promise | undefined; + +function getBundledChannelsModule() { + bundledChannelsModulePromise ??= import("../channels/plugins/bundled.js"); + return bundledChannelsModulePromise; +} + +function getIdentityAvatarModule() { + identityAvatarModulePromise ??= import("../agents/identity-avatar.js"); + return identityAvatarModulePromise; +} + +function getControlUiModule() { + controlUiModulePromise ??= import("./control-ui.js"); + return controlUiModulePromise; +} + +function getEmbeddingsHttpModule() { + embeddingsHttpModulePromise ??= import("./embeddings-http.js"); + return embeddingsHttpModulePromise; +} + +function getModelsHttpModule() { + modelsHttpModulePromise ??= import("./models-http.js"); + return modelsHttpModulePromise; +} + +function getOpenAiHttpModule() { + openAiHttpModulePromise ??= import("./openai-http.js"); + return openAiHttpModulePromise; +} + +function getOpenResponsesHttpModule() { + openResponsesHttpModulePromise ??= import("./openresponses-http.js"); + return openResponsesHttpModulePromise; +} + +function getSessionHistoryHttpModule() { + sessionHistoryHttpModulePromise ??= import("./sessions-history-http.js"); + return sessionHistoryHttpModulePromise; +} + +function getSessionKillHttpModule() { + sessionKillHttpModulePromise ??= import("./session-kill-http.js"); + return sessionKillHttpModulePromise; +} + +function getToolsInvokeHttpModule() { + toolsInvokeHttpModulePromise ??= import("./tools-invoke-http.js"); + return toolsInvokeHttpModulePromise; +} + type HookDispatchers = { dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void; dispatchAgentHook: (value: HookAgentDispatchPayload) => string; @@ -138,8 +189,11 @@ const GATEWAY_PROBE_STATUS_BY_PATH = new Map([ ["/ready", "ready"], ["/readyz", "ready"], ]); -function resolvePluginGatewayAuthBypassPaths(configSnapshot: OpenClawConfig): Set { +async function resolvePluginGatewayAuthBypassPaths( + configSnapshot: OpenClawConfig, +): Promise> { const paths = new Set(); + const { listBundledChannelPlugins } = await getBundledChannelsModule(); for (const plugin of listBundledChannelPlugins()) { for (const path of plugin.gateway?.resolveGatewayAuthBypassPaths?.({ cfg: configSnapshot }) ?? []) { @@ -151,6 +205,38 @@ function resolvePluginGatewayAuthBypassPaths(configSnapshot: OpenClawConfig): Se return paths; } +function isOpenAiModelsPath(pathname: string): boolean { + return pathname === "/v1/models" || pathname.startsWith("/v1/models/"); +} + +function isEmbeddingsPath(pathname: string): boolean { + return pathname === "/v1/embeddings"; +} + +function isOpenAiChatCompletionsPath(pathname: string): boolean { + return pathname === "/v1/chat/completions"; +} + +function isOpenResponsesPath(pathname: string): boolean { + return pathname === "/v1/responses"; +} + +function isToolsInvokePath(pathname: string): boolean { + return pathname === "/tools/invoke"; +} + +function isSessionKillPath(pathname: string): boolean { + return /^\/sessions\/[^/]+\/kill$/.test(pathname); +} + +function isSessionHistoryPath(pathname: string): boolean { + return /^\/sessions\/[^/]+\/history$/.test(pathname); +} + +function isA2uiPath(pathname: string): boolean { + return pathname === A2UI_PATH || pathname.startsWith(`${A2UI_PATH}/`); +} + function shouldEnforceDefaultPluginGatewayAuth(pathContext: PluginRoutePathContext): boolean { return ( pathContext.malformedEncoding || @@ -300,7 +386,7 @@ function buildPluginRequestStages(params: { req: IncomingMessage; res: ServerResponse; requestPath: string; - gatewayAuthBypassPaths: ReadonlySet; + getGatewayAuthBypassPaths: () => Promise>; pluginPathContext: PluginRoutePathContext | null; handlePluginRequest?: PluginHttpRequestHandler; shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean; @@ -319,9 +405,6 @@ function buildPluginRequestStages(params: { { name: "plugin-auth", run: async () => { - if (params.gatewayAuthBypassPaths.has(params.requestPath)) { - return false; - } const pathContext = params.pluginPathContext ?? resolvePluginRoutePathContext(params.requestPath); if ( @@ -331,6 +414,9 @@ function buildPluginRequestStages(params: { ) { return false; } + if ((await params.getGatewayAuthBypassPaths()).has(params.requestPath)) { + return false; + } const requestAuth = await authorizeGatewayHttpRequestOrReply({ req: params.req, res: params.res, @@ -807,7 +893,6 @@ export function createGatewayHttpServer(opts: { req.url = scopedCanvas.rewrittenUrl; } const requestPath = new URL(req.url ?? "/", "http://localhost").pathname; - const gatewayAuthBypassPaths = resolvePluginGatewayAuthBypassPaths(configSnapshot); const pluginPathContext = handlePluginRequest ? resolvePluginRoutePathContext(requestPath) : null; @@ -816,66 +901,72 @@ export function createGatewayHttpServer(opts: { name: "hooks", run: () => handleHooksRequest(req, res), }, - { - name: "models", - run: () => - openAiCompatEnabled - ? handleOpenAiModelsHttpRequest(req, res, { - auth: resolvedAuth, - trustedProxies, - allowRealIpFallback, - rateLimiter, - }) - : false, - }, - { - name: "embeddings", - run: () => - openAiCompatEnabled - ? handleOpenAiEmbeddingsHttpRequest(req, res, { - auth: resolvedAuth, - trustedProxies, - allowRealIpFallback, - rateLimiter, - }) - : false, - }, - { - name: "tools-invoke", - run: () => - handleToolsInvokeHttpRequest(req, res, { - auth: resolvedAuth, - trustedProxies, - allowRealIpFallback, - rateLimiter, - }), - }, - { - name: "sessions-kill", - run: () => - handleSessionKillHttpRequest(req, res, { - auth: resolvedAuth, - trustedProxies, - allowRealIpFallback, - rateLimiter, - }), - }, - { - name: "sessions-history", - run: () => - handleSessionHistoryHttpRequest(req, res, { - auth: resolvedAuth, - trustedProxies, - allowRealIpFallback, - rateLimiter, - }), - }, ]; - if (openResponsesEnabled) { + if (openAiCompatEnabled && isOpenAiModelsPath(requestPath)) { + requestStages.push({ + name: "models", + run: async () => + (await getModelsHttpModule()).handleOpenAiModelsHttpRequest(req, res, { + auth: resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, + }), + }); + } + if (openAiCompatEnabled && isEmbeddingsPath(requestPath)) { + requestStages.push({ + name: "embeddings", + run: async () => + (await getEmbeddingsHttpModule()).handleOpenAiEmbeddingsHttpRequest(req, res, { + auth: resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, + }), + }); + } + if (isToolsInvokePath(requestPath)) { + requestStages.push({ + name: "tools-invoke", + run: async () => + (await getToolsInvokeHttpModule()).handleToolsInvokeHttpRequest(req, res, { + auth: resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, + }), + }); + } + if (isSessionKillPath(requestPath)) { + requestStages.push({ + name: "sessions-kill", + run: async () => + (await getSessionKillHttpModule()).handleSessionKillHttpRequest(req, res, { + auth: resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, + }), + }); + } + if (isSessionHistoryPath(requestPath)) { + requestStages.push({ + name: "sessions-history", + run: async () => + (await getSessionHistoryHttpModule()).handleSessionHistoryHttpRequest(req, res, { + auth: resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, + }), + }); + } + if (openResponsesEnabled && isOpenResponsesPath(requestPath)) { requestStages.push({ name: "openresponses", - run: () => - handleOpenResponsesHttpRequest(req, res, { + run: async () => + (await getOpenResponsesHttpModule()).handleOpenResponsesHttpRequest(req, res, { auth: resolvedAuth, config: openResponsesConfig, trustedProxies, @@ -884,11 +975,11 @@ export function createGatewayHttpServer(opts: { }), }); } - if (openAiChatCompletionsEnabled) { + if (openAiChatCompletionsEnabled && isOpenAiChatCompletionsPath(requestPath)) { requestStages.push({ name: "openai", - run: () => - handleOpenAiHttpRequest(req, res, { + run: async () => + (await getOpenAiHttpModule()).handleOpenAiHttpRequest(req, res, { auth: resolvedAuth, config: openAiChatCompletionsConfig, trustedProxies, @@ -923,7 +1014,7 @@ export function createGatewayHttpServer(opts: { }); requestStages.push({ name: "a2ui", - run: () => handleA2uiHttpRequest(req, res), + run: () => (isA2uiPath(requestPath) ? handleA2uiHttpRequest(req, res) : false), }); requestStages.push({ name: "canvas-http", @@ -938,7 +1029,7 @@ export function createGatewayHttpServer(opts: { req, res, requestPath, - gatewayAuthBypassPaths, + getGatewayAuthBypassPaths: () => resolvePluginGatewayAuthBypassPaths(configSnapshot), pluginPathContext, handlePluginRequest, shouldEnforcePluginGatewayAuth, @@ -952,8 +1043,8 @@ export function createGatewayHttpServer(opts: { if (controlUiEnabled) { requestStages.push({ name: "control-ui-assistant-media", - run: () => - handleControlUiAssistantMediaRequest(req, res, { + run: async () => + (await getControlUiModule()).handleControlUiAssistantMediaRequest(req, res, { basePath: controlUiBasePath, config: configSnapshot, agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId, @@ -965,17 +1056,20 @@ export function createGatewayHttpServer(opts: { }); requestStages.push({ name: "control-ui-avatar", - run: () => - handleControlUiAvatarRequest(req, res, { + run: async () => { + const { handleControlUiAvatarRequest } = await getControlUiModule(); + const { resolveAgentAvatar } = await getIdentityAvatarModule(); + return handleControlUiAvatarRequest(req, res, { basePath: controlUiBasePath, resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId, { includeUiOverride: true }), - }), + }); + }, }); requestStages.push({ name: "control-ui-http", - run: () => - handleControlUiHttpRequest(req, res, { + run: async () => + (await getControlUiModule()).handleControlUiHttpRequest(req, res, { basePath: controlUiBasePath, config: configSnapshot, agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId, diff --git a/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts b/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts index 67011cde309..8511a2d418d 100644 --- a/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts +++ b/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts @@ -52,6 +52,7 @@ describe("loadGatewayStartupPlugins browser plugin integration", () => { log: createTestLog(), coreGatewayHandlers, baseMethods: listGatewayMethods(), + pluginIds: ["browser"], logDiagnostics: false, }); @@ -79,6 +80,7 @@ describe("loadGatewayStartupPlugins browser plugin integration", () => { log: createTestLog(), coreGatewayHandlers, baseMethods: listGatewayMethods(), + pluginIds: ["browser"], logDiagnostics: false, }); diff --git a/src/gateway/server-startup-config.secrets.test.ts b/src/gateway/server-startup-config.secrets.test.ts new file mode 100644 index 00000000000..f29c1a38e81 --- /dev/null +++ b/src/gateway/server-startup-config.secrets.test.ts @@ -0,0 +1,296 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js"; +import type { PreparedSecretsRuntimeSnapshot, SecretResolverWarning } from "../secrets/runtime.js"; +import { + createRuntimeSecretsActivator, + prepareGatewayStartupConfig, +} from "./server-startup-config.js"; +import { buildTestConfigSnapshot } from "./test-helpers.config-snapshots.js"; + +function gatewayTokenConfig(config: OpenClawConfig): OpenClawConfig { + return { + ...config, + gateway: { + ...config.gateway, + auth: { + ...config.gateway?.auth, + mode: config.gateway?.auth?.mode ?? "token", + token: config.gateway?.auth?.token ?? "startup-test-token", + }, + }, + }; +} + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +function buildSnapshot(config: OpenClawConfig): ConfigFileSnapshot { + const raw = `${JSON.stringify(config, null, 2)}\n`; + return buildTestConfigSnapshot({ + path: "/tmp/openclaw-startup-secrets-test.json", + exists: true, + raw, + parsed: config, + valid: true, + config, + issues: [], + legacyIssues: [], + }); +} + +function preparedSnapshot(config: OpenClawConfig): PreparedSecretsRuntimeSnapshot { + return { + sourceConfig: config, + config, + authStores: [], + warnings: [], + webTools: { + search: { + providerSource: "none", + diagnostics: [], + }, + fetch: { + providerSource: "none", + diagnostics: [], + }, + diagnostics: [], + }, + }; +} + +describe("gateway startup config secret preflight", () => { + const previousSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS; + const previousSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS; + + afterEach(() => { + if (previousSkipChannels === undefined) { + delete process.env.OPENCLAW_SKIP_CHANNELS; + } else { + process.env.OPENCLAW_SKIP_CHANNELS = previousSkipChannels; + } + if (previousSkipProviders === undefined) { + delete process.env.OPENCLAW_SKIP_PROVIDERS; + } else { + process.env.OPENCLAW_SKIP_PROVIDERS = previousSkipProviders; + } + }); + + it("wraps startup secret activation failures without emitting reload state events", async () => { + const error = new Error('Environment variable "OPENAI_API_KEY" is missing or empty.'); + const prepareRuntimeSecretsSnapshot = vi.fn(async () => { + throw error; + }); + const emitStateEvent = vi.fn(); + const activateRuntimeSecrets = createRuntimeSecretsActivator({ + logSecrets: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + emitStateEvent, + prepareRuntimeSecretsSnapshot, + activateRuntimeSecretsSnapshot: vi.fn(), + }); + + await expect( + activateRuntimeSecrets(gatewayTokenConfig({}), { + reason: "startup", + activate: false, + }), + ).rejects.toThrow( + 'Startup failed: required secrets are unavailable. Error: Environment variable "OPENAI_API_KEY" is missing or empty.', + ); + expect(emitStateEvent).not.toHaveBeenCalled(); + }); + + it("does not emit degraded or recovered events for warning-only secret reloads", async () => { + const warning: SecretResolverWarning = { + code: "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED", + path: "plugins.entries.google.config.webSearch.apiKey", + message: "web search provider fell back to environment credentials", + }; + const prepareRuntimeSecretsSnapshot = vi.fn(async ({ config }) => ({ + ...preparedSnapshot(config), + warnings: [warning], + })); + const emitStateEvent = vi.fn(); + const logSecrets = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const activateRuntimeSecrets = createRuntimeSecretsActivator({ + logSecrets, + emitStateEvent, + prepareRuntimeSecretsSnapshot, + activateRuntimeSecretsSnapshot: vi.fn(), + }); + + await expect( + activateRuntimeSecrets( + { + plugins: { + entries: { + google: { + enabled: true, + config: { + webSearch: { + apiKey: { source: "env", provider: "default", id: "MISSING_GEMINI_KEY" }, + }, + }, + }, + }, + }, + }, + { + reason: "reload", + activate: true, + }, + ), + ).resolves.toMatchObject({ + warnings: [warning], + }); + expect(logSecrets.warn).toHaveBeenCalledWith( + "[WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED] web search provider fell back to environment credentials", + ); + expect(emitStateEvent).not.toHaveBeenCalled(); + }); + + it("prunes channel refs from startup secret preflight when channels are skipped", async () => { + process.env.OPENCLAW_SKIP_CHANNELS = "1"; + const prepareRuntimeSecretsSnapshot = vi.fn(async ({ config }) => preparedSnapshot(config)); + const activateRuntimeSecrets = createRuntimeSecretsActivator({ + logSecrets: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + emitStateEvent: vi.fn(), + prepareRuntimeSecretsSnapshot, + activateRuntimeSecretsSnapshot: vi.fn(), + }); + const config = gatewayTokenConfig( + asConfig({ + channels: { + telegram: { + botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }, + }, + }, + }), + ); + + await expect( + activateRuntimeSecrets(config, { + reason: "startup", + activate: false, + }), + ).resolves.toMatchObject({ + config: expect.objectContaining({ + gateway: expect.any(Object), + }), + }); + expect(prepareRuntimeSecretsSnapshot).toHaveBeenCalledWith({ + config: expect.not.objectContaining({ + channels: expect.anything(), + }), + }); + }); + + it("honors startup auth overrides before secret preflight gating", async () => { + const prepareRuntimeSecretsSnapshot = vi.fn(async ({ config }) => preparedSnapshot(config)); + const activateRuntimeSecretsSnapshot = vi.fn(); + const result = await prepareGatewayStartupConfig({ + configSnapshot: buildSnapshot({ + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_STARTUP_GW_TOKEN" }, + }, + }, + }), + authOverride: { + mode: "password", + password: "override-password", // pragma: allowlist secret + }, + activateRuntimeSecrets: createRuntimeSecretsActivator({ + logSecrets: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + emitStateEvent: vi.fn(), + prepareRuntimeSecretsSnapshot, + activateRuntimeSecretsSnapshot, + }), + }); + + expect(result.auth).toMatchObject({ + mode: "password", + password: "override-password", + }); + expect(prepareRuntimeSecretsSnapshot).toHaveBeenNthCalledWith(1, { + config: expect.objectContaining({ + gateway: expect.objectContaining({ + auth: expect.objectContaining({ + mode: "password", + password: "override-password", + }), + }), + }), + }); + expect(activateRuntimeSecretsSnapshot).toHaveBeenCalledTimes(1); + }); + + it("uses gateway auth strings resolved during startup preflight for bootstrap auth", async () => { + const prepareRuntimeSecretsSnapshot = vi.fn(async ({ config }) => + preparedSnapshot({ + ...config, + gateway: { + ...config.gateway, + auth: { + ...config.gateway?.auth, + token: "resolved-gateway-token", + }, + }, + }), + ); + + const result = await prepareGatewayStartupConfig({ + configSnapshot: buildSnapshot({ + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "GATEWAY_TOKEN_REF" }, + }, + }, + }), + activateRuntimeSecrets: createRuntimeSecretsActivator({ + logSecrets: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + emitStateEvent: vi.fn(), + prepareRuntimeSecretsSnapshot, + activateRuntimeSecretsSnapshot: vi.fn(), + }), + }); + + expect(result.auth).toMatchObject({ + mode: "token", + token: "resolved-gateway-token", + }); + expect(prepareRuntimeSecretsSnapshot).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index a4aa6eabd5d..2043bc7e03b 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -39,6 +39,9 @@ export type ActivateRuntimeSecrets = ( params: { reason: "startup" | "reload" | "restart-check"; activate: boolean }, ) => Promise>>; +type PrepareRuntimeSecretsSnapshot = typeof prepareSecretsRuntimeSnapshot; +type ActivateRuntimeSecretsSnapshot = typeof activateSecretsRuntimeSnapshot; + type GatewayStartupConfigOverrides = { auth?: GatewayAuthConfig; tailscale?: GatewayTailscaleConfig; @@ -86,9 +89,15 @@ export function createRuntimeSecretsActivator(params: { message: string, cfg: OpenClawConfig, ) => void; + prepareRuntimeSecretsSnapshot?: PrepareRuntimeSecretsSnapshot; + activateRuntimeSecretsSnapshot?: ActivateRuntimeSecretsSnapshot; }): ActivateRuntimeSecrets { let secretsDegraded = false; let secretsActivationTail: Promise = Promise.resolve(); + const prepareRuntimeSecretsSnapshot = + params.prepareRuntimeSecretsSnapshot ?? prepareSecretsRuntimeSnapshot; + const activateRuntimeSecretsSnapshot = + params.activateRuntimeSecretsSnapshot ?? activateSecretsRuntimeSnapshot; const runWithSecretsActivationLock = async (operation: () => Promise): Promise => { const run = secretsActivationTail.then(operation, operation); @@ -102,11 +111,11 @@ export function createRuntimeSecretsActivator(params: { return async (config, activationParams) => await runWithSecretsActivationLock(async () => { try { - const prepared = await prepareSecretsRuntimeSnapshot({ + const prepared = await prepareRuntimeSecretsSnapshot({ config: pruneSkippedStartupSecretSurfaces(config), }); if (activationParams.activate) { - activateSecretsRuntimeSnapshot(prepared); + activateRuntimeSecretsSnapshot(prepared); logGatewayAuthSurfaceDiagnostics(prepared, params.logSecrets); } for (const warning of prepared.warnings) { diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 4cbf487b467..498112374aa 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -45,10 +45,16 @@ vi.mock("../agents/subagent-registry.js", () => ({ scheduleSubagentOrphanRecovery: hoisted.scheduleSubagentOrphanRecovery, })); -vi.mock("../config/paths.js", () => ({ - STATE_DIR: "/tmp/openclaw-state", - resolveStateDir: vi.fn(() => "/tmp/openclaw-state"), -})); +vi.mock("../config/paths.js", async () => { + const actual = await vi.importActual("../config/paths.js"); + return { + ...actual, + STATE_DIR: "/tmp/openclaw-state", + resolveConfigPath: vi.fn(() => "/tmp/openclaw-state/openclaw.json"), + resolveGatewayPort: vi.fn(() => 18789), + resolveStateDir: vi.fn(() => "/tmp/openclaw-state"), + }; +}); vi.mock("../hooks/gmail-watcher-lifecycle.js", () => ({ startGmailWatcherWithLogs: hoisted.startGmailWatcherWithLogs, diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index ad5869d9137..549fda4ff02 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -211,49 +211,50 @@ export function registerControlUiAndPairingSuite(): void { return { identityPath, identity: { deviceId: identity.deviceId } }; }; - for (const tc of trustedProxyControlUiCases) { - test(tc.name, async () => { - await configureTrustedProxyControlUiAuth(); - await withControlUiGatewayServer(async ({ port }) => { + test("rejects untrusted trusted-proxy control ui device identity states", async () => { + await configureTrustedProxyControlUiAuth(); + await withControlUiGatewayServer(async ({ port }) => { + for (const tc of trustedProxyControlUiCases) { const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS); - const scopes = tc.withUnpairedNodeDevice ? [] : undefined; - let device: Awaited>["device"] | null = null; - if (tc.withUnpairedNodeDevice) { - const challengeNonce = await readConnectChallengeNonce(ws); - expect(challengeNonce).toBeTruthy(); - ({ device } = await createSignedDevice({ - token: null, - role: "node", - scopes: [], - clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, - clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, - nonce: challengeNonce, - })); - } - const res = await connectReq(ws, { - skipDefaultAuth: true, - role: tc.role, - scopes, - device, - client: { ...CONTROL_UI_CLIENT }, - }); - expect(res.ok).toBe(tc.expectedOk); - if (!tc.expectedOk) { - if (tc.expectedErrorSubstring) { - expect(res.error?.message ?? "").toContain(tc.expectedErrorSubstring); + try { + const scopes = tc.withUnpairedNodeDevice ? [] : undefined; + let device: Awaited>["device"] | null = null; + if (tc.withUnpairedNodeDevice) { + const challengeNonce = await readConnectChallengeNonce(ws); + expect(challengeNonce, tc.name).toBeTruthy(); + ({ device } = await createSignedDevice({ + token: null, + role: "node", + scopes: [], + clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, + nonce: challengeNonce, + })); } - if (tc.expectedErrorCode) { - expect((res.error?.details as { code?: string } | undefined)?.code).toBe( - tc.expectedErrorCode, - ); + const res = await connectReq(ws, { + skipDefaultAuth: true, + role: tc.role, + scopes, + device, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok, tc.name).toBe(tc.expectedOk); + if (!tc.expectedOk) { + if (tc.expectedErrorSubstring) { + expect(res.error?.message ?? "", tc.name).toContain(tc.expectedErrorSubstring); + } + if (tc.expectedErrorCode) { + expect((res.error?.details as { code?: string } | undefined)?.code, tc.name).toBe( + tc.expectedErrorCode, + ); + } } + } finally { ws.close(); - return; } - ws.close(); - }); + } }); - } + }); test("rejects trusted-proxy control ui without device identity even with self-declared scopes", async () => { await configureTrustedProxyControlUiAuth(); @@ -394,16 +395,16 @@ export function registerControlUiAndPairingSuite(): void { } }); - test("allows control ui with stale device identity when device auth is disabled", async () => { + test("allows control ui auth bypasses when device auth is disabled", async () => { testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true }; testState.gatewayAuth = { mode: "token", token: "secret" }; const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; try { await withControlUiGatewayServer(async ({ port }) => { - const ws = await openWs(port, { origin: originForPort(port) }); - const challengeNonce = await readConnectChallengeNonce(ws); - expect(challengeNonce).toBeTruthy(); + const staleDeviceWs = await openWs(port, { origin: originForPort(port) }); + const challengeNonce = await readConnectChallengeNonce(staleDeviceWs); + expect(challengeNonce, "stale device challenge").toBeTruthy(); const { device } = await createSignedDevice({ token: "secret", scopes: [], @@ -412,7 +413,7 @@ export function registerControlUiAndPairingSuite(): void { signedAtMs: Date.now() - 60 * 60 * 1000, nonce: challengeNonce, }); - const res = await connectReq(ws, { + const res = await connectReq(staleDeviceWs, { token: "secret", scopes: ["operator.read"], device, @@ -422,38 +423,26 @@ export function registerControlUiAndPairingSuite(): void { }); expect(res.ok).toBe(true); expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined(); - const health = await rpcReq(ws, "health"); + const health = await rpcReq(staleDeviceWs, "health"); expect(health.ok).toBe(true); - ws.close(); - }); - } finally { - restoreGatewayToken(prevToken); - } - }); + staleDeviceWs.close(); - test("preserves requested control ui scopes when dangerouslyDisableDeviceAuth bypasses device identity", async () => { - testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true }; - testState.gatewayAuth = { mode: "token", token: "secret" }; - const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; - process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; - try { - await withControlUiGatewayServer(async ({ port }) => { - const ws = await openWs(port, { origin: originForPort(port) }); - const res = await connectReq(ws, { + const scopedWs = await openWs(port, { origin: originForPort(port) }); + const scopedRes = await connectReq(scopedWs, { token: "secret", scopes: ["operator.read"], client: { ...CONTROL_UI_CLIENT, }, }); - expect(res.ok).toBe(true); + expect(scopedRes.ok, "requested scope bypass").toBe(true); - const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(true); + const scopedHealth = await rpcReq(scopedWs, "health"); + expect(scopedHealth.ok).toBe(true); - const talk = await rpcReq(ws, "chat.history", { sessionKey: "main", limit: 1 }); + const talk = await rpcReq(scopedWs, "chat.history", { sessionKey: "main", limit: 1 }); expect(talk.ok).toBe(true); - ws.close(); + scopedWs.close(); }); } finally { restoreGatewayToken(prevToken); diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index 2f12ef17c21..49317cea7e2 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -87,12 +87,11 @@ describe("gateway config methods", () => { expect(current.payload?.config).toBeTruthy(); const nextConfig = structuredClone(current.payload?.config ?? {}); - const channels = (nextConfig.channels ??= {}) as Record; - const telegram = (channels.telegram ??= {}) as Record; - telegram.botToken = { source: "env", provider: "default", id: missingEnvVar }; - const telegramAccounts = (telegram.accounts ??= {}) as Record; - const defaultTelegramAccount = (telegramAccounts.default ??= {}) as Record; - defaultTelegramAccount.enabled = true; + const gateway = (nextConfig.gateway ??= {}) as Record; + gateway.auth = { + mode: "token", + token: { source: "env", provider: "default", id: missingEnvVar }, + }; const res = await rpcReq<{ ok?: boolean; error?: { message?: string } }>( requireWs(), @@ -306,18 +305,14 @@ describe("gateway config methods", () => { "config.patch", { raw: JSON.stringify({ - channels: { - telegram: { - botToken: { + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: missingEnvVar, }, - accounts: { - default: { - enabled: true, - }, - }, }, }, }), @@ -344,12 +339,11 @@ describe("gateway config.apply", () => { expect(current.ok).toBe(true); expect(typeof current.payload?.hash).toBe("string"); const nextConfig = structuredClone(current.payload?.config ?? {}); - const channels = (nextConfig.channels ??= {}) as Record; - const telegram = (channels.telegram ??= {}) as Record; - telegram.botToken = { source: "env", provider: "default", id: missingEnvVar }; - const telegramAccounts = (telegram.accounts ??= {}) as Record; - const defaultTelegramAccount = (telegramAccounts.default ??= {}) as Record; - defaultTelegramAccount.enabled = true; + const gateway = (nextConfig.gateway ??= {}) as Record; + gateway.auth = { + mode: "token", + token: { source: "env", provider: "default", id: missingEnvVar }, + }; const res = await sendConfigApply( { diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index ee31ed976d2..765562bd61a 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -240,14 +240,12 @@ describe("gateway hot reload", () => { let prevSkipGmail: string | undefined; let prevSkipProviders: string | undefined; let prevOpenAiApiKey: string | undefined; - let prevGeminiApiKey: string | undefined; beforeEach(() => { prevSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS; prevSkipGmail = process.env.OPENCLAW_SKIP_GMAIL_WATCHER; prevSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS; prevOpenAiApiKey = process.env.OPENAI_API_KEY; - prevGeminiApiKey = process.env.GEMINI_API_KEY; process.env.OPENCLAW_SKIP_CHANNELS = "0"; delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER; delete process.env.OPENCLAW_SKIP_PROVIDERS; @@ -278,11 +276,6 @@ describe("gateway hot reload", () => { } else { process.env.OPENAI_API_KEY = prevOpenAiApiKey; } - if (prevGeminiApiKey === undefined) { - delete process.env.GEMINI_API_KEY; - } else { - process.env.GEMINI_API_KEY = prevGeminiApiKey; - } }); async function writeEnvRefConfig() { @@ -299,16 +292,6 @@ describe("gateway hot reload", () => { }); } - async function writeChannelEnvRefConfig() { - await writeConfigFile({ - channels: { - telegram: { - botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }, - }, - }, - }); - } - async function writeConfigFile(config: unknown) { const configPath = process.env.OPENCLAW_CONFIG_PATH; if (!configPath) { @@ -336,22 +319,6 @@ describe("gateway hot reload", () => { }); } - async function writeGatewayTraversalExecRefConfig() { - await writeConfigFile({ - gateway: { - auth: { - mode: "token", - token: { source: "exec", provider: "vault", id: "a/../b" }, - }, - }, - secrets: { - providers: { - vault: testNodeExecProvider, - }, - }, - }); - } - async function writeGatewayTokenExecRefConfig(params: { resolverScriptPath: string; modePath: string; @@ -376,145 +343,6 @@ describe("gateway hot reload", () => { }); } - async function writeDisabledSurfaceRefConfig() { - const configPath = process.env.OPENCLAW_CONFIG_PATH; - if (!configPath) { - throw new Error("OPENCLAW_CONFIG_PATH is not set"); - } - await fs.writeFile( - configPath, - `${JSON.stringify( - { - channels: { - telegram: { - enabled: false, - botToken: { source: "env", provider: "default", id: "DISABLED_TELEGRAM_STARTUP_REF" }, - }, - }, - tools: { - web: { - search: { - enabled: false, - apiKey: { - source: "env", - provider: "default", - id: "DISABLED_WEB_SEARCH_STARTUP_REF", - }, - }, - }, - }, - }, - null, - 2, - )}\n`, - "utf8", - ); - } - - async function writeGatewayTokenRefConfig() { - const configPath = process.env.OPENCLAW_CONFIG_PATH; - if (!configPath) { - throw new Error("OPENCLAW_CONFIG_PATH is not set"); - } - await fs.writeFile( - configPath, - `${JSON.stringify( - { - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "MISSING_STARTUP_GW_TOKEN" }, - }, - }, - }, - null, - 2, - )}\n`, - "utf8", - ); - } - - async function writeAuthProfileEnvRefStore() { - const stateDir = process.env.OPENCLAW_STATE_DIR; - if (!stateDir) { - throw new Error("OPENCLAW_STATE_DIR is not set"); - } - const authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"); - await fs.mkdir(path.dirname(authStorePath), { recursive: true }); - await fs.writeFile( - authStorePath, - `${JSON.stringify( - { - version: 1, - profiles: { - missing: { - type: "api_key", - provider: "openai", - keyRef: { source: "env", provider: "default", id: "MISSING_OPENCLAW_AUTH_REF" }, - }, - }, - selectedProfileId: "missing", - lastUsedProfileByModel: {}, - usageStats: {}, - }, - null, - 2, - )}\n`, - "utf8", - ); - } - - async function writeWebSearchGeminiRefConfig() { - const configPath = process.env.OPENCLAW_CONFIG_PATH; - if (!configPath) { - throw new Error("OPENCLAW_CONFIG_PATH is not set"); - } - await fs.writeFile( - configPath, - `${JSON.stringify( - { - plugins: { - entries: { - google: { - enabled: true, - config: { - webSearch: { - apiKey: "gemini-startup-key", - }, - }, - }, - }, - }, - tools: { - web: { - search: { - enabled: true, - provider: "gemini", - }, - }, - }, - }, - null, - 2, - )}\n`, - "utf8", - ); - } - - async function removeMainAuthProfileStore() { - const stateDir = process.env.OPENCLAW_STATE_DIR; - if (!stateDir) { - return; - } - const authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"); - await fs.rm(authStorePath, { force: true }); - } - async function expectOneShotSecretReloadEvents(params: { applyReload: () => Promise | undefined; sessionKey: string; @@ -641,75 +469,6 @@ describe("gateway hot reload", () => { }); }); - it("fails startup when required secret refs are unresolved", async () => { - await writeEnvRefConfig(); - delete process.env.OPENAI_API_KEY; - await expect(withGatewayServer(async () => {})).rejects.toThrow( - "Startup failed: required secrets are unavailable", - ); - }); - - it("allows startup when unresolved channel refs exist but channels are skipped", async () => { - await writeChannelEnvRefConfig(); - delete process.env.TELEGRAM_BOT_TOKEN; - process.env.OPENCLAW_SKIP_CHANNELS = "1"; - await expect(withGatewayServer(async () => {})).resolves.toBeUndefined(); - }); - - it("fails startup when an active exec ref id contains traversal segments", async () => { - await writeGatewayTraversalExecRefConfig(); - const previousGatewayAuth = testState.gatewayAuth; - const previousGatewayTokenEnv = process.env.OPENCLAW_GATEWAY_TOKEN; - testState.gatewayAuth = undefined; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - try { - await expect(withGatewayServer(async () => {})).rejects.toThrow( - /must not include "\." or "\.\." path segments/i, - ); - } finally { - testState.gatewayAuth = previousGatewayAuth; - if (previousGatewayTokenEnv === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = previousGatewayTokenEnv; - } - } - }); - - it("allows startup when unresolved refs exist only on disabled surfaces", async () => { - await writeDisabledSurfaceRefConfig(); - delete process.env.DISABLED_TELEGRAM_STARTUP_REF; - delete process.env.DISABLED_WEB_SEARCH_STARTUP_REF; - await expect(withGatewayServer(async () => {})).resolves.toBeUndefined(); - }); - - it("honors startup auth overrides before secret preflight gating", async () => { - await writeGatewayTokenRefConfig(); - delete process.env.MISSING_STARTUP_GW_TOKEN; - await expect( - withGatewayServer(async () => {}, { - serverOptions: { - auth: { - mode: "password", - password: "override-password", // pragma: allowlist secret - }, - }, - }), - ).resolves.toBeUndefined(); - }); - - it("fails startup when auth-profile secret refs are unresolved", async () => { - await writeAuthProfileEnvRefStore(); - delete process.env.MISSING_OPENCLAW_AUTH_REF; - try { - await expect(withGatewayServer(async () => {})).rejects.toThrow( - 'Environment variable "MISSING_OPENCLAW_AUTH_REF" is missing or empty.', - ); - } finally { - await removeMainAuthProfileStore(); - } - }); - it("emits one-shot degraded and recovered system events during secret reload transitions", async () => { await writeEnvRefConfig(); process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret @@ -757,78 +516,6 @@ describe("gateway hot reload", () => { }); }); - it("does not emit secrets reloader events for web search secret reload transitions", async () => { - await writeWebSearchGeminiRefConfig(); - - await withGatewayServer(async () => { - const onHotReload = hoisted.getOnHotReload(); - expect(onHotReload).toBeTypeOf("function"); - const sessionKey = resolveMainSessionKeyFromConfig(); - const plan = { - changedPaths: ["plugins.entries.google.config.webSearch.apiKey"], - restartGateway: false, - restartReasons: [], - hotReasons: ["plugins.entries.google.config.webSearch.apiKey"], - reloadHooks: false, - restartGmailWatcher: false, - restartCron: false, - restartHeartbeat: false, - restartChannels: new Set(), - noopPaths: [], - }; - const degradedConfig = { - tools: { - web: { - search: { - enabled: true, - provider: "gemini", - }, - }, - }, - plugins: { - entries: { - google: { - enabled: true, - config: { - webSearch: { - apiKey: { - source: "env", - provider: "default", - id: "OPENCLAW_TEST_MISSING_GEMINI_API_KEY", - }, - }, - }, - }, - }, - }, - }; - const recoveredConfig = { - tools: degradedConfig.tools, - plugins: { - entries: { - google: { - enabled: true, - config: { - webSearch: { - apiKey: "gemini-recovered-key", - }, - }, - }, - }, - }, - }; - - delete process.env.GEMINI_API_KEY; - delete process.env.OPENCLAW_TEST_MISSING_GEMINI_API_KEY; - expect(drainSystemEvents(sessionKey)).toEqual([]); - await expect(onHotReload?.(plan, degradedConfig)).resolves.toBeUndefined(); - expect(drainSystemEvents(sessionKey)).toEqual([]); - - await expect(onHotReload?.(plan, recoveredConfig)).resolves.toBeUndefined(); - expect(drainSystemEvents(sessionKey)).toEqual([]); - }); - }); - it("serves secrets.reload immediately after startup without race failures", async () => { await writeEnvRefConfig(); process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret diff --git a/src/gateway/server.talk-runtime.test.ts b/src/gateway/server.talk-runtime.test.ts index d7501bf4516..97b3bc82b0c 100644 --- a/src/gateway/server.talk-runtime.test.ts +++ b/src/gateway/server.talk-runtime.test.ts @@ -24,20 +24,46 @@ type SpeechProvider = Parameters[0][number]["provide const ALIAS_STUB_VOICE_ID = "VoiceAlias1234567890"; -async function writeAcmeTalkConfig() { - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ - talk: { - provider: "acme", - providers: { - acme: { - voiceId: "plugin-voice", +async function setTalkConfig(talk: Record) { + const { setRuntimeConfigSnapshot } = await import("../config/config.js"); + const config = { + commands: { + ownerDisplaySecret: "openclaw-test-owner-display-secret", + }, + talk, + }; + setRuntimeConfigSnapshot(config, config); +} + +async function setAcmeTalkConfig() { + await setTalkConfig({ + provider: "acme", + providers: { + acme: { + voiceId: "plugin-voice", + }, + }, + }); +} + +async function setElevenLabsTalkConfig() { + await setTalkConfig({ + provider: "elevenlabs", + providers: { + elevenlabs: { + voiceId: "stub-default-voice", + voiceAliases: { + Clawd: ALIAS_STUB_VOICE_ID, }, }, }, }); } +async function setEmptyTalkConfig() { + await setTalkConfig({}); +} + async function withAcmeSpeechProvider( synthesize: SpeechProvider["synthesize"], run: () => Promise, @@ -73,17 +99,7 @@ describe("gateway talk runtime", () => { }); it("allows extension speech providers through the talk setup", async () => { - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ - talk: { - provider: "acme", - providers: { - acme: { - voiceId: "plugin-voice", - }, - }, - }, - }); + await setAcmeTalkConfig(); await withSpeechProviders( [ @@ -134,7 +150,7 @@ describe("gateway talk runtime", () => { }); it("allows extension speech providers through talk.speak", async () => { - await writeAcmeTalkConfig(); + await setAcmeTalkConfig(); await withAcmeSpeechProvider( async () => ({ @@ -157,20 +173,7 @@ describe("gateway talk runtime", () => { }); it("resolves talk voice aliases case-insensitively and forwards provider overrides", async () => { - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ - talk: { - provider: "elevenlabs", - providers: { - elevenlabs: { - voiceId: "stub-default-voice", - voiceAliases: { - Clawd: ALIAS_STUB_VOICE_ID, - }, - }, - }, - }, - }); + await setElevenLabsTalkConfig(); await withSpeechProviders( [ @@ -242,8 +245,7 @@ describe("gateway talk runtime", () => { }); it("returns fallback-eligible details when talk provider is not configured", async () => { - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ talk: {} }); + await setEmptyTalkConfig(); const res = await invokeTalkSpeakDirect({ text: "Hello from talk mode." }); expect(res?.ok).toBe(false); @@ -255,7 +257,7 @@ describe("gateway talk runtime", () => { }); it("returns synthesis_failed details when the provider rejects synthesis", async () => { - await writeAcmeTalkConfig(); + await setAcmeTalkConfig(); await withAcmeSpeechProvider( async () => ({}) as never, @@ -275,7 +277,7 @@ describe("gateway talk runtime", () => { }); it("rejects empty audio results as invalid_audio_result", async () => { - await writeAcmeTalkConfig(); + await setAcmeTalkConfig(); await withAcmeSpeechProvider( async () => ({}) as never, diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 68222bdeeea..ea2131d7340 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -192,6 +192,29 @@ vi.mock("../config/config.js", async () => { return createGatewayConfigModuleMock(actual); }); +vi.mock("../config/io.js", async () => { + const actual = await vi.importActual("../config/io.js"); + const configActual = + await vi.importActual("../config/config.js"); + const configMock = createGatewayConfigModuleMock(configActual); + const createConfigIO = vi.fn(() => ({ + ...actual.createConfigIO(), + loadConfig: configMock.loadConfig, + readConfigFileSnapshot: configMock.readConfigFileSnapshot, + readConfigFileSnapshotForWrite: configMock.readConfigFileSnapshotForWrite, + writeConfigFile: configMock.writeConfigFile, + })); + return { + ...actual, + createConfigIO, + getRuntimeConfig: configMock.getRuntimeConfig, + loadConfig: configMock.loadConfig, + readConfigFileSnapshot: configMock.readConfigFileSnapshot, + readConfigFileSnapshotForWrite: configMock.readConfigFileSnapshotForWrite, + writeConfigFile: configMock.writeConfigFile, + }; +}); + vi.mock("../agents/pi-embedded.js", async () => { return await importEmbeddedRunMockModule( "../agents/pi-embedded.js", diff --git a/src/gateway/test-temp-config.ts b/src/gateway/test-temp-config.ts index b040b6e0354..dfeae6a699a 100644 --- a/src/gateway/test-temp-config.ts +++ b/src/gateway/test-temp-config.ts @@ -1,9 +1,35 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { clearConfigCache, resetConfigRuntimeState } from "../config/config.js"; +import { + clearConfigCache, + resetConfigRuntimeState, + setRuntimeConfigSnapshot, +} from "../config/config.js"; +import type { OpenClawConfig } from "../config/config.js"; import { clearSecretsRuntimeSnapshot } from "../secrets/runtime.js"; +function withStableOwnerDisplaySecretForTest(cfg: unknown): unknown { + if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) { + return cfg; + } + const record = cfg as Record; + const commands = + record.commands && typeof record.commands === "object" && !Array.isArray(record.commands) + ? (record.commands as Record) + : {}; + if (typeof commands.ownerDisplaySecret === "string" && commands.ownerDisplaySecret.length > 0) { + return cfg; + } + return { + ...record, + commands: { + ...commands, + ownerDisplaySecret: "openclaw-test-owner-display-secret", + }, + }; +} + export async function withTempConfig(params: { cfg: unknown; run: () => Promise; @@ -11,16 +37,18 @@ export async function withTempConfig(params: { }): Promise { const prevConfigPath = process.env.OPENCLAW_CONFIG_PATH; + const testConfig = withStableOwnerDisplaySecretForTest(params.cfg) as OpenClawConfig; const dir = await mkdtemp(path.join(os.tmpdir(), params.prefix ?? "openclaw-test-config-")); const configPath = path.join(dir, "openclaw.json"); process.env.OPENCLAW_CONFIG_PATH = configPath; try { - await writeFile(configPath, JSON.stringify(params.cfg, null, 2), "utf-8"); + await writeFile(configPath, JSON.stringify(testConfig, null, 2), "utf-8"); clearConfigCache(); resetConfigRuntimeState(); clearSecretsRuntimeSnapshot(); + setRuntimeConfigSnapshot(testConfig, testConfig); await params.run(); } finally { if (prevConfigPath === undefined) { diff --git a/test/vitest/vitest.gateway-core.config.ts b/test/vitest/vitest.gateway-core.config.ts index 77c8b13ce92..3de855f5df2 100644 --- a/test/vitest/vitest.gateway-core.config.ts +++ b/test/vitest/vitest.gateway-core.config.ts @@ -9,6 +9,11 @@ const nonCoreGatewayTestExclude = [ "src/gateway/**/*gateway-cli-backend*.test.ts", "src/gateway/**/*server*.test.ts", "src/gateway/gateway.test.ts", + "src/gateway/embeddings-http.test.ts", + "src/gateway/models-http.test.ts", + "src/gateway/openai-http.test.ts", + "src/gateway/openresponses-http.test.ts", + "src/gateway/probe.auth.integration.test.ts", "src/gateway/server.startup-matrix-migration.integration.test.ts", "src/gateway/sessions-history-http.test.ts", ]; diff --git a/test/vitest/vitest.gateway-server.config.ts b/test/vitest/vitest.gateway-server.config.ts index 8648cc98ac6..a1bdcb00068 100644 --- a/test/vitest/vitest.gateway-server.config.ts +++ b/test/vitest/vitest.gateway-server.config.ts @@ -1,17 +1,28 @@ import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; +const gatewayServerBackedHttpTests = [ + "src/gateway/embeddings-http.test.ts", + "src/gateway/models-http.test.ts", + "src/gateway/openai-http.test.ts", + "src/gateway/openresponses-http.test.ts", + "src/gateway/probe.auth.integration.test.ts", +]; + export function createGatewayServerVitestConfig(env?: Record) { - return createScopedVitestConfig(["src/gateway/**/*server*.test.ts"], { - dir: "src/gateway", - env, - exclude: [ - "src/gateway/server-methods/**/*.test.ts", - "src/gateway/gateway.test.ts", - "src/gateway/server.startup-matrix-migration.integration.test.ts", - "src/gateway/sessions-history-http.test.ts", - ], - name: "gateway-server", - }); + return createScopedVitestConfig( + ["src/gateway/**/*server*.test.ts", ...gatewayServerBackedHttpTests], + { + dir: "src/gateway", + env, + exclude: [ + "src/gateway/server-methods/**/*.test.ts", + "src/gateway/gateway.test.ts", + "src/gateway/server.startup-matrix-migration.integration.test.ts", + "src/gateway/sessions-history-http.test.ts", + ], + name: "gateway-server", + }, + ); } export default createGatewayServerVitestConfig();