Files
PaperClipAI/server/src/__tests__/tool-review-queue-unsigned-request.test.ts
T
Nicky LeachandPaperclip 05d58cd884 fix(tool-gateway): keep unsigned ask-first requests out of the review queue without cancelling them (#11338)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The tool gateway creates approval requests and the review queue
reads them
> - The gateway creates a request row before it adds the signature
> - A review-queue read can see the row during that short unsigned state
> - The old read path cancels the unsigned row, so approval returns `409
action_not_pending`
> - This pull request hides unsigned in-flight rows and keeps them
pending until signing finishes
> - The benefit is that approval succeeds while invalid signed requests
remain cancelled

## Linked Issues or Issue Description

**What happened?**

A review-queue read cancelled a pending tool action request when the
request had no signature yet. The next approval call returned `409
action_not_pending`.

**Expected behavior**

The review queue must hide an unsigned in-flight request and keep its
state as `pending`. A request with an invalid signature must remain
cancelled.

**Steps to reproduce**

1. Create a require-approval tool action request.
2. Read the review queue while the request signature is still null.
3. Approve the request after the creator adds the signature.
4. Observe that the old code cancels the request and the approval call
fails.

**Paperclip version or commit**

Commit `720aa0a494bbaa1711bc7a3d795f810765915bfe`.

**Deployment mode**

Local dev with the embedded PGlite database.

**Installation method**

Built from source with pnpm.

**Agent adapter(s) involved**

Not adapter-specific. This is a core tool access service bug.

**Database mode**

Embedded PGlite.

**Access context**

Board and agent tool approval flow.

## What Changed

- Keep a pending request with a null signature out of
`listActionRequests` results.
- Cancel a request when its non-null signature fails verification.
- Add a permanent regression test for the unsigned request transition.
- Update the contract test for unsigned and invalid-signature requests.

## Verification

- Run the tool access service, tool gateway service, tool gateway, and
tool access policy service tests.
- Confirm 227 tests pass.
- Run the `@mcp-runnable` Playwright end-to-end suite in CI.
- Run the US-9 loop 30 times in CI.

## Risks

The change alters review-queue filtering for unsigned requests. A null
signature now means that signing remains in progress. Invalid signed
requests keep the existing cancellation behavior. The change has no
database migration.

## Model Used

OpenAI Codex, GPT-5, with tool use and code execution. The model
reviewed the handoff, repository rules, and pull request state. The
implementation author supplied the code and tests.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-13 16:15:12 -07:00

158 lines
6.1 KiB
TypeScript

// Regression: the tool review queue must not cancel an ask-first action request
// that the gateway has created but not signed yet.
//
// The gateway builds a require-approval action request in two steps: it first
// inserts the row with a null signature, then signs the row a moment later. A
// review-queue read (listActionRequests) that lands inside that window must hide
// the unsigned row from the queue, but must leave it pending. If the read
// cancels the row, the following approve call fails with action_not_pending.
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
companies,
createDb,
toolApplications,
toolActionRequests,
toolCatalogEntries,
toolConnections,
toolInvocations,
} from "@paperclipai/db";
import { toolAccessService } from "../services/tool-access.js";
import { createToolGatewayService } from "../services/tool-gateway.js";
import { canonicalToolArguments, signToolArguments } from "../services/tool-content-guards.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const signingSecret = "review-queue-regression-secret";
describeEmbeddedPostgres("tool review queue vs unsigned ask-first request", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-review-queue-unsigned-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterAll(async () => {
await tempDb?.cleanup();
});
it("hides an unsigned pending request from the queue, keeps it pending, and lets approve succeed once it is signed", async () => {
vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", signingSecret);
const [company] = await db.insert(companies).values({
name: `Review Queue ${randomUUID()}`,
issuePrefix: `RQ${randomUUID().slice(0, 6).toUpperCase()}`,
}).returning();
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
name: `Review Queue app ${randomUUID()}`,
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: `Review Queue connection ${randomUUID()}`,
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: { url: "https://fixture.example/mcp" },
}).returning();
const [catalogEntry] = await db.insert(toolCatalogEntries).values({
companyId: company.id,
applicationId: application.id,
connectionId: connection.id,
name: "sheets:update_cell",
toolName: "sheets:update_cell",
title: "Update sheet cell",
riskLevel: "write",
isWrite: true,
status: "active",
versionHash: "v1",
schemaHash: "s1",
}).returning();
const parameters = { cell: "B1", value: "first" };
const canonicalArguments = canonicalToolArguments(parameters);
// A test-origin ask-first invocation, exactly as recordInvocation records it.
const [invocation] = await db.insert(toolInvocations).values({
companyId: company.id,
actorType: "user",
actorId: "board",
agentId: null,
runId: null,
issueId: null,
applicationId: application.id,
connectionId: connection.id,
catalogEntryId: catalogEntry.id,
toolName: "sheets:update_cell",
argumentsHash: "args-hash",
argumentsSummary: { summary: canonicalArguments, sha256: "args-hash", sizeBytes: canonicalArguments.length },
policyDecision: "require_approval",
approvalState: "pending",
status: "awaiting_approval",
}).returning();
// Step one of the two-step create: the row exists, pending, not yet signed.
const [actionRequest] = await db.insert(toolActionRequests).values({
companyId: company.id,
invocationId: invocation.id,
status: "pending",
canonicalArgumentsHash: "args-hash",
canonicalArgumentsSummary: { summary: canonicalArguments, sha256: "args-hash", sizeBytes: canonicalArguments.length },
signedArguments: null,
}).returning();
// A concurrent review-queue read lands inside the create window.
const listedDuringCreate = await toolAccessService(db).listActionRequests(company.id, "pending");
expect(listedDuringCreate.map((item) => item.request.id)).not.toContain(actionRequest.id);
const [afterRead] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.id, actionRequest.id));
expect(afterRead.status).toBe("pending");
// Step two of the create: the gateway signs the row.
const signedArguments = signToolArguments({
invocationId: invocation.id,
toolName: invocation.toolName,
canonicalArguments,
executionOnApprove: true,
signingSecret,
});
await db
.update(toolActionRequests)
.set({ signedArguments, updatedAt: new Date() })
.where(eq(toolActionRequests.id, actionRequest.id));
// The queue now shows the signed request.
const listedAfterSign = await toolAccessService(db).listActionRequests(company.id, "pending");
expect(listedAfterSign.map((item) => item.request.id)).toContain(actionRequest.id);
// Approve no longer races a cancelled row.
const gateway = createToolGatewayService(db, { toolActionSigningSecret: signingSecret });
await expect(
gateway.approveActionRequest({
companyId: company.id,
actionRequestId: actionRequest.id,
actor: { userId: "board" },
}),
).resolves.toBeTruthy();
const [afterApprove] = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.id, actionRequest.id));
expect(["approved", "executed", "failed"]).toContain(afterApprove.status);
});
});