paperclip/server/src/__tests__/issue-agent-mutation-owners...

2439 lines
94 KiB
TypeScript

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<string, unknown> | 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<string, unknown> = {}) {
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<string, unknown> = {}) {
return {
id,
companyId,
role: "engineer",
reportsTo: null,
permissions: { canCreateAgents: false },
...overrides,
};
}
function createRunContextDb(
contextSnapshot: Record<string, unknown> = {},
runAgentOrRows: string | Record<string, unknown>[] = 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<string, unknown>) => {
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<string, unknown>) => {
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<unknown>) => callback(dbStub),
select: vi.fn((selection: Record<string, unknown> = {}) => ({
from: vi.fn(() => buildQuery(selection)),
})),
insert: vi.fn(() => ({ values: vi.fn(async () => undefined) })),
};
return dbStub;
}
async function createApp(actor: Record<string, unknown>, 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<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../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<string, unknown> = {}) {
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<string, unknown>) => ({
...makeIssue({
id: "88888888-8888-4888-8888-888888888888",
status: "todo",
assigneeAgentId: null,
}),
...input,
companyId,
}));
mockIssueService.createChild.mockImplementation(async (_parentId: string, input: Record<string, unknown>) => ({
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<string, unknown>) => {
const children = input.children as Record<string, unknown>[];
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<string, unknown>) => ({
...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<string, unknown>) => ({
...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<string, unknown>) => ({
...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<string, unknown>) => ({
...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<string, unknown>[];
} = {}) {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, never>) => Promise<unknown>) => callback({}),
select: vi.fn((selection: Record<string, unknown> = {}) => ({
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<string, unknown>) => ({
...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<string, unknown>) => ({
...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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<string, unknown>) => ({
...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();
});
});
});