Files
PaperClipAI/server/src/__tests__/plugin-routes-authz.test.ts
T
Nicky LeachandClaude Opus 5.5 e1332b997f fix(plugins): align tool run context and interaction input types (#19493)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Plugins add tools and issue-thread interactions through
`@paperclipai/plugin-sdk`
> - The SDK types are the public contract between the host and plugin
authors
> - Two parts of that contract do not match what the host does at
runtime
> - First, `ToolRunContext.projectId` says it is always a project UUID.
The tool gateway sends an empty string when a run has no project. The
direct tool route rejects such runs with 400
> - Second, three interaction helpers ask for the parsed output type, so
plugin authors must pass fields that the host fills in with defaults
> - This pull request makes the types and both tool paths agree with the
runtime
> - The benefit is that agents with no project can run plugin tools, and
plugin authors can omit the fields that have defaults

## Linked Issues or Issue Description

Fixes #3273

Related: #3274 removes the `projectId` requirement from the route. That
change alone is not enough today: the route now also looks up the
project to check its company, and that lookup needs a value. This pull
request also skips that lookup when the run has no project. It gives
plugins one value for "no project" on both tool paths.

The new test typecheck in #18739 and #18839 also listed both problems as
possible product problems.

## What Changed

- `server/src/routes/plugins.ts`: `POST /api/plugins/tools/execute`
accepts a `runContext.projectId` that is missing, `null`, or an empty
string. It passes an empty string to the plugin, the same value that the
tool gateway sends. It checks the project's company only when a project
ID is present. A `projectId` that is not a string returns 400.
- `server/src/routes/openapi.ts`: the request schema marks
`runContext.projectId` as optional and nullable.
- `packages/plugins/sdk/src/types.ts`:
- The `ToolRunContext.projectId` comment says the value is an empty
string when the run has no project.
- `suggestTasks`, `requestConfirmation` and
`requestCheckboxConfirmation` take `CreateIssueThreadInteractionInput`,
as `askUserQuestions` already does. The host parses this input with the
schema, so the defaults apply.
- `packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts`:
the example passes `undefined` instead of an empty project ID to
`issues.create`.
- `doc/plugins/PLUGIN_SPEC.md` (section 13.10): documents the empty
project ID.
- Tests:
- `server/src/__tests__/plugin-routes-authz.test.ts`: tests a run with
an empty, missing, or `null` project ID, which skips the project lookup.
Also tests that a project ID that is not a string returns 400.
- `packages/plugins/sdk/tests/testing-actions.test.ts`: removes the cast
that the output type required.

## Verification

- `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/openapi-routes.test.ts
packages/plugins/sdk/tests/testing-actions.test.ts`: 3 files, 81 tests
pass.
- `cd server && pnpm typecheck`: passes. This includes the source layers
and the three test-tree shards.
- `tsc --noEmit` passes for `packages/plugins/sdk` (`tsconfig.json` and
`tsconfig.test.json`) and for the kitchen-sink example.
- Not run locally: the full `pnpm test:run` and the root `pnpm build`.
Required CI covers them.

## Risks

- Low risk. The direct tool route now accepts calls that it rejected
before. It still checks that the agent and the run belong to the
company. With no project ID, there is no project to check.
- A plugin that reads `runCtx.projectId` can get an empty string on both
tool paths. The tool gateway already sent this value before this change.
Only the documentation is new.
- The SDK parameter types are wider. Existing plugin code still
compiles.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edits) and extended thinking.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:25:43 +00:00

1246 lines
38 KiB
TypeScript

