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:
parent
1b8738da5c
commit
e55d702916
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue