feat(plugin-sdk): human-attributed issue comments for chat gateway plugins (#10050)

Adds the `issue.comments.create_human_attributed` capability and `ctx.issues.createComment` `actorUserId` option, with host-side active-human-member verification. LOOA-627.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Michael Nguyen 2026-07-22 15:54:46 -07:00 committed by GitHub
parent 1b8738da5c
commit e55d702916
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 459 additions and 11 deletions

View File

@ -706,6 +706,7 @@ Governance helpers:
- `ctx.issues.assertCheckoutOwner({ issueId, companyId, actorAgentId, actorRunId })` lets plugin actions preserve agent-run checkout ownership.
- `ctx.issues.requestWakeup(issueId, companyId, options)` requests assignment wakeups through host heartbeat semantics, including terminal-status, blocker, assignee, and budget hard-stop checks.
- `ctx.issues.requestWakeups(issueIds, companyId, options)` applies the same host-owned wakeup semantics to a batch and may use an idempotency key prefix for stable coordinator retries.
- `ctx.issues.createComment(issueId, body, companyId, options)` posts a comment attributed to the plugin's own agent by default (`options.authorAgentId`). Passing `options.actorUserId` instead attributes the comment to that human company member — this requires `issue.comments.create_human_attributed` in addition to `issue.comments.create`, and the host independently verifies `actorUserId` is an active human member of the company before applying it, so a plugin can never forge attribution to an arbitrary or inactive user. A human-attributed comment on a non-terminal-status issue with an assignee also triggers the same assignee wakeup a board user's comment gets.
Plugin-originated issue, relation, document, comment, and wakeup mutations must write activity entries with `actorType: "plugin"` and details fields for `sourcePluginId`, `sourcePluginKey`, `initiatingActorType`, `initiatingActorId`, and `initiatingRunId` when a user or agent run initiated the plugin work.
@ -810,6 +811,7 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr
- `issues.create`
- `issues.update`
- `issue.comments.create`
- `issue.comments.create_human_attributed`
- `issue.interactions.create`
- `issue.documents.write`
- `issue.relations.write`

View File

@ -334,6 +334,7 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `issues.checkout` |
| | `issues.wakeup` |
| | `issue.comments.create` |
| | `issue.comments.create_human_attributed` |
| | `issue.documents.write` |
| | `issue.relations.write` |
| | `activity.log.write` |
@ -569,6 +570,26 @@ const summary = await ctx.issues.summaries.getOrchestration({
});
```
By default, `ctx.issues.createComment` attributes the comment to the calling
plugin's own agent (`authorAgentId`). A plugin that relays a message a human
actually sent — a chat gateway bridging Slack/Telegram replies back onto an
issue, for example — can instead attribute the comment to that person by
passing `actorUserId`:
```ts
await ctx.issues.createComment(issueId, replyText, companyId, {
actorUserId: verifiedSlackUser.paperclipUserId,
});
```
This requires the `issue.comments.create_human_attributed` capability in
addition to `issue.comments.create`. The host independently verifies that
`actorUserId` is an active human member of the issue's company before
applying the comment — a plugin cannot forge attribution to an arbitrary or
inactive user id. When the issue has a non-terminal status and an assigned
agent, a human-attributed comment also wakes that assignee, the same way a
board user's comment does in the web app.
Required capabilities:
| API | Capability |
@ -577,6 +598,8 @@ Required capabilities:
| `ctx.issues.relations.setBlockedBy` / `addBlockers` / `removeBlockers` | `issue.relations.write` |
| `ctx.issues.getSubtree` | `issue.subtree.read` |
| `ctx.issues.assertCheckoutOwner` | `issues.checkout` |
| `ctx.issues.createComment` | `issue.comments.create` |
| `ctx.issues.createComment` with `actorUserId` | `issue.comments.create` + `issue.comments.create_human_attributed` |
| `ctx.issues.requestWakeup` / `requestWakeups` | `issues.wakeup` |
| `ctx.issues.summaries.getOrchestration` | `issues.orchestration.read` |

View File

@ -882,6 +882,13 @@ export function createHostClientHandlers(
return services.issues.listComments(params);
}),
"issues.createComment": gated("issues.createComment", async (params) => {
if (params.actorUserId && !capabilitySet.has("issue.comments.create_human_attributed")) {
throw new CapabilityDeniedError(
pluginId,
"issues.createComment",
"issue.comments.create_human_attributed",
);
}
return services.issues.createComment(params);
}),
"issues.createInteraction": gated("issues.createInteraction", async (params) => {

View File

@ -1449,7 +1449,14 @@ export interface WorkerToHostMethods {
result: IssueComment[],
];
"issues.createComment": [
params: { issueId: string; body: string; companyId: string; authorAgentId?: string },
params: {
issueId: string;
body: string;
companyId: string;
authorAgentId?: string;
/** Active human company member the comment is attributed to. Requires `issue.comments.create_human_attributed`. */
actorUserId?: string;
},
result: IssueComment,
];
"issues.createInteraction": [

View File

@ -1673,18 +1673,40 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
},
async createComment(issueId, body, companyId, options) {
requireCapability(manifest, capabilitySet, "issue.comments.create");
if (options?.actorUserId) {
requireCapability(manifest, capabilitySet, "issue.comments.create_human_attributed");
}
const parentIssue = issues.get(issueId);
if (!isInCompany(parentIssue, companyId)) {
throw new Error(`Issue not found: ${issueId}`);
}
if (options?.actorUserId) {
// Mirror the host's `requireActiveHumanMember` check so the harness
// rejects the same forged attributions production does: the actor
// must be an active `user` member of the issue's company. Seed
// members via `createTestPluginHost({ accessMembers: [...] })`.
const actorUserId = options.actorUserId;
const isActiveHumanMember = [...accessMembers.values()].some(
(member) =>
member.companyId === companyId
&& member.principalType === "user"
&& member.principalId === actorUserId
&& member.status === "active",
);
if (!isActiveHumanMember) {
throw new Error(
`actorUserId "${actorUserId}" is not an active human member of this company`,
);
}
}
const now = new Date();
const comment: IssueComment = {
id: randomUUID(),
companyId: parentIssue.companyId,
issueId,
authorType: options?.authorAgentId ? "agent" : "system",
authorAgentId: options?.authorAgentId ?? null,
authorUserId: null,
authorType: options?.actorUserId ? "user" : options?.authorAgentId ? "agent" : "system",
authorAgentId: options?.actorUserId ? null : options?.authorAgentId ?? null,
authorUserId: options?.actorUserId ?? null,
body,
presentation: null,
metadata: null,

View File

@ -1331,6 +1331,7 @@ export interface PluginIssueSummariesClient {
* - `issues.orchestration.read` for orchestration summaries
* - `issue.comments.read` for `listComments`
* - `issue.comments.create` for `createComment`
* - `issue.comments.create_human_attributed` for `createComment` calls that pass `actorUserId`
* - `issue.interactions.create` for `createInteraction`, `suggestTasks`, `askUserQuestions`, `requestConfirmation`, and `requestCheckboxConfirmation`
* - `issue.documents.read` for `documents.list` and `documents.get`
* - `issue.documents.write` for `documents.upsert` and `documents.delete`
@ -1434,11 +1435,29 @@ export interface PluginIssuesClient {
} & PluginIssueMutationActor,
): Promise<PluginIssueWakeupBatchResult[]>;
listComments(issueId: string, companyId: string): Promise<IssueComment[]>;
/**
* Post a comment on an issue.
*
* Pass `authorAgentId` to attribute the comment to the plugin's own agent
* identity (requires `issue.comments.create`, the default).
*
* Pass `actorUserId` to attribute the comment to a real human instead
* for example, relaying a paired chat user's reply back into the issue
* thread. Requires the additional `issue.comments.create_human_attributed`
* capability. The host independently verifies that `actorUserId` is an
* active human member of the issue's company before applying the
* attribution a plugin can only ever attribute comments to identities
* that could have posted them in the web app. A human-attributed comment
* also participates in the normal wake-the-assignee behavior a board
* user's comment gets in the web app (subject to the same closed-issue /
* no-assignee exclusions) unlike a plugin's own agent-attributed
* comments, which never wake anyone.
*/
createComment(
issueId: string,
body: string,
companyId: string,
options?: { authorAgentId?: string },
options?: { authorAgentId?: string; actorUserId?: string },
): Promise<IssueComment>;
createInteraction(
issueId: string,

View File

@ -864,8 +864,19 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
return callHost("issues.listComments", { issueId, companyId });
},
async createComment(issueId: string, body: string, companyId: string, options?: { authorAgentId?: string }) {
return callHost("issues.createComment", { issueId, body, companyId, authorAgentId: options?.authorAgentId });
async createComment(
issueId: string,
body: string,
companyId: string,
options?: { authorAgentId?: string; actorUserId?: string },
) {
return callHost("issues.createComment", {
issueId,
body,
companyId,
authorAgentId: options?.authorAgentId,
actorUserId: options?.actorUserId,
});
},
async createInteraction(issueId: string, interaction, companyId: string, options?: { authorAgentId?: string }) {

View File

@ -231,4 +231,78 @@ describe("createHostClientHandlers invocation company scope", () => {
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
expect(searchAudit).not.toHaveBeenCalled();
});
it("rejects a human-attributed createComment call when only issue.comments.create is granted", async () => {
const createComment = vi.fn(async () => ({ id: "comment-1" }));
const services = {
issues: { createComment },
} as unknown as HostServices;
const handlers = createHostClientHandlers({
pluginId: "paperclip.test",
capabilities: ["issue.comments.create"],
services,
});
const context = { invocationScope: { companyId: "company-a" } };
await expect(
handlers["issues.createComment"]({
issueId: "issue-a",
body: "hello",
companyId: "company-a",
actorUserId: "user-a",
}, context),
).rejects.toBeInstanceOf(CapabilityDeniedError);
expect(createComment).not.toHaveBeenCalled();
});
it("allows a human-attributed createComment call once issue.comments.create_human_attributed is also granted", async () => {
const createComment = vi.fn(async () => ({ id: "comment-1" }));
const services = {
issues: { createComment },
} as unknown as HostServices;
const handlers = createHostClientHandlers({
pluginId: "paperclip.test",
capabilities: ["issue.comments.create", "issue.comments.create_human_attributed"],
services,
});
const context = { invocationScope: { companyId: "company-a" } };
await expect(
handlers["issues.createComment"]({
issueId: "issue-a",
body: "hello",
companyId: "company-a",
actorUserId: "user-a",
}, context),
).resolves.toEqual({ id: "comment-1" });
expect(createComment).toHaveBeenCalledWith({
issueId: "issue-a",
body: "hello",
companyId: "company-a",
actorUserId: "user-a",
});
});
it("still allows a plain agent-attributed createComment call without the human-attribution capability", async () => {
const createComment = vi.fn(async () => ({ id: "comment-2" }));
const services = {
issues: { createComment },
} as unknown as HostServices;
const handlers = createHostClientHandlers({
pluginId: "paperclip.test",
capabilities: ["issue.comments.create"],
services,
});
const context = { invocationScope: { companyId: "company-a" } };
await expect(
handlers["issues.createComment"]({
issueId: "issue-a",
body: "hello",
companyId: "company-a",
authorAgentId: "agent-a",
}, context),
).resolves.toEqual({ id: "comment-2" });
expect(createComment).toHaveBeenCalled();
});
});

View File

@ -1256,6 +1256,7 @@ export const PLUGIN_CAPABILITIES = [
"issues.checkout",
"issues.wakeup",
"issue.comments.create",
"issue.comments.create_human_attributed",
"issue.interactions.create",
"issue.documents.write",
"projects.managed",

View File

@ -9,10 +9,13 @@ import {
agentWakeupRequests,
agents,
companies,
companyMemberships,
costEvents,
createDb,
executionWorkspaces,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueRelations,
issues,
pluginManagedResources,
@ -59,19 +62,45 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
db = createDb(tempDb.connectionString);
}, 20_000);
function isHeartbeatRunDependentFkError(error: unknown) {
const message = error instanceof Error ? `${error.message} ${String(error.cause ?? "")}` : String(error);
return (
message.includes("heartbeat_run_events_run_id_heartbeat_runs_id_fk")
|| message.includes("activity_log_run_id_heartbeat_runs_id_fk")
);
}
// A real (fire-and-forget) heartbeat run may still be executing in the
// background when a test's own createComment-triggered wakeup completes —
// retry deletion so that race doesn't fail cleanup with an FK violation.
async function deleteHeartbeatRunsWithDependents() {
for (let attempt = 0; attempt < 5; attempt += 1) {
await db.delete(heartbeatRunEvents);
await db.delete(activityLog);
try {
await db.delete(heartbeatRuns);
return;
} catch (error) {
if (!isHeartbeatRunDependentFkError(error) || attempt === 4) throw error;
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
}
afterEach(async () => {
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
tempRoots.length = 0;
await db.delete(activityLog);
await db.delete(costEvents);
await db.delete(heartbeatRuns);
await deleteHeartbeatRunsWithDependents();
await db.delete(agentWakeupRequests);
await db.delete(issueRelations);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(pluginManagedResources);
await db.delete(projects);
await db.delete(plugins);
await db.delete(companyMemberships);
await db.delete(agents);
await db.delete(companies);
});
@ -731,4 +760,96 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
outputTokens: 6,
});
});
it("rejects a human-attributed plugin comment when actorUserId is not an active company member", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Needs human input",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
await expect(
services.issues.createComment({
issueId,
companyId,
body: "Here's my answer",
actorUserId: randomUUID(),
}),
).rejects.toThrow("is not an active human member of this company");
await expect(db.select().from(agentWakeupRequests)).resolves.toHaveLength(0);
});
it("creates a human-attributed plugin comment and wakes the issue's assignee", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const humanUserId = randomUUID();
const issueId = randomUUID();
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: humanUserId,
status: "active",
membershipRole: "owner",
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Needs human input",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
});
// Cap concurrency at 1 and pre-seed a running run so enqueueWakeup's
// queued-run bookkeeping is exercised without startNextQueuedRunForAgent
// going on to actually claim/execute the new run — that path spins up
// real environment/adapter orchestration this test has no business
// depending on (and which races the test's own db teardown).
await db.update(agents).set({ runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } }).where(eq(agents.id, agentId));
await db.insert(heartbeatRuns).values({
companyId,
agentId,
status: "running",
invocationSource: "assignment",
contextSnapshot: {},
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
const comment = await services.issues.createComment({
issueId,
companyId,
body: "Here's my answer",
actorUserId: humanUserId,
});
expect(comment).toMatchObject({
authorType: "user",
authorUserId: humanUserId,
authorAgentId: null,
body: "Here's my answer",
});
const [wakeupRequest] = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
expect(wakeupRequest).toMatchObject({
reason: "issue_commented",
requestedByActorType: "user",
requestedByActorId: humanUserId,
});
expect(wakeupRequest?.status).toBe("queued");
expect(wakeupRequest?.runId).toEqual(expect.any(String));
const [run] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, wakeupRequest!.runId!));
expect(run).toMatchObject({ agentId, companyId, status: "queued" });
});
});

View File

@ -160,4 +160,96 @@ describe("plugin SDK test harness", () => {
expect(JSON.stringify(comments)).not.toContain("secret plugin-visible body");
expect(JSON.stringify(comments)).not.toContain("secret plugin metadata");
});
it("rejects a human-attributed comment when the actorUserId is not an active member", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-human-attributed-comment-unverified",
apiVersion: 1,
version: "0.1.0",
displayName: "Human-Attributed Comment (unverified)",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["issue.comments.create", "issue.comments.create_human_attributed"],
entrypoints: { worker: "./dist/worker.js" },
};
const harness = createTestHarness({ manifest });
harness.seed({
issues: [{
id: "issue-1",
companyId: "company-1",
title: "Human attribution",
status: "todo",
priority: "medium",
}],
// A suspended member must not satisfy the active-human-member check —
// the harness mirrors the host's `requireActiveHumanMember` guard so a
// plugin test cannot pass an attribution production would reject.
accessMembers: [{
id: "member-suspended",
companyId: "company-1",
principalType: "user",
principalId: "user-1",
status: "suspended",
membershipRole: "member",
grants: [],
createdAt: new Date("2026-06-03T11:00:00.000Z"),
updatedAt: new Date("2026-06-03T11:00:00.000Z"),
}],
});
await expect(
harness.ctx.issues.createComment("issue-1", "relayed reply", "company-1", { actorUserId: "user-1" }),
).rejects.toThrow('actorUserId "user-1" is not an active human member of this company');
});
it("attributes a comment to an active human member for a verified actorUserId", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-human-attributed-comment-verified",
apiVersion: 1,
version: "0.1.0",
displayName: "Human-Attributed Comment (verified)",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["issue.comments.create", "issue.comments.create_human_attributed"],
entrypoints: { worker: "./dist/worker.js" },
};
const harness = createTestHarness({ manifest });
harness.seed({
issues: [{
id: "issue-1",
companyId: "company-1",
title: "Human attribution",
status: "todo",
priority: "medium",
}],
accessMembers: [{
id: "member-active",
companyId: "company-1",
principalType: "user",
principalId: "user-1",
status: "active",
membershipRole: "member",
grants: [],
createdAt: new Date("2026-06-03T11:00:00.000Z"),
updatedAt: new Date("2026-06-03T11:00:00.000Z"),
}],
});
const comment = await harness.ctx.issues.createComment(
"issue-1",
"relayed reply",
"company-1",
{ actorUserId: "user-1" },
);
expect(comment).toMatchObject({
issueId: "issue-1",
authorType: "user",
authorUserId: "user-1",
authorAgentId: null,
body: "relayed reply",
});
});
});

View File

@ -4,6 +4,7 @@ import {
agentTaskSessions as agentTaskSessionsTable,
agents as agentsTable,
budgetIncidents,
companyMemberships,
costEvents,
heartbeatRuns,
invites,
@ -654,6 +655,30 @@ export function buildHostServices(
return record;
};
/**
* Verify `userId` is an active human member of `companyId` before letting a
* plugin attribute a mutation to them. Mirrors the authorization bar the
* web app's own board routes apply a plugin can only ever attribute an
* action to an identity that could have taken it in the web app itself.
* Used by any plugin capability that accepts an `actorUserId` (currently
* `createComment`'s human-attributed path).
*/
const requireActiveHumanMember = async (companyId: string, userId: string): Promise<void> => {
const [membership] = await db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
))
.limit(1);
if (!membership) {
throw new Error(`actorUserId "${userId}" is not an active human member of this company`);
}
};
const pluginActivityDetails = (
details: Record<string, unknown> | null | undefined,
actor?: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null },
@ -2012,23 +2037,67 @@ export function buildHostServices(
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
if (params.actorUserId) {
await requireActiveHumanMember(companyId, params.actorUserId);
}
const comment = (await issues.addComment(
params.issueId,
params.body,
{ agentId: params.authorAgentId },
{ agentId: params.actorUserId ? undefined : params.authorAgentId, userId: params.actorUserId },
)) as IssueComment;
await logPluginActivity({
companyId,
action: "issue.comment.created",
entityType: "issue",
entityId: issue.id,
actor: { actorAgentId: params.authorAgentId ?? null },
actor: { actorAgentId: params.actorUserId ? null : params.authorAgentId ?? null, actorUserId: params.actorUserId ?? null },
details: {
identifier: issue.identifier,
commentId: comment.id,
bodySnippet: comment.body.slice(0, 120),
},
});
// Human-attributed comments participate in the same "wake the
// assignee" behavior a board user's comment gets in the web app
// (routes/issues.ts's addComment route) — a plugin's own
// agent-attributed comments never do this. Deliberately narrower
// than the HTTP route: no reopen/resume/interrupt/scheduled-retry
// handling here, just the core wake. An assignee-less or
// closed-status issue is a silent no-op, matching the route's own
// guard.
if (
params.actorUserId
&& issue.assigneeAgentId
&& issue.status !== "done"
&& issue.status !== "cancelled"
) {
await heartbeat.wakeup(issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: {
issueId: issue.id,
commentId: comment.id,
mutation: "comment",
},
requestedByActorType: "user",
requestedByActorId: params.actorUserId,
contextSnapshot: {
issueId: issue.id,
taskId: issue.id,
sourceCommentId: comment.id,
wakeReason: "issue_commented",
source: `plugin:${pluginKey}`,
},
}).catch((err) => logger.warn({
err,
issueId: issue.id,
commentId: comment.id,
agentId: issue.assigneeAgentId,
}, "failed to wake assignee on plugin-relayed human comment"));
}
return comment;
},
async createInteraction(params) {