import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockRegistry = vi.hoisted(() => ({
getById: vi.fn(),
getByKey: vi.fn(),
upsertConfig: vi.fn(),
getCompanySettings: vi.fn(),
upsertCompanySettings: vi.fn(),
}));
const mockLifecycle = vi.hoisted(() => ({
load: vi.fn(),
upgrade: vi.fn(),
unload: vi.fn(),
enable: vi.fn(),
disable: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
getById: vi.fn(),
syncSecretRefsForTarget: vi.fn(),
}));
vi.mock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));
vi.mock("../services/plugin-lifecycle.js", () => ({
pluginLifecycleManager: () => mockLifecycle,
}));
vi.mock("../services/activity-log.js", () => ({
logActivity: vi.fn(),
}));
vi.mock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.mock("../services/live-events.js", () => ({
publishGlobalLiveEvent: vi.fn(),
}));
async function createApp(
actor: Record<string, unknown>,
loaderOverrides: Record<string, unknown> = {},
routeOverrides: {
db?: unknown;
jobDeps?: unknown;
toolDeps?: unknown;
bridgeDeps?: unknown;
captureJsonContext?: (context: unknown, body: unknown) => void;
} = {},
) {
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
import("../routes/plugins.js"),
import("../middleware/index.js"),
]);
const loader = {
installPlugin: vi.fn(),
...loaderOverrides,
};
const app = express();
app.use(express.json());
if (routeOverrides.captureJsonContext) {
app.use((_req, res, next) => {
const originalJson = res.json.bind(res);
res.json = ((body: unknown) => {
routeOverrides.captureJsonContext?.((res as any).__errorContext, body);
return originalJson(body);
}) as typeof res.json;
next();
});
}
app.use((req, _res, next) => {
req.actor = actor as typeof req.actor;
next();
});
app.use("/api", pluginRoutes(
(routeOverrides.db ?? {}) as never,
loader as never,
routeOverrides.jobDeps as never,
undefined,
routeOverrides.toolDeps as never,
routeOverrides.bridgeDeps as never,
));
app.use(errorHandler);
return { app, loader };
}
function createSelectQueueDb(rows: Array<Array<Record<string, unknown>>>) {
return {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(() => Promise.resolve(rows.shift() ?? [])),
})),
})),
})),
};
}
const companyA = "22222222-2222-4222-8222-222222222222";
const companyB = "33333333-3333-4333-8333-333333333333";
const agentA = "44444444-4444-4444-8444-444444444444";
const runA = "55555555-5555-4555-8555-555555555555";
const projectA = "66666666-6666-4666-8666-666666666666";
const pluginId = "11111111-1111-4111-8111-111111111111";
const secretId = "77777777-7777-4777-8777-777777777777";
function boardActor(overrides: Record<string, unknown> = {}) {
return {
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: [companyA],
...overrides,
};
}
function agentActor(overrides: Record<string, unknown> = {}) {
return {
type: "agent",
agentId: agentA,
companyId: companyA,
runId: runA,
source: "agent_jwt",
...overrides,
};
}
function readyPlugin() {
mockRegistry.getById.mockResolvedValue({
id: pluginId,
pluginKey: "paperclip.example",
version: "1.0.0",
status: "ready",
});
}
describe("plugin install and upgrade authz", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("lists bundled monorepo plugin packages", async () => {
const { app } = await createApp(boardActor());
const res = await request(app).get("/api/plugins/examples");
expect(res.status).toBe(200);
const packageNames = res.body.map((plugin: { packageName: string }) => plugin.packageName);
const byPackageName = new Map<string, { experimental: boolean; hasBuiltEntrypoints: boolean }>(
res.body.map((plugin: { packageName: string; experimental: boolean; hasBuiltEntrypoints: boolean }) => [plugin.packageName, plugin]),
);
expect(packageNames).toContain("@paperclipai/plugin-workspace-diff");
expect(packageNames).toContain("@paperclipai/plugin-llm-wiki");
expect(packageNames).toContain("@paperclipai/plugin-modal");
expect(packageNames).toContain("@paperclipai/plugin-authoring-smoke-example");
expect(packageNames).not.toContain("@paperclipai/plugin-sdk");
expect(byPackageName.get("@paperclipai/plugin-workspace-diff")?.experimental).toBe(true);
expect(byPackageName.get("@paperclipai/plugin-llm-wiki")?.experimental).toBe(true);
expect(byPackageName.get("@paperclipai/plugin-modal")?.experimental).toBe(true);
expect(byPackageName.get("@paperclipai/plugin-authoring-smoke-example")?.experimental).toBe(false);
expect(typeof byPackageName.get("@paperclipai/plugin-workspace-diff")?.hasBuiltEntrypoints).toBe("boolean");
}, 20_000);
it("rejects plugin installation for non-admin board users", async () => {
const { app, loader } = await createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app)
.post("/api/plugins/install")
.send({ packageName: "paperclip-plugin-example" });
expect(res.status).toBe(403);
expect(loader.installPlugin).not.toHaveBeenCalled();
}, 20_000);
it("allows instance admins to install plugins", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
const pluginKey = "paperclip.example";
const discovered = {
manifest: {
id: pluginKey,
},
};
mockRegistry.getByKey.mockResolvedValue({
id: pluginId,
pluginKey,
packageName: "paperclip-plugin-example",
version: "1.0.0",
});
mockRegistry.getById.mockResolvedValue({
id: pluginId,
pluginKey,
packageName: "paperclip-plugin-example",
version: "1.0.0",
});
mockLifecycle.load.mockResolvedValue(undefined);
const { app, loader } = await createApp(
{
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [],
},
{ installPlugin: vi.fn().mockResolvedValue(discovered) },
);
const res = await request(app)
.post("/api/plugins/install")
.send({ packageName: "paperclip-plugin-example" });
expect(res.status).toBe(200);
expect(loader.installPlugin).toHaveBeenCalledWith({
packageName: "paperclip-plugin-example",
version: undefined,
});
expect(mockLifecycle.load).toHaveBeenCalledWith(pluginId);
}, 20_000);
it("rejects plugin upgrades for non-admin board users", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
const { app } = await createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/upgrade`)
.send({});
expect(res.status).toBe(403);
expect(mockRegistry.getById).not.toHaveBeenCalled();
expect(mockLifecycle.upgrade).not.toHaveBeenCalled();
}, 20_000);
it.each([
["delete", "delete", "/api/plugins/11111111-1111-4111-8111-111111111111", undefined],
["enable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/enable", {}],
["disable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/disable", {}],
["config", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/config", { configJson: {} }],
] as const)("rejects plugin %s for non-admin board users", async (_name, method, path, body) => {
const { app } = await createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const req = method === "delete" ? request(app).delete(path) : request(app).post(path).send(body);
const res = await req;
expect(res.status).toBe(403);
expect(mockRegistry.getById).not.toHaveBeenCalled();
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
expect(mockLifecycle.unload).not.toHaveBeenCalled();
expect(mockLifecycle.enable).not.toHaveBeenCalled();
expect(mockLifecycle.disable).not.toHaveBeenCalled();
}, 20_000);
it("resolves plugin keys without probing the UUID id column for core plugin actions", async () => {
const pluginKey = "paperclipqa.hello-plugin";
const plugin = {
id: pluginId,
pluginKey,
version: "1.0.0",
status: "ready",
};
mockRegistry.getById.mockImplementation(() => {
throw new Error("getById should not be called for plugin keys");
});
mockRegistry.getByKey.mockResolvedValue(plugin);
mockLifecycle.unload.mockResolvedValue(plugin);
mockLifecycle.enable.mockResolvedValue(plugin);
mockLifecycle.disable.mockResolvedValue(plugin);
const { app } = await createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [companyA],
});
const inspectRes = await request(app).get(`/api/plugins/${pluginKey}`);
const disableRes = await request(app).post(`/api/plugins/${pluginKey}/disable`).send({});
const enableRes = await request(app).post(`/api/plugins/${pluginKey}/enable`).send({});
const uninstallRes = await request(app).delete(`/api/plugins/${pluginKey}?purge=true`);
expect(inspectRes.status).toBe(200);
expect(disableRes.status).toBe(200);
expect(enableRes.status).toBe(200);
expect(uninstallRes.status).toBe(200);
expect(mockRegistry.getById).not.toHaveBeenCalled();
expect(mockRegistry.getByKey).toHaveBeenCalledWith(pluginKey);
expect(mockLifecycle.disable).toHaveBeenCalledWith(pluginId, undefined);
expect(mockLifecycle.enable).toHaveBeenCalledWith(pluginId);
expect(mockLifecycle.unload).toHaveBeenCalledWith(pluginId, true);
}, 20_000);
it("allows instance admins to save company-scoped secret refs and sync plugin bindings", async () => {
readyPlugin();
const configJson = {
apiKeyRef: { type: "secret_ref", secretId, version: "latest" },
};
mockSecretService.getById.mockResolvedValue({ id: secretId, companyId: companyA, status: "active" });
mockSecretService.syncSecretRefsForTarget.mockResolvedValue([]);
mockRegistry.upsertConfig.mockResolvedValue({ id: "config-1", pluginId, companyId: companyA, configJson });
const { app } = await createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [companyA],
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/config`)
.send({ companyId: companyA, configJson });
expect(res.status).toBe(200);
expect(mockSecretService.getById).toHaveBeenCalledWith(secretId);
expect(mockSecretService.syncSecretRefsForTarget).toHaveBeenCalledWith(
companyA,
{ targetType: "plugin", targetId: pluginId },
[expect.objectContaining({ secretId, configPath: "apiKeyRef", versionSelector: "latest" })],
{ replaceAll: true },
);
expect(mockRegistry.upsertConfig).toHaveBeenCalledWith(pluginId, companyA, {
companyId: companyA,
configJson,
});
}, 20_000);
it("rejects plugin config saves that reference another company's secret before syncing bindings", async () => {
readyPlugin();
mockSecretService.getById.mockResolvedValue({ id: secretId, companyId: companyB, status: "active" });
mockSecretService.syncSecretRefsForTarget.mockResolvedValue([]);
const { app } = await createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [companyA],
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/config`)
.send({
companyId: companyA,
configJson: {
apiKeyRef: { type: "secret_ref", secretId, version: "latest" },
},
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/outside the selected company/i);
expect(mockSecretService.syncSecretRefsForTarget).not.toHaveBeenCalled();
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
}, 20_000);
it("allows instance admins to upgrade plugins", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
mockRegistry.getById.mockResolvedValue({
id: pluginId,
pluginKey: "paperclip.example",
version: "1.0.0",
});
mockLifecycle.upgrade.mockResolvedValue({
id: pluginId,
version: "1.1.0",
});
const { app } = await createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [],
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/upgrade`)
.send({ version: "1.1.0" });
expect(res.status).toBe(200);
expect(mockLifecycle.upgrade).toHaveBeenCalledWith(pluginId, "1.1.0");
}, 20_000);
});
describe("scoped plugin API routes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("dispatches manifest-declared scoped routes after company access checks", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
const workerManager = {
call: vi.fn().mockResolvedValue({
status: 202,
body: { ok: true },
}),
};
mockRegistry.getById.mockResolvedValue(null);
mockRegistry.getByKey.mockResolvedValue({
id: pluginId,
pluginKey: "paperclip.example",
version: "1.0.0",
status: "ready",
manifestJson: {
id: "paperclip.example",
capabilities: ["api.routes.register"],
apiRoutes: [
{
routeKey: "smoke",
method: "GET",
path: "/smoke",
auth: "board-or-agent",
capability: "api.routes.register",
companyResolution: { from: "query", key: "companyId" },
},
],
},
});
const { app } = await createApp(
{
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
},
{},
{ bridgeDeps: { workerManager } },
);
const res = await request(app)
.get("/api/plugins/paperclip.example/api/smoke")
.query({ companyId: "company-1" });
expect(res.status).toBe(202);
expect(res.body).toEqual({ ok: true });
expect(workerManager.call).toHaveBeenCalledWith(
pluginId,
"handleApiRequest",
expect.objectContaining({
routeKey: "smoke",
method: "GET",
companyId: "company-1",
query: { companyId: "company-1" },
}),
);
}, 20_000);
});
describe("plugin local folder routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRegistry.getCompanySettings.mockResolvedValue(null);
});
function readyLocalFolderPlugin() {
mockRegistry.getById.mockResolvedValue({
id: pluginId,
pluginKey: "paperclip.example",
version: "1.0.0",
status: "ready",
manifestJson: {
id: "paperclip.example",
capabilities: ["local.folders"],
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
requiredDirectories: ["docs"],
requiredFiles: ["README.md"],
},
],
},
});
}
it("rejects validation for undeclared local folder keys", async () => {
readyLocalFolderPlugin();
const { app } = await createApp(boardActor());
const res = await request(app)
.post(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh/validate`)
.send({ path: "/tmp" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("Local folder key is not declared");
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
});
it("rejects saving undeclared local folder keys", async () => {
readyLocalFolderPlugin();
const { app } = await createApp(boardActor());
const res = await request(app)
.put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh`)
.send({ path: "/tmp" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("Local folder key is not declared");
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
});
});
describe("plugin tool and bridge authz", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rejects tool execution when the board user cannot access runContext.companyId", async () => {
const executeTool = vi.fn();
const getTool = vi.fn();
const { app } = await createApp(boardActor(), {}, {
toolDeps: {
toolDispatcher: {
listToolsForAgent: vi.fn(),
getTool,
executeTool,
},
},
});
const res = await request(app)
.post("/api/plugins/tools/execute")
.send({
tool: "paperclip.example:search",
parameters: {},
runContext: {
agentId: agentA,
runId: runA,
companyId: companyB,
projectId: projectA,
},
});
expect(res.status).toBe(403);
expect(getTool).not.toHaveBeenCalled();
expect(executeTool).not.toHaveBeenCalled();
});
it("rejects tool execution when any runContext reference is outside the company scope", async () => {
const cases: Array<[string, Array<Array<Record<string, unknown>>>]> = [
[
"agentId",
[
[{ companyId: companyB }],
],
],
[
"runId company",
[
[{ companyId: companyA }],
[{ companyId: companyB, agentId: agentA }],
],
],
[
"runId agent",
[
[{ companyId: companyA }],
[{ companyId: companyA, agentId: "77777777-7777-4777-8777-777777777777" }],
],
],
[
"projectId",
[
[{ companyId: companyA }],
[{ companyId: companyA, agentId: agentA }],
[{ companyId: companyB }],
],
],
];
for (const [label, rows] of cases) {
const executeTool = vi.fn();
const { app } = await createApp(boardActor(), {}, {
db: createSelectQueueDb(rows),
toolDeps: {
toolDispatcher: {
listToolsForAgent: vi.fn(),
getTool: vi.fn(() => ({ name: "paperclip.example:search" })),
executeTool,
},
},
});
const res = await request(app)
.post("/api/plugins/tools/execute")
.send({
tool: "paperclip.example:search",
parameters: {},
runContext: {
agentId: agentA,
runId: runA,
companyId: companyA,
projectId: projectA,
},
});
expect(res.status, label).toBe(403);
expect(executeTool).not.toHaveBeenCalled();
}
});
it("allows tool execution when agent, run, and project all belong to runContext.companyId", async () => {
const executeTool = vi.fn().mockResolvedValue({ content: "ok" });
const { app } = await createApp(boardActor(), {}, {
db: createSelectQueueDb([
[{ companyId: companyA }],
[{ companyId: companyA, agentId: agentA }],
[{ companyId: companyA }],
]),
toolDeps: {
toolDispatcher: {
listToolsForAgent: vi.fn(),
getTool: vi.fn(() => ({ name: "paperclip.example:search" })),
executeTool,
},
},
});
const res = await request(app)
.post("/api/plugins/tools/execute")
.send({
tool: "paperclip.example:search",
parameters: { q: "test" },
runContext: {
agentId: agentA,
runId: runA,
companyId: companyA,
projectId: projectA,
},
});
expect(res.status).toBe(200);
expect(executeTool).toHaveBeenCalledWith(
"paperclip.example:search",
{ q: "test" },
{
agentId: agentA,
runId: runA,
companyId: companyA,
projectId: projectA,
},
);
});
it("allows tool execution for a run with no project and skips the project lookup", async () => {
const executeTool = vi.fn().mockResolvedValue({ content: "ok" });
const { app } = await createApp(boardActor(), {}, {
// Only the agent and run lookups. A project lookup would get no row and fail.
db: createSelectQueueDb([
[{ companyId: companyA }],
[{ companyId: companyA, agentId: agentA }],
]),
toolDeps: {
toolDispatcher: {
listToolsForAgent: vi.fn(),
getTool: vi.fn(() => ({ name: "paperclip.example:search" })),
executeTool,
},
},
});
const res = await request(app)
.post("/api/plugins/tools/execute")
.send({
tool: "paperclip.example:search",
parameters: {},
runContext: { agentId: agentA, runId: runA, companyId: companyA, projectId: "" },
});
expect(res.status).toBe(200);
expect(executeTool).toHaveBeenCalledWith(
"paperclip.example:search",
{},
{ agentId: agentA, runId: runA, companyId: companyA, projectId: "" },
);
});
it.each([
["missing", {}],
["null", { projectId: null }],
])("passes an empty projectId to the plugin when runContext.projectId is %s", async (_label, project) => {
const executeTool = vi.fn().mockResolvedValue({ content: "ok" });
const { app } = await createApp(boardActor(), {}, {
db: createSelectQueueDb([
[{ companyId: companyA }],
[{ companyId: companyA, agentId: agentA }],
]),
toolDeps: {
toolDispatcher: {
listToolsForAgent: vi.fn(),
getTool: vi.fn(() => ({ name: "paperclip.example:search" })),
executeTool,
},
},
});
const res = await request(app)
.post("/api/plugins/tools/execute")
.send({
tool: "paperclip.example:search",
parameters: {},
runContext: { agentId: agentA, runId: runA, companyId: companyA, ...project },
});
expect(res.status).toBe(200);
expect(executeTool).toHaveBeenCalledWith(
"paperclip.example:search",
{},
{ agentId: agentA, runId: runA, companyId: companyA, projectId: "" },
);
});
it("rejects tool execution when runContext.projectId is not a string", async () => {
const executeTool = vi.fn();
const { app } = await createApp(boardActor(), {}, {
toolDeps: {
toolDispatcher: { listToolsForAgent: vi.fn(), getTool: vi.fn(), executeTool },
},
});
const res = await request(app)
.post("/api/plugins/tools/execute")
.send({
tool: "paperclip.example:search",
parameters: {},
runContext: { agentId: agentA, runId: runA, companyId: companyA, projectId: 42 },
});
expect(res.status).toBe(400);
expect(executeTool).not.toHaveBeenCalled();
});
it.each([
["legacy data", "post", `/api/plugins/${pluginId}/bridge/data`, { key: "health" }],
["legacy action", "post", `/api/plugins/${pluginId}/bridge/action`, { key: "sync" }],
["url data", "post", `/api/plugins/${pluginId}/data/health`, {}],
["url action", "post", `/api/plugins/${pluginId}/actions/sync`, {}],
] as const)("rejects %s bridge calls without companyId for non-admin users", async (_name, _method, path, body) => {
readyPlugin();
const call = vi.fn();
const { app } = await createApp(boardActor(), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(path)
.send(body);
expect(res.status).toBe(403);
expect(call).not.toHaveBeenCalled();
});
it("forwards authorized bridge company scope to the plugin worker", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(boardActor(), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/data/health`)
.send({ companyId: companyA, params: { view: "compact" } });
expect(res.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "getData", {
key: "health",
companyId: companyA,
params: { view: "compact" },
renderEnvironment: null,
});
});
it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(boardActor({
userId: "admin-1",
isInstanceAdmin: true,
companyIds: [],
}), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/actions/sync`)
.send({});
expect(res.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
key: "sync",
params: {},
actorContext: {
type: "user",
userId: "admin-1",
agentId: null,
runId: null,
companyId: null,
},
renderEnvironment: null,
});
});
it("passes authenticated actor context and overrides spoofed company scope for plugin actions", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(boardActor({ runId: runA }), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/actions/sync`)
.send({
companyId: companyA,
params: {
companyId: companyB,
reviewerUserId: "spoofed-user",
},
});
expect(res.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
key: "sync",
params: {
companyId: companyA,
reviewerUserId: "spoofed-user",
},
actorContext: {
type: "user",
userId: "user-1",
agentId: null,
runId: runA,
companyId: companyA,
},
renderEnvironment: null,
});
});
it("uses null for board actor userId when no authenticated user id is present", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(boardActor({ userId: undefined }), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/actions/sync`)
.send({ companyId: companyA });
expect(res.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "performAction", expect.objectContaining({
actorContext: expect.objectContaining({
type: "user",
userId: null,
companyId: companyA,
}),
}));
});
it("allows agent-scoped plugin actions with authenticated actor context", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(agentActor(), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/actions/sync`)
.send({
companyId: companyA,
params: {
companyId: companyB,
reviewerAgentId: "spoofed-agent",
},
});
expect(res.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
key: "sync",
params: {
companyId: companyA,
reviewerAgentId: "spoofed-agent",
},
actorContext: {
type: "agent",
userId: null,
agentId: agentA,
runId: runA,
companyId: companyA,
},
renderEnvironment: null,
});
call.mockClear();
const legacyRes = await request(app)
.post(`/api/plugins/${pluginId}/bridge/action`)
.send({
key: "sync",
companyId: companyA,
params: {
companyId: companyB,
reviewerAgentId: "spoofed-agent",
},
});
expect(legacyRes.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
key: "sync",
params: {
companyId: companyA,
reviewerAgentId: "spoofed-agent",
},
actorContext: {
type: "agent",
userId: null,
agentId: agentA,
runId: runA,
companyId: companyA,
},
renderEnvironment: null,
});
});
it("rejects agent plugin actions outside the authenticated company scope", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(agentActor(), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/actions/sync`)
.send({ companyId: companyB });
expect(res.status).toBe(403);
expect(call).not.toHaveBeenCalled();
});
it("attaches worker bridge errors to the HTTP logger context", async () => {
readyPlugin();
const call = vi.fn().mockRejectedValue(new Error("missing source_objects column"));
const captured: Array<{ context: any; body: unknown }> = [];
const { app } = await createApp(boardActor(), {}, {
bridgeDeps: {
workerManager: { call },
},
captureJsonContext: (context, body) => {
captured.push({ context, body });
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/data/source-objects`)
.send({ companyId: companyA });
expect(res.status).toBe(502);
expect(res.body).toMatchObject({
code: "UNKNOWN",
message: "missing source_objects column",
});
expect(captured.at(-1)?.context?.error).toMatchObject({
message: "missing source_objects column",
details: {
pluginId,
pluginKey: "paperclip.example",
bridgeMethod: "getData",
dataKey: "source-objects",
bridgeCode: "UNKNOWN",
},
});
});
it("rejects manual job triggers for non-admin board users", async () => {
const scheduler = { triggerJob: vi.fn() };
const jobStore = { getJobByIdForPlugin: vi.fn() };
const { app } = await createApp(boardActor(), {}, {
jobDeps: { scheduler, jobStore },
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/jobs/job-1/trigger`)
.send({});
expect(res.status).toBe(403);
expect(scheduler.triggerJob).not.toHaveBeenCalled();
expect(jobStore.getJobByIdForPlugin).not.toHaveBeenCalled();
}, 15_000);
it("allows manual job triggers for instance admins", async () => {
readyPlugin();
const scheduler = { triggerJob: vi.fn().mockResolvedValue({ runId: "run-1", jobId: "job-1" }) };
const jobStore = { getJobByIdForPlugin: vi.fn().mockResolvedValue({ id: "job-1" }) };
const { app } = await createApp(boardActor({
userId: "admin-1",
isInstanceAdmin: true,
companyIds: [],
}), {}, {
jobDeps: { scheduler, jobStore },
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/jobs/job-1/trigger`)
.send({});
expect(res.status).toBe(200);
expect(res.body).toEqual({ runId: "run-1", jobId: "job-1" });
expect(scheduler.triggerJob).toHaveBeenCalledWith("job-1", "manual");
});
// ─── Agent JWT tool execution (cherry-picked from #5549) ─────────────────────
it("rejects board users with no company memberships from listing plugin tools", async () => {
const listToolsForAgent = vi.fn(() => []);
const { app } = await createApp(
boardActor({ companyIds: [], isInstanceAdmin: false, source: "session" }),
{},
{
toolDeps: {
toolDispatcher: {
listToolsForAgent,
getTool: vi.fn(),
executeTool: vi.fn(),
},
},
},
);
const res = await request(app).get("/api/plugins/tools");
expect(res.status).toBe(403);
expect(listToolsForAgent).not.toHaveBeenCalled();
});
it("allows agent JWT to list available plugin tools", async () => {
const listToolsForAgent = vi.fn(() => []);
const { app } = await createApp(agentActor(), {}, {
toolDeps: {
toolDispatcher: {
listToolsForAgent,
getTool: vi.fn(),
executeTool: vi.fn(),
},
},
});
const res = await request(app).get("/api/plugins/tools");
expect(res.status).toBe(200);
expect(listToolsForAgent).toHaveBeenCalled();
});
it("allows agent JWT to execute a tool within its company scope", async () => {
const executeTool = vi.fn().mockResolvedValue({ content: "ok" });
const { app } = await createApp(
agentActor(),
{},
{
db: createSelectQueueDb([
[{ companyId: companyA }],
[{ companyId: companyA, agentId: agentA }],
[{ companyId: companyA }],
]),
toolDeps: {
toolDispatcher: {
listToolsForAgent: vi.fn(),
getTool: vi.fn(() => ({ name: "paperclip.example:search", pluginDbId: pluginId })),
executeTool,
},
},
},
);
const res = await request(app)
.post("/api/plugins/tools/execute")
.send({
tool: "paperclip.example:search",
parameters: { q: "test" },
runContext: { agentId: agentA, runId: runA, companyId: companyA, projectId: projectA },
});
expect(res.status).toBe(200);
expect(executeTool).toHaveBeenCalledWith(
"paperclip.example:search",
{ q: "test" },
{ agentId: agentA, runId: runA, companyId: companyA, projectId: projectA },
);
});
it("rejects agent JWT when runContext.companyId is outside the agent's company scope", async () => {
const executeTool = vi.fn();
const { app } = await createApp(
agentActor(),
{},
{
db: createSelectQueueDb([]),
toolDeps: {
toolDispatcher: {
listToolsForAgent: vi.fn(),
getTool: vi.fn(() => ({ name: "paperclip.example:search", pluginDbId: pluginId })),
executeTool,
},
},
},
);
const res = await request(app)
.post("/api/plugins/tools/execute")
.send({
tool: "paperclip.example:search",
parameters: {},
runContext: { agentId: agentA, runId: runA, companyId: companyB, projectId: projectA },
});
expect(res.status).toBe(403);
expect(executeTool).not.toHaveBeenCalled();
});
it("rejects agent JWT when runContext.agentId does not belong to runContext.companyId", async () => {
const otherAgent = "77777777-7777-4777-8777-777777777777";
const executeTool = vi.fn();
const { app } = await createApp(
agentActor(),
{},
{
db: createSelectQueueDb([
[{ companyId: companyB }],
]),
toolDeps: {
toolDispatcher: {
listToolsForAgent: vi.fn(),
getTool: vi.fn(() => ({ name: "paperclip.example:search", pluginDbId: pluginId })),
executeTool,
},
},
},
);
const res = await request(app)
.post("/api/plugins/tools/execute")
.send({
tool: "paperclip.example:search",
parameters: {},
runContext: { agentId: otherAgent, runId: runA, companyId: companyA, projectId: projectA },
});
expect(res.status).toBe(403);
expect(executeTool).not.toHaveBeenCalled();
});
});
describe("operator-hidden plugin management floor", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.plugins";
});
afterEach(() => {
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
});
const instanceAdmin = () => boardActor({ isInstanceAdmin: true, userId: "instance-admin" });
it("floors plugin lifecycle and config writes for instance admins", async () => {
const { app, loader } = await createApp(instanceAdmin());
readyPlugin();
const attempts: Array<[string, request.Test]> = [
["install", request(app).post("/api/plugins/install").send({ packageName: "@paperclipai/plugin-modal" })],
["uninstall", request(app).delete(`/api/plugins/${pluginId}`)],
["enable", request(app).post(`/api/plugins/${pluginId}/enable`)],
["disable", request(app).post(`/api/plugins/${pluginId}/disable`).send({})],
["upgrade", request(app).post(`/api/plugins/${pluginId}/upgrade`).send({})],
["config", request(app).post(`/api/plugins/${pluginId}/config`).send({ config: {} })],
["config test", request(app).post(`/api/plugins/${pluginId}/config/test`).send({ config: {} })],
[
"local folder",
request(app)
.put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/data`)
.send({ path: "/tmp/folder" }),
],
];
for (const [name, attempt] of attempts) {
const res = await attempt;
expect(res.status, `${name}: ${JSON.stringify(res.body)}`).toBe(403);
expect(res.body.details, name).toMatchObject({ code: "settings_operator_managed" });
}
expect(loader.installPlugin).not.toHaveBeenCalled();
expect(mockLifecycle.enable).not.toHaveBeenCalled();
expect(mockLifecycle.disable).not.toHaveBeenCalled();
expect(mockLifecycle.upgrade).not.toHaveBeenCalled();
expect(mockLifecycle.unload).not.toHaveBeenCalled();
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
});
it("keeps plugin reads open while the surface is hidden", async () => {
const { app } = await createApp(boardActor());
const res = await request(app).get("/api/plugins/examples");
expect(res.status, JSON.stringify(res.body)).toBe(200);
}, 20_000);
});