diff --git a/src/agents/bash-tools.e2e.test.ts b/src/agents/bash-tools.e2e.test.ts index 5a8238c814..9cf93ab2be 100644 --- a/src/agents/bash-tools.e2e.test.ts +++ b/src/agents/bash-tools.e2e.test.ts @@ -1,30 +1,12 @@ -import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js"; import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js"; import { createExecTool, createProcessTool, execTool, processTool } from "./bash-tools.js"; import { buildDockerExecArgs } from "./bash-tools.shared.js"; -import { sanitizeBinaryOutput } from "./shell-utils.js"; +import { resolveShellFromPath, sanitizeBinaryOutput } from "./shell-utils.js"; const isWin = process.platform === "win32"; -const resolveShellFromPath = (name: string) => { - const envPath = process.env.PATH ?? ""; - if (!envPath) { - return undefined; - } - const entries = envPath.split(path.delimiter).filter(Boolean); - for (const entry of entries) { - const candidate = path.join(entry, name); - try { - fs.accessSync(candidate, fs.constants.X_OK); - return candidate; - } catch { - // ignore missing or non-executable entries - } - } - return undefined; -}; const defaultShell = isWin ? undefined : process.env.OPENCLAW_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh"; diff --git a/src/agents/shell-utils.ts b/src/agents/shell-utils.ts index 386fd65e69..ca4faa3019 100644 --- a/src/agents/shell-utils.ts +++ b/src/agents/shell-utils.ts @@ -49,7 +49,7 @@ export function getShellConfig(): { shell: string; args: string[] } { return { shell, args: ["-c"] }; } -function resolveShellFromPath(name: string): string | undefined { +export function resolveShellFromPath(name: string): string | undefined { const envPath = process.env.PATH ?? ""; if (!envPath) { return undefined; diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index 281d7a6ec0..1acd0004e5 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -38,6 +38,20 @@ describe("cdp", () => { return wsPort; }; + const startVersionHttpServer = async (versionBody: Record) => { + httpServer = createServer((req, res) => { + if (req.url === "/json/version") { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(versionBody)); + return; + } + res.statusCode = 404; + res.end("not found"); + }); + await new Promise((resolve) => httpServer?.listen(0, "127.0.0.1", resolve)); + return (httpServer.address() as { port: number }).port; + }; + afterEach(async () => { await new Promise((resolve) => { if (!httpServer) { @@ -68,23 +82,10 @@ describe("cdp", () => { ); }); - httpServer = createServer((req, res) => { - if (req.url === "/json/version") { - res.setHeader("content-type", "application/json"); - res.end( - JSON.stringify({ - webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`, - }), - ); - return; - } - res.statusCode = 404; - res.end("not found"); + const httpPort = await startVersionHttpServer({ + webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`, }); - await new Promise((resolve) => httpServer?.listen(0, "127.0.0.1", resolve)); - const httpPort = (httpServer.address() as { port: number }).port; - const created = await createTargetViaCdp({ cdpUrl: `http://127.0.0.1:${httpPort}`, url: "https://example.com", @@ -122,23 +123,10 @@ describe("cdp", () => { ); }); - httpServer = createServer((req, res) => { - if (req.url === "/json/version") { - res.setHeader("content-type", "application/json"); - res.end( - JSON.stringify({ - webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`, - }), - ); - return; - } - res.statusCode = 404; - res.end("not found"); + const httpPort = await startVersionHttpServer({ + webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`, }); - await new Promise((resolve) => httpServer?.listen(0, "127.0.0.1", resolve)); - const httpPort = (httpServer.address() as { port: number }).port; - const created = await createTargetViaCdp({ cdpUrl: `http://127.0.0.1:${httpPort}`, url: "http://127.0.0.1:8080", @@ -174,6 +162,16 @@ describe("cdp", () => { expect(res.result.value).toBe(2); }); + it("fails when /json/version omits webSocketDebuggerUrl", async () => { + const httpPort = await startVersionHttpServer({}); + await expect( + createTargetViaCdp({ + cdpUrl: `http://127.0.0.1:${httpPort}`, + url: "https://example.com", + }), + ).rejects.toThrow("CDP /json/version missing webSocketDebuggerUrl"); + }); + it("captures an aria snapshot via CDP", async () => { const wsPort = await startWsServerWithMessages((msg, socket) => { if (msg.method === "Accessibility.enable") { diff --git a/src/browser/server.control-server.test-harness.ts b/src/browser/server.control-server.test-harness.ts index 93487aa633..f4e96f862c 100644 --- a/src/browser/server.control-server.test-harness.ts +++ b/src/browser/server.control-server.test-harness.ts @@ -39,6 +39,14 @@ export function getBrowserControlServerBaseUrl(): string { return `http://127.0.0.1:${state.testPort}`; } +export function restoreGatewayPortEnv(prevGatewayPort: string | undefined): void { + if (prevGatewayPort === undefined) { + delete process.env.OPENCLAW_GATEWAY_PORT; + return; + } + process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort; +} + export function setBrowserControlServerCreateTargetId(targetId: string | null): void { state.createTargetId = targetId; } @@ -332,11 +340,7 @@ export function installBrowserControlServerHooks() { afterEach(async () => { vi.unstubAllGlobals(); vi.restoreAllMocks(); - if (state.prevGatewayPort === undefined) { - delete process.env.OPENCLAW_GATEWAY_PORT; - } else { - process.env.OPENCLAW_GATEWAY_PORT = state.prevGatewayPort; - } + restoreGatewayPortEnv(state.prevGatewayPort); if (state.prevGatewayToken === undefined) { delete process.env.OPENCLAW_GATEWAY_TOKEN; } else { diff --git a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts index 3fd00cdc1b..3d68bf0ee6 100644 --- a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts +++ b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts @@ -8,6 +8,7 @@ import { installBrowserControlServerHooks, makeResponse, getPwMocks, + restoreGatewayPortEnv, startBrowserControlServerFromConfig, stopBrowserControlServer, } from "./server.control-server.test-harness.js"; @@ -80,11 +81,7 @@ describe("profile CRUD endpoints", () => { afterEach(async () => { vi.unstubAllGlobals(); vi.restoreAllMocks(); - if (state.prevGatewayPort === undefined) { - delete process.env.OPENCLAW_GATEWAY_PORT; - } else { - process.env.OPENCLAW_GATEWAY_PORT = state.prevGatewayPort; - } + restoreGatewayPortEnv(state.prevGatewayPort); await stopBrowserControlServer(); }); diff --git a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts index 922dcb575c..59d983e5de 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { createChannelTestPluginBase } from "../test-utils/channel-plugins.js"; import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js"; import { createRegistry } from "./server.e2e-registry-helpers.js"; import { @@ -95,22 +96,15 @@ const createStubChannelPlugin = (params: { label: string; resolveAllowFrom?: (cfg: Record) => string[]; }): ChannelPlugin => ({ - id: params.id, - meta: { + ...createChannelTestPluginBase({ id: params.id, label: params.label, - selectionLabel: params.label, - docsPath: `/channels/${params.id}`, - blurb: "test stub.", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - resolveAllowFrom: params.resolveAllowFrom - ? ({ cfg }) => params.resolveAllowFrom?.(cfg as Record) ?? [] - : undefined, - }, + config: { + resolveAllowFrom: params.resolveAllowFrom + ? ({ cfg }) => params.resolveAllowFrom?.(cfg as Record) ?? [] + : undefined, + }, + }), outbound: { deliveryMode: "direct", resolveTarget: ({ to, allowFrom }) => { diff --git a/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts index fc02f9393a..ae16d9cc6a 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts @@ -7,11 +7,11 @@ import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import { BARE_SESSION_RESET_PROMPT } from "../auto-reply/reply/session-reset-prompt.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js"; import { createRegistry } from "./server.e2e-registry-helpers.js"; import { agentCommand, + connectWebchatClient, connectOk, installGatewayTestHooks, onceMessage, @@ -367,18 +367,7 @@ describe("gateway server agent", () => { test("agent events stream to webchat clients when run context is registered", async () => { await writeMainSessionEntry({ sessionId: "sess-main" }); - const webchatWs = new WebSocket(`ws://127.0.0.1:${port}`, { - headers: { origin: `http://127.0.0.1:${port}` }, - }); - await new Promise((resolve) => webchatWs.once("open", resolve)); - await connectOk(webchatWs, { - client: { - id: GATEWAY_CLIENT_NAMES.WEBCHAT, - version: "1.0.0", - platform: "test", - mode: GATEWAY_CLIENT_MODES.WEBCHAT, - }, - }); + const webchatWs = await connectWebchatClient({ port }); registerAgentRunContext("run-auto-1", { sessionKey: "main" }); diff --git a/src/gateway/server.canvas-auth.e2e.test.ts b/src/gateway/server.canvas-auth.e2e.test.ts index 4035159573..503f4b7bf8 100644 --- a/src/gateway/server.canvas-auth.e2e.test.ts +++ b/src/gateway/server.canvas-auth.e2e.test.ts @@ -83,6 +83,16 @@ function scopedCanvasPath(capability: string, path: string): string { return `${CANVAS_CAPABILITY_PATH_PREFIX}/${encodeURIComponent(capability)}${path}`; } +const allowCanvasHostHttp: CanvasHostHandler["handleHttpRequest"] = async (req, res) => { + const url = new URL(req.url ?? "/", "http://localhost"); + if (url.pathname !== CANVAS_HOST_PATH && !url.pathname.startsWith(`${CANVAS_HOST_PATH}/`)) { + return false; + } + res.statusCode = 200; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("ok"); + return true; +}; async function withCanvasGatewayHarness(params: { resolvedAuth: ResolvedGatewayAuth; listenHost?: string; @@ -162,19 +172,7 @@ describe("gateway canvas host auth", () => { run: async () => { await withCanvasGatewayHarness({ resolvedAuth, - handleHttpRequest: async (req, res) => { - const url = new URL(req.url ?? "/", "http://localhost"); - if ( - url.pathname !== CANVAS_HOST_PATH && - !url.pathname.startsWith(`${CANVAS_HOST_PATH}/`) - ) { - return false; - } - res.statusCode = 200; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("ok"); - return true; - }, + handleHttpRequest: allowCanvasHostHttp, run: async ({ listener, clients }) => { const host = "127.0.0.1"; const operatorOnlyCapability = "operator-only"; @@ -287,19 +285,7 @@ describe("gateway canvas host auth", () => { run: async () => { await withCanvasGatewayHarness({ resolvedAuth, - handleHttpRequest: async (req, res) => { - const url = new URL(req.url ?? "/", "http://localhost"); - if ( - url.pathname !== CANVAS_HOST_PATH && - !url.pathname.startsWith(`${CANVAS_HOST_PATH}/`) - ) { - return false; - } - res.statusCode = 200; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("ok"); - return true; - }, + handleHttpRequest: allowCanvasHostHttp, run: async ({ listener, clients }) => { clients.add( makeWsClient({ diff --git a/src/gateway/server.channels.e2e.test.ts b/src/gateway/server.channels.e2e.test.ts index d7ee02e99e..c8a05c8676 100644 --- a/src/gateway/server.channels.e2e.test.ts +++ b/src/gateway/server.channels.e2e.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { PluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase } from "../test-utils/channel-plugins.js"; import { createRegistry } from "./server.e2e-registry-helpers.js"; import { connectOk, @@ -48,20 +49,11 @@ const createStubChannelPlugin = (params: { summary?: Record; logoutCleared?: boolean; }): ChannelPlugin => ({ - id: params.id, - meta: { + ...createChannelTestPluginBase({ id: params.id, label: params.label, - selectionLabel: params.label, - docsPath: `/channels/${params.id}`, - blurb: "test stub.", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - isConfigured: async () => false, - }, + config: { isConfigured: async () => false }, + }), status: { buildChannelSummary: async () => ({ configured: false, diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 506aed49c0..feb527c577 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -573,6 +573,43 @@ export async function connectOk(ws: WebSocket, opts?: Parameters[1]>["client"]; +}): Promise { + const origin = params.origin ?? `http://127.0.0.1:${params.port}`; + const ws = new WebSocket(`ws://127.0.0.1:${params.port}`, { + headers: { origin }, + }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000); + const onOpen = () => { + clearTimeout(timer); + ws.off("error", onError); + resolve(); + }; + const onError = (err: Error) => { + clearTimeout(timer); + ws.off("open", onOpen); + reject(err); + }; + ws.once("open", onOpen); + ws.once("error", onError); + }); + await connectOk(ws, { + client: + params.client ?? + ({ + id: GATEWAY_CLIENT_NAMES.WEBCHAT, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + } as NonNullable[1]>["client"]), + }); + return ws; +} + export async function rpcReq>( ws: WebSocket, method: string, diff --git a/src/hooks/install.ts b/src/hooks/install.ts index 8fe4ce6fb0..a394d92d57 100644 --- a/src/hooks/install.ts +++ b/src/hooks/install.ts @@ -13,11 +13,10 @@ import { resolveSafeInstallDir, unscopedPackageName } from "../infra/install-saf import { type NpmIntegrityDrift, type NpmSpecResolution, - packNpmSpecToArchive, resolveArchiveSourcePath, withTempDir, } from "../infra/install-source-utils.js"; -import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js"; +import { installFromNpmSpecArchive } from "../infra/npm-pack-install.js"; import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { isPathInside, isPathInsideWithRealpath } from "../security/scan-paths.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; @@ -415,58 +414,38 @@ export async function installHooksFromNpmSpec(params: { return { ok: false, error: specError }; } - return await withTempDir("openclaw-hook-pack-", async (tmpDir) => { - logger.info?.(`Downloading ${spec}…`); - const packedResult = await packNpmSpecToArchive({ - spec, - timeoutMs, - cwd: tmpDir, - }); - if (!packedResult.ok) { - return packedResult; - } - - const npmResolution: NpmSpecResolution = { - ...packedResult.metadata, - resolvedAt: new Date().toISOString(), - }; - - const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({ - spec, - expectedIntegrity: params.expectedIntegrity, - resolution: npmResolution, - onIntegrityDrift: params.onIntegrityDrift, - warn: (message) => { - logger.warn?.(message); - }, - }); - const integrityDrift = driftResult.integrityDrift; - if (driftResult.error) { - return { - ok: false, - error: driftResult.error, - }; - } - - const installResult = await installHooksFromArchive({ - archivePath: packedResult.archivePath, - hooksDir: params.hooksDir, - timeoutMs, - logger, - mode, - dryRun, - expectedHookPackId, - }); - if (!installResult.ok) { - return installResult; - } - - return { - ...installResult, - npmResolution, - integrityDrift, - }; + logger.info?.(`Downloading ${spec}…`); + const flowResult = await installFromNpmSpecArchive({ + tempDirPrefix: "openclaw-hook-pack-", + spec, + timeoutMs, + expectedIntegrity: params.expectedIntegrity, + onIntegrityDrift: params.onIntegrityDrift, + warn: (message) => { + logger.warn?.(message); + }, + installFromArchive: async ({ archivePath }) => + await installHooksFromArchive({ + archivePath, + hooksDir: params.hooksDir, + timeoutMs, + logger, + mode, + dryRun, + expectedHookPackId, + }), }); + if (!flowResult.ok) { + return flowResult; + } + if (!flowResult.installResult.ok) { + return flowResult.installResult; + } + return { + ...flowResult.installResult, + npmResolution: flowResult.npmResolution, + integrityDrift: flowResult.integrityDrift, + }; } export async function installHooksFromPath(params: { diff --git a/src/infra/npm-pack-install.test.ts b/src/infra/npm-pack-install.test.ts new file mode 100644 index 0000000000..c1014f8cb5 --- /dev/null +++ b/src/infra/npm-pack-install.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { packNpmSpecToArchive, withTempDir } from "./install-source-utils.js"; +import { installFromNpmSpecArchive } from "./npm-pack-install.js"; + +vi.mock("./install-source-utils.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + withTempDir: vi.fn(async (_prefix: string, fn: (tmpDir: string) => Promise) => { + return await fn("/tmp/openclaw-npm-pack-install-test"); + }), + packNpmSpecToArchive: vi.fn(), + }; +}); + +describe("installFromNpmSpecArchive", () => { + beforeEach(() => { + vi.mocked(packNpmSpecToArchive).mockReset(); + vi.mocked(withTempDir).mockClear(); + }); + + it("returns pack errors without invoking installer", async () => { + vi.mocked(packNpmSpecToArchive).mockResolvedValue({ ok: false, error: "pack failed" }); + const installFromArchive = vi.fn(async () => ({ ok: true as const })); + + const result = await installFromNpmSpecArchive({ + tempDirPrefix: "openclaw-test-", + spec: "@openclaw/test@1.0.0", + timeoutMs: 1000, + installFromArchive, + }); + + expect(result).toEqual({ ok: false, error: "pack failed" }); + expect(installFromArchive).not.toHaveBeenCalled(); + expect(withTempDir).toHaveBeenCalledWith("openclaw-test-", expect.any(Function)); + }); + + it("returns resolution metadata and installer result on success", async () => { + vi.mocked(packNpmSpecToArchive).mockResolvedValue({ + ok: true, + archivePath: "/tmp/openclaw-test.tgz", + metadata: { + name: "@openclaw/test", + version: "1.0.0", + resolvedSpec: "@openclaw/test@1.0.0", + integrity: "sha512-same", + }, + }); + const installFromArchive = vi.fn(async () => ({ ok: true as const, target: "done" })); + + const result = await installFromNpmSpecArchive({ + tempDirPrefix: "openclaw-test-", + spec: "@openclaw/test@1.0.0", + timeoutMs: 1000, + expectedIntegrity: "sha512-same", + installFromArchive, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.installResult).toEqual({ ok: true, target: "done" }); + expect(result.integrityDrift).toBeUndefined(); + expect(result.npmResolution.resolvedSpec).toBe("@openclaw/test@1.0.0"); + expect(result.npmResolution.resolvedAt).toBeTruthy(); + expect(installFromArchive).toHaveBeenCalledWith({ archivePath: "/tmp/openclaw-test.tgz" }); + }); + + it("aborts when integrity drift callback rejects drift", async () => { + vi.mocked(packNpmSpecToArchive).mockResolvedValue({ + ok: true, + archivePath: "/tmp/openclaw-test.tgz", + metadata: { + resolvedSpec: "@openclaw/test@1.0.0", + integrity: "sha512-new", + }, + }); + const installFromArchive = vi.fn(async () => ({ ok: true as const })); + + const result = await installFromNpmSpecArchive({ + tempDirPrefix: "openclaw-test-", + spec: "@openclaw/test@1.0.0", + timeoutMs: 1000, + expectedIntegrity: "sha512-old", + onIntegrityDrift: async () => false, + installFromArchive, + }); + + expect(result).toEqual({ + ok: false, + error: "aborted: npm package integrity drift detected for @openclaw/test@1.0.0", + }); + expect(installFromArchive).not.toHaveBeenCalled(); + }); +}); diff --git a/src/infra/npm-pack-install.ts b/src/infra/npm-pack-install.ts new file mode 100644 index 0000000000..6663c41699 --- /dev/null +++ b/src/infra/npm-pack-install.ts @@ -0,0 +1,73 @@ +import { + type NpmIntegrityDrift, + type NpmSpecResolution, + packNpmSpecToArchive, + withTempDir, +} from "./install-source-utils.js"; +import { + type NpmIntegrityDriftPayload, + resolveNpmIntegrityDriftWithDefaultMessage, +} from "./npm-integrity.js"; + +export type NpmSpecArchiveInstallFlowResult = + | { + ok: false; + error: string; + } + | { + ok: true; + installResult: TResult; + npmResolution: NpmSpecResolution; + integrityDrift?: NpmIntegrityDrift; + }; + +export async function installFromNpmSpecArchive(params: { + tempDirPrefix: string; + spec: string; + timeoutMs: number; + expectedIntegrity?: string; + onIntegrityDrift?: (payload: NpmIntegrityDriftPayload) => boolean | Promise; + warn?: (message: string) => void; + installFromArchive: (params: { archivePath: string }) => Promise; +}): Promise> { + return await withTempDir(params.tempDirPrefix, async (tmpDir) => { + const packedResult = await packNpmSpecToArchive({ + spec: params.spec, + timeoutMs: params.timeoutMs, + cwd: tmpDir, + }); + if (!packedResult.ok) { + return packedResult; + } + + const npmResolution: NpmSpecResolution = { + ...packedResult.metadata, + resolvedAt: new Date().toISOString(), + }; + + const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({ + spec: params.spec, + expectedIntegrity: params.expectedIntegrity, + resolution: npmResolution, + onIntegrityDrift: params.onIntegrityDrift, + warn: params.warn, + }); + if (driftResult.error) { + return { + ok: false, + error: driftResult.error, + }; + } + + const installResult = await params.installFromArchive({ + archivePath: packedResult.archivePath, + }); + + return { + ok: true, + installResult, + npmResolution, + integrityDrift: driftResult.integrityDrift, + }; + }); +} diff --git a/src/plugins/install.ts b/src/plugins/install.ts index c357f4c1a2..500162af70 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -17,11 +17,10 @@ import { import { type NpmIntegrityDrift, type NpmSpecResolution, - packNpmSpecToArchive, resolveArchiveSourcePath, withTempDir, } from "../infra/install-source-utils.js"; -import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js"; +import { installFromNpmSpecArchive } from "../infra/npm-pack-install.js"; import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; import * as skillScanner from "../security/skill-scanner.js"; @@ -443,58 +442,38 @@ export async function installPluginFromNpmSpec(params: { return { ok: false, error: specError }; } - return await withTempDir("openclaw-npm-pack-", async (tmpDir) => { - logger.info?.(`Downloading ${spec}…`); - const packedResult = await packNpmSpecToArchive({ - spec, - timeoutMs, - cwd: tmpDir, - }); - if (!packedResult.ok) { - return packedResult; - } - - const npmResolution: NpmSpecResolution = { - ...packedResult.metadata, - resolvedAt: new Date().toISOString(), - }; - - const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({ - spec, - expectedIntegrity: params.expectedIntegrity, - resolution: npmResolution, - onIntegrityDrift: params.onIntegrityDrift, - warn: (message) => { - logger.warn?.(message); - }, - }); - const integrityDrift = driftResult.integrityDrift; - if (driftResult.error) { - return { - ok: false, - error: driftResult.error, - }; - } - - const installResult = await installPluginFromArchive({ - archivePath: packedResult.archivePath, - extensionsDir: params.extensionsDir, - timeoutMs, - logger, - mode, - dryRun, - expectedPluginId, - }); - if (!installResult.ok) { - return installResult; - } - - return { - ...installResult, - npmResolution, - integrityDrift, - }; + logger.info?.(`Downloading ${spec}…`); + const flowResult = await installFromNpmSpecArchive({ + tempDirPrefix: "openclaw-npm-pack-", + spec, + timeoutMs, + expectedIntegrity: params.expectedIntegrity, + onIntegrityDrift: params.onIntegrityDrift, + warn: (message) => { + logger.warn?.(message); + }, + installFromArchive: async ({ archivePath }) => + await installPluginFromArchive({ + archivePath, + extensionsDir: params.extensionsDir, + timeoutMs, + logger, + mode, + dryRun, + expectedPluginId, + }), }); + if (!flowResult.ok) { + return flowResult; + } + if (!flowResult.installResult.ok) { + return flowResult.installResult; + } + return { + ...flowResult.installResult, + npmResolution: flowResult.npmResolution, + integrityDrift: flowResult.integrityDrift, + }; } export async function installPluginFromPath(params: { diff --git a/src/test-utils/channel-plugins.test.ts b/src/test-utils/channel-plugins.test.ts new file mode 100644 index 0000000000..453c7d2345 --- /dev/null +++ b/src/test-utils/channel-plugins.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { createChannelTestPluginBase, createOutboundTestPlugin } from "./channel-plugins.js"; + +describe("createChannelTestPluginBase", () => { + it("builds a plugin base with defaults", () => { + const cfg = {} as never; + const base = createChannelTestPluginBase({ id: "telegram", label: "Telegram" }); + expect(base.id).toBe("telegram"); + expect(base.meta.label).toBe("Telegram"); + expect(base.meta.selectionLabel).toBe("Telegram"); + expect(base.meta.docsPath).toBe("/channels/telegram"); + expect(base.capabilities.chatTypes).toEqual(["direct"]); + expect(base.config.listAccountIds(cfg)).toEqual(["default"]); + expect(base.config.resolveAccount(cfg)).toEqual({}); + }); + + it("honors config and metadata overrides", async () => { + const cfg = {} as never; + const base = createChannelTestPluginBase({ + id: "discord", + label: "Discord Bot", + docsPath: "/custom/discord", + capabilities: { chatTypes: ["group"] }, + config: { + listAccountIds: () => ["acct-1"], + isConfigured: async () => true, + }, + }); + expect(base.meta.docsPath).toBe("/custom/discord"); + expect(base.capabilities.chatTypes).toEqual(["group"]); + expect(base.config.listAccountIds(cfg)).toEqual(["acct-1"]); + const account = base.config.resolveAccount(cfg); + await expect(base.config.isConfigured?.(account, cfg)).resolves.toBe(true); + }); +}); + +describe("createOutboundTestPlugin", () => { + it("keeps outbound test plugin account list behavior", () => { + const cfg = {} as never; + const plugin = createOutboundTestPlugin({ + id: "signal", + outbound: { + deliveryMode: "direct", + resolveTarget: () => ({ ok: true, to: "target" }), + sendText: async () => ({ channel: "signal", messageId: "m1" }), + }, + }); + expect(plugin.config.listAccountIds(cfg)).toEqual([]); + }); +}); diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index e6246e3214..ab74e932cf 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -28,13 +28,13 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl diagnostics: [], }); -export const createOutboundTestPlugin = (params: { +export const createChannelTestPluginBase = (params: { id: ChannelId; - outbound: ChannelOutboundAdapter; label?: string; docsPath?: string; capabilities?: ChannelCapabilities; -}): ChannelPlugin => ({ + config?: Partial; +}): Pick => ({ id: params.id, meta: { id: params.id, @@ -45,8 +45,25 @@ export const createOutboundTestPlugin = (params: { }, capabilities: params.capabilities ?? { chatTypes: ["direct"] }, config: { - listAccountIds: () => [], + listAccountIds: () => ["default"], resolveAccount: () => ({}), + ...params.config, }, +}); + +export const createOutboundTestPlugin = (params: { + id: ChannelId; + outbound: ChannelOutboundAdapter; + label?: string; + docsPath?: string; + capabilities?: ChannelCapabilities; +}): ChannelPlugin => ({ + ...createChannelTestPluginBase({ + id: params.id, + label: params.label, + docsPath: params.docsPath, + capabilities: params.capabilities, + config: { listAccountIds: () => [] }, + }), outbound: params.outbound, });