diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 598377fb137..cafdfabf3ba 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -1,40 +1,7 @@ import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { encodePairingSetupCode } from "../pairing/setup-code.js"; -import type { CliMockOutputRuntime } from "./test-runtime-capture.js"; - -const runtimeState = vi.hoisted(() => { - const runtimeLogs: string[] = []; - const runtimeErrors: string[] = []; - const stringifyArgs = (args: unknown[]) => args.map((value) => String(value)).join(" "); - const defaultRuntime: CliMockOutputRuntime = { - log: vi.fn((...args: unknown[]) => { - runtimeLogs.push(stringifyArgs(args)); - }), - error: vi.fn((...args: unknown[]) => { - runtimeErrors.push(stringifyArgs(args)); - }), - writeStdout: vi.fn((value: string) => { - const normalized = value.endsWith("\n") ? value.slice(0, -1) : value; - defaultRuntime.log(normalized); - }), - writeJson: vi.fn((value: unknown, space = 2) => { - defaultRuntime.log(JSON.stringify(value, null, space > 0 ? space : undefined)); - }), - exit: vi.fn((code: number) => { - throw new Error(`__exit__:${code}`); - }), - }; - return { - runtimeLogs, - runtimeErrors, - defaultRuntime, - resetRuntimeCapture: () => { - runtimeLogs.length = 0; - runtimeErrors.length = 0; - }, - }; -}); +import { createCliRuntimeCapture, mockRuntimeModule } from "./test-runtime-capture.js"; const mocks = vi.hoisted(() => ({ loadConfig: vi.fn(), @@ -47,14 +14,16 @@ const mocks = vi.hoisted(() => ({ cb("ASCII-QR"); }), })); -const runtime = runtimeState.defaultRuntime; +const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture(); +const runtimeLog = runtime.log; +const runtimeError = runtime.error; +const runtimeExit = runtime.exit; vi.mock("../runtime.js", async () => { - const actual = await vi.importActual("../runtime.js"); - return { - ...actual, - defaultRuntime: runtimeState.defaultRuntime, - }; + return mockRuntimeModule( + () => vi.importActual("../runtime.js"), + runtime, + ); }); vi.mock("../config/config.js", () => ({ loadConfig: mocks.loadConfig })); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWithTimeout })); @@ -168,7 +137,7 @@ describe("registerQrCli", () => { } function parseLastLoggedQrJson() { - const raw = vi.mocked(runtime.log).mock.calls.at(-1)?.[0]; + const raw = runtimeLog.mock.calls.at(-1)?.[0]; return JSON.parse(typeof raw === "string" ? raw : "{}") as { setupCode?: string; gatewayUrl?: string; @@ -199,10 +168,10 @@ describe("registerQrCli", () => { beforeEach(() => { vi.clearAllMocks(); - runtimeState.resetRuntimeCapture(); + resetRuntimeCapture(); vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", ""); vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", ""); - vi.mocked(runtime.exit).mockImplementation(() => { + runtimeExit.mockImplementation(() => { throw new Error("exit"); }); }); @@ -243,10 +212,7 @@ describe("registerQrCli", () => { await runQr([]); expect(qrGenerate).toHaveBeenCalledTimes(1); - const output = vi - .mocked(runtime.log) - .mock.calls.map((call) => readRuntimeCallText(call)) - .join("\n"); + const output = runtimeLog.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); expect(output).toContain("Pairing QR"); expect(output).toContain("ASCII-QR"); expect(output).toContain("Gateway:"); @@ -264,7 +230,7 @@ describe("registerQrCli", () => { await expectQrExit(["--setup-code-only"]); - const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); + const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); expect(output).toContain("Tailscale and public mobile pairing require a secure gateway URL"); expect(output).toContain("gateway.tailscale.mode=serve"); }); @@ -397,7 +363,7 @@ describe("registerQrCli", () => { }); await expectQrExit(["--setup-code-only"]); - const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); + const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); expect(output).toContain("gateway.auth.mode is unset"); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -443,11 +409,9 @@ describe("registerQrCli", () => { await runQr(["--remote"]); expect( - vi - .mocked(runtime.log) - .mock.calls.some((call) => - readRuntimeCallText(call).includes("gateway.remote.token inactive"), - ), + runtimeLog.mock.calls.some((call) => + readRuntimeCallText(call).includes("gateway.remote.token inactive"), + ), ).toBe(true); }); @@ -461,7 +425,7 @@ describe("registerQrCli", () => { await runQr(["--setup-code-only", "--remote"]); expect( - runtime.error.mock.calls.some((call) => + runtimeError.mock.calls.some((call) => readRuntimeCallText(call).includes("gateway.remote.token inactive"), ), ).toBe(true); @@ -501,11 +465,9 @@ describe("registerQrCli", () => { const payload = parseLastLoggedQrJson(); expect(payload.gatewayUrl).toBe("wss://remote.example.com:444"); expect( - vi - .mocked(runtime.error) - .mock.calls.some((call) => - readRuntimeCallText(call).includes("gateway.remote.password inactive"), - ), + runtimeError.mock.calls.some((call) => + readRuntimeCallText(call).includes("gateway.remote.password inactive"), + ), ).toBe(true); }); @@ -519,7 +481,7 @@ describe("registerQrCli", () => { }); await expectQrExit(["--remote"]); - const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); + const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); expect(output).toContain("qr --remote requires"); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -546,12 +508,7 @@ describe("registerQrCli", () => { await runQr(["--json", "--remote"]); - const raw = vi.mocked(runtime.log).mock.calls.at(-1)?.[0]; - const payload = JSON.parse(typeof raw === "string" ? raw : "{}") as { - gatewayUrl?: string; - auth?: string; - urlSource?: string; - }; + const payload = parseLastLoggedQrJson(); expect(payload.gatewayUrl).toBe("wss://ts-host.tailnet.ts.net"); expect(payload.auth).toBe("token"); expect(payload.urlSource).toBe("gateway.tailscale.mode=serve"); diff --git a/src/cli/qr-dashboard.integration.test.ts b/src/cli/qr-dashboard.integration.test.ts index e5a74949bbb..1b2281ae947 100644 --- a/src/cli/qr-dashboard.integration.test.ts +++ b/src/cli/qr-dashboard.integration.test.ts @@ -1,56 +1,43 @@ import { Command } from "commander"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../runtime.js"; import { captureEnv } from "../test-utils/env.js"; -import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; const loadConfigMock = vi.hoisted(() => vi.fn()); const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789)); const copyToClipboardMock = vi.hoisted(() => vi.fn(async () => false)); +const { + runtimeLogs, + runtimeErrors, + defaultRuntime: runtime, + resetRuntimeCapture, +} = createCliRuntimeCapture(); +const runtimeExit = runtime.exit; -type CliRuntimeEnv = RuntimeEnv & { - log: MockFn; - error: MockFn; - exit: MockFn; -}; - -const runtimeLogs: string[] = []; -const runtimeErrors: string[] = []; -const runtime = vi.hoisted(() => ({ - log: vi.fn((...args: unknown[]) => { - runtimeLogs.push(args.map(String).join(" ")); - }), - error: vi.fn((...args: unknown[]) => { - runtimeErrors.push(args.map(String).join(" ")); - }), - exit: vi.fn<(code: number) => void>(), +vi.mock("../config/config.js", () => ({ + loadConfig: loadConfigMock, + readConfigFileSnapshot: readConfigFileSnapshotMock, + resolveGatewayPort: resolveGatewayPortMock, })); -vi.mock("../config/config.js", async () => { - const actual = await vi.importActual("../config/config.js"); - return { - ...actual, - loadConfig: loadConfigMock, - readConfigFileSnapshot: readConfigFileSnapshotMock, - resolveGatewayPort: resolveGatewayPortMock, - }; -}); - vi.mock("../infra/clipboard.js", () => ({ copyToClipboard: copyToClipboardMock, })); -vi.mock("../runtime.js", async () => { - const actual = await vi.importActual("../runtime.js"); - return { - ...actual, - defaultRuntime: runtime, - }; -}); +vi.mock("../infra/device-bootstrap.js", () => ({ + issueDeviceBootstrapToken: vi.fn(async () => ({ + token: "bootstrap-123", + expiresAtMs: 123, + })), +})); -let dashboardCommand: typeof import("../commands/dashboard.js").dashboardCommand; -let registerQrCli: typeof import("./qr-cli.js").registerQrCli; +vi.mock("../runtime.js", () => ({ + defaultRuntime: runtime, +})); + +const { dashboardCommand } = await import("../commands/dashboard.js"); +const { registerQrCli } = await import("./qr-cli.js"); function createGatewayTokenRefFixture() { return { @@ -124,16 +111,11 @@ describe("cli integration: qr + dashboard token SecretRef", () => { "OPENCLAW_GATEWAY_PASSWORD", ]); }); - beforeAll(async () => { - ({ dashboardCommand } = await import("../commands/dashboard.js")); - ({ registerQrCli } = await import("./qr-cli.js")); - }); beforeEach(() => { - runtimeLogs.length = 0; - runtimeErrors.length = 0; + resetRuntimeCapture(); vi.clearAllMocks(); - vi.mocked(runtime.exit).mockImplementation(() => {}); + runtimeExit.mockImplementation(() => {}); delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.OPENCLAW_GATEWAY_PASSWORD; delete process.env.SHARED_GATEWAY_TOKEN; diff --git a/src/cli/update-cli.test-helpers.test.ts b/src/cli/update-cli.test-helpers.test.ts new file mode 100644 index 00000000000..6c1c8a8663a --- /dev/null +++ b/src/cli/update-cli.test-helpers.test.ts @@ -0,0 +1,21 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { isOwningNpmCommand } from "./update-cli.test-helpers.js"; + +describe("isOwningNpmCommand", () => { + it("accepts absolute npm binaries under the owning prefix", () => { + const prefix = path.join(path.sep, "opt", "homebrew"); + + expect(isOwningNpmCommand(path.join(prefix, "bin", "npm"), prefix)).toBe(true); + expect(isOwningNpmCommand(path.join(prefix, "npm.cmd"), prefix)).toBe(true); + }); + + it("rejects plain npm and paths outside the owning prefix", () => { + const prefix = path.join(path.sep, "opt", "homebrew"); + + expect(isOwningNpmCommand("npm", prefix)).toBe(false); + expect(isOwningNpmCommand(path.join(path.sep, "usr", "local", "bin", "npm"), prefix)).toBe( + false, + ); + }); +}); diff --git a/src/cli/update-cli.test-helpers.ts b/src/cli/update-cli.test-helpers.ts new file mode 100644 index 00000000000..1685b961a19 --- /dev/null +++ b/src/cli/update-cli.test-helpers.ts @@ -0,0 +1,18 @@ +import path from "node:path"; + +function isPathInsideRoot(candidate: string, root: string): boolean { + const relative = path.relative(path.resolve(root), path.resolve(candidate)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function isOwningNpmCommand(value: unknown, owningPrefix: string): boolean { + if (typeof value !== "string" || !path.isAbsolute(value)) { + return false; + } + const normalized = path.normalize(value); + return ( + normalized !== path.normalize("npm") && + isPathInsideRoot(normalized, owningPrefix) && + /npm(?:\.cmd)?$/i.test(normalized) + ); +} diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index d4b8667de9e..9dd47175a85 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -8,6 +8,7 @@ import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.opencla import type { UpdateRunResult } from "../infra/update-runner.js"; import { withEnvAsync } from "../test-utils/env.js"; import { createCliRuntimeCapture } from "./test-runtime-capture.js"; +import { isOwningNpmCommand } from "./update-cli.test-helpers.js"; const confirm = vi.fn(); const select = vi.fn(); @@ -747,18 +748,6 @@ describe("update-cli", () => { const pkgRoot = path.join(brewRoot, "openclaw"); const brewNpm = path.join(brewPrefix, "bin", "npm"); const win32PrefixNpm = path.join(brewPrefix, "npm.cmd"); - const isOwningNpmCommand = (value: unknown): boolean => { - if (typeof value !== "string") { - return false; - } - const normalized = path.normalize(value); - return ( - normalized !== path.normalize("npm") && - path.isAbsolute(value) && - normalized.includes(path.normalize(brewPrefix)) && - /npm(?:\.cmd)?$/i.test(normalized) - ); - }; const pathNpmRoot = createCaseDir("nvm-root"); mockPackageInstallStatus(pkgRoot); pathExists.mockResolvedValue(false); @@ -784,7 +773,7 @@ describe("update-cli", () => { termination: "exit", }; } - if (isOwningNpmCommand(argv[0]) && argv[1] === "root" && argv[2] === "-g") { + if (isOwningNpmCommand(argv[0], brewPrefix) && argv[1] === "root" && argv[2] === "-g") { return { stdout: `${brewRoot}\n`, stderr: "", @@ -817,7 +806,7 @@ describe("update-cli", () => { .mock.calls.find( ([argv]) => Array.isArray(argv) && - isOwningNpmCommand(argv[0]) && + isOwningNpmCommand(argv[0], brewPrefix) && argv[1] === "i" && argv[2] === "-g" && argv[3] === "openclaw@latest", diff --git a/src/plugin-sdk/lazy-value.test.ts b/src/plugin-sdk/lazy-value.test.ts new file mode 100644 index 00000000000..a11aae743a7 --- /dev/null +++ b/src/plugin-sdk/lazy-value.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from "vitest"; +import { createCachedLazyValueGetter } from "./lazy-value.js"; + +describe("createCachedLazyValueGetter", () => { + it("memoizes lazy factories", () => { + const resolveSchema = vi.fn(() => ({ type: "object" as const })); + const getSchema = createCachedLazyValueGetter(resolveSchema); + + expect(getSchema()).toEqual({ type: "object" }); + expect(getSchema()).toEqual({ type: "object" }); + expect(resolveSchema).toHaveBeenCalledTimes(1); + }); + + it("uses the fallback when the lazy value resolves nullish", () => { + const fallback = { type: "object" as const, properties: {} }; + const getSchema = createCachedLazyValueGetter(() => undefined, fallback); + + expect(getSchema()).toBe(fallback); + }); +}); diff --git a/src/plugin-sdk/lazy-value.ts b/src/plugin-sdk/lazy-value.ts new file mode 100644 index 00000000000..6a0a859e0a0 --- /dev/null +++ b/src/plugin-sdk/lazy-value.ts @@ -0,0 +1,24 @@ +type LazyValue = T | (() => T); + +export function createCachedLazyValueGetter(value: LazyValue): () => T; +export function createCachedLazyValueGetter( + value: LazyValue, + fallback: T, +): () => T; +export function createCachedLazyValueGetter( + value: LazyValue, + fallback?: T, +): () => T | undefined { + let resolved = false; + let cached: T | undefined; + + return () => { + if (!resolved) { + const nextValue = + typeof value === "function" ? (value as () => T | null | undefined)() : value; + cached = nextValue ?? fallback; + resolved = true; + } + return cached; + }; +} diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index 43be5b139f5..28d50b523b3 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -65,6 +65,7 @@ import type { SpeechProviderPlugin, PluginCommandContext, } from "../plugins/types.js"; +import { createCachedLazyValueGetter } from "./lazy-value.js"; export type { AnyAgentTool, @@ -154,13 +155,6 @@ type DefinedPluginEntry = { register: NonNullable; } & Pick; -/** Resolve either a concrete config schema or a lazy schema factory. */ -function resolvePluginConfigSchema( - configSchema: DefinePluginEntryOptions["configSchema"] = emptyPluginConfigSchema, -): OpenClawPluginConfigSchema { - return typeof configSchema === "function" ? configSchema() : configSchema; -} - /** * Canonical entry helper for non-channel plugins. * @@ -176,11 +170,7 @@ export function definePluginEntry({ configSchema = emptyPluginConfigSchema, register, }: DefinePluginEntryOptions): DefinedPluginEntry { - let resolvedConfigSchema: OpenClawPluginConfigSchema | undefined; - const getConfigSchema = (): OpenClawPluginConfigSchema => { - resolvedConfigSchema ??= resolvePluginConfigSchema(configSchema); - return resolvedConfigSchema; - }; + const getConfigSchema = createCachedLazyValueGetter(configSchema); return { id, name,