diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index af3b060021b..d028c2867b1 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -682,6 +682,7 @@ The host provides: - tool name (without plugin namespace prefix) - parsed parameters matching the tool's declared schema - run context: agent ID, run ID, company ID, project ID + (the project ID is an empty string when the run has no project) The worker executes the tool and returns a typed result (string content, structured data, or error). diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts index cff71c53767..91b3c0cbaa6 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts @@ -897,7 +897,8 @@ async function registerToolHandlers(ctx: PluginContext): Promise { } const issue = await ctx.issues.create({ companyId: runCtx.companyId, - projectId: runCtx.projectId, + // The run context has an empty project ID when the run has no project. + projectId: runCtx.projectId || undefined, title: payload.title, description: payload.description, }); diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 7bd956f0fe0..e6be5116e5d 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -36,7 +36,6 @@ import type { AskUserQuestionsInteraction, RequestConfirmationInteraction, RequestCheckboxConfirmationInteraction, - CreateIssueThreadInteraction, CreateIssueThreadInteractionInput, PluginIssueOriginKind, IssueSurfaceVisibility, @@ -270,7 +269,10 @@ export interface ToolRunContext { runId: string; /** UUID of the company the run belongs to. */ companyId: string; - /** UUID of the project the run belongs to. */ + /** + * UUID of the project the run belongs to, or an empty string when the run + * has no project. Check the value before you use it as a project ID. + */ projectId: string; } @@ -1600,7 +1602,7 @@ export interface PluginIssuesClient { ): Promise; suggestTasks( issueId: string, - interaction: Omit, "kind">, + interaction: Omit, "kind">, companyId: string, options?: { authorAgentId?: string }, ): Promise; @@ -1612,13 +1614,13 @@ export interface PluginIssuesClient { ): Promise; requestConfirmation( issueId: string, - interaction: Omit, "kind">, + interaction: Omit, "kind">, companyId: string, options?: { authorAgentId?: string }, ): Promise; requestCheckboxConfirmation( issueId: string, - interaction: Omit, "kind">, + interaction: Omit, "kind">, companyId: string, options?: { authorAgentId?: string }, ): Promise; diff --git a/packages/plugins/sdk/tests/testing-actions.test.ts b/packages/plugins/sdk/tests/testing-actions.test.ts index 203a9851d9d..987bd37e5f2 100644 --- a/packages/plugins/sdk/tests/testing-actions.test.ts +++ b/packages/plugins/sdk/tests/testing-actions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { createTestHarness } from "../src/testing.js"; -import type { PaperclipPluginManifestV1, PluginIssuesClient } from "../src/types.js"; +import type { PaperclipPluginManifestV1 } from "../src/types.js"; const manifest = { id: "paperclip.test-actions", @@ -136,8 +136,8 @@ describe("createTestHarness issue interactions", () => { minSelected: 1, maxSelected: 2, }, - // Omits the fields the schema defaults, so the assertions below cover those defaults. - } as Parameters[1], + // Omits the fields the schema defaults. The input type allows that. + }, "company-1", { authorAgentId: "agent-1" }, ); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 4cd0828c79e..49b08a9efea 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -673,6 +673,94 @@ describe("plugin tool and bridge authz", () => { ); }); + 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" }], diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 09f23d3eb92..98d52aeff80 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -9586,7 +9586,7 @@ registry.registerPath({ agentId: z.string(), runId: z.string(), companyId: z.string(), - projectId: z.string(), + projectId: z.string().nullable().optional(), }), }), ), diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 9c1a82df448..0479f3466c6 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -482,8 +482,8 @@ interface PluginToolExecuteRequest { tool: string; /** Parameters matching the tool's declared JSON Schema. */ parameters?: unknown; - /** Agent run context. */ - runContext: ToolRunContext; + /** Agent run context. projectId may be omitted or null when the run has no project. */ + runContext: Omit & { projectId?: string | null }; } /** @@ -862,13 +862,15 @@ export function pluginRoutes( return '"runContext.runId" does not belong to "runContext.agentId"'; } - const [project] = await db - .select({ companyId: projects.companyId }) - .from(projects) - .where(eq(projects.id, runContext.projectId)) - .limit(1); - if (!project || project.companyId !== runContext.companyId) { - return '"runContext.projectId" does not belong to "runContext.companyId"'; + if (runContext.projectId) { + const [project] = await db + .select({ companyId: projects.companyId }) + .from(projects) + .where(eq(projects.id, runContext.projectId)) + .limit(1); + if (!project || project.companyId !== runContext.companyId) { + return '"runContext.projectId" does not belong to "runContext.companyId"'; + } } return null; @@ -1060,7 +1062,7 @@ export function pluginRoutes( return; } - const { tool, parameters, runContext } = body; + const { tool, parameters, runContext: requestedRunContext } = body; // Validate required fields if (!tool || typeof tool !== "string") { @@ -1068,18 +1070,27 @@ export function pluginRoutes( return; } - if (!runContext || typeof runContext !== "object") { + if (!requestedRunContext || typeof requestedRunContext !== "object") { res.status(400).json({ error: '"runContext" is required and must be an object' }); return; } - if (!runContext.agentId || !runContext.runId || !runContext.companyId || !runContext.projectId) { + if (!requestedRunContext.agentId || !requestedRunContext.runId || !requestedRunContext.companyId) { res.status(400).json({ - error: '"runContext" must include agentId, runId, companyId, and projectId', + error: '"runContext" must include agentId, runId, and companyId', }); return; } + const requestedProjectId: unknown = requestedRunContext.projectId; + if (requestedProjectId != null && typeof requestedProjectId !== "string") { + res.status(400).json({ error: '"runContext.projectId" must be a string when present' }); + return; + } + // A run can have no project. Plugins then get an empty projectId, the same + // value the tool gateway sends, so both paths give plugins one contract. + const runContext: ToolRunContext = { ...requestedRunContext, projectId: requestedProjectId ?? "" }; + assertCompanyAccess(req, runContext.companyId); const scopeError = await validateToolRunContextScope(runContext); if (scopeError) {