import { Readable } from "node:stream"; import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { HttpError } from "../errors.js"; const issueId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; const ownerAgentId = "33333333-3333-4333-8333-333333333333"; const peerAgentId = "44444444-4444-4444-8444-444444444444"; const ownerRunId = "55555555-5555-4555-8555-555555555555"; const recoveryActionId = "77777777-7777-4777-8777-777777777777"; const mockIssueService = vi.hoisted(() => ({ addComment: vi.fn(), assertCheckoutOwner: vi.fn(), create: vi.fn(), createChild: vi.fn(), decomposeAcceptedPlan: vi.fn(), getAttachmentById: vi.fn(), getByIdentifier: vi.fn(), getById: vi.fn(), getByIdForUpdate: vi.fn(), getComment: vi.fn(), getDependencyReadiness: vi.fn(), getRelationSummaries: vi.fn(), getWakeableParentAfterChildCompletion: vi.fn(), list: vi.fn(), listAttachments: vi.fn(), listComments: vi.fn(), listWakeableBlockedDependents: vi.fn(), remove: vi.fn(), removeAttachment: vi.fn(), update: vi.fn(), findMentionedAgents: vi.fn(), })); const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), decide: vi.fn(), hasPermission: vi.fn(), })); const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), list: vi.fn(), resolveByReference: vi.fn(), })); const mockCompanyService = vi.hoisted(() => ({ getById: vi.fn(), })); const mockBudgetService = vi.hoisted(() => ({ getInvocationBlock: vi.fn(async () => null), })); const mockProjectService = vi.hoisted(() => ({ getById: vi.fn(async () => null), })); const mockDocumentService = vi.hoisted(() => ({ upsertIssueDocument: vi.fn(), })); const mockWorkProductService = vi.hoisted(() => ({ createForIssue: vi.fn(), getById: vi.fn(), latestRunDiffSummary: vi.fn(), resolveCommitDiffSummary: vi.fn(), remove: vi.fn(), update: vi.fn(), })); const mockStorageService = vi.hoisted(() => ({ provider: "local_disk", putFile: vi.fn(), getObject: vi.fn(), headObject: vi.fn(), deleteObject: vi.fn(), })); const mockIssueThreadInteractionService = vi.hoisted(() => ({ expirePendingInteractionsForTerminalIssue: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(async () => []), listForIssue: vi.fn(async () => []), })); const mockIssueApprovalService = vi.hoisted(() => ({ link: vi.fn(), unlink: vi.fn(), listApprovalsForIssue: vi.fn(async () => []), })); const mockIssueRecoveryActionService = vi.hoisted(() => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), resolveActiveForIssue: vi.fn(async () => null), })); const mockTaskWatchdogService = vi.hoisted(() => ({ getActiveForIssue: vi.fn(async () => null), revalidateMutationScope: vi.fn(async () => ({ allowed: true, classification: { state: "stopped", stopFingerprint: "task_watchdog_stop:test" }, })), reconcileForIssueAndAncestors: vi.fn(async () => ({ checked: 0, triggered: 0, skipped: 0, watchdogIssueIds: [], })), upsertForIssue: vi.fn(), disableForIssue: vi.fn(async () => null), })); const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn(async () => undefined), reportRunActivity: vi.fn(async () => undefined), getRun: vi.fn(async () => null), getActiveRunForAgent: vi.fn(async () => null), cancelRun: vi.fn(async () => null), })); const mockExternalObjectService = vi.hoisted(() => ({ getIssueSummaries: vi.fn(async () => new Map()), getIssueSummary: vi.fn(async () => ({ authRequiredCount: 0, byLiveness: {}, byStatusCategory: {}, highestSeverity: "muted", objects: [], staleCount: 0, total: 0, unreachableCount: 0, })), getProjectSummary: vi.fn(async () => ({ authRequiredCount: 0, byLiveness: {}, byStatusCategory: {}, highestSeverity: "muted", objects: [], staleCount: 0, total: 0, unreachableCount: 0, })), listForIssue: vi.fn(async () => []), refreshIssueObjects: vi.fn(async () => []), syncCommentSafely: vi.fn(async () => undefined), syncDocumentSafely: vi.fn(async () => undefined), syncIssueSafely: vi.fn(async () => undefined), })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const mockObserveCrossIssueInfluence = vi.hoisted(() => vi.fn(async () => null)); function registerRouteMocks() { vi.doMock("@paperclipai/shared/telemetry", () => ({ trackAgentTaskCompleted: vi.fn(), trackErrorHandlerCrash: vi.fn(), })); vi.doMock("../telemetry.js", () => ({ getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), })); vi.doMock("../services/access.js", () => ({ accessService: () => mockAccessService, })); vi.doMock("../services/agents.js", () => ({ agentService: () => mockAgentService, })); vi.doMock("../services/documents.js", () => ({ documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => mockDocumentService, })); vi.doMock("../services/issues.js", () => ({ issueService: () => mockIssueService, })); vi.doMock("../services/work-products.js", () => ({ workProductService: () => mockWorkProductService, })); vi.doMock("../services/external-objects.js", () => ({ externalObjectService: () => mockExternalObjectService, })); vi.doMock("../services/activity-log.js", () => ({ logActivity: mockLogActivity, })); vi.doMock("../services/cross-issue-influence-limit.js", () => ({ observeCrossIssueInfluence: mockObserveCrossIssueInfluence, crossIssueInfluenceLimitError: vi.fn(), crossIssueInfluenceRunContextError: () => new HttpError( 403, "Agent issue comments and updates require a valid heartbeat run so cross-issue influence can be contained", { code: "cross_issue_influence_run_context_required" }, ), })); vi.doMock("../services/index.js", () => ({ ISSUE_LIST_DEFAULT_LIMIT: 100, ISSUE_LIST_MAX_LIMIT: 500, accessService: () => mockAccessService, agentService: () => mockAgentService, budgetService: () => mockBudgetService, clampIssueListLimit: (value: number) => Math.min(Math.max(value, 1), 500), companySkillService: () => ({ completeTestRunForIssue: vi.fn(async () => null), }), companyService: () => mockCompanyService, documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => mockDocumentService, enrichWorkProductMetadataWithDiff: ( metadata: Record | null | undefined, summary: { additions: number | null; deletions: number | null; changedFiles: number } | null, ) => summary ? { ...(metadata ?? {}), ...(summary.additions === null ? {} : { additions: summary.additions }), ...(summary.deletions === null ? {} : { deletions: summary.deletions }), changedFiles: summary.changedFiles, } : metadata ?? null, executionWorkspaceService: () => ({}), feedbackService: () => ({ listIssueVotesForUser: vi.fn(async () => []), saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), }), goalService: () => ({}), heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => ({ get: vi.fn(async () => ({ id: "instance-settings-1", general: { censorUsernameInLogs: false, feedbackDataSharingPreference: "prompt", }, })), listCompanyIds: vi.fn(async () => [companyId]), }), issueApprovalService: () => mockIssueApprovalService, issueRecoveryActionService: () => mockIssueRecoveryActionService, issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ addedReferencedIssues: [], removedReferencedIssues: [], currentReferencedIssues: [], }), emptySummary: () => ({ outbound: [], inbound: [] }), listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), syncComment: async () => undefined, syncDocument: async () => undefined, syncIssue: async () => undefined, }), issueService: () => mockIssueService, issueThreadInteractionService: () => mockIssueThreadInteractionService, taskWatchdogService: () => mockTaskWatchdogService, logActivity: mockLogActivity, projectService: () => mockProjectService, routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined), }), workProductService: () => mockWorkProductService, })); } function makeIssue(overrides: Record = {}) { return { id: issueId, companyId, status: "in_progress", priority: "high", projectId: null, goalId: null, parentId: null, assigneeAgentId: ownerAgentId, assigneeUserId: null, createdByUserId: "board-user", identifier: "PAP-1649", title: "Owned active issue", executionPolicy: null, executionState: null, checkoutRunId: null, executionRunId: null, hiddenAt: null, ...overrides, }; } function makeAgent(id: string, overrides: Record = {}) { return { id, companyId, role: "engineer", reportsTo: null, permissions: { canCreateAgents: false }, ...overrides, }; } function createRunContextDb( contextSnapshot: Record = {}, runAgentOrRows: string | Record[] = ownerAgentId, runId: string = ownerRunId, ) { const runRows = Array.isArray(runAgentOrRows) ? runAgentOrRows : [{ id: runId, companyId, agentId: runAgentOrRows, agentCompanyId: companyId, contextSnapshot, }]; const firstRun = runRows[0] ?? {}; const runAgentId = typeof firstRun.agentId === "string" ? firstRun.agentId : ownerAgentId; const runAgentCompanyId = typeof firstRun.agentCompanyId === "string" ? firstRun.agentCompanyId : companyId; const rowsForSelection = async (selection: Record) => { const keys = Object.keys(selection); if (keys.includes("entityId")) return []; if (keys.includes("contextSnapshot")) return runRows; if (keys.includes("agentCompanyId")) return runRows; if (keys.length === 0) { const issue = await mockIssueService.getById(issueId); return issue ? [issue] : []; } return [{ id: runAgentId, companyId: runAgentCompanyId, permissions: {}, role: "engineer", reportsTo: null }]; }; const buildQuery = (selection: Record) => { const whereResult = { orderBy: vi.fn(async () => []), limit: vi.fn(() => ({ then: async (resolve: (limitedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection)), })), for: vi.fn(() => ({ then: async (resolve: (selectedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection)), })), then: async (resolve: (selectedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection)), }; const query = { innerJoin: vi.fn(() => query), where: vi.fn(() => whereResult), }; return query; }; const dbStub = { transaction: async (callback: (tx: typeof dbStub) => Promise) => callback(dbStub), select: vi.fn((selection: Record = {}) => ({ from: vi.fn(() => buildQuery(selection)), })), insert: vi.fn(() => ({ values: vi.fn(async () => undefined) })), }; return dbStub; } async function createApp(actor: Record, db?: unknown) { const routeDb = db ?? createRunContextDb( {}, typeof actor.agentId === "string" ? actor.agentId : ownerAgentId, typeof actor.runId === "string" ? actor.runId : ownerRunId, ); const [{ errorHandler }, { issueRoutes }] = await Promise.all([ vi.importActual("../middleware/index.js"), vi.importActual("../routes/issues.js"), ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as any).actor = actor; next(); }); app.use("/api", issueRoutes(routeDb as any, mockStorageService as any)); app.use(errorHandler); return app; } function peerActor(overrides: Record = {}) { return { type: "agent", agentId: peerAgentId, companyId, source: "agent_key", runId: "66666666-6666-4666-8666-666666666666", ...overrides, }; } function ownerActor() { return { type: "agent", agentId: ownerAgentId, companyId, source: "agent_key", runId: ownerRunId, }; } function boardActor() { return { type: "board", userId: "board-user", companyIds: [companyId], source: "local_implicit", isInstanceAdmin: false, }; } describe("agent issue mutation checkout ownership", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("@paperclipai/shared/telemetry"); vi.doUnmock("../telemetry.js"); vi.doUnmock("../services/access.js"); vi.doUnmock("../services/activity-log.js"); vi.doUnmock("../services/cross-issue-influence-limit.js"); vi.doUnmock("../services/agents.js"); vi.doUnmock("../services/documents.js"); vi.doUnmock("../services/external-objects.js"); vi.doUnmock("../services/index.js"); vi.doUnmock("../services/issues.js"); vi.doUnmock("../services/work-products.js"); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerRouteMocks(); vi.clearAllMocks(); mockAccessService.canUser.mockReset(); mockAccessService.decide.mockReset(); mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "tasks:assign" || input.action === "issue:comment" || input.action === "issue:read" || input.action === "issue:mutate" || input.action === "company_scope:read", action: input.action, reason: input.action === "tasks:assign" || input.action === "issue:comment" || input.action === "issue:read" || input.action === "issue:mutate" || input.action === "company_scope:read" ? "allow_explicit_grant" : "deny_missing_grant", explanation: input.action === "tasks:assign" || input.action === "issue:comment" || input.action === "issue:read" || input.action === "issue:mutate" || input.action === "company_scope:read" ? "Allowed by test default." : "Missing permission.", })); mockAccessService.hasPermission.mockReset(); mockAgentService.getById.mockReset(); mockAgentService.list.mockReset(); mockAgentService.resolveByReference.mockReset(); mockCompanyService.getById.mockReset(); mockBudgetService.getInvocationBlock.mockReset(); mockBudgetService.getInvocationBlock.mockResolvedValue(null); mockProjectService.getById.mockReset(); mockProjectService.getById.mockResolvedValue(null); mockIssueService.addComment.mockReset(); mockIssueService.assertCheckoutOwner.mockReset(); mockIssueService.create.mockReset(); mockIssueService.createChild.mockReset(); mockIssueService.decomposeAcceptedPlan.mockReset(); mockIssueService.getAttachmentById.mockReset(); mockIssueService.getByIdentifier.mockReset(); mockIssueService.getById.mockReset(); mockIssueService.getByIdForUpdate.mockReset(); mockIssueService.getComment.mockReset(); mockIssueService.getDependencyReadiness.mockReset(); mockIssueService.getDependencyReadiness.mockResolvedValue({ blockerIssueIds: [], isDependencyReady: false, unresolvedBlockerCount: 0, }); mockIssueService.getRelationSummaries.mockReset(); mockIssueService.getWakeableParentAfterChildCompletion.mockReset(); mockIssueService.list.mockReset(); mockIssueService.listAttachments.mockReset(); mockIssueService.listComments.mockReset(); mockIssueService.listWakeableBlockedDependents.mockReset(); mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByComment.mockReset(); mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByComment.mockResolvedValue([]); mockIssueThreadInteractionService.expireStaleRequestConfirmationsForIssueDocument.mockReset(); mockIssueThreadInteractionService.expireStaleRequestConfirmationsForIssueDocument.mockResolvedValue([]); mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockReset(); mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValue([]); mockIssueThreadInteractionService.listForIssue.mockReset(); mockIssueThreadInteractionService.listForIssue.mockResolvedValue([]); mockIssueRecoveryActionService.getActiveForIssue.mockReset(); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue(null); mockIssueRecoveryActionService.listActiveForIssues.mockReset(); mockIssueRecoveryActionService.listActiveForIssues.mockResolvedValue(new Map()); mockIssueRecoveryActionService.resolveActiveForIssue.mockReset(); mockIssueRecoveryActionService.resolveActiveForIssue.mockResolvedValue({ id: recoveryActionId, companyId, sourceIssueId: issueId, recoveryIssueId: null, kind: "issue_graph_liveness", status: "resolved", ownerType: "agent", ownerAgentId, ownerUserId: null, previousOwnerAgentId: null, returnOwnerAgentId: null, cause: "issue_graph_liveness", fingerprint: "graph-liveness:test", evidence: {}, nextAction: "Restore a live execution path.", wakePolicy: null, monitorPolicy: null, attemptCount: 1, maxAttempts: null, timeoutAt: null, lastAttemptAt: new Date("2026-05-13T18:00:00.000Z"), outcome: "restored", resolutionNote: "Resolved by recovery owner", resolvedAt: new Date("2026-05-13T18:05:00.000Z"), createdAt: new Date("2026-05-13T17:55:00.000Z"), updatedAt: new Date("2026-05-13T18:05:00.000Z"), }); mockTaskWatchdogService.getActiveForIssue.mockReset(); mockTaskWatchdogService.getActiveForIssue.mockResolvedValue(null); mockTaskWatchdogService.revalidateMutationScope.mockReset(); mockTaskWatchdogService.revalidateMutationScope.mockResolvedValue({ allowed: true, classification: { state: "stopped", stopFingerprint: "task_watchdog_stop:test" }, }); mockTaskWatchdogService.reconcileForIssueAndAncestors.mockReset(); mockTaskWatchdogService.reconcileForIssueAndAncestors.mockResolvedValue({ checked: 0, triggered: 0, skipped: 0, watchdogIssueIds: [], }); mockTaskWatchdogService.upsertForIssue.mockReset(); mockTaskWatchdogService.disableForIssue.mockReset(); mockTaskWatchdogService.disableForIssue.mockResolvedValue(null); mockHeartbeatService.wakeup.mockReset(); mockHeartbeatService.wakeup.mockResolvedValue(undefined); mockHeartbeatService.reportRunActivity.mockReset(); mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined); mockHeartbeatService.getRun.mockReset(); mockHeartbeatService.getRun.mockResolvedValue(null); mockHeartbeatService.getActiveRunForAgent.mockReset(); mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); mockHeartbeatService.cancelRun.mockReset(); mockHeartbeatService.cancelRun.mockResolvedValue(null); mockIssueApprovalService.link.mockReset(); mockIssueApprovalService.unlink.mockReset(); mockIssueApprovalService.listApprovalsForIssue.mockReset(); mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([]); mockIssueThreadInteractionService.listForIssue.mockReset(); mockIssueThreadInteractionService.listForIssue.mockResolvedValue([]); mockIssueService.remove.mockReset(); mockIssueService.removeAttachment.mockReset(); mockIssueService.update.mockReset(); mockIssueService.findMentionedAgents.mockReset(); mockLogActivity.mockClear(); mockObserveCrossIssueInfluence.mockReset(); mockObserveCrossIssueInfluence.mockResolvedValue(null); mockDocumentService.upsertIssueDocument.mockReset(); mockWorkProductService.createForIssue.mockReset(); mockWorkProductService.latestRunDiffSummary.mockReset(); mockWorkProductService.latestRunDiffSummary.mockResolvedValue(null); mockWorkProductService.resolveCommitDiffSummary.mockReset(); mockWorkProductService.resolveCommitDiffSummary.mockResolvedValue(null); mockExternalObjectService.getIssueSummaries.mockClear(); mockExternalObjectService.getIssueSummary.mockClear(); mockExternalObjectService.getProjectSummary.mockClear(); mockExternalObjectService.listForIssue.mockClear(); mockExternalObjectService.refreshIssueObjects.mockClear(); mockExternalObjectService.syncCommentSafely.mockClear(); mockExternalObjectService.syncDocumentSafely.mockClear(); mockExternalObjectService.syncIssueSafely.mockClear(); mockWorkProductService.getById.mockReset(); mockWorkProductService.remove.mockReset(); mockWorkProductService.update.mockReset(); mockStorageService.putFile.mockReset(); mockStorageService.getObject.mockReset(); mockStorageService.headObject.mockReset(); mockStorageService.deleteObject.mockReset(); mockAccessService.canUser.mockResolvedValue(true); mockAccessService.hasPermission.mockResolvedValue(false); mockAgentService.getById.mockImplementation(async (id: string) => { if (id === ownerAgentId) return makeAgent(ownerAgentId); if (id === peerAgentId) return makeAgent(peerAgentId); return null; }); mockAgentService.list.mockResolvedValue([ makeAgent(ownerAgentId), makeAgent(peerAgentId), ]); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: null }); mockCompanyService.getById.mockResolvedValue({ id: companyId, issuePrefix: "PAP" }); mockIssueService.getById.mockResolvedValue(makeIssue()); mockIssueService.getByIdForUpdate.mockImplementation(async () => mockIssueService.getById()); mockIssueService.getByIdentifier.mockResolvedValue(null); mockIssueService.getComment.mockResolvedValue({ id: "comment-1", issueId, companyId, body: "Mentioned reply context.", }); mockIssueService.list.mockResolvedValue([makeIssue()]); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockIssueService.create.mockImplementation(async (_companyId: string, input: Record) => ({ ...makeIssue({ id: "88888888-8888-4888-8888-888888888888", status: "todo", assigneeAgentId: null, }), ...input, companyId, })); mockIssueService.createChild.mockImplementation(async (_parentId: string, input: Record) => ({ issue: { ...makeIssue({ id: "99999999-9999-4999-8999-999999999999", status: "todo", parentId: issueId, assigneeAgentId: null, }), ...input, companyId, }, parentBlockerAdded: false, })); mockIssueService.decomposeAcceptedPlan.mockImplementation(async (_sourceIssueId: string, input: Record) => { const children = input.children as Record[]; return { decomposition: { id: "decomposition-1", status: "completed", childIssueIds: children.map((child) => child.id), }, childIssueIds: children.map((child) => child.id), newlyCreatedIssues: children.map((child) => ({ ...makeIssue({ id: child.id, parentId: issueId, status: child.status, assigneeAgentId: child.assigneeAgentId ?? null, }), ...child, companyId, })), }; }); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue(), ...patch, })); mockIssueService.addComment.mockResolvedValue({ id: "77777777-7777-4777-8777-777777777777", issueId, companyId, body: "comment", }); mockIssueService.listAttachments.mockResolvedValue([]); mockIssueService.listComments.mockResolvedValue([ { id: "comment-1", issueId, companyId, body: "Mentioned reply context.", }, ]); mockIssueService.remove.mockResolvedValue(makeIssue({ status: "cancelled" })); mockIssueService.getAttachmentById.mockResolvedValue({ id: "attachment-1", issueId, companyId, objectKey: "issues/attachment-1/report.txt", contentType: "text/plain", byteSize: 6, originalFilename: "report.txt", }); mockIssueService.removeAttachment.mockResolvedValue({ id: "attachment-1", issueId, companyId, objectKey: "issues/attachment-1/report.txt", }); mockDocumentService.upsertIssueDocument.mockResolvedValue({ created: false, document: { id: "document-1", key: "plan", title: "Plan", format: "markdown", latestRevisionNumber: 2, }, }); mockWorkProductService.createForIssue.mockResolvedValue({ id: "product-2", issueId, companyId, type: "artifact", provider: "test", title: "Artifact", }); mockWorkProductService.getById.mockResolvedValue({ id: "product-1", issueId, companyId, type: "artifact", }); mockWorkProductService.update.mockResolvedValue({ id: "product-1", issueId, companyId, type: "artifact", title: "Updated", }); mockWorkProductService.remove.mockResolvedValue({ id: "product-1", issueId, companyId, type: "artifact", }); mockStorageService.putFile.mockResolvedValue({ provider: "local_disk", objectKey: "issues/upload.txt", contentType: "text/plain", byteSize: 6, sha256: "sha256", originalFilename: "upload.txt", }); mockStorageService.getObject.mockResolvedValue({ stream: Readable.from(Buffer.from("report")), contentLength: 6, }); mockStorageService.deleteObject.mockResolvedValue(undefined); }); it("denies company-wide issue list routes for task bridge keys", async () => { const app = await createApp(peerActor({ keyId: "99999999-9999-4999-8999-999999999999", keyScope: { kind: "task_bridge", parentIssueId: issueId, }, })); const res = await request(app).get(`/api/companies/${companyId}/issues`); expect(res.status).toBe(403); expect(res.body.error).toContain("Task bridge keys cannot use company-wide issue list APIs"); expect(mockIssueService.list).not.toHaveBeenCalled(); }); it("uses the company-scope fast path on the issue list route", async () => { mockAccessService.decide.mockImplementation(async (input: { action: string }) => { if (input.action === "company_scope:read") { return { allowed: true, action: input.action, reason: "allow_explicit_grant", explanation: "Allowed by test company scope.", }; } if (input.action === "issue:read") { throw new Error("issue:read should not be evaluated for company-scope readers"); } return { allowed: true, action: input.action, reason: "allow_test_default", explanation: "Allowed by test default.", }; }); const app = await createApp(boardActor()); const res = await request(app).get(`/api/companies/${companyId}/issues`); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(res.body).toEqual([expect.objectContaining({ id: issueId })]); expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "company_scope:read", resource: { type: "company", companyId }, })); expect(mockAccessService.decide).not.toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read", })); }); it.each([ ["patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Blocked" })], ["delete", (app: express.Express) => request(app).delete(`/api/issues/${issueId}`)], [ "document upsert", (app: express.Express) => request(app).put(`/api/issues/${issueId}/documents/plan`).send({ format: "markdown", body: "# blocked" }), ], ["work product update", (app: express.Express) => request(app).patch("/api/work-products/product-1").send({ title: "Blocked" })], [ "low-trust promotion", (app: express.Express) => request(app).post(`/api/issues/${issueId}/low-trust/promotions`).send({ sourceArtifactKind: "comment", sourceArtifactId: recoveryActionId, title: "Promoted artifact", summary: "Sanitized output", }), ], [ "attachment upload", (app: express.Express) => request(app) .post(`/api/companies/${companyId}/issues/${issueId}/attachments`) .attach("file", Buffer.from("report"), { filename: "report.txt", contentType: "text/plain" }), ], ["attachment delete", (app: express.Express) => request(app).delete("/api/attachments/attachment-1")], ])("rejects peer agent %s on another agent's active checkout", async (_name, sendRequest) => { const res = await sendRequest(await createApp(peerActor())); expect(res.status, JSON.stringify(res.body)).toBe(409); // Plan ยง6: the run lock names the boundary and routes to the open channel. expect(res.body.details.code).toBe("issue_write_assignee_run_lock"); expect(res.body.details.boundary).toBe("Run checkout lock"); expect(res.body.error).toContain("Who can act:"); expect(res.body.error).toContain("Comment instead"); expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); expect(mockIssueService.update).not.toHaveBeenCalled(); expect(mockIssueService.addComment).not.toHaveBeenCalled(); expect(mockDocumentService.upsertIssueDocument).not.toHaveBeenCalled(); expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled(); expect(mockWorkProductService.update).not.toHaveBeenCalled(); expect(mockStorageService.putFile).not.toHaveBeenCalled(); expect(mockStorageService.deleteObject).not.toHaveBeenCalled(); }); it("allows mentioned peer agents to post comments without ownership of an active checkout", async () => { mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "issue:comment", action: input.action, reason: input.action === "issue:comment" ? "allow_issue_mention_grant" : "deny_missing_grant", explanation: input.action === "issue:comment" ? "Allowed by a mention-scoped issue comment grant." : "Missing permission.", })); const res = await request(await createApp(peerActor())) .post(`/api/issues/${issueId}/comments`) .send({ body: "I can respond here." }); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockIssueService.addComment).toHaveBeenCalledWith( issueId, "I can respond here.", expect.any(Object), expect.any(Object), ); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("keeps visible peer comments agent-class even when authorType tries to smuggle user wake privilege", async () => { mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "issue:read" || input.action === "issue:comment", action: input.action, reason: input.action === "issue:comment" ? "allow_visible_issue_write" : "allow_explicit_grant", explanation: "Allowed by the shared visible-issue write rule.", })); const res = await request(await createApp(peerActor())) .post(`/api/issues/${issueId}/comments`) .send({ body: "I was not mentioned.", authorType: "user" }); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockIssueService.addComment).toHaveBeenCalledWith( issueId, "I was not mentioned.", expect.any(Object), expect.any(Object), ); await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( ownerAgentId, expect.objectContaining({ reason: "issue_commented", requestedByActorType: "agent", requestedByActorId: peerAgentId, }), )); }); it("keeps default-open peer comments on closed issues inert", async () => { mockIssueService.getById.mockResolvedValue(makeIssue({ status: "done", assigneeAgentId: ownerAgentId })); mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "issue:comment" || input.action === "issue:read", action: input.action, reason: input.action === "issue:comment" ? "allow_visible_issue_write" : "allow_company_agent", explanation: "Allowed by the shared visible-issue write rule.", })); const res = await request(await createApp(peerActor())) .post(`/api/issues/${issueId}/comments`) .send({ body: "Closed issue context only." }); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockIssueService.addComment).toHaveBeenCalled(); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("rejects peer agents from listing comments when issue read is outside their boundary", async () => { mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: false, action: input.action, reason: "deny_low_trust_boundary", explanation: "Issue is outside this low-trust boundary.", })); const res = await request(await createApp(peerActor())) .get(`/api/issues/${issueId}/comments`); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("Issue is outside this actor's authorization boundary"); expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" })); }); it("rejects peer agents from listing interactions when issue read is outside their boundary", async () => { mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: false, action: input.action, reason: "deny_low_trust_boundary", explanation: "Issue is outside this low-trust boundary.", })); const res = await request(await createApp(peerActor())) .get(`/api/issues/${issueId}/interactions`); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("Issue is outside this actor's authorization boundary"); expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" })); expect(mockIssueThreadInteractionService.listForIssue).not.toHaveBeenCalled(); }); it("allows mentioned peer agents to list comments through an issue read grant", async () => { mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "issue:read", action: input.action, reason: input.action === "issue:read" ? "allow_issue_mention_grant" : "deny_missing_grant", explanation: input.action === "issue:read" ? "Allowed by a mention-scoped issue comment grant." : "Missing permission.", })); const res = await request(await createApp(peerActor())) .get(`/api/issues/${issueId}/comments`); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(res.body).toEqual([ expect.objectContaining({ id: "comment-1", body: "Mentioned reply context.", }), ]); expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" })); expect(mockIssueService.listComments).toHaveBeenCalledWith(issueId, { afterCommentId: null, order: "desc", limit: null, }); }); it("rejects peer agents from reading a specific comment when issue read is outside their boundary", async () => { mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: false, action: input.action, reason: "deny_low_trust_boundary", explanation: "Issue is outside this low-trust boundary.", })); const res = await request(await createApp(peerActor())) .get(`/api/issues/${issueId}/comments/comment-1`); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("Issue is outside this actor's authorization boundary"); expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" })); expect(mockIssueService.getComment).not.toHaveBeenCalled(); }); it("allows visible issue field updates for peer agents", async () => { mockIssueService.getById.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: ownerAgentId })); mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "issue:comment" || input.action === "issue:mutate", action: input.action, reason: input.action === "issue:comment" ? "allow_issue_mention_grant" : input.action === "issue:mutate" ? "allow_explicit_grant" : "deny_missing_grant", explanation: input.action === "issue:comment" ? "Allowed by a mention-scoped issue comment grant." : input.action === "issue:mutate" ? "Allowed by test boundary default." : "Missing permission.", })); const res = await request(await createApp(peerActor())) .patch(`/api/issues/${issueId}`) .send({ status: "done" }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( issueId, expect.objectContaining({ status: "done" }), expect.anything(), undefined, expect.any(Array), ); }); it("denies cross-company agents before comment authorization is evaluated", async () => { const res = await request(await createApp(peerActor({ companyId: "99999999-9999-4999-8999-999999999999" }))) .post(`/api/issues/${issueId}/comments`) .send({ body: "Wrong company." }); // Cross-tenant requests return 404 (not 403) so the response is // indistinguishable from a nonexistent issue โ€” no existence oracle. expect(res.status, JSON.stringify(res.body)).toBe(404); expect(res.body.error).toBe("Issue not found"); expect(mockAccessService.decide).not.toHaveBeenCalledWith(expect.objectContaining({ action: "issue:comment" })); expect(mockIssueService.addComment).not.toHaveBeenCalled(); }); it("rejects the checked-out owner without a run id on attachment upload (401)", async () => { // Regression: an agent-authenticated client (e.g. the CLI's attachment:upload) // that fails to send X-Paperclip-Run-Id must be rejected โ€” mutating your own // in-progress checkout requires proving run ownership. const app = await createApp({ type: "agent", agentId: ownerAgentId, companyId, source: "agent_key", // intentionally no runId }); const res = await request(app) .post(`/api/companies/${companyId}/issues/${issueId}/attachments`) .attach("file", Buffer.from("report"), { filename: "report.html", contentType: "text/html" }); expect(res.status, JSON.stringify(res.body)).toBe(401); expect(res.body.error).toBe("Agent run id required"); expect(mockStorageService.putFile).not.toHaveBeenCalled(); }); it("allows the checked-out owner with the matching run id to patch and update documents", async () => { const app = await createApp(ownerActor()); await request(app).patch(`/api/issues/${issueId}`).send({ title: "Updated" }).expect(200); await request(app) .put(`/api/issues/${issueId}/documents/plan`) .send({ format: "markdown", body: "# updated" }) .expect(200); expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, ownerAgentId, ownerRunId); expect(mockIssueService.update).toHaveBeenCalled(); expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalledWith( expect.objectContaining({ issueId, key: "plan", createdByAgentId: ownerAgentId, createdByRunId: ownerRunId, lockedDocumentStrategy: "create_new_document", }), ); }); it("stores the authenticated agent run id when creating work products", async () => { const app = await createApp(ownerActor()); await request(app).post(`/api/issues/${issueId}/work-products`).send({ type: "artifact", provider: "test", title: "Artifact", }).expect(201); expect(mockWorkProductService.createForIssue).toHaveBeenCalledWith( issueId, companyId, expect.objectContaining({ createdByRunId: ownerRunId }), ); }); it("adds the authenticated run diff summary to PR work products", async () => { mockWorkProductService.latestRunDiffSummary.mockResolvedValue({ additions: 17, deletions: 5, changedFiles: 3, }); const app = await createApp(ownerActor()); await request(app).post(`/api/issues/${issueId}/work-products`).send({ type: "pull_request", provider: "github", title: "PR 42", url: "https://github.com/paperclipai/paperclip/pull/42", metadata: { repo: "paperclipai/paperclip", number: 42 }, }).expect(201); expect(mockWorkProductService.latestRunDiffSummary).toHaveBeenCalledWith(ownerRunId); expect(mockWorkProductService.createForIssue).toHaveBeenCalledWith( issueId, companyId, expect.objectContaining({ createdByRunId: ownerRunId, metadata: expect.objectContaining({ additions: 17, deletions: 5, changedFiles: 3, }), }), ); }); it("falls back to GitHub commit stats when the authenticated run has no diff event", async () => { mockWorkProductService.resolveCommitDiffSummary.mockResolvedValue({ additions: 11, deletions: 3, changedFiles: 2, }); const app = await createApp(ownerActor()); await request(app).post(`/api/issues/${issueId}/work-products`).send({ type: "commit", provider: "github", title: "Commit 9c12ae7", url: "https://github.com/paperclipai/paperclip/commit/9c12ae7b41e5", metadata: { repo: "paperclipai/paperclip", sha: "9c12ae7b41e5" }, }).expect(201); expect(mockWorkProductService.resolveCommitDiffSummary).toHaveBeenCalledWith( companyId, expect.objectContaining({ type: "commit", provider: "github" }), ); expect(mockWorkProductService.createForIssue).toHaveBeenCalledWith( issueId, companyId, expect.objectContaining({ metadata: expect.objectContaining({ additions: 11, deletions: 3, changedFiles: 2 }), }), ); }); it("rejects agent-created work products with a forged run id", async () => { const app = await createApp(ownerActor()); const res = await request(app).post(`/api/issues/${issueId}/work-products`).send({ type: "artifact", provider: "test", title: "Artifact", createdByRunId: "66666666-6666-4666-8666-666666666666", }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("createdByRunId must match the authenticated agent run"); expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled(); }); it("rejects work product updates with a forged agent run id", async () => { const app = await createApp(ownerActor()); const res = await request(app).patch("/api/work-products/product-1").send({ createdByRunId: "66666666-6666-4666-8666-666666666666", }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("createdByRunId must match the authenticated agent run"); expect(mockWorkProductService.update).not.toHaveBeenCalled(); }); it("rejects board-created work products with a foreign-company run id", async () => { const app = await createApp( boardActor(), createRunContextDb({}, [{ id: "66666666-6666-4666-8666-666666666666", companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", agentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", agentCompanyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", contextSnapshot: {}, }]), ); const res = await request(app).post(`/api/issues/${issueId}/work-products`).send({ type: "artifact", provider: "test", title: "Artifact", createdByRunId: "66666666-6666-4666-8666-666666666666", }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("createdByRunId is not valid for this company"); expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled(); }); it.each([ [ "work product create", (app: express.Express) => request(app).post(`/api/issues/${issueId}/work-products`).send({ type: "artifact", provider: "test", title: "Artifact", }), "Status-only recovery runs cannot update issue documents", ], [ "work product update", (app: express.Express) => request(app).patch("/api/work-products/product-1").send({ title: "Blocked" }), "Status-only recovery runs cannot update issue documents", ], [ "work product delete", (app: express.Express) => request(app).delete("/api/work-products/product-1"), "Status-only recovery runs cannot update issue documents", ], [ "low-trust promotion", (app: express.Express) => request(app).post(`/api/issues/${issueId}/low-trust/promotions`).send({ sourceArtifactKind: "comment", sourceArtifactId: recoveryActionId, title: "Promoted artifact", summary: "Sanitized output", }), "Status-only recovery runs cannot update issue documents", ], [ "attachment upload", (app: express.Express) => request(app) .post(`/api/companies/${companyId}/issues/${issueId}/attachments`) .attach("file", Buffer.from("report"), { filename: "report.txt", contentType: "text/plain" }), "Status-only recovery runs cannot update issue documents", ], [ "attachment delete", (app: express.Express) => request(app).delete("/api/attachments/attachment-1"), "Status-only recovery runs cannot update issue documents", ], [ "issue approval link", (app: express.Express) => request(app).post(`/api/issues/${issueId}/approvals`).send({ approvalId: "88888888-8888-4888-8888-888888888888", }), "Status-only recovery runs cannot create or modify approvals", ], [ "issue approval unlink", (app: express.Express) => request(app).delete(`/api/issues/${issueId}/approvals/88888888-8888-4888-8888-888888888888`), "Status-only recovery runs cannot create or modify approvals", ], ])("blocks status-only recovery runs from %s", async (_name, sendRequest, expectedError) => { const app = await createApp( ownerActor(), createRunContextDb({ recoveryIntent: "status_only", allowDeliverableWork: false, allowDocumentUpdates: false, resumeRequiresNormalModel: true, }), ); const res = await sendRequest(app); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toContain(expectedError); expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, ownerAgentId, ownerRunId); expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled(); expect(mockWorkProductService.update).not.toHaveBeenCalled(); expect(mockWorkProductService.remove).not.toHaveBeenCalled(); expect(mockStorageService.putFile).not.toHaveBeenCalled(); expect(mockStorageService.deleteObject).not.toHaveBeenCalled(); expect(mockIssueService.removeAttachment).not.toHaveBeenCalled(); expect(mockIssueApprovalService.link).not.toHaveBeenCalled(); expect(mockIssueApprovalService.unlink).not.toHaveBeenCalled(); }); it("defaults agent-created root follow-up issues to inherit the current run workspace", async () => { const app = await createApp( ownerActor(), createRunContextDb({ issueId, executionWorkspaceId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", }), ); const res = await request(app) .post(`/api/companies/${companyId}/issues`) .send({ title: "Follow-up in same worktree", projectId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", }); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockIssueService.create).toHaveBeenCalledWith( companyId, expect.objectContaining({ title: "Follow-up in same worktree", inheritExecutionWorkspaceFromIssueId: issueId, }), ); }); it("authorizes child creation through the shared visible-issue write path", async () => { mockIssueService.getById.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: ownerAgentId })); const res = await request(await createApp(peerActor())) .post(`/api/issues/${issueId}/children`) .send({ title: "Peer-created child" }); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:mutate", resource: expect.objectContaining({ issueId }), })); expect(mockIssueService.createChild).toHaveBeenCalledWith( issueId, expect.objectContaining({ title: "Peer-created child" }), ); }); it("preserves explicit workspace choices on agent-created root issues", async () => { const app = await createApp( ownerActor(), createRunContextDb({ issueId, executionWorkspaceId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", }), ); const explicitExecutionWorkspaceId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; const res = await request(app) .post(`/api/companies/${companyId}/issues`) .send({ title: "Explicit different workspace", executionWorkspaceId: explicitExecutionWorkspaceId, executionWorkspacePreference: "reuse_existing", }); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockIssueService.create).toHaveBeenCalledWith( companyId, expect.objectContaining({ title: "Explicit different workspace", executionWorkspaceId: explicitExecutionWorkspaceId, executionWorkspacePreference: "reuse_existing", }), ); expect(mockIssueService.create).toHaveBeenCalledWith( companyId, expect.not.objectContaining({ inheritExecutionWorkspaceFromIssueId: issueId, }), ); }); it("rejects agent-created issues that supply responsibleUserId", async () => { const app = await createApp(ownerActor()); const res = await request(app) .post(`/api/companies/${companyId}/issues`) .send({ title: "Spoof responsible user", responsibleUserId: "spoofed-user", }); expect(res.status, JSON.stringify(res.body)).toBe(422); expect(res.body.error).toContain("responsibleUserId"); expect(mockIssueService.create).not.toHaveBeenCalled(); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ companyId, actorType: "agent", actorId: ownerAgentId, action: "issue.attribution_spoof_rejected", entityType: "company", details: expect.objectContaining({ surface: "issues.create", field: "responsibleUserId", requestedValue: "spoofed-user", }), }), ); }); it("strips agent-supplied createdByUserId and derives attribution from the authenticated actor", async () => { const app = await createApp(ownerActor()); const res = await request(app) .post(`/api/companies/${companyId}/issues`) .send({ title: "Spoof creator", createdByUserId: "spoofed-user", }); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockIssueService.create).toHaveBeenCalledWith( companyId, expect.objectContaining({ title: "Spoof creator", createdByAgentId: ownerAgentId, createdByUserId: null, actorRunId: ownerRunId, }), ); expect(mockIssueService.create).toHaveBeenCalledWith( companyId, expect.not.objectContaining({ createdByUserId: "spoofed-user", }), ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ companyId, actorType: "agent", actorId: ownerAgentId, action: "issue.attribution_spoof_stripped", details: expect.objectContaining({ surface: "issues.create", field: "createdByUserId", requestedValue: "spoofed-user", }), }), ); }); it("allows board-created issues to pass explicit responsibleUserId as trusted attribution", async () => { const app = await createApp(boardActor()); const res = await request(app) .post(`/api/companies/${companyId}/issues`) .send({ title: "Board-owned work", responsibleUserId: "responsible-board-user", }); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockIssueService.create).toHaveBeenCalledWith( companyId, expect.objectContaining({ title: "Board-owned work", responsibleUserId: "responsible-board-user", createdByUserId: "board-user", trustExplicitResponsibleUserId: true, }), ); }); it("rejects agent-created child issues that supply responsibleUserId", async () => { const app = await createApp(ownerActor()); const res = await request(app) .post(`/api/issues/${issueId}/children`) .send({ title: "Spoof child responsible user", responsibleUserId: "spoofed-user", }); expect(res.status, JSON.stringify(res.body)).toBe(422); expect(mockIssueService.createChild).not.toHaveBeenCalled(); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ companyId, action: "issue.attribution_spoof_rejected", entityType: "issue", entityId: issueId, details: expect.objectContaining({ surface: "issues.children.create", field: "responsibleUserId", }), }), ); }); it("rejects accepted-plan child creation when an agent child body supplies responsibleUserId", async () => { const app = await createApp(ownerActor()); const res = await request(app) .post(`/api/issues/${issueId}/accepted-plan-decompositions`) .send({ acceptedPlanRevisionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", children: [ { title: "Spoof plan child responsible user", responsibleUserId: "spoofed-user", }, ], }); expect(res.status, JSON.stringify(res.body)).toBe(422); expect(mockIssueService.decomposeAcceptedPlan).not.toHaveBeenCalled(); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ companyId, action: "issue.attribution_spoof_rejected", entityType: "issue", entityId: issueId, details: expect.objectContaining({ surface: "issues.accepted_plan_decomposition", field: "responsibleUserId", }), }), ); }); it("rejects retired issue assignee profile overrides", async () => { const app = await createApp(boardActor()); await request(app) .patch(`/api/issues/${issueId}`) .send({ assigneeAdapterOverrides: { modelProfile: "cheap" } }) .expect(400); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("preserves committed issue updates, comments, documents, and work product writes when recovery revalidation fails", async () => { const app = await createApp(ownerActor()); mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed")); await request(app) .patch(`/api/issues/${issueId}`) .send({ title: "Updated after commit" }) .expect(200); mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed")); await request(app) .post(`/api/issues/${issueId}/comments`) .send({ body: "progress update" }) .expect(201); mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed")); await request(app) .put(`/api/issues/${issueId}/documents/plan`) .send({ format: "markdown", body: "# updated" }) .expect(200); mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed")); await request(app) .patch("/api/work-products/product-1") .send({ title: "Updated product" }) .expect(200); expect(mockIssueService.update).toHaveBeenCalledWith( issueId, expect.objectContaining({ title: "Updated after commit" }), ); expect(mockIssueService.addComment).toHaveBeenCalledWith( issueId, "progress update", expect.any(Object), expect.any(Object), ); expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalled(); expect(mockWorkProductService.update).toHaveBeenCalledWith("product-1", { title: "Updated product" }); }); it("preserves board mutations on active checkouts", async () => { const app = await createApp(boardActor()); await request(app).patch(`/api/issues/${issueId}`).send({ title: "Board update" }).expect(200); await request(app) .put(`/api/issues/${issueId}/documents/plan`) .send({ format: "markdown", body: "# board" }) .expect(200); expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); expect(mockIssueService.update).toHaveBeenCalled(); expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalled(); }); it("allows agents with the active-checkout management grant to mutate active checkouts", async () => { mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts", action: input.action, reason: input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts" ? "allow_explicit_grant" : "deny_missing_grant", explanation: input.action === "tasks:manage_active_checkouts" ? "Allowed by checkout management grant." : input.action === "issue:mutate" ? "Allowed by test boundary default." : "Missing permission.", })); const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" }); expect(res.status).toBe(200); expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); expect(mockIssueService.update).toHaveBeenCalled(); }); it.each([ ["todo", "patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Todo update" })], ["blocked", "patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Blocked update" })], ])("allows peer agent %s issue %s updates outside active checkout ownership", async (status, _kind, sendRequest) => { mockIssueService.getById.mockResolvedValue(makeIssue({ status: status as "todo" | "blocked", assigneeAgentId: ownerAgentId })); const res = await sendRequest(await createApp(peerActor())); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); expect(mockIssueService.update).toHaveBeenCalled(); expect(mockIssueService.addComment).not.toHaveBeenCalled(); }); it.each([ ["done", "todo", 403, "Agent cannot request follow-up for another agent's issue"], ["cancelled", "todo", 409, "Cancelled issues must be restored through the dedicated restore flow"], ["blocked", "done", 403, "Agent cannot request follow-up for another agent's issue"], ])( "rejects peer agent direct status transitions from %s to %s", async (status, nextStatus, expectedStatus, expectedError) => { mockIssueService.getById.mockResolvedValue(makeIssue({ status, assigneeAgentId: ownerAgentId })); const res = await request(await createApp(peerActor())) .patch(`/api/issues/${issueId}`) .send({ status: nextStatus }); expect(res.status, JSON.stringify(res.body)).toBe(expectedStatus); expect(res.body.error).toBe(expectedError); expect(mockIssueService.update).not.toHaveBeenCalled(); }, ); it("allows same-company agent mutations on unassigned in-progress issues", async () => { mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: null })); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue({ assigneeAgentId: null }), ...patch, })); const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Claimable update" }); expect(res.status).toBe(200); expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); expect(res.body).toMatchObject({ id: issueId, assigneeAgentId: null, title: "Claimable update", }); }); it.each([ ["board", "board"], ["a company user", { userId: "board-user" }], ])("rejects an agent naming %s as unblock owner", async (_label, unblockOwner) => { mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress" })); const res = await request(await createApp(ownerActor())).patch(`/api/issues/${issueId}`).send({ status: "blocked", unblockDescriptor: { owner: unblockOwner, action: "Review the blocker" }, }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("Agents may only name themselves as an unblock owner"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it.each([ ["board", "board"], ["a company user", { userId: "board-user" }], ])("rejects an agent changing an already-blocked issue owner to %s", async (_label, unblockOwner) => { mockIssueService.getById.mockResolvedValue(makeIssue({ status: "blocked" })); const res = await request(await createApp(ownerActor())).patch(`/api/issues/${issueId}`).send({ unblockDescriptor: { owner: unblockOwner, action: "Review the blocker" }, }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("Agents may only name themselves as an unblock owner"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("allows a board actor to name the board as unblock owner", async () => { mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress" })); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue({ status: "in_progress" }), ...patch, })); const res = await request(await createApp(boardActor())).patch(`/api/issues/${issueId}`).send({ status: "blocked", unblockDescriptor: { owner: "board", action: "Review the blocker" }, }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( issueId, expect.objectContaining({ status: "blocked", unblockDescriptor: { owner: "board", action: "Review the blocker" }, }), ); }); it("rejects peer-agent status updates that would clear a recovery action they do not own", async () => { mockIssueService.getById.mockResolvedValue( makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }), ); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId, }); const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ status: "todo" }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("Agent cannot resolve another owner's recovery action"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("rejects peer-agent recovery resolution on a board-owned source issue", async () => { mockIssueService.getById.mockResolvedValue( makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }), ); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId, }); const res = await request(await createApp(peerActor())) .post(`/api/issues/${issueId}/recovery-actions/resolve`) .send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "done", }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("Agent cannot resolve another owner's recovery action"); expect(mockIssueRecoveryActionService.resolveActiveForIssue).not.toHaveBeenCalled(); }); it("rejects the named recovery owner completing a board-owned source issue", async () => { mockIssueService.getById.mockResolvedValue( makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }), ); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId, }); const res = await request(await createApp(ownerActor())) .post(`/api/issues/${issueId}/recovery-actions/resolve`) .send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "done", }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.details?.code).toBe("recovery_source_authority_required"); expect(mockIssueService.update).not.toHaveBeenCalled(); expect(mockIssueRecoveryActionService.resolveActiveForIssue).not.toHaveBeenCalled(); }); it("rejects a recovery owner completing an independently agent-owned source issue", async () => { mockIssueService.getById.mockResolvedValue( makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }), ); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId: peerAgentId, }); const res = await request(await createApp(peerActor())) .post(`/api/issues/${issueId}/recovery-actions/resolve`) .send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "done", }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.details?.code).toBe("recovery_source_authority_required"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it.each(["done", "cancelled"])( "rejects recovery-owner PATCH of an agent-owned source to %s", async (status) => { mockIssueService.getById.mockResolvedValue( makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }), ); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId: peerAgentId, }); const res = await request(await createApp(peerActor())) .patch(`/api/issues/${issueId}`) .send({ status }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.details?.code).toBe("recovery_source_authority_required"); expect(mockIssueService.update).not.toHaveBeenCalled(); }, ); it("rejects recovery-owner reassignment of an independently agent-owned source", async () => { mockIssueService.getById.mockResolvedValue( makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }), ); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId: peerAgentId, }); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: makeAgent(peerAgentId), }); const res = await request(await createApp(peerActor())) .patch(`/api/issues/${issueId}`) .send({ assigneeAgentId: peerAgentId }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.details?.code).toBe("recovery_source_authority_required"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("rejects a recovery owner who is not the current governed review participant", async () => { mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_review", assigneeAgentId: ownerAgentId, executionState: { status: "pending", currentStageId: "88888888-8888-4888-8888-888888888888", currentStageIndex: 0, currentStageType: "review", currentParticipant: { type: "agent", agentId: ownerAgentId }, returnAssignee: { type: "agent", agentId: ownerAgentId }, completedStageIds: [], lastDecisionId: null, lastDecisionOutcome: null, }, })); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId: peerAgentId, }); const res = await request(await createApp(peerActor())) .post(`/api/issues/${issueId}/recovery-actions/resolve`) .send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "done" }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.details?.code).toBe("recovery_source_authority_required"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("keeps configured review policy authoritative during recovery resolution", async () => { mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_review", assigneeAgentId: ownerAgentId, reviewPolicy: "human_only", })); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId, }); const res = await request(await createApp(ownerActor())) .post(`/api/issues/${issueId}/recovery-actions/resolve`) .send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "done" }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.details?.code).toBe("review_policy_denied"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("allows a recovery owner to record a receipt without mutating a board-owned source", async () => { mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_review", assigneeAgentId: null, assigneeUserId: "board-user", })); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId, }); const res = await request(await createApp(ownerActor())) .post(`/api/issues/${issueId}/recovery-actions/resolve`) .send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "in_review" }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockIssueService.update).not.toHaveBeenCalled(); expect(mockIssueRecoveryActionService.resolveActiveForIssue).toHaveBeenCalled(); }); it("wakes the assigned agent when recovery resolution restores a source issue to todo", async () => { mockIssueService.getById.mockResolvedValue( makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }), ); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }), ...patch, })); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId: peerAgentId, returnOwnerAgentId: ownerAgentId, }); const res = await request(await createApp(peerActor())) .post(`/api/issues/${issueId}/recovery-actions/resolve`) .send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "todo", }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( issueId, expect.not.objectContaining({ assigneeAgentId: expect.anything() }), expect.anything(), expect.any(Array), ); expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( ownerAgentId, expect.objectContaining({ reason: "issue_recovery_action_restored", payload: expect.objectContaining({ issueId, recoveryActionId, mutation: "recovery_action_resolution", }), }), ); }); it.each([ ["checkoutRunId", ownerRunId], ["executionRunId", ownerRunId], ])("blocks safe hand-back while the source has an active %s", async (lockField, lockRunId) => { mockIssueService.getById.mockResolvedValue(makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId, [lockField]: lockRunId, })); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId: peerAgentId, returnOwnerAgentId: ownerAgentId, }); const res = await request(await createApp(peerActor())) .post(`/api/issues/${issueId}/recovery-actions/resolve`) .send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "todo" }); expect(res.status, JSON.stringify(res.body)).toBe(409); expect(res.body.details?.code).toBe("recovery_source_run_lock"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("blocks safe hand-back while the original owner's budget is paused", async () => { mockIssueService.getById.mockResolvedValue( makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }), ); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId: peerAgentId, returnOwnerAgentId: ownerAgentId, }); mockBudgetService.getInvocationBlock.mockResolvedValue({ scope: "agent", reason: "hard_limit_reached", }); const res = await request(await createApp(peerActor())) .post(`/api/issues/${issueId}/recovery-actions/resolve`) .send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "todo" }); expect(res.status, JSON.stringify(res.body)).toBe(409); expect(res.body.details?.code).toBe("recovery_safe_hand_back_budget_blocked"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("blocks safe hand-back while a governed approval remains pending", async () => { mockIssueService.getById.mockResolvedValue( makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }), ); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ id: recoveryActionId, ownerAgentId: peerAgentId, returnOwnerAgentId: ownerAgentId, }); mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([{ status: "pending" }]); const res = await request(await createApp(peerActor())) .post(`/api/issues/${issueId}/recovery-actions/resolve`) .send({ actionId: recoveryActionId, outcome: "restored", sourceIssueStatus: "todo" }); expect(res.status, JSON.stringify(res.body)).toBe(409); expect(res.body.details?.code).toBe("recovery_governed_approval_pending"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("uses the authorization decision path for assignment changes", async () => { const decide = vi.fn(async () => ({ allowed: false, action: "tasks:assign", reason: "deny_policy_restricted", explanation: "Target agent requires approval before task assignment.", })); decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "issue:mutate", action: input.action, reason: input.action === "issue:mutate" ? "allow_self" : "deny_policy_restricted", explanation: input.action === "issue:mutate" ? "Allowed because the actor owns the assigned issue." : "Target agent requires approval before task assignment.", })); (mockAccessService as any).decide = decide; mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId })); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: makeAgent(peerAgentId), }); const app = await createApp(ownerActor()); const res = await request(app) .patch(`/api/issues/${issueId}`) .send({ assigneeAgentId: peerAgentId }); expect(res.status).toBe(403); expect(res.body.error).toContain("requires approval"); expect(decide).toHaveBeenCalledWith(expect.objectContaining({ action: "tasks:assign", resource: expect.objectContaining({ type: "issue", companyId, issueId, assigneeAgentId: peerAgentId, }), })); expect(mockIssueService.update).not.toHaveBeenCalled(); }); describe("task watchdog scope grants", () => { const watchdogRunId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaab"; const watchdogReportIssueId = "cccccccc-cccc-4ccc-8ccc-cccccccccccd"; // The watchdog agent (peerAgentId) is NOT the assignee of the watched issue // (ownerAgentId), so the base authorization boundary (issue:mutate) denies. // The watchdog scope must grant the mutation regardless. function watchdogActor(runId: string = watchdogRunId) { return { type: "agent", agentId: peerAgentId, companyId, source: "agent_key", runId, }; } function createWatchdogDb(options: { watchedIssueId?: string; watchdogIssueId?: string | null; ancestryParentId?: string | null; watchdogRows?: Record[]; } = {}) { const watchedIssueId = options.watchedIssueId ?? issueId; const runRows = [{ id: watchdogRunId, companyId, agentId: peerAgentId, contextSnapshot: { taskWatchdog: { watchedIssueId, stopFingerprint: "task_watchdog_stop:test" } }, }]; const watchdogRows = options.watchdogRows ?? [{ id: "dddddddd-dddd-4ddd-8ddd-ddddddddddde", companyId, issueId: watchedIssueId, watchdogAgentId: peerAgentId, watchdogIssueId: options.watchdogIssueId ?? watchdogReportIssueId, status: "active", }]; const ancestryRows = [{ id: "ancestry", companyId, parentId: options.ancestryParentId ?? null, }]; const rowsForSelection = (selection: Record) => { const keys = Object.keys(selection); if (keys.includes("entityId")) return []; if (keys.includes("contextSnapshot")) return runRows; if (keys.includes("watchdogAgentId")) return watchdogRows; if (keys.includes("parentId")) return ancestryRows; if (keys.includes("status")) return []; if (keys.includes("agentCompanyId")) return runRows; return [{ id: peerAgentId, companyId, permissions: {}, role: "engineer", reportsTo: null }]; }; const buildQuery = (selection: Record) => { const rows = rowsForSelection(selection); const whereResult = { orderBy: vi.fn(async () => []), limit: vi.fn(() => ({ then: async (resolve: (limitedRows: unknown[]) => unknown) => resolve(rows), })), then: async (resolve: (selectedRows: unknown[]) => unknown) => resolve(rows), }; const query = { innerJoin: vi.fn(() => query), where: vi.fn(() => whereResult), }; return query; }; return { transaction: async (callback: (tx: Record) => Promise) => callback({}), select: vi.fn((selection: Record = {}) => ({ from: vi.fn(() => buildQuery(selection)), })), }; } // The base boundary always denies a cross-agent issue:mutate; only the // watchdog scope can widen access. Denying it here proves the grant works. function denyBaseBoundary() { mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "company_scope:read" || input.action === "issue:read" || input.action === "tasks:assign", action: input.action, reason: input.action === "company_scope:read" || input.action === "issue:read" || input.action === "tasks:assign" ? "allow_explicit_grant" : "deny_missing_grant", explanation: "Watchdog test boundary default.", })); } it("lets a watchdog run comment on a watched issue assigned to a different agent", async () => { denyBaseBoundary(); mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId })); const app = await createApp(watchdogActor(), createWatchdogDb()); const res = await request(app).post(`/api/issues/${issueId}/comments`).send({ body: "Watchdog finding" }); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockIssueService.addComment).toHaveBeenCalledWith( issueId, "Watchdog finding", expect.any(Object), expect.any(Object), ); }); it.each([ ["in_progress"], ["blocked"], ["todo"], ])("lets a watchdog run transition a watched issue to %s", async (status) => { denyBaseBoundary(); mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress", assigneeAgentId: ownerAgentId })); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue({ assigneeAgentId: ownerAgentId }), ...patch, })); const app = await createApp(watchdogActor(), createWatchdogDb()); const res = await request(app).patch(`/api/issues/${issueId}`).send({ status }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith(issueId, expect.objectContaining({ status })); }); it("lets a watchdog run transition a watched issue to in_review with a live review path", async () => { denyBaseBoundary(); mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress", assigneeAgentId: ownerAgentId })); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue({ assigneeAgentId: ownerAgentId }), ...patch, })); // A pending interaction is a valid review path, so the agent in_review guard // is satisfied โ€” this isolates the test to the watchdog boundary grant. mockIssueThreadInteractionService.listForIssue.mockResolvedValue([{ status: "pending" }] as never); const app = await createApp(watchdogActor(), createWatchdogDb()); const res = await request(app).patch(`/api/issues/${issueId}`).send({ status: "in_review" }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( issueId, expect.objectContaining({ status: "in_review" }), expect.anything(), ); }); it("rejects stale watchdog source mutations when revalidation finds a live path", async () => { denyBaseBoundary(); mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_progress", assigneeAgentId: ownerAgentId })); mockTaskWatchdogService.revalidateMutationScope.mockResolvedValueOnce({ allowed: false, reason: "Task-watchdog review is stale because the watched subtree now has a live, waiting, already-reviewed, or not-applicable path; refresh the source state before mutating it.", classification: { state: "live", liveIssueIds: [issueId] }, }); const app = await createApp(watchdogActor(), createWatchdogDb()); const res = await request(app).patch(`/api/issues/${issueId}`).send({ status: "blocked" }); expect(res.status, JSON.stringify(res.body)).toBe(409); expect(res.body.error).toContain("Task-watchdog review is stale"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("suppresses watchdog follow-up creation when current source revalidation is live", async () => { denyBaseBoundary(); mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId })); mockTaskWatchdogService.revalidateMutationScope.mockResolvedValueOnce({ allowed: false, reason: "Task-watchdog review is stale because the watched subtree now has a live, waiting, already-reviewed, or not-applicable path; refresh the source state before mutating it.", classification: { state: "live", liveIssueIds: [issueId] }, }); const app = await createApp(watchdogActor(), createWatchdogDb()); const res = await request(app) .post(`/api/issues/${issueId}/children`) .send({ title: "Stale follow-up", status: "todo" }); expect(res.status, JSON.stringify(res.body)).toBe(409); expect(res.body.error).toContain("Task-watchdog review is stale"); expect(mockIssueService.createChild).not.toHaveBeenCalled(); }); it("serializes watchdog accepted-plan follow-ups behind one active child lane", async () => { denyBaseBoundary(); mockIssueService.list.mockResolvedValue([]); mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => ({ ambiguous: false, agent: reference === ownerAgentId ? makeAgent(ownerAgentId) : null, })); mockIssueService.getById.mockImplementation(async (id: string) => { if (id === watchdogReportIssueId) { return makeIssue({ id: watchdogReportIssueId, originKind: "task_watchdog", status: "in_progress", assigneeAgentId: peerAgentId, }); } return makeIssue({ assigneeAgentId: ownerAgentId }); }); const app = await createApp(watchdogActor(), createWatchdogDb()); const res = await request(app) .post(`/api/issues/${issueId}/accepted-plan-decompositions`) .send({ acceptedPlanRevisionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", children: [ { title: "Fix watchdog authorization", assigneeAgentId: ownerAgentId }, { title: "Fix watchdog startup race", assigneeAgentId: ownerAgentId }, ], }); expect(res.status, JSON.stringify(res.body)).toBe(200); const decompositionInput = mockIssueService.decomposeAcceptedPlan.mock.calls[0]?.[1]; const children = decompositionInput.children as Array>; expect(children).toHaveLength(2); expect(children[0]).toEqual(expect.objectContaining({ title: "Fix watchdog authorization", status: "todo", assigneeAgentId: ownerAgentId, })); expect(children[1]).toEqual(expect.objectContaining({ title: "Fix watchdog startup race", status: "blocked", assigneeAgentId: ownerAgentId, blockedByIssueIds: [children[0]?.id], })); expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( ownerAgentId, expect.objectContaining({ payload: expect.objectContaining({ issueId: children[0]?.id }), }), ); expect(mockIssueService.update).toHaveBeenCalledWith( watchdogReportIssueId, expect.objectContaining({ status: "blocked", blockedByIssueIds: [children[0]?.id], actorAgentId: peerAgentId, }), ); }); it("preserves normal accepted-plan decomposition parallel wakeups outside watchdog context", async () => { mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => ({ ambiguous: false, agent: reference === ownerAgentId ? makeAgent(ownerAgentId) : null, })); const app = await createApp(ownerActor()); const res = await request(app) .post(`/api/issues/${issueId}/accepted-plan-decompositions`) .send({ acceptedPlanRevisionId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", children: [ { title: "Implement backend", assigneeAgentId: ownerAgentId }, { title: "Implement frontend", assigneeAgentId: ownerAgentId }, ], }); expect(res.status, JSON.stringify(res.body)).toBe(200); const decompositionInput = mockIssueService.decomposeAcceptedPlan.mock.calls[0]?.[1]; const children = decompositionInput.children as Array>; expect(children).toHaveLength(2); expect(children[0]).toEqual(expect.objectContaining({ status: "todo" })); expect(children[1]).toEqual(expect.objectContaining({ status: "todo" })); expect(children[1]?.blockedByIssueIds).toBeUndefined(); expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(2); expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith( 1, ownerAgentId, expect.objectContaining({ payload: expect.objectContaining({ issueId: children[0]?.id }), }), ); expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith( 2, ownerAgentId, expect.objectContaining({ payload: expect.objectContaining({ issueId: children[1]?.id }), }), ); expect(mockIssueService.update).not.toHaveBeenCalledWith( watchdogReportIssueId, expect.anything(), ); }); it("lets a watchdog run reassign a watched issue to an active same-company agent", async () => { denyBaseBoundary(); mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId })); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue({ assigneeAgentId: ownerAgentId }), ...patch, })); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: makeAgent(peerAgentId) }); const app = await createApp(watchdogActor(), createWatchdogDb()); const res = await request(app).patch(`/api/issues/${issueId}`).send({ assigneeAgentId: peerAgentId }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( issueId, expect.objectContaining({ assigneeAgentId: peerAgentId }), ); }); it("still denies a watchdog run mutating an issue outside the watched subtree", async () => { denyBaseBoundary(); mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId })); // The watched issue is a different issue, and the target's ancestry chain // (parentId === null) never reaches it, so it is outside the subtree. const outsideWatched = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeef"; const app = await createApp( watchdogActor(), createWatchdogDb({ watchedIssueId: outsideWatched, ancestryParentId: null }), ); const res = await request(app).patch(`/api/issues/${issueId}`).send({ status: "blocked" }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("Task-watchdog runs can only mutate the watched issue subtree."); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("still enforces normal assignment guards for watchdog reassignment", async () => { // Base boundary denied AND tasks:assign denied: the watchdog grant lets the // mutation past the ownership boundary, but the assignment guard must still bite. mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "company_scope:read" || input.action === "issue:read", action: input.action, reason: input.action === "company_scope:read" || input.action === "issue:read" ? "allow_explicit_grant" : "deny_policy_restricted", explanation: input.action === "tasks:assign" ? "Target agent requires approval before task assignment." : "Watchdog test boundary default.", })); mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId })); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: makeAgent(peerAgentId) }); const app = await createApp(watchdogActor(), createWatchdogDb()); const res = await request(app).patch(`/api/issues/${issueId}`).send({ assigneeAgentId: peerAgentId }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toContain("requires approval"); expect(mockAccessService.decide).toHaveBeenCalledWith( expect.objectContaining({ action: "tasks:assign" }), ); expect(mockIssueService.update).not.toHaveBeenCalled(); }); it("denies an invalid watchdog run context even when the base boundary would allow it", async () => { // Run context claims a watched issue, but no active persisted watchdog backs it. const app = await createApp( watchdogActor(), createWatchdogDb({ watchdogRows: [] }), ); mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: peerAgentId })); const res = await request(app).patch(`/api/issues/${issueId}`).send({ status: "blocked" }); expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("Task-watchdog run context is not backed by an active persisted watchdog."); expect(mockIssueService.update).not.toHaveBeenCalled(); }); }); });