diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 3264afbc..8fc8fdfa 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -79,7 +79,7 @@ export class CapabilityDeniedError extends Error { */ export class InvocationScopeDeniedError extends Error { override readonly name = "InvocationScopeDeniedError"; - readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED; + readonly code = PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED; constructor(pluginId: string, method: string, message: string) { super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`); diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 2ca0fa9a..1fbf172b 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -149,6 +149,9 @@ export type { RunJobParams, GetDataParams, PerformActionParams, + PluginPerformActionActorType, + PluginPerformActionActorContext, + PluginPerformActionContext, ExecuteToolParams, PluginEnvironmentDiagnostic, PluginEnvironmentDriverBaseParams, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 48075b2f..2c76beab 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -233,6 +233,8 @@ export const PLUGIN_RPC_ERROR_CODES = { TIMEOUT: -32003, /** The worker does not implement the requested optional method. */ METHOD_NOT_IMPLEMENTED: -32004, + /** The worker→host call attempted to escape the current invocation company scope. */ + INVOCATION_SCOPE_DENIED: -32005, /** A catch-all for errors that do not fit other categories. */ UNKNOWN: -32099, } as const; @@ -368,6 +370,28 @@ export interface GetDataParams { * * @see PLUGIN_SPEC.md §13.9 — `performAction` */ +export type PluginPerformActionActorType = "user" | "agent" | "system"; + +export interface PluginPerformActionActorContext { + /** Authenticated principal type resolved by the Paperclip host. */ + type: PluginPerformActionActorType; + /** Authenticated board user id when `type === "user"`, otherwise null. */ + userId: string | null; + /** Authenticated agent id when `type === "agent"`, otherwise null. */ + agentId: string | null; + /** Authenticated heartbeat/run id when available. */ + runId: string | null; + /** Company id authorized by the host bridge for this action, when applicable. */ + companyId: string | null; +} + +export interface PluginPerformActionContext { + /** Immutable authenticated actor context supplied by the host. */ + actor: Readonly; + /** Convenience alias for `actor.companyId`. */ + companyId: string | null; +} + export interface PerformActionParams { /** Plugin-defined action key (e.g. `"resync"`). */ key: string; @@ -375,6 +399,8 @@ export interface PerformActionParams { companyId?: string | null; /** Action parameters from the UI. */ params: Record; + /** Authenticated actor context resolved by the host, never by caller params. */ + actorContext?: PluginPerformActionActorContext | null; /** Optional launcher/container metadata from the host render environment. */ renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 299af7c6..f8f1fc26 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -57,6 +57,8 @@ import type { PluginEnvironmentRealizeWorkspaceResult, PluginEnvironmentExecuteParams, PluginEnvironmentExecuteResult, + PluginPerformActionActorContext, + PluginPerformActionContext, } from "./protocol.js"; export interface TestHarnessOptions { @@ -74,6 +76,20 @@ export interface TestHarnessLogEntry { meta?: Record; } +export interface TestHarnessPerformActionOptions { + /** + * Authenticated actor context to expose to the action handler. Omitted fields + * default to null, and `type` defaults to `system`. + */ + actor?: Partial | null; + /** + * Host-authorized company scope. When provided, this is injected into + * `params.companyId` so tests match the production bridge's anti-spoofing + * behavior. + */ + companyId?: string | null; +} + export interface TestHarness { /** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */ ctx: PluginContext; @@ -98,7 +114,11 @@ export interface TestHarness { /** Invoke a `ctx.data.register(...)` handler by key. */ getData(key: string, params?: Record): Promise; /** Invoke a `ctx.actions.register(...)` handler by key. */ - performAction(key: string, params?: Record): Promise; + performAction( + key: string, + params?: Record, + options?: TestHarnessPerformActionOptions, + ): Promise; /** Execute a registered tool handler via `ctx.tools.execute(...)`. */ executeTool(name: string, params: unknown, runCtx?: Partial): Promise; /** Read raw in-memory state for assertions. */ @@ -491,7 +511,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const jobs = new Map Promise>(); const launchers = new Map(); const dataHandlers = new Map) => Promise>(); - const actionHandlers = new Map) => Promise>(); + const actionHandlers = new Map< + string, + (params: Record, context: PluginPerformActionContext) => Promise + >(); const toolHandlers = new Map Promise>(); function localFolderKey(companyId: string, folderKey: string): string { @@ -502,6 +525,41 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { return `${localFolderKey(companyId, folderKey)}:${relativePath}`; } + function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] { + return value === "user" || value === "agent" || value === "system" ? value : "system"; + } + + function actionContextFor( + params: Record, + options?: TestHarnessPerformActionOptions, + ): PluginPerformActionContext { + const actorInput = options?.actor ?? null; + const companyId = stringOrNull(options?.companyId) ?? stringOrNull(actorInput?.companyId) ?? stringOrNull(params.companyId); + const actor = Object.freeze({ + type: actorTypeOrSystem(actorInput?.type), + userId: stringOrNull(actorInput?.userId), + agentId: stringOrNull(actorInput?.agentId), + runId: stringOrNull(actorInput?.runId), + companyId, + }); + return Object.freeze({ actor, companyId }); + } + + function paramsWithHostCompanyScope( + params: Record, + context: PluginPerformActionContext, + options?: TestHarnessPerformActionOptions, + ): Record { + if (Object.prototype.hasOwnProperty.call(options ?? {}, "companyId")) { + return context.companyId ? { ...params, companyId: context.companyId } : { ...params }; + } + return params; + } + function normalizeLocalFolderRelativePath(relativePath: string): string { const parts: string[] = []; for (const segment of relativePath.split(/[\\/]+/)) { @@ -2302,10 +2360,15 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { if (!handler) throw new Error(`No data handler registered for '${key}'`); return await handler(params) as T; }, - async performAction(key: string, params: Record = {}) { + async performAction( + key: string, + params: Record = {}, + options?: TestHarnessPerformActionOptions, + ) { const handler = actionHandlers.get(key); if (!handler) throw new Error(`No action handler registered for '${key}'`); - return await handler(params) as T; + const context = actionContextFor(params, options); + return await handler(paramsWithHostCompanyScope(params, context, options), context) as T; }, async executeTool(name: string, params: unknown, runCtx: Partial = {}) { const handler = toolHandlers.get(name); diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 456f4a9e..90af99b4 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -46,6 +46,7 @@ import type { PrincipalPermissionGrant, PrincipalType, } from "@paperclipai/shared"; +import type { PluginPerformActionContext } from "./protocol.js"; // --------------------------------------------------------------------------- // Re-exports from @paperclipai/shared (plugin authors import from one place) @@ -957,9 +958,12 @@ export interface PluginActionsClient { * Register a handler for a plugin-defined action key. * * @param key - Stable string identifier for this action (e.g. `"resync"`) - * @param handler - Async function that receives action params and returns a result + * @param handler - Async function that receives action params plus immutable host actor context and returns a result */ - register(key: string, handler: (params: Record) => Promise): void; + register( + key: string, + handler: (params: Record, context: PluginPerformActionContext) => Promise, + ): void; } /** diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts index 8923803e..10478216 100644 --- a/packages/plugins/sdk/src/ui/types.ts +++ b/packages/plugins/sdk/src/ui/types.ts @@ -54,6 +54,7 @@ export type { * Error codes: * - `WORKER_UNAVAILABLE` — plugin worker is not running * - `CAPABILITY_DENIED` — plugin lacks the required capability + * - `INVOCATION_SCOPE_DENIED` — plugin call escaped the invocation company scope * - `WORKER_ERROR` — worker returned an error from its handler * - `TIMEOUT` — worker did not respond within the configured timeout * - `UNKNOWN` — unexpected bridge-level failure diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index a50b6d8f..a135d1f0 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -78,6 +78,8 @@ import type { RunJobParams, GetDataParams, PerformActionParams, + PluginPerformActionActorContext, + PluginPerformActionContext, ExecuteToolParams, PluginEnvironmentAcquireLeaseParams, PluginEnvironmentDestroyLeaseParams, @@ -289,7 +291,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost const jobHandlers = new Map Promise>(); const launcherRegistrations = new Map(); const dataHandlers = new Map) => Promise>(); - const actionHandlers = new Map) => Promise>(); + const actionHandlers = new Map< + string, + (params: Record, context: PluginPerformActionContext) => Promise + >(); const toolHandlers = new Map; fn: (params: unknown, runCtx: ToolRunContext) => Promise; @@ -1184,7 +1189,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, actions: { - register(key: string, handler: (params: Record) => Promise): void { + register( + key: string, + handler: (params: Record, context: PluginPerformActionContext) => Promise, + ): void { actionHandlers.set(key, handler); }, }, @@ -1514,16 +1522,44 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }); } + function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] { + return value === "user" || value === "agent" || value === "system" ? value : "system"; + } + + function actionContextFromParams(params: PerformActionParams): PluginPerformActionContext { + const rawActor = params.actorContext && typeof params.actorContext === "object" + ? params.actorContext + : null; + const actor = Object.freeze({ + type: actorTypeOrSystem(rawActor?.type), + userId: stringOrNull(rawActor?.userId), + agentId: stringOrNull(rawActor?.agentId), + runId: stringOrNull(rawActor?.runId), + companyId: stringOrNull(rawActor?.companyId), + }); + return Object.freeze({ + actor, + companyId: actor.companyId, + }); + } + async function handlePerformAction(params: PerformActionParams): Promise { const handler = actionHandlers.get(params.key); if (!handler) { throw new Error(`No action handler registered for key "${params.key}"`); } - return handler({ - ...params.params, - ...(params.companyId === undefined ? {} : { companyId: params.companyId }), - ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }), - }); + return handler( + { + ...params.params, + ...(params.companyId === undefined ? {} : { companyId: params.companyId }), + ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }), + }, + actionContextFromParams(params), + ); } async function handleExecuteTool(params: ExecuteToolParams): Promise { diff --git a/packages/plugins/sdk/tests/host-client-factory.test.ts b/packages/plugins/sdk/tests/host-client-factory.test.ts index 8cded9c8..eb79043e 100644 --- a/packages/plugins/sdk/tests/host-client-factory.test.ts +++ b/packages/plugins/sdk/tests/host-client-factory.test.ts @@ -6,6 +6,7 @@ import { createHostClientHandlers, InvocationScopeDeniedError, } from "../src/host-client-factory.js"; +import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js"; describe("createHostClientHandlers invocation company scope", () => { it("rejects company-scoped host calls outside the current invocation company", async () => { @@ -28,6 +29,14 @@ describe("createHostClientHandlers invocation company scope", () => { { invocationScope: { companyId: "company-a" } }, ), ).rejects.toBeInstanceOf(InvocationScopeDeniedError); + await expect( + handlers["projects.list"]( + { companyId: "company-b" }, + { invocationScope: { companyId: "company-a" } }, + ), + ).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, + }); expect(projectsList).not.toHaveBeenCalled(); }); diff --git a/packages/plugins/sdk/tests/testing-actions.test.ts b/packages/plugins/sdk/tests/testing-actions.test.ts new file mode 100644 index 00000000..ab0026b2 --- /dev/null +++ b/packages/plugins/sdk/tests/testing-actions.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { createTestHarness } from "../src/testing.js"; +import type { PaperclipPluginManifestV1 } from "../src/types.js"; + +const manifest = { + id: "paperclip.test-actions", + apiVersion: 1, + version: "1.0.0", + displayName: "Test Actions", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: {}, +} satisfies PaperclipPluginManifestV1; + +describe("createTestHarness action context", () => { + it("passes immutable authenticated actor context and overrides caller company scope", async () => { + const harness = createTestHarness({ manifest }); + + harness.ctx.actions.register("inspect", async (params, context) => ({ + paramsCompanyId: params.companyId, + actor: context.actor, + companyId: context.companyId, + contextFrozen: Object.isFrozen(context), + actorFrozen: Object.isFrozen(context.actor), + })); + + const result = await harness.performAction<{ + paramsCompanyId: unknown; + actor: { + type: string; + userId: string | null; + agentId: string | null; + runId: string | null; + companyId: string | null; + }; + companyId: string | null; + contextFrozen: boolean; + actorFrozen: boolean; + }>( + "inspect", + { companyId: "spoofed-company", value: true }, + { + companyId: "host-company", + actor: { + type: "user", + userId: "board-user-1", + runId: "run-1", + }, + }, + ); + + expect(result.paramsCompanyId).toBe("host-company"); + expect(result.companyId).toBe("host-company"); + expect(result.actor).toEqual({ + type: "user", + userId: "board-user-1", + agentId: null, + runId: "run-1", + companyId: "host-company", + }); + expect(result.contextFrozen).toBe(true); + expect(result.actorFrozen).toBe(true); + }); + + it("keeps existing one-argument action handlers compatible", async () => { + const harness = createTestHarness({ manifest }); + harness.ctx.actions.register("legacy", async (params) => ({ ok: params.ok })); + + await expect(harness.performAction("legacy", { ok: true })).resolves.toEqual({ ok: true }); + }); +}); diff --git a/packages/plugins/sdk/tests/worker-rpc-host.test.ts b/packages/plugins/sdk/tests/worker-rpc-host.test.ts index b7f15781..d41aa863 100644 --- a/packages/plugins/sdk/tests/worker-rpc-host.test.ts +++ b/packages/plugins/sdk/tests/worker-rpc-host.test.ts @@ -71,6 +71,90 @@ describe("isWorkerEntrypoint", () => { }); }); +describe("worker performAction context", () => { + it("does not derive context companyId from caller params without host actor context", async () => { + const hostToWorker = new PassThrough(); + const workerToHost = new PassThrough(); + const hostReadline = createInterface({ input: workerToHost }); + const pending = new Map void>(); + let nextRequestId = 1; + const plugin = definePlugin({ + async setup(ctx) { + ctx.actions.register("inspect", async (params, context) => ({ + paramsCompanyId: params.companyId, + actor: context.actor, + companyId: context.companyId, + })); + }, + }); + const worker = startWorkerRpcHost({ + plugin, + stdin: hostToWorker, + stdout: workerToHost, + }); + + function callWorker(method: string, params: unknown) { + const id = `host-${nextRequestId++}`; + const result = new Promise((resolve, reject) => { + pending.set(id, (response) => { + if ("error" in response && response.error) { + reject(new Error(response.error.message)); + return; + } + resolve((response as { result?: unknown }).result); + }); + }); + hostToWorker.write(serializeMessage(createRequest(method, params, id))); + return result; + } + + hostReadline.on("line", (line) => { + const message = parseMessage(line); + if (!isJsonRpcResponse(message)) return; + pending.get(String(message.id))?.(message); + pending.delete(String(message.id)); + }); + + try { + await expect(callWorker("initialize", { + manifest: { + id: "paperclip.test-worker-context", + apiVersion: 1, + version: "1.0.0", + displayName: "Worker Context Test", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: {}, + }, + config: {}, + databaseNamespace: null, + })).resolves.toMatchObject({ ok: true }); + + await expect(callWorker("performAction", { + key: "inspect", + params: { companyId: "spoofed-company" }, + })).resolves.toEqual({ + paramsCompanyId: "spoofed-company", + actor: { + type: "system", + userId: null, + agentId: null, + runId: null, + companyId: null, + }, + companyId: null, + }); + } finally { + worker.stop(); + hostReadline.close(); + hostToWorker.destroy(); + workerToHost.destroy(); + } + }); +}); + describe("worker invocation scope propagation", () => { it("keeps overlapping company scopes local to each getData invocation", async () => { const hostToWorker = new PassThrough(); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b0c92acb..c102fa73 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1095,6 +1095,7 @@ export type PluginEventType = (typeof PLUGIN_EVENT_TYPES)[number]; export const PLUGIN_BRIDGE_ERROR_CODES = [ "WORKER_UNAVAILABLE", "CAPABILITY_DENIED", + "INVOCATION_SCOPE_DENIED", "WORKER_ERROR", "TIMEOUT", "UNKNOWN", diff --git a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs index 6e54689f..9e1f06df 100644 --- a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs +++ b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs @@ -68,13 +68,13 @@ rl.on("line", (line) => { id: message.id, result: { ok: true, - supportedMethods: ["getData"], + supportedMethods: ["getData", "performAction"], }, }); return; } - if (method === "getData") { + if (method === "getData" || method === "performAction") { sendNestedHostRequest(message, message.paperclipInvocation?.id); return; } diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ef328416..150625c3 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -773,6 +773,8 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const normalIssueId = randomUUID(); const pluginVisibleIssueId = randomUUID(); const operationIssueId = randomUUID(); + const typedOperationIssueId = randomUUID(); + const legacyContentMachineOperationIssueId = randomUUID(); await db.insert(companies).values({ id: companyId, @@ -826,12 +828,36 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { originKind: "plugin:paperclip.missions:operation", originId: "mission-alpha:operation-1", }, + { + id: typedOperationIssueId, + companyId, + projectId, + title: "Typed plugin operation issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + originKind: "plugin:paperclip.missions:operation:evaluation", + originId: "mission-alpha:operation-2", + }, + { + id: legacyContentMachineOperationIssueId, + companyId, + projectId, + title: "Legacy Content Machine operation issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + originKind: "plugin:paperclipai.content-machine:evaluation", + originId: "content-machine-operation-1", + }, ]); const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id); expect(defaultIssueIds).toContain(normalIssueId); expect(defaultIssueIds).toContain(pluginVisibleIssueId); expect(defaultIssueIds).not.toContain(operationIssueId); + expect(defaultIssueIds).not.toContain(typedOperationIssueId); + expect(defaultIssueIds).not.toContain(legacyContentMachineOperationIssueId); const inboxIssueIds = (await svc.list(companyId, { assigneeAgentId: agentId, @@ -840,17 +866,28 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { })).map((issue) => issue.id); expect(inboxIssueIds).toContain(normalIssueId); expect(inboxIssueIds).not.toContain(operationIssueId); + expect(inboxIssueIds).not.toContain(typedOperationIssueId); + expect(inboxIssueIds).not.toContain(legacyContentMachineOperationIssueId); await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" })) .resolves.toEqual([expect.objectContaining({ id: operationIssueId })]); await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" })) .resolves.toEqual([expect.objectContaining({ id: operationIssueId })]); + await expect(svc.list(companyId, { originKindPrefix: "plugin:paperclip.missions:operation" })) + .resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ id: operationIssueId }), + expect.objectContaining({ id: typedOperationIssueId }), + ])); const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id); expect(projectIssueIds).toContain(operationIssueId); + expect(projectIssueIds).toContain(typedOperationIssueId); + expect(projectIssueIds).toContain(legacyContentMachineOperationIssueId); const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id); expect(advancedIssueIds).toContain(operationIssueId); + expect(advancedIssueIds).toContain(typedOperationIssueId); + expect(advancedIssueIds).toContain(legacyContentMachineOperationIssueId); }); it("excludes plugin operation issues from unread inbox counts", async () => { diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 52fe1c84..272e20c3 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -114,6 +114,17 @@ function boardActor(overrides: Record = {}) { }; } +function agentActor(overrides: Record = {}) { + return { + type: "agent", + agentId: agentA, + companyId: companyA, + runId: runA, + source: "agent_jwt", + ...overrides, + }; +} + function readyPlugin() { mockRegistry.getById.mockResolvedValue({ id: pluginId, @@ -656,10 +667,160 @@ describe.sequential("plugin tool and bridge authz", () => { 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")); diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts index 626ed448..8d2d7194 100644 --- a/server/src/__tests__/plugin-worker-manager.test.ts +++ b/server/src/__tests__/plugin-worker-manager.test.ts @@ -186,6 +186,58 @@ describe("plugin-worker-manager stderr failure context", () => { } }); + 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", { @@ -223,6 +275,104 @@ describe("plugin-worker-manager stderr failure context", () => { } }); + 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({ @@ -258,7 +408,7 @@ describe("plugin-worker-manager stderr failure context", () => { requestedCompanyId: "company-2", }, } as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({ - code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED, + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, }); } diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 741fa151..d572c90d 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -55,7 +55,7 @@ import type { PluginJobStore } from "../services/plugin-job-store.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; -import type { ToolRunContext } from "@paperclipai/plugin-sdk"; +import type { PluginPerformActionActorContext, ToolRunContext } from "@paperclipai/plugin-sdk"; import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; import { assertAuthenticated, @@ -572,6 +572,43 @@ export function pluginRoutes( return companyId; } + function performActionActorContext(req: Request, companyId: string | undefined): PluginPerformActionActorContext { + const scopedCompanyId = companyId ?? null; + if (req.actor.type === "agent") { + return { + type: "agent", + userId: null, + agentId: req.actor.agentId ?? null, + runId: req.actor.runId ?? null, + companyId: scopedCompanyId, + }; + } + if (req.actor.type === "board") { + return { + type: "user", + userId: req.actor.userId ?? null, + agentId: null, + runId: req.actor.runId ?? null, + companyId: scopedCompanyId, + }; + } + return { + type: "system", + userId: null, + agentId: null, + runId: req.actor.runId ?? null, + companyId: scopedCompanyId, + }; + } + + function actionParamsWithAuthorizedCompanyScope( + params: Record | undefined, + companyId: string | undefined, + ): Record { + const base = params ?? {}; + return companyId === undefined ? base : { ...base, companyId }; + } + async function validateToolRunContextScope(runContext: ToolRunContext): Promise { const [agent] = await db .select({ companyId: agents.companyId }) @@ -984,6 +1021,12 @@ export function pluginRoutes( message: err.message, details: err.data, }; + case PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED: + return { + code: "INVOCATION_SCOPE_DENIED", + message: err.message, + details: err.data, + }; case PLUGIN_RPC_ERROR_CODES.TIMEOUT: return { code: "TIMEOUT", @@ -1171,7 +1214,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/action", async (req, res) => { - assertBoardOrgAccess(req); + assertAuthenticated(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1217,8 +1260,8 @@ export function pluginRoutes( "performAction", { key: body.key, - ...(companyId ? { companyId } : {}), - params: body.params ?? {}, + params: actionParamsWithAuthorizedCompanyScope(body.params, companyId), + actorContext: performActionActorContext(req, companyId), renderEnvironment: body.renderEnvironment ?? null, }, ); @@ -1355,7 +1398,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/actions/:key", async (req, res) => { - assertBoardOrgAccess(req); + assertAuthenticated(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1401,8 +1444,8 @@ export function pluginRoutes( "performAction", { key, - ...(companyId ? { companyId } : {}), - params: body?.params ?? {}, + params: actionParamsWithAuthorizedCompanyScope(body?.params, companyId), + actorContext: performActionActorContext(req, companyId), renderEnvironment: body?.renderEnvironment ?? null, }, ); @@ -1606,7 +1649,10 @@ export function pluginRoutes( } catch (err) { const status = typeof (err as { status?: unknown }).status === "number" ? (err as { status: number }).status - : err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED + : err instanceof JsonRpcCallError && ( + err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED || + err.code === PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED + ) ? 403 : err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED ? 501 diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 0ab88d3e..a61d7f2b 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -683,14 +683,25 @@ function inboxVisibleForUserCondition(companyId: string, userId: string) { `; } +const LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS = [ + "plugin:paperclipai.content-machine:case", + "plugin:paperclipai.content-machine:evaluation", + "plugin:paperclipai.content-machine:source-sync", +] as const; + function nonPluginOperationIssueCondition() { - return sql`NOT (${issues.originKind} LIKE 'plugin:%:operation' OR ${issues.originKind} LIKE 'plugin:%:operation:%')`; + return sql`NOT ( + ${issues.originKind} LIKE 'plugin:%:operation' + OR ${issues.originKind} LIKE 'plugin:%:operation:%' + OR ${inArray(issues.originKind, LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS)} + )`; } function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) { return Boolean( filters?.includePluginOperations || filters?.originKind || + filters?.originKindPrefix || filters?.originId || filters?.projectId, ); diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index 81f21a91..9847e879 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -204,6 +204,8 @@ interface PendingRequest { timer: ReturnType; /** Timestamp when the request was sent. */ sentAt: number; + /** Active host-owned invocation id attached to this host→worker call. */ + invocationId?: string; } interface ActiveInvocation { @@ -435,6 +437,14 @@ export function createPluginWorkerHandle( childProcess.stdin.write(serialized); } + function errorCodeForWorkerHostError(err: unknown): number { + const code = (err as { code?: unknown } | null)?.code; + const pluginErrorCodes: readonly number[] = Object.values(PLUGIN_RPC_ERROR_CODES); + return typeof code === "number" && pluginErrorCodes.includes(code) + ? code + : JSONRPC_ERROR_CODES.INTERNAL_ERROR; + } + // ----------------------------------------------------------------------- // Incoming message handling // ----------------------------------------------------------------------- @@ -503,6 +513,11 @@ export function createPluginWorkerHandle( const directCompanyId = readNonEmptyString(params.companyId); if (directCompanyId) return { companyId: directCompanyId }; + if (method === "performAction" && isRecord(params.actorContext)) { + const companyId = readNonEmptyString(params.actorContext.companyId); + return companyId ? { companyId } : null; + } + if (method === "executeTool" && isRecord(params.runContext)) { const companyId = readNonEmptyString(params.runContext.companyId); return companyId ? { companyId } : null; @@ -585,15 +600,12 @@ export function createPluginWorkerHandle( }); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); - const errorCode = typeof (err as { code?: unknown }).code === "number" - ? (err as { code: number }).code - : JSONRPC_ERROR_CODES.INTERNAL_ERROR; log.error({ method, err: errorMessage }, "host handler error"); try { sendMessage( createErrorResponse( request.id, - errorCode, + errorCodeForWorkerHostError(err), errorMessage, ), ); @@ -1161,6 +1173,7 @@ export function createPluginWorkerHandle( }, timer, sentAt: Date.now(), + invocationId: invocation?.id, }; pendingRequests.set(id, pending);