mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work; issues get commented on by both humans and agents, and the assignee is woken to act on new comments. > - #10050 added human-attributed issue comments for chat gateway plugins, with the host waking the issue's assignee the same way a board user's comment does. > - Greptile's review on #10050 flagged that the wakeup guard in `plugin-host-services.ts` decides whether to wake the assignee using the issue snapshot fetched *before* the comment was inserted. > - If another request closes, cancels, unassigns, or reassigns the issue in the window between that fetch and the wakeup call, the guard still acts on the stale snapshot — it can wake an agent for a now-terminal issue, or wake the old assignee instead of the new one. > - The PR discussion noted the HTTP add-comment route (`routes/issues.ts`) has the identical pattern outside its reopen/auto-approval branches, and deferred a fix to a follow-up covering both call sites — this PR is that follow-up. > - The fix re-fetches the issue immediately before the wake decision in both places, so the decision reflects the latest committed state instead of a pre-insert snapshot. ## Linked Issues or Issue Description Refs #10050 **Problem or motivation** Both the plugin-comment wakeup guard (`plugin-host-services.ts`) and the HTTP add-comment route's wakeup guard (`routes/issues.ts`, outside its reopen/auto-approval branches) decide whether to wake the issue's assignee using the issue state fetched before the comment was inserted. A concurrent close/unassign/reassign landing in that window is invisible to the guard, so it can enqueue a wakeup for a stale assignee or an issue that is no longer open. **Proposed solution** Re-fetch the issue immediately before the wake decision in both call sites, and base the assignee/status checks on that fresh read instead of the earlier snapshot. This shrinks the race window to essentially nothing (the fetch happens right before the fire-and-forget wakeup call), and any residual window is already covered by the heartbeat/checkout machinery re-validating issue status and assignee ownership when a woken run actually starts. **Alternatives considered** Wrap the whole comment-insert + wake-decision sequence in a single serializable transaction with row locking (rejected for this change — much larger blast radius across two already-complex handlers for a wakeup that is explicitly best-effort; the woken run's own re-validation already makes a stale wake degrade to a no-op rather than incorrect work). Leaving the plugin path fixed but not the HTTP route (rejected — that was the exact gap the original PR discussion flagged as needing a follow-up covering both call sites). **Roadmap alignment** Bug fix / hardening follow-up to #10050; no change to planned core roadmap items. ## What Changed - `server/src/services/plugin-host-services.ts`: `issues.createComment`'s assignee-wakeup guard now re-fetches the issue after the comment is inserted and bases the assignee/status checks on that fresh read, instead of the snapshot fetched before the insert. - `server/src/routes/issues.ts`: the `POST /issues/:id/comments` route's wakeup guard (outside the reopen/auto-approval branches, which already use post-mutation state) now does the same re-fetch before deciding whether — and whom — to wake. - Adds regression coverage for both: - `server/src/__tests__/plugin-orchestration-apis.test.ts`: a new embedded-Postgres test holds a row lock on the issue to deterministically force the race (comment-insert's internal update blocks until a concurrent transaction commits a cancellation), then asserts no wakeup is enqueued. - `server/src/__tests__/issue-comment-reopen-routes.test.ts`: two new mocked-service tests assert the route skips the wakeup when the fresh re-fetch shows the issue cancelled, and wakes the freshly reassigned agent (not the pre-insert snapshot's assignee) when the fresh re-fetch shows a different assignee. ## Verification - `pnpm --filter @paperclipai/server typecheck` — clean. - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/plugin-orchestration-apis.test.ts` — 13/13 (1 new). - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/issue-comment-reopen-routes.test.ts` — 74/74 (2 new). - `pnpm --filter @paperclipai/plugin-sdk exec vitest run tests/host-client-factory.test.ts` — 14/14. - Broader sweep of 38 `routes/issues.ts`-adjacent test files (447 tests) — all passing, confirming the added re-fetch doesn't change behavior for any existing reopen/auto-approval/interrupt/scheduled-retry/dependency-wake scenario. ## Risks Low risk. Both changes are additive guards around an existing best-effort, fire-and-forget wakeup (failures already logged, not thrown) — no change to the comment-write path itself, response shape, or status codes. The HTTP route's fix only touches the plain (non-reopen, non-auto-approval) wake-decision path; the reopen and auto-approval branches already used post-mutation state for the reasons documented inline and are unchanged. Adds one extra `SELECT` per comment on each call site, negligible relative to the existing query volume in both handlers. ## Model Used Claude Sonnet 5 (`claude-sonnet-5`), extended thinking, tool use enabled, via Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I searched the GitHub PR list (open and recently closed) for similar PRs; found no duplicate — this is a direct follow-up to the review discussion on #10050 - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: anicca <annica@Michaels-Mac-Studio.local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1288 lines
48 KiB
TypeScript
1288 lines
48 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { promises as fs } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
activityLog,
|
|
agentWakeupRequests,
|
|
agents,
|
|
approvals,
|
|
assets,
|
|
companies,
|
|
companyMemberships,
|
|
costEvents,
|
|
createDb,
|
|
executionWorkspaces,
|
|
heartbeatRunEvents,
|
|
heartbeatRuns,
|
|
issueAttachments,
|
|
issueComments,
|
|
issueRelations,
|
|
issueThreadInteractions,
|
|
issues,
|
|
pluginManagedResources,
|
|
plugins,
|
|
projects,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { buildHostServices } from "../services/plugin-host-services.js";
|
|
import { heartbeatService } from "../services/heartbeat.js";
|
|
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
function createEventBusStub() {
|
|
return {
|
|
forPlugin() {
|
|
return {
|
|
emit: async () => {},
|
|
subscribe: () => {},
|
|
};
|
|
},
|
|
} as any;
|
|
}
|
|
|
|
function issuePrefix(id: string) {
|
|
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
}
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres plugin orchestration API tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
const tempRoots: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-orchestration-");
|
|
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 every in-flight background heartbeat run to quiescence before the
|
|
// deletes below. A createComment-triggered wakeup dispatches its run
|
|
// fire-and-forget (void heartbeat.wakeup(...)), so a run or wakeup can still
|
|
// write heartbeat_runs and issues rows when teardown starts and would race
|
|
// the deletes. The heartbeat service tracks in-flight run and wakeup
|
|
// promises in module state shared across service instances, so a fresh
|
|
// instance here drains the runs the per-test host services dispatched.
|
|
await drainHeartbeatRunsToQuiescence(db, heartbeatService(db));
|
|
await db.delete(costEvents);
|
|
await deleteHeartbeatRunsWithDependents();
|
|
await db.delete(agentWakeupRequests);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issueComments);
|
|
await db.delete(issueThreadInteractions);
|
|
await db.delete(issueAttachments);
|
|
await db.delete(assets);
|
|
await db.delete(approvals);
|
|
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);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedCompanyAndAgent() {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: issuePrefix(companyId),
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Engineer",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "process",
|
|
adapterConfig: { command: "true" },
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
return { companyId, agentId };
|
|
}
|
|
|
|
async function makeLocalRoot() {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-host-folder-"));
|
|
tempRoots.push(root);
|
|
return root;
|
|
}
|
|
|
|
it("returns plugin-safe execution workspace metadata scoped to the company", async () => {
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const otherCompanyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const workspaceId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: otherCompanyId,
|
|
name: "Other",
|
|
issuePrefix: issuePrefix(otherCompanyId),
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Workspaces",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(executionWorkspaces).values({
|
|
id: workspaceId,
|
|
companyId,
|
|
projectId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Feature workspace",
|
|
status: "active",
|
|
cwd: "/tmp/paperclip-feature",
|
|
repoUrl: "https://example.com/paperclip.git",
|
|
baseRef: "main",
|
|
branchName: "feature/workspace",
|
|
providerType: "git_worktree",
|
|
providerRef: "/tmp/paperclip-feature",
|
|
metadata: {
|
|
providerMetadata: { sandboxId: "sandbox-1" },
|
|
workspaceRealizationRequest: { hiddenInternal: true },
|
|
},
|
|
});
|
|
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.workspace", createEventBusStub());
|
|
|
|
await expect(services.executionWorkspaces.get({ workspaceId, companyId })).resolves.toMatchObject({
|
|
id: workspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId: null,
|
|
path: "/tmp/paperclip-feature",
|
|
cwd: "/tmp/paperclip-feature",
|
|
repoUrl: "https://example.com/paperclip.git",
|
|
baseRef: "main",
|
|
branchName: "feature/workspace",
|
|
providerType: "git_worktree",
|
|
providerMetadata: { sandboxId: "sandbox-1" },
|
|
});
|
|
await expect(services.executionWorkspaces.get({ workspaceId, companyId: otherCompanyId })).resolves.toBeNull();
|
|
});
|
|
|
|
it("creates plugin-origin issues with full orchestration fields and audit activity", async () => {
|
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
|
const blockerIssueId = randomUUID();
|
|
const originRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: originRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "assignment",
|
|
contextSnapshot: { issueId: blockerIssueId },
|
|
});
|
|
await db.insert(issues).values({
|
|
id: blockerIssueId,
|
|
companyId,
|
|
title: "Blocker",
|
|
status: "todo",
|
|
priority: "medium",
|
|
identifier: `${issuePrefix(companyId)}-blocker`,
|
|
});
|
|
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
|
const issue = await services.issues.create({
|
|
companyId,
|
|
title: "Plugin child issue",
|
|
status: "todo",
|
|
assigneeAgentId: agentId,
|
|
billingCode: "mission:alpha",
|
|
originId: "mission-alpha",
|
|
blockedByIssueIds: [blockerIssueId],
|
|
actorAgentId: agentId,
|
|
actorRunId: originRunId,
|
|
});
|
|
|
|
const [stored] = await db.select().from(issues).where(eq(issues.id, issue.id));
|
|
expect(stored?.originKind).toBe("plugin:paperclip.missions");
|
|
expect(stored?.originId).toBe("mission-alpha");
|
|
expect(stored?.billingCode).toBe("mission:alpha");
|
|
expect(stored?.assigneeAgentId).toBe(agentId);
|
|
expect(stored?.createdByAgentId).toBe(agentId);
|
|
expect(stored?.originRunId).toBe(originRunId);
|
|
|
|
const [relation] = await db
|
|
.select()
|
|
.from(issueRelations)
|
|
.where(and(eq(issueRelations.issueId, blockerIssueId), eq(issueRelations.relatedIssueId, issue.id)));
|
|
expect(relation?.type).toBe("blocks");
|
|
|
|
const activities = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(and(eq(activityLog.entityType, "issue"), eq(activityLog.entityId, issue.id)));
|
|
expect(activities).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
actorType: "plugin",
|
|
actorId: "plugin-record-id",
|
|
action: "issue.created",
|
|
agentId,
|
|
details: expect.objectContaining({
|
|
sourcePluginId: "plugin-record-id",
|
|
sourcePluginKey: "paperclip.missions",
|
|
initiatingActorType: "agent",
|
|
initiatingActorId: agentId,
|
|
initiatingRunId: originRunId,
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("enforces plugin origin namespaces", async () => {
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
|
|
|
const featureIssue = await services.issues.create({
|
|
companyId,
|
|
title: "Feature issue",
|
|
originKind: "plugin:paperclip.missions:feature",
|
|
originId: "mission-alpha:feature-1",
|
|
});
|
|
expect(featureIssue.originKind).toBe("plugin:paperclip.missions:feature");
|
|
|
|
await expect(
|
|
services.issues.create({
|
|
companyId,
|
|
title: "Spoofed issue",
|
|
originKind: "plugin:other.plugin:feature",
|
|
}),
|
|
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions");
|
|
|
|
await expect(
|
|
services.issues.update({
|
|
issueId: featureIssue.id,
|
|
companyId,
|
|
patch: { originKind: "plugin:other.plugin:feature" },
|
|
}),
|
|
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions");
|
|
});
|
|
|
|
it("creates plugin operation issues with the generic operation origin", async () => {
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
|
|
|
const issue = await services.issues.create({
|
|
companyId,
|
|
title: "Background operation",
|
|
surfaceVisibility: "plugin_operation",
|
|
originId: "mission-alpha:operation-1",
|
|
});
|
|
|
|
expect(issue.originKind).toBe("plugin:paperclip.missions:operation");
|
|
expect(issue.originId).toBe("mission-alpha:operation-1");
|
|
});
|
|
|
|
it("lets bootstrap-style actions initialize required local folders from an empty root", async () => {
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const pluginId = randomUUID();
|
|
await db.insert(plugins).values({
|
|
id: pluginId,
|
|
pluginKey: "paperclipai.plugin-llm-wiki",
|
|
packageName: "@paperclipai/plugin-llm-wiki",
|
|
version: "0.1.0",
|
|
manifestJson: {
|
|
id: "paperclipai.plugin-llm-wiki",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "LLM Wiki",
|
|
description: "Local-file LLM Wiki plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["local.folders"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
localFolders: [
|
|
{
|
|
folderKey: "wiki-root",
|
|
displayName: "Wiki root",
|
|
access: "readWrite",
|
|
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
|
|
requiredFiles: ["WIKI.md", "AGENTS.md"],
|
|
},
|
|
],
|
|
},
|
|
status: "ready",
|
|
});
|
|
const root = await makeLocalRoot();
|
|
const services = buildHostServices(
|
|
db,
|
|
pluginId,
|
|
"paperclipai.plugin-llm-wiki",
|
|
createEventBusStub(),
|
|
undefined,
|
|
{
|
|
manifest: {
|
|
id: "paperclipai.plugin-llm-wiki",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "LLM Wiki",
|
|
description: "Local-file LLM Wiki plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["local.folders"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
localFolders: [
|
|
{
|
|
folderKey: "wiki-root",
|
|
displayName: "Wiki root",
|
|
access: "readWrite",
|
|
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
|
|
requiredFiles: ["WIKI.md", "AGENTS.md"],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
|
|
const configured = await services.localFolders.configure({
|
|
companyId,
|
|
folderKey: "wiki-root",
|
|
path: root,
|
|
access: "readWrite",
|
|
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
|
|
requiredFiles: ["WIKI.md", "AGENTS.md"],
|
|
});
|
|
expect(configured.healthy).toBe(false);
|
|
expect(configured.missingDirectories).toEqual([]);
|
|
expect(configured.missingFiles).toEqual(["WIKI.md", "AGENTS.md"]);
|
|
|
|
await fs.rm(path.join(root, "raw"), { recursive: true, force: true });
|
|
await fs.rm(path.join(root, "wiki"), { recursive: true, force: true });
|
|
await expect(services.localFolders.readText({ companyId, folderKey: "wiki-root", relativePath: "WIKI.md" }))
|
|
.rejects.toThrow("Local folder is not healthy");
|
|
await services.localFolders.writeTextAtomic({
|
|
companyId,
|
|
folderKey: "wiki-root",
|
|
relativePath: "WIKI.md",
|
|
contents: "# Wiki\n",
|
|
});
|
|
await services.localFolders.writeTextAtomic({
|
|
companyId,
|
|
folderKey: "wiki-root",
|
|
relativePath: "AGENTS.md",
|
|
contents: "# Agents\n",
|
|
});
|
|
|
|
const finalStatus = await services.localFolders.status({ companyId, folderKey: "wiki-root" });
|
|
expect(finalStatus.healthy).toBe(true);
|
|
await expect(fs.stat(path.join(root, "raw"))).resolves.toMatchObject({});
|
|
await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
|
|
await expect(fs.readFile(path.join(root, "WIKI.md"), "utf8")).resolves.toBe("# Wiki\n");
|
|
});
|
|
|
|
it("rejects worker local-folder access for undeclared manifest keys", async () => {
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const pluginId = randomUUID();
|
|
await db.insert(plugins).values({
|
|
id: pluginId,
|
|
pluginKey: "paperclip.local-folders",
|
|
packageName: "@paperclip/plugin-local-folders",
|
|
version: "0.1.0",
|
|
manifestJson: {
|
|
id: "paperclip.local-folders",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Local Folders",
|
|
description: "Local folder fixture",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["local.folders"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
localFolders: [
|
|
{
|
|
folderKey: "content-root",
|
|
displayName: "Content root",
|
|
access: "readWrite",
|
|
},
|
|
],
|
|
},
|
|
status: "ready",
|
|
});
|
|
const services = buildHostServices(
|
|
db,
|
|
pluginId,
|
|
"paperclip.local-folders",
|
|
createEventBusStub(),
|
|
undefined,
|
|
{
|
|
manifest: {
|
|
id: "paperclip.local-folders",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Local Folders",
|
|
description: "Local folder fixture",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["local.folders"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
localFolders: [
|
|
{
|
|
folderKey: "content-root",
|
|
displayName: "Content root",
|
|
access: "readWrite",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
await expect(services.localFolders.configure({
|
|
companyId,
|
|
folderKey: "ssh",
|
|
path: "/tmp",
|
|
access: "read",
|
|
})).rejects.toThrow("Local folder key is not declared");
|
|
await expect(services.localFolders.status({ companyId, folderKey: "ssh" }))
|
|
.rejects.toThrow("Local folder key is not declared");
|
|
await expect(services.localFolders.readText({ companyId, folderKey: "ssh", relativePath: "id_rsa" }))
|
|
.rejects.toThrow("Local folder key is not declared");
|
|
await expect(services.localFolders.writeTextAtomic({
|
|
companyId,
|
|
folderKey: "ssh",
|
|
relativePath: "id_rsa",
|
|
contents: "secret",
|
|
})).rejects.toThrow("Local folder key is not declared");
|
|
});
|
|
|
|
it("resolves plugin-managed projects by stable key without overwriting user edits", async () => {
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const pluginId = randomUUID();
|
|
await db.insert(plugins).values({
|
|
id: pluginId,
|
|
pluginKey: "paperclip.missions",
|
|
packageName: "@paperclip/plugin-missions",
|
|
version: "0.1.0",
|
|
apiVersion: 1,
|
|
categories: ["automation"],
|
|
status: "ready",
|
|
manifestJson: {
|
|
id: "paperclip.missions",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Missions",
|
|
description: "Mission orchestration",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["projects.managed"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
projects: [{
|
|
projectKey: "operations",
|
|
displayName: "Mission Operations",
|
|
description: "Plugin operation inspection area",
|
|
status: "in_progress",
|
|
color: "#14b8a6",
|
|
settings: { surface: "operations" },
|
|
}],
|
|
},
|
|
});
|
|
|
|
const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub());
|
|
const missing = await services.projects.getManaged({ companyId, projectKey: "operations" });
|
|
expect(missing.status).toBe("missing");
|
|
expect(missing.projectId).toBeNull();
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(pluginManagedResources)
|
|
.where(and(
|
|
eq(pluginManagedResources.companyId, companyId),
|
|
eq(pluginManagedResources.pluginId, pluginId),
|
|
eq(pluginManagedResources.resourceKind, "project"),
|
|
eq(pluginManagedResources.resourceKey, "operations"),
|
|
)),
|
|
).resolves.toHaveLength(0);
|
|
|
|
const created = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
|
|
|
expect(created.status).toBe("created");
|
|
expect(created.projectId).toEqual(expect.any(String));
|
|
expect(created.project?.managedByPlugin).toMatchObject({
|
|
pluginId,
|
|
pluginKey: "paperclip.missions",
|
|
pluginDisplayName: "Missions",
|
|
resourceKind: "project",
|
|
resourceKey: "operations",
|
|
});
|
|
|
|
await db
|
|
.update(projects)
|
|
.set({ name: "Renamed by operator", description: "User-owned text", updatedAt: new Date() })
|
|
.where(eq(projects.id, created.projectId!));
|
|
await db
|
|
.update(plugins)
|
|
.set({
|
|
manifestJson: {
|
|
id: "paperclip.missions",
|
|
apiVersion: 1,
|
|
version: "0.2.0",
|
|
displayName: "Missions",
|
|
description: "Mission orchestration",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["projects.managed"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
projects: [{
|
|
projectKey: "operations",
|
|
displayName: "Upgraded Default Name",
|
|
description: "Upgraded default description",
|
|
status: "planned",
|
|
color: "#f97316",
|
|
settings: { surface: "operations", upgraded: true },
|
|
}],
|
|
},
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(plugins.id, pluginId));
|
|
|
|
const resolved = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
|
|
|
expect(resolved.status).toBe("resolved");
|
|
expect(resolved.projectId).toBe(created.projectId);
|
|
expect(resolved.project?.name).toBe("Renamed by operator");
|
|
expect(resolved.project?.description).toBe("User-owned text");
|
|
expect(resolved.project?.managedByPlugin?.defaultsJson).toMatchObject({
|
|
displayName: "Upgraded Default Name",
|
|
settings: { upgraded: true },
|
|
});
|
|
});
|
|
|
|
it("asserts checkout ownership for run-scoped plugin actions", async () => {
|
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
invocationSource: "assignment",
|
|
contextSnapshot: { issueId },
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Checked out issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: runId,
|
|
executionRunId: runId,
|
|
});
|
|
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
|
await expect(
|
|
services.issues.assertCheckoutOwner({
|
|
issueId,
|
|
companyId,
|
|
actorAgentId: agentId,
|
|
actorRunId: runId,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
issueId,
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
checkoutRunId: runId,
|
|
});
|
|
});
|
|
|
|
it("refuses plugin wakeups for issues with unresolved blockers", async () => {
|
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
|
const blockerIssueId = randomUUID();
|
|
const blockedIssueId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{
|
|
id: blockerIssueId,
|
|
companyId,
|
|
title: "Unresolved blocker",
|
|
status: "todo",
|
|
priority: "medium",
|
|
},
|
|
{
|
|
id: blockedIssueId,
|
|
companyId,
|
|
title: "Blocked issue",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
},
|
|
]);
|
|
await db.insert(issueRelations).values({
|
|
companyId,
|
|
issueId: blockerIssueId,
|
|
relatedIssueId: blockedIssueId,
|
|
type: "blocks",
|
|
});
|
|
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
|
await expect(
|
|
services.issues.requestWakeup({
|
|
issueId: blockedIssueId,
|
|
companyId,
|
|
reason: "mission_advance",
|
|
}),
|
|
).rejects.toThrow("Issue is blocked by unresolved blockers");
|
|
});
|
|
|
|
it("narrows orchestration cost summaries by subtree and billing code", async () => {
|
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
|
const rootIssueId = randomUUID();
|
|
const childIssueId = randomUUID();
|
|
const unrelatedIssueId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{
|
|
id: rootIssueId,
|
|
companyId,
|
|
title: "Root mission",
|
|
status: "todo",
|
|
priority: "medium",
|
|
billingCode: "mission:alpha",
|
|
},
|
|
{
|
|
id: childIssueId,
|
|
companyId,
|
|
parentId: rootIssueId,
|
|
title: "Child mission",
|
|
status: "todo",
|
|
priority: "medium",
|
|
billingCode: "mission:alpha",
|
|
},
|
|
{
|
|
id: unrelatedIssueId,
|
|
companyId,
|
|
title: "Different mission",
|
|
status: "todo",
|
|
priority: "medium",
|
|
billingCode: "mission:alpha",
|
|
},
|
|
]);
|
|
await db.insert(costEvents).values([
|
|
{
|
|
companyId,
|
|
agentId,
|
|
issueId: rootIssueId,
|
|
billingCode: "mission:alpha",
|
|
provider: "test",
|
|
model: "unit",
|
|
inputTokens: 10,
|
|
cachedInputTokens: 1,
|
|
outputTokens: 2,
|
|
costCents: 100,
|
|
occurredAt: new Date(),
|
|
},
|
|
{
|
|
companyId,
|
|
agentId,
|
|
issueId: childIssueId,
|
|
billingCode: "mission:alpha",
|
|
provider: "test",
|
|
model: "unit",
|
|
inputTokens: 20,
|
|
cachedInputTokens: 2,
|
|
outputTokens: 4,
|
|
costCents: 200,
|
|
occurredAt: new Date(),
|
|
},
|
|
{
|
|
companyId,
|
|
agentId,
|
|
issueId: childIssueId,
|
|
billingCode: "mission:beta",
|
|
provider: "test",
|
|
model: "unit",
|
|
inputTokens: 30,
|
|
cachedInputTokens: 3,
|
|
outputTokens: 6,
|
|
costCents: 300,
|
|
occurredAt: new Date(),
|
|
},
|
|
{
|
|
companyId,
|
|
agentId,
|
|
issueId: unrelatedIssueId,
|
|
billingCode: "mission:alpha",
|
|
provider: "test",
|
|
model: "unit",
|
|
inputTokens: 40,
|
|
cachedInputTokens: 4,
|
|
outputTokens: 8,
|
|
costCents: 400,
|
|
occurredAt: new Date(),
|
|
},
|
|
]);
|
|
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
|
const summary = await services.issues.getOrchestrationSummary({
|
|
companyId,
|
|
issueId: rootIssueId,
|
|
includeSubtree: true,
|
|
});
|
|
|
|
expect(new Set(summary.subtreeIssueIds)).toEqual(new Set([rootIssueId, childIssueId]));
|
|
expect(summary.costs).toMatchObject({
|
|
billingCode: "mission:alpha",
|
|
costCents: 300,
|
|
inputTokens: 30,
|
|
cachedInputTokens: 3,
|
|
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("rejects a human-attributed plugin comment when actorUserId is a viewer-role (read-only) member", async () => {
|
|
// LOOA-648: a viewer is a real active member but is read-only in the web
|
|
// app (routes/authz.ts "Viewer access is read-only"). Attributing a comment
|
|
// to them is a write the paired user could not make interactively.
|
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
|
const viewerUserId = randomUUID();
|
|
await db.insert(companyMemberships).values({
|
|
companyId, principalType: "user", principalId: viewerUserId, status: "active", membershipRole: "viewer",
|
|
});
|
|
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: viewerUserId }),
|
|
).rejects.toThrow("viewer (read-only) access");
|
|
|
|
// No comment persisted, no assignee woken.
|
|
await expect(db.select().from(agentWakeupRequests)).resolves.toHaveLength(0);
|
|
const comments = await services.issues.listComments({ issueId, companyId });
|
|
expect(comments).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(),
|
|
undefined,
|
|
{ heartbeatRuntimeEnv: {} },
|
|
);
|
|
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" });
|
|
});
|
|
|
|
it("skips the assignee wakeup when the issue is cancelled concurrently with the comment being written", 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,
|
|
});
|
|
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
|
|
// Hold a row lock on the issue so createComment's own `UPDATE issues SET
|
|
// updated_at` (inside addComment) blocks until this transaction commits a
|
|
// concurrent cancellation. That deterministically reproduces "another
|
|
// request closes the issue while the comment is being written" — the
|
|
// race Greptile flagged on the pre-insert `issue` snapshot — without
|
|
// relying on timing luck for the outcome, only for scheduling.
|
|
const lockAndCancelPromise = db.transaction(async (tx) => {
|
|
await tx.select().from(issues).where(eq(issues.id, issueId)).for("update");
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
await tx.update(issues).set({ status: "cancelled" }).where(eq(issues.id, issueId));
|
|
});
|
|
|
|
const commentPromise = services.issues.createComment({
|
|
issueId,
|
|
companyId,
|
|
body: "Here's my answer",
|
|
actorUserId: humanUserId,
|
|
});
|
|
|
|
const [comment] = await Promise.all([commentPromise, lockAndCancelPromise]);
|
|
|
|
expect(comment).toMatchObject({
|
|
authorType: "user",
|
|
authorUserId: humanUserId,
|
|
body: "Here's my answer",
|
|
});
|
|
await expect(db.select().from(agentWakeupRequests)).resolves.toHaveLength(0);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LOOA-641 — interactions.respond / approvals.decide impersonation surface.
|
|
// The host must independently re-verify the paired user's active membership
|
|
// at apply time and never trust the plugin-supplied identity.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function seedInteraction(
|
|
companyId: string,
|
|
issueId: string,
|
|
overrides: Partial<typeof issueThreadInteractions.$inferInsert> = {},
|
|
) {
|
|
const interactionId = randomUUID();
|
|
await db.insert(issueThreadInteractions).values({
|
|
id: interactionId,
|
|
companyId,
|
|
issueId,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: { version: 1, prompt: "Proceed?" } as never,
|
|
...overrides,
|
|
});
|
|
return interactionId;
|
|
}
|
|
|
|
it("respondInteraction fails closed when actorUserId is omitted", async () => {
|
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: issueId, companyId, title: "Decision", status: "in_review", priority: "medium", assigneeAgentId: agentId,
|
|
});
|
|
const interactionId = await seedInteraction(companyId, issueId);
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
await expect(
|
|
services.issues.respondInteraction({ issueId, interactionId, companyId, action: "accept" }),
|
|
).rejects.toThrow("actorUserId is required");
|
|
});
|
|
|
|
it("respondInteraction rejects an actorUserId that is not an active company member", async () => {
|
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: issueId, companyId, title: "Decision", status: "in_review", priority: "medium", assigneeAgentId: agentId,
|
|
});
|
|
const interactionId = await seedInteraction(companyId, issueId);
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
await expect(
|
|
services.issues.respondInteraction({
|
|
issueId, interactionId, companyId, action: "accept", actorUserId: randomUUID(),
|
|
}),
|
|
).rejects.toThrow("is not an active human member of this company");
|
|
|
|
// The interaction must remain pending — no resolution was applied.
|
|
const [row] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId));
|
|
expect(row?.status).toBe("pending");
|
|
});
|
|
|
|
it("respondInteraction rejects a viewer-role active member and leaves the interaction pending", async () => {
|
|
// LOOA-648: viewers are read-only board members (routes/authz.ts). A plugin
|
|
// holding issue.interactions.respond must not resolve an interaction on
|
|
// their behalf — that is a write the viewer is 403'd on in the web app.
|
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
|
const viewerUserId = randomUUID();
|
|
await db.insert(companyMemberships).values({
|
|
companyId, principalType: "user", principalId: viewerUserId, status: "active", membershipRole: "viewer",
|
|
});
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: issueId, companyId, title: "Decision", status: "in_review", priority: "medium", assigneeAgentId: agentId,
|
|
});
|
|
const interactionId = await seedInteraction(companyId, issueId);
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
await expect(
|
|
services.issues.respondInteraction({
|
|
issueId, interactionId, companyId, action: "accept", actorUserId: viewerUserId,
|
|
}),
|
|
).rejects.toThrow("viewer (read-only) access");
|
|
|
|
const [row] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId));
|
|
expect(row?.status).toBe("pending");
|
|
});
|
|
|
|
it("respondInteraction applies for a non-viewer human role (operator)", async () => {
|
|
// LOOA-648 regression: owner/admin/operator must still pass the write bar.
|
|
// Issue has no assignee so the continuation wakeup is a no-op — this
|
|
// isolates the membership check from run orchestration.
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const operatorUserId = randomUUID();
|
|
await db.insert(companyMemberships).values({
|
|
companyId, principalType: "user", principalId: operatorUserId, status: "active", membershipRole: "operator",
|
|
});
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: issueId, companyId, title: "Decision", status: "in_review", priority: "medium",
|
|
});
|
|
const interactionId = await seedInteraction(companyId, issueId);
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
const result = await services.issues.respondInteraction({
|
|
issueId, interactionId, companyId, action: "accept", actorUserId: operatorUserId,
|
|
});
|
|
expect(result.applied).toBe(true);
|
|
const [row] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId));
|
|
expect(row?.status).toBe("accepted");
|
|
});
|
|
|
|
it.each(["accept", "reject"] as const)(
|
|
"respondInteraction rejects %s after the issue closes",
|
|
async (action) => {
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const operatorUserId = randomUUID();
|
|
await db.insert(companyMemberships).values({
|
|
companyId,
|
|
principalType: "user",
|
|
principalId: operatorUserId,
|
|
status: "active",
|
|
membershipRole: "operator",
|
|
});
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Closed decision",
|
|
status: "done",
|
|
priority: "medium",
|
|
});
|
|
const interactionId = await seedInteraction(companyId, issueId);
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
|
|
await expect(services.issues.respondInteraction({
|
|
issueId,
|
|
interactionId,
|
|
companyId,
|
|
action,
|
|
actorUserId: operatorUserId,
|
|
})).rejects.toThrow("Interaction is no longer actionable because the issue is closed");
|
|
|
|
const [row] = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId));
|
|
expect(row?.status).toBe("pending");
|
|
},
|
|
);
|
|
|
|
it("respondInteraction converges (applied:false) when the interaction is already resolved", async () => {
|
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
|
const humanUserId = randomUUID();
|
|
await db.insert(companyMemberships).values({
|
|
companyId, principalType: "user", principalId: humanUserId, status: "active", membershipRole: "owner",
|
|
});
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: issueId, companyId, title: "Decision", status: "in_review", priority: "medium", assigneeAgentId: agentId,
|
|
});
|
|
const interactionId = await seedInteraction(companyId, issueId, { status: "rejected" });
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
const result = await services.issues.respondInteraction({
|
|
issueId, interactionId, companyId, action: "accept", actorUserId: humanUserId,
|
|
});
|
|
expect(result.applied).toBe(false);
|
|
expect(result.interaction).toMatchObject({ id: interactionId, status: "rejected" });
|
|
});
|
|
|
|
it("approvals.decide fails closed when actorUserId is omitted", async () => {
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const approvalId = randomUUID();
|
|
await db.insert(approvals).values({
|
|
id: approvalId, companyId, type: "request_board_approval", status: "pending", payload: { title: "Ship it" },
|
|
});
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
await expect(
|
|
services.approvals.decide({ approvalId, companyId, action: "approve" }),
|
|
).rejects.toThrow("actorUserId is required");
|
|
const [row] = await db.select().from(approvals).where(eq(approvals.id, approvalId));
|
|
expect(row?.status).toBe("pending");
|
|
});
|
|
|
|
it("approvals.decide rejects an actorUserId that is not an active company member", async () => {
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const approvalId = randomUUID();
|
|
await db.insert(approvals).values({
|
|
id: approvalId, companyId, type: "request_board_approval", status: "pending", payload: { title: "Ship it" },
|
|
});
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
await expect(
|
|
services.approvals.decide({ approvalId, companyId, action: "approve", actorUserId: randomUUID() }),
|
|
).rejects.toThrow("is not an active human member of this company");
|
|
const [row] = await db.select().from(approvals).where(eq(approvals.id, approvalId));
|
|
expect(row?.status).toBe("pending");
|
|
});
|
|
|
|
it("approvals.decide rejects a viewer-role active member and leaves the approval pending", async () => {
|
|
// LOOA-648: the exploit at the heart of the review — a plugin holding
|
|
// approvals.respond decides an approval for a viewer who is 403'd in the
|
|
// web UI. The host must reject the viewer before applying the decision.
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const viewerUserId = randomUUID();
|
|
await db.insert(companyMemberships).values({
|
|
companyId, principalType: "user", principalId: viewerUserId, status: "active", membershipRole: "viewer",
|
|
});
|
|
const approvalId = randomUUID();
|
|
await db.insert(approvals).values({
|
|
id: approvalId, companyId, type: "request_board_approval", status: "pending", payload: { title: "Ship it" },
|
|
});
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
await expect(
|
|
services.approvals.decide({ approvalId, companyId, action: "approve", actorUserId: viewerUserId }),
|
|
).rejects.toThrow("viewer (read-only) access");
|
|
const [row] = await db.select().from(approvals).where(eq(approvals.id, approvalId));
|
|
expect(row?.status).toBe("pending");
|
|
});
|
|
|
|
it("approvals.decide applies for a non-viewer human role (admin)", async () => {
|
|
// LOOA-648 regression: owner/admin/operator must still pass the write bar.
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const adminUserId = randomUUID();
|
|
await db.insert(companyMemberships).values({
|
|
companyId, principalType: "user", principalId: adminUserId, status: "active", membershipRole: "admin",
|
|
});
|
|
const approvalId = randomUUID();
|
|
await db.insert(approvals).values({
|
|
id: approvalId, companyId, type: "request_board_approval", status: "pending", payload: { title: "Ship it" },
|
|
});
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
const result = await services.approvals.decide({
|
|
approvalId, companyId, action: "approve", actorUserId: adminUserId,
|
|
});
|
|
expect(result.applied).toBe(true);
|
|
expect(result.approval).toMatchObject({ id: approvalId, status: "approved", decidedByUserId: adminUserId });
|
|
const [row] = await db.select().from(approvals).where(eq(approvals.id, approvalId));
|
|
expect(row?.status).toBe("approved");
|
|
});
|
|
|
|
it("approvals.decide applies for an active member and redacts secret payload fields", async () => {
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const humanUserId = randomUUID();
|
|
await db.insert(companyMemberships).values({
|
|
companyId, principalType: "user", principalId: humanUserId, status: "active", membershipRole: "owner",
|
|
});
|
|
const approvalId = randomUUID();
|
|
await db.insert(approvals).values({
|
|
id: approvalId,
|
|
companyId,
|
|
type: "request_board_approval",
|
|
status: "pending",
|
|
payload: { title: "Ship it", botToken: "xoxb-super-secret" },
|
|
});
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
const result = await services.approvals.decide({
|
|
approvalId, companyId, action: "approve", actorUserId: humanUserId, decisionNote: "lgtm",
|
|
});
|
|
expect(result.applied).toBe(true);
|
|
expect(result.approval).toMatchObject({ id: approvalId, status: "approved", decidedByUserId: humanUserId });
|
|
// Bridge output must be redacted the same way the web app redacts approvals.
|
|
expect(result.approval.payload.title).toBe("Ship it");
|
|
expect(result.approval.payload.botToken).toBe("***REDACTED***");
|
|
|
|
// Persisted row is decided; the raw stored secret is untouched (redaction is
|
|
// an output transform, not a mutation).
|
|
const [row] = await db.select().from(approvals).where(eq(approvals.id, approvalId));
|
|
expect(row?.status).toBe("approved");
|
|
});
|
|
|
|
it("approvals.list redacts payloads and is company-scoped", async () => {
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const otherCompany = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: otherCompany, name: "Other", issuePrefix: issuePrefix(otherCompany), requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(approvals).values({
|
|
id: randomUUID(), companyId, type: "request_board_approval", status: "pending",
|
|
payload: { title: "Mine", accessToken: "sekret" },
|
|
});
|
|
await db.insert(approvals).values({
|
|
id: randomUUID(), companyId: otherCompany, type: "request_board_approval", status: "pending",
|
|
payload: { title: "Theirs" },
|
|
});
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
const rows = await services.approvals.list({ companyId });
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]!.payload.title).toBe("Mine");
|
|
expect(rows[0]!.payload.accessToken).toBe("***REDACTED***");
|
|
});
|
|
|
|
it("getAttachmentContent returns null for a cross-company attachment id", async () => {
|
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
|
const otherCompany = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: otherCompany, name: "Other", issuePrefix: issuePrefix(otherCompany), requireBoardApprovalForNewAgents: false,
|
|
});
|
|
const otherIssueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: otherIssueId, companyId: otherCompany, title: "Theirs", status: "todo", priority: "medium",
|
|
});
|
|
const assetId = randomUUID();
|
|
await db.insert(assets).values({
|
|
id: assetId, companyId: otherCompany, provider: "local_disk", objectKey: "k", contentType: "image/png",
|
|
byteSize: 10, sha256: "abc",
|
|
});
|
|
const attachmentId = randomUUID();
|
|
await db.insert(issueAttachments).values({
|
|
id: attachmentId, companyId: otherCompany, issueId: otherIssueId, assetId,
|
|
});
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
// Requested under our own company: the cross-company id must be invisible.
|
|
void agentId;
|
|
const content = await services.issues.getAttachmentContent({ attachmentId, companyId });
|
|
expect(content).toBeNull();
|
|
});
|
|
|
|
it("getAttachmentContent refuses an over-cap attachment", async () => {
|
|
const { companyId } = await seedCompanyAndAgent();
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({
|
|
id: issueId, companyId, title: "Has asset", status: "todo", priority: "medium",
|
|
});
|
|
const assetId = randomUUID();
|
|
await db.insert(assets).values({
|
|
id: assetId, companyId, provider: "local_disk", objectKey: "k", contentType: "image/png",
|
|
byteSize: 5_000_000, sha256: "abc",
|
|
});
|
|
const attachmentId = randomUUID();
|
|
await db.insert(issueAttachments).values({
|
|
id: attachmentId, companyId, issueId, assetId,
|
|
});
|
|
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
|
await expect(
|
|
services.issues.getAttachmentContent({ attachmentId, companyId, maxBytes: 1_000_000 }),
|
|
).rejects.toThrow("over the");
|
|
});
|
|
});
|