Files
PaperClipAI/server/src/__tests__/plugin-worker-manager.test.ts
T
Dotta a1835cfa5e [codex] Harden plugin runtime invocation scope (#6547)
## Thinking Path

> - Paperclip orchestrates AI-agent companies through a company-scoped
control plane.
> - Plugins extend that control plane, but plugin workers still call
back into host APIs.
> - Those worker-to-host calls need the same company boundary guarantees
as normal API routes.
> - Plugin action handlers also need authenticated actor context from
the host instead of trusting caller-supplied params.
> - This pull request hardens plugin bridge/action scope and keeps
plugin operation issues out of normal issue surfaces.
> - The benefit is safer plugin execution with clearer authorization
boundaries and better test coverage.

## What Changed

- Added host-owned invocation context plumbing for nested plugin worker
calls.
- Added actor context to plugin `performAction` calls and test harness
helpers.
- Enforced company invocation scope on worker-to-host calls and filtered
company lists to the active invocation scope.
- Extended plugin action route tests for board and agent actor context,
spoofed company params, and cross-company rejection.
- Extended plugin worker manager coverage for invocation-scope
propagation.
- Filtered typed and legacy plugin operation issue origins from default
issue/inbox lists.

## Verification

- `pnpm --filter @paperclipai/plugin-sdk build`
- `NODE_ENV=test pnpm exec vitest run
packages/plugins/sdk/tests/host-client-factory.test.ts
packages/plugins/sdk/tests/testing-actions.test.ts
server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/plugin-worker-manager.test.ts
server/src/__tests__/issues-service.test.ts`

Note: embedded Postgres issue-service tests reported host-level Postgres
init skip for 47 tests; the non-embedded targeted tests passed.

## Risks

- Medium: plugin host authorization paths are sensitive, and external
plugins may rely on previously loose company params.
- Mitigation: the change only tightens calls when the host attached a
company invocation scope and includes explicit tests for board, agent,
and nested worker calls.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session;
exact context window not exposed by this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-05-22 09:16:24 -05:00

421 lines
12 KiB
TypeScript

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);
}
});
});