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, beforeEach, describe, expect, it } from "vitest"; import { activityLog, agentWakeupRequests, agents, approvals, assets, companies, companyMemberships, costEvents, createDb, documents, executionWorkspaces, heartbeatRunEvents, heartbeatRuns, issueAttachments, issueComments, issueDocuments, 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; let tempDb: Awaited> | 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(issues).values({ id: blockerIssueId, companyId, title: "Blocker", status: "todo", priority: "medium", identifier: `${issuePrefix(companyId)}-blocker`, }); await db.insert(heartbeatRuns).values({ id: originRunId, companyId, agentId, status: "running", invocationSource: "assignment", contextSnapshot: { issueId: blockerIssueId }, }); 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 = {}, ) { 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"); }); // PAP-16091 / PAP-16050: a plugin is a non-member principal. Every issue-read // path the host exposes (which a Slack notifier/digest plugin would fan out // to shared channels) must apply the canonical visibility predicate so private // issue content and relationship metadata never reach non-members. describe("non-member plugin issue-read privacy (PAP-16091)", () => { let previousMode: string | undefined; beforeEach(() => { previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; }); afterEach(() => { if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode; }); async function seedOpenAndPrivate() { const { companyId, agentId } = await seedCompanyAndAgent(); const openIssueId = randomUUID(); const privateIssueId = randomUUID(); await db.insert(issues).values([ { id: openIssueId, companyId, title: "Public status", status: "todo", priority: "medium", visibility: "open" }, { id: privateIssueId, companyId, title: "Confidential HR matter", status: "todo", priority: "medium", visibility: "private" }, ]); return { companyId, agentId, openIssueId, privateIssueId }; } it("omits private issues from issues.list", async () => { const { companyId, openIssueId, privateIssueId } = await seedOpenAndPrivate(); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); const listed = await services.issues.list({ companyId }); const ids = listed.map((issue) => issue.id); expect(ids).toContain(openIssueId); expect(ids).not.toContain(privateIssueId); }); it("returns null from issues.get for a private issue", async () => { const { companyId, openIssueId, privateIssueId } = await seedOpenAndPrivate(); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); await expect(services.issues.get({ issueId: privateIssueId, companyId })).resolves.toBeNull(); // Sanity: the same path still returns readable (open) issues. await expect(services.issues.get({ issueId: openIssueId, companyId })).resolves.toMatchObject({ id: openIssueId }); }); it("returns no comments from issues.listComments for a private issue", async () => { const { companyId, privateIssueId } = await seedOpenAndPrivate(); await db.insert(issueComments).values({ id: randomUUID(), companyId, issueId: privateIssueId, body: "secret decision rationale", }); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); await expect(services.issues.listComments({ issueId: privateIssueId, companyId })).resolves.toEqual([]); }); it("returns no attachments from issues.listAttachments for a private issue", async () => { const { companyId, privateIssueId } = await seedOpenAndPrivate(); const assetId = randomUUID(); await db.insert(assets).values({ id: assetId, companyId, provider: "local_disk", objectKey: "secret", contentType: "image/png", byteSize: 10, sha256: "z", }); await db.insert(issueAttachments).values({ id: randomUUID(), companyId, issueId: privateIssueId, assetId }); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); await expect(services.issues.listAttachments({ issueId: privateIssueId, companyId })).resolves.toEqual([]); }); it("returns null from issues.getAttachmentContent for a private issue's attachment", async () => { const { companyId, privateIssueId } = await seedOpenAndPrivate(); const assetId = randomUUID(); await db.insert(assets).values({ id: assetId, companyId, provider: "local_disk", objectKey: "secret2", contentType: "text/plain", byteSize: 4, sha256: "y", }); const attachmentId = randomUUID(); await db.insert(issueAttachments).values({ id: attachmentId, companyId, issueId: privateIssueId, assetId }); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); await expect( services.issues.getAttachmentContent({ attachmentId, companyId, maxBytes: 1_000_000 }), ).resolves.toBeNull(); }); it("excludes a private descendant and redacts private blocker edges in getOrchestrationSummary", async () => { const { companyId } = await seedCompanyAndAgent(); const rootId = randomUUID(); const publicChildId = randomUUID(); const privateChildId = randomUUID(); const privateBlockerId = randomUUID(); await db.insert(issues).values([ { id: rootId, companyId, title: "Root", status: "todo", priority: "medium", visibility: "open" }, { id: publicChildId, companyId, parentId: rootId, title: "Public child", status: "todo", priority: "medium", visibility: "open" }, { id: privateChildId, companyId, parentId: rootId, title: "Private child", status: "todo", priority: "medium", visibility: "private" }, { id: privateBlockerId, companyId, title: "Private blocker", status: "todo", priority: "medium", visibility: "private" }, ]); // privateBlocker blocks the public root -> root.blockedBy includes it pre-redaction. await db.insert(issueRelations).values({ companyId, issueId: privateBlockerId, relatedIssueId: rootId, type: "blocks", }); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); const summary = await services.issues.getOrchestrationSummary({ companyId, issueId: rootId, includeSubtree: true }); // Private descendant never enters the subtree. expect(new Set(summary.subtreeIssueIds)).toEqual(new Set([rootId, publicChildId])); // No relation edge anywhere references the private blocker or private child. const edgeIds = new Set(); for (const relation of Object.values(summary.relations)) { for (const edge of [...relation.blockedBy, ...relation.blocks]) edgeIds.add(edge.id); } expect(edgeIds.has(privateBlockerId)).toBe(false); expect(edgeIds.has(privateChildId)).toBe(false); }); it("reports a private root issue as not-found from getOrchestrationSummary", async () => { const { companyId, privateIssueId } = await seedOpenAndPrivate(); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); await expect( services.issues.getOrchestrationSummary({ companyId, issueId: privateIssueId, includeSubtree: true }), ).rejects.toThrow("Issue not found"); }); async function seedIssueDocument(companyId: string, issueId: string, key: string, body: string) { const documentId = randomUUID(); await db.insert(documents).values({ id: documentId, companyId, latestBody: body, format: "markdown" }); await db.insert(issueDocuments).values({ id: randomUUID(), companyId, issueId, documentId, key }); return documentId; } it("throws not-found from issues.getRelations for a private issue", async () => { const { companyId, privateIssueId } = await seedOpenAndPrivate(); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); await expect( services.issues.getRelations({ issueId: privateIssueId, companyId }), ).rejects.toThrow("Issue not found"); }); it("redacts a private blocker edge from issues.getRelations on a public issue", async () => { const { companyId, openIssueId, privateIssueId } = await seedOpenAndPrivate(); // privateIssue blocks the open issue -> open.blockedBy would name it pre-redaction. await db.insert(issueRelations).values({ companyId, issueId: privateIssueId, relatedIssueId: openIssueId, type: "blocks", }); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); const relations = await services.issues.getRelations({ issueId: openIssueId, companyId }); const edgeIds = [...relations.blockedBy, ...relations.blocks].map((edge) => edge.id); expect(edgeIds).not.toContain(privateIssueId); }); it("reports a private root issue as not-found from issues.getSubtree", async () => { const { companyId, privateIssueId } = await seedOpenAndPrivate(); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); await expect( services.issues.getSubtree({ issueId: privateIssueId, companyId }), ).rejects.toThrow("Issue not found"); }); it("excludes a private child and its documents from issues.getSubtree on an open root", async () => { const { companyId } = await seedCompanyAndAgent(); const rootId = randomUUID(); const publicChildId = randomUUID(); const privateChildId = randomUUID(); await db.insert(issues).values([ { id: rootId, companyId, title: "Root", status: "todo", priority: "medium", visibility: "open" }, { id: publicChildId, companyId, parentId: rootId, title: "Public child", status: "todo", priority: "medium", visibility: "open" }, { id: privateChildId, companyId, parentId: rootId, title: "Private child", status: "todo", priority: "medium", visibility: "private" }, ]); // A document on the private child would surface in the subtree summary pre-fix. await seedIssueDocument(companyId, privateChildId, "plan", "confidential subtree plan"); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); const subtree = await services.issues.getSubtree({ issueId: rootId, companyId, includeDocuments: true, includeRelations: true, }); expect(new Set(subtree.issueIds)).toEqual(new Set([rootId, publicChildId])); expect(subtree.issues.map((issue) => issue.id)).not.toContain(privateChildId); expect(Object.keys(subtree.documents ?? {})).not.toContain(privateChildId); }); it("returns no interactions from issues.listInteractions for a private issue", async () => { const { companyId, privateIssueId } = await seedOpenAndPrivate(); await db.insert(issueThreadInteractions).values({ id: randomUUID(), companyId, issueId: privateIssueId, kind: "request_confirmation", payload: { version: 1, prompt: "Confirm confidential action?" } as any, }); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); await expect(services.issues.listInteractions({ issueId: privateIssueId, companyId })).resolves.toEqual([]); }); it("throws not-found from issueDocuments.list for a private issue", async () => { const { companyId, privateIssueId } = await seedOpenAndPrivate(); await seedIssueDocument(companyId, privateIssueId, "plan", "confidential plan body"); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); await expect( services.issueDocuments.list({ issueId: privateIssueId, companyId }), ).rejects.toThrow("Issue not found"); }); it("throws not-found from issueDocuments.get for a private issue", async () => { const { companyId, privateIssueId } = await seedOpenAndPrivate(); await seedIssueDocument(companyId, privateIssueId, "plan", "confidential plan body"); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); await expect( services.issueDocuments.get({ issueId: privateIssueId, companyId, key: "plan" }), ).rejects.toThrow("Issue not found"); }); it("is pass-through when privacy mode is off (enforce flip is the single switch)", async () => { process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "off"; const { companyId, privateIssueId } = await seedOpenAndPrivate(); const services = buildHostServices(db, "plugin-record-id", "paperclip.slack-digest", createEventBusStub()); const ids = (await services.issues.list({ companyId })).map((issue) => issue.id); expect(ids).toContain(privateIssueId); await expect(services.issues.get({ issueId: privateIssueId, companyId })).resolves.toMatchObject({ id: privateIssueId }); }); }); });