import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; import { createHostClientHandlers, JsonRpcCallError, PLUGIN_RPC_ERROR_CODES, type HostServices, type HostToWorkerMethods, } from "@paperclipai/plugin-sdk"; import { appendStderrExcerpt, createPluginWorkerHandle, formatWorkerFailureMessage, } from "../services/plugin-worker-manager.js"; const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); const DELAYED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-delayed.cjs"); const INVOCATION_SCOPE_WORKER_ENTRYPOINT = path.join( FIXTURES_DIR, "plugin-worker-invocation-scope.cjs", ); const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs"); const TEST_MANIFEST: PaperclipPluginManifestV1 = { id: "test.plugin", apiVersion: 1, version: "1.0.0", displayName: "Test plugin", description: "Test plugin", author: "Paperclip", categories: ["automation"], capabilities: [], entrypoints: { worker: "dist/worker.js" }, }; describe("plugin-worker-manager stderr failure context", () => { it("appends worker stderr context to failure messages", () => { expect( formatWorkerFailureMessage( "Worker process exited (code=1, signal=null)", "TypeError: Unknown file extension \".ts\"", ), ).toBe( "Worker process exited (code=1, signal=null)\n\nWorker stderr:\nTypeError: Unknown file extension \".ts\"", ); }); it("does not duplicate stderr that is already present", () => { const message = [ "Worker process exited (code=1, signal=null)", "", "Worker stderr:", "TypeError: Unknown file extension \".ts\"", ].join("\n"); expect( formatWorkerFailureMessage(message, "TypeError: Unknown file extension \".ts\""), ).toBe(message); }); it("keeps only the latest stderr excerpt", () => { let excerpt = ""; excerpt = appendStderrExcerpt(excerpt, "first line"); excerpt = appendStderrExcerpt(excerpt, "second line"); expect(excerpt).toContain("first line"); expect(excerpt).toContain("second line"); excerpt = appendStderrExcerpt(excerpt, "x".repeat(9_000)); expect(excerpt).not.toContain("first line"); expect(excerpt).not.toContain("second line"); expect(excerpt.length).toBeLessThanOrEqual(8_000); }); it("times out environmentExecute calls using the handle default when no override is provided", async () => { const handle = createPluginWorkerHandle("test.plugin", { entrypointPath: DELAYED_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {}, instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0", }, apiVersion: 1, hostHandlers: {}, rpcTimeoutMs: 10, }); try { await handle.start(); await expect(handle.call("environmentExecute", { driverKey: "e2b", companyId: "company-1", environmentId: "environment-1", config: {}, lease: { providerLeaseId: "lease-1" }, command: "echo", delayMs: 50, } as HostToWorkerMethods["environmentExecute"][0])).rejects.toMatchObject({ message: expect.stringContaining("timed out after 10ms"), }); } finally { await handle.stop().catch(() => undefined); } }); it("honors per-call timeout overrides for environmentExecute", async () => { const handle = createPluginWorkerHandle("test.plugin", { entrypointPath: DELAYED_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {}, instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0", }, apiVersion: 1, hostHandlers: {}, rpcTimeoutMs: 10, }); try { await handle.start(); await expect(handle.call("environmentExecute", { driverKey: "e2b", companyId: "company-1", environmentId: "environment-1", config: {}, lease: { providerLeaseId: "lease-1" }, command: "echo", delayMs: 50, } as HostToWorkerMethods["environmentExecute"][0], 100)).resolves.toMatchObject({ exitCode: 0, stdout: "ok\n", }); } finally { await handle.stop().catch(() => undefined); } }); it("does not emit an unhandled rejection when a plugin responds with terminated before callers attach handlers", async () => { const unhandledRejection = vi.fn(); process.on("unhandledRejection", unhandledRejection); const handle = createPluginWorkerHandle("test.plugin", { entrypointPath: TERMINATED_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {}, instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0", }, apiVersion: 1, hostHandlers: {}, }); try { await handle.start(); const pendingCall = handle.call( "environmentExecute" as keyof HostToWorkerMethods, { driverKey: "e2b", companyId: "company-1", environmentId: "environment-1", config: {}, lease: { providerLeaseId: "lease-1" }, command: "echo", } as HostToWorkerMethods[keyof HostToWorkerMethods][0], ); await new Promise((resolve) => setImmediate(resolve)); await expect(pendingCall).rejects.toBeInstanceOf(JsonRpcCallError); await expect(pendingCall).rejects.toMatchObject({ message: expect.stringContaining("terminated"), }); expect(unhandledRejection).not.toHaveBeenCalled(); } finally { process.off("unhandledRejection", unhandledRejection); await handle.stop().catch(() => undefined); } }); it("passes performAction invocation scope to nested worker host calls", async () => { const companiesGet = vi.fn(async ( params: { companyId: string }, context?: { invocationScope?: { companyId?: string | null } | null }, ) => ({ id: params.companyId, scopedCompanyId: context?.invocationScope?.companyId ?? null, })); const handle = createPluginWorkerHandle("test.plugin", { entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {}, instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0", }, apiVersion: 1, hostHandlers: { "companies.get": companiesGet as never, }, }); try { await handle.start(); await expect(handle.call("performAction", { key: "probe", params: { mode: "echo", requestedCompanyId: "company-a", }, actorContext: { type: "agent", userId: null, agentId: "agent-1", runId: "run-1", companyId: "company-a", }, renderEnvironment: null, })).resolves.toEqual({ id: "company-a", scopedCompanyId: "company-a", }); expect(companiesGet).toHaveBeenCalledWith( { companyId: "company-a" }, { invocationScope: { companyId: "company-a" } }, ); } finally { await handle.stop().catch(() => undefined); } }); it("passes echoed invocation scope to worker-to-host handlers", async () => { const companiesGet = vi.fn(async () => ({ id: "company-1" })); const handle = createPluginWorkerHandle("test.plugin", { entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {}, instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0", }, apiVersion: 1, hostHandlers: { "companies.get": companiesGet, }, }); try { await handle.start(); await expect(handle.call("getData", { key: "probe", companyId: "company-1", params: { mode: "echo", requestedCompanyId: "company-1", }, } as HostToWorkerMethods["getData"][0])).resolves.toEqual({ id: "company-1" }); expect(companiesGet).toHaveBeenCalledWith( { companyId: "company-1" }, { invocationScope: { companyId: "company-1" } }, ); } finally { await handle.stop().catch(() => undefined); } }); it("rejects performAction nested host calls that omit the invocation id", async () => { const handlers = createHostClientHandlers({ pluginId: "test.plugin", capabilities: ["companies.read"], services: { companies: { list: vi.fn(async () => []), get: vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })), }, } as unknown as HostServices, }); const handle = createPluginWorkerHandle("test.plugin", { entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {}, instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0", }, apiVersion: 1, hostHandlers: handlers, }); try { await handle.start(); await expect(handle.call("performAction", { key: "probe", params: { requestedCompanyId: "company-b", }, actorContext: { type: "agent", userId: null, agentId: "agent-1", runId: "run-1", companyId: "company-a", }, renderEnvironment: null, })).rejects.toMatchObject({ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, message: expect.stringContaining("unknown invocation scope"), }); } finally { await handle.stop().catch(() => undefined); } }); it("rejects nested worker host calls that forge an unknown invocation id", async () => { const companiesGet = vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })); const handlers = createHostClientHandlers({ pluginId: "test.plugin", capabilities: ["companies.read"], services: { companies: { get: companiesGet, }, } as unknown as HostServices, }); const handle = createPluginWorkerHandle("test.plugin", { entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {}, instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0", }, apiVersion: 1, hostHandlers: handlers, }); try { await handle.start(); await expect(handle.call("performAction", { key: "probe", params: { mode: "unknown", requestedCompanyId: "company-a", }, actorContext: { type: "agent", userId: null, agentId: "agent-1", runId: "run-1", companyId: "company-a", }, renderEnvironment: null, })).rejects.toMatchObject({ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, message: expect.stringContaining("unknown invocation scope"), }); expect(companiesGet).not.toHaveBeenCalled(); } finally { await handle.stop().catch(() => undefined); } }); it("rejects missing or unknown invocation ids while a company invocation is active", async () => { const companiesGet = vi.fn(async () => ({ id: "company-2" })); const hostHandlers = createHostClientHandlers({ pluginId: "test.plugin", capabilities: ["companies.read"], services: { companies: { get: companiesGet, }, } as unknown as HostServices, }); const handle = createPluginWorkerHandle("test.plugin", { entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {}, instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0", }, apiVersion: 1, hostHandlers, }); try { await handle.start(); for (const mode of ["omit", "unknown"]) { await expect(handle.call("getData", { key: "probe", companyId: "company-1", params: { mode, requestedCompanyId: "company-2", }, } as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, }); } expect(companiesGet).not.toHaveBeenCalled(); } finally { await handle.stop().catch(() => undefined); } }); });