mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
Fix recovery action resolution guard
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3b48e4cf58
commit
372e9349de
3 files changed
+83
-31
No files matched your search
@@ -768,6 +768,48 @@ describeEmbeddedPostgres("issue recovery actions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects stale recovery action IDs before mutating the source issue", async () => {
|
||||
const { companyId, managerId, sourceIssueId } = await seedCompany();
|
||||
const recoveryActionSvc = issueRecoveryActionService(db);
|
||||
const action = await recoveryActionSvc.upsertSourceScoped({
|
||||
companyId,
|
||||
sourceIssueId,
|
||||
kind: "issue_graph_liveness",
|
||||
ownerType: "agent",
|
||||
ownerAgentId: managerId,
|
||||
cause: "issue_graph_liveness",
|
||||
fingerprint: "graph-liveness:stale-action-id",
|
||||
evidence: { latestIssueStatus: "in_progress" },
|
||||
nextAction: "Only the current active recovery action can be resolved.",
|
||||
wakePolicy: { type: "wake_owner" },
|
||||
});
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId: managerId,
|
||||
companyId,
|
||||
source: "agent_jwt",
|
||||
});
|
||||
|
||||
const rejected = await request(app)
|
||||
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
|
||||
.send({
|
||||
actionId: randomUUID(),
|
||||
outcome: "restored",
|
||||
sourceIssueStatus: "done",
|
||||
})
|
||||
.expect(404);
|
||||
|
||||
expect(rejected.body.error).toContain("Active recovery action not found");
|
||||
|
||||
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
|
||||
expect(sourceIssue?.status).toBe("in_progress");
|
||||
expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toMatchObject({
|
||||
id: action.id,
|
||||
status: "active",
|
||||
ownerAgentId: managerId,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows the assigned recovery owner to resolve an unassigned recovery action", async () => {
|
||||
const { companyId, managerId, sourceIssueId } = await seedCompany();
|
||||
await db.update(issues).set({ assigneeAgentId: null, status: "todo" }).where(eq(issues.id, sourceIssueId));
|
||||
|
||||
@@ -1150,13 +1150,25 @@ export function issueRoutes(
|
||||
},
|
||||
) {
|
||||
const activeAction = await recoveryActionsSvc.getActiveForIssue(input.issue.companyId, input.issue.id);
|
||||
if (!activeAction || (input.actionId && activeAction.id !== input.actionId)) {
|
||||
if (!activeAction) {
|
||||
return { allowed: true, activeAction: null as typeof activeAction };
|
||||
}
|
||||
if (req.actor.type === "board") {
|
||||
if (input.actionId && activeAction.id !== input.actionId) {
|
||||
res.status(404).json({
|
||||
error: "Active recovery action not found",
|
||||
details: {
|
||||
issueId: input.issue.id,
|
||||
requestedRecoveryActionId: input.actionId,
|
||||
activeRecoveryActionId: activeAction.id,
|
||||
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
|
||||
},
|
||||
});
|
||||
return { allowed: false, activeAction };
|
||||
}
|
||||
if (req.actor.type !== "agent") {
|
||||
return { allowed: true, activeAction };
|
||||
}
|
||||
const actorAgentId = req.actor.type === "agent" ? req.actor.agentId : null;
|
||||
const actorAgentId = req.actor.agentId;
|
||||
if (activeAction.ownerType === "agent" && actorAgentId && activeAction.ownerAgentId === actorAgentId) {
|
||||
return { allowed: true, activeAction };
|
||||
}
|
||||
|
||||
@@ -516,34 +516,32 @@ export function IssueRecoveryActionCard({
|
||||
Resolve recovery
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{visibleResolveOptions.map((option) => (
|
||||
(() => {
|
||||
const disabled = option.requiresBlocker && !canMarkBlocked;
|
||||
return (
|
||||
<button
|
||||
key={option.outcome}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onResolve?.(option.outcome);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
title={disabled ? "Add a first-class blocker before marking recovery blocked." : undefined}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
|
||||
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:bg-transparent",
|
||||
option.destructive ? "text-destructive" : null,
|
||||
)}
|
||||
>
|
||||
<span className="font-medium leading-5">{option.label}</span>
|
||||
<span className="text-[11px] leading-4 text-muted-foreground">
|
||||
{disabled ? "Add a first-class blocker before using this outcome." : option.description}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
{visibleResolveOptions.map((option) => {
|
||||
const disabled = option.requiresBlocker && !canMarkBlocked;
|
||||
return (
|
||||
<button
|
||||
key={option.outcome}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onResolve?.(option.outcome);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
title={disabled ? "Add a first-class blocker before marking recovery blocked." : undefined}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
|
||||
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:bg-transparent",
|
||||
option.destructive ? "text-destructive" : null,
|
||||
)}
|
||||
>
|
||||
<span className="font-medium leading-5">{option.label}</span>
|
||||
<span className="text-[11px] leading-4 text-muted-foreground">
|
||||
{disabled ? "Add a first-class blocker before using this outcome." : option.description}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
Reference in new issue
Block a user