Preserve privileged ACP approval projection and delivered lifecycle

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-28 12:30:19 -05:00
1 parent 170652d2d4
commit 5aeebb20c9
5 files changed
+248 -5

No files matched your search

@@ -0,0 +1,61 @@
import type { Db } from "@paperclipai/db";
import type { PrpEvent } from "../../vendor/paperclip-runner/index.js";
import { describe, expect, it, vi } from "vitest";
import { projectNativeRuntimeRequest } from "./native-question-bridge.js";
const binding = {
companyId: "company-1", issueId: "issue-1", runId: "run-1", agentId: "agent-1",
normalizedSessionId: "session-1", runnerSourceInstanceId: "runner-1",
};
function permissionEvent(): PrpEvent {
return {
schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceEventId: "permission-1",
sourceSeq: 1, sourceKind: "runner", sourceInstanceId: "runner-1", runId: "run-1",
normalizedSessionId: "session-1", turnId: "turn-1", itemId: "item-1",
eventType: "runtime_request.created", priority: 0, emittedAt: "2026-09-28T12:00:00Z",
payload: { request: {
schema: "paperclip.runtime_request.v2", requestKind: "permission_approval", type: "permission",
requestId: "permission-1", turnId: "turn-1", itemId: "item-1", status: "pending",
prompt: "Allow editing src/example.ts?",
choices: [{ key: "accept", label: "Allow once" }, { key: "decline", label: "Deny" }],
details: { toolCallId: "tool-1" },
origin: { adapter: "acpx-runtime-sidecar", provider: "acpx", method: "session/request_permission" },
} },
};
}
describe("native permission projection", () => {
it("admits the existing privileged runtime card without a human-only question mutation", async () => {
const event = permissionEvent();
const original = structuredClone(event);
const select = vi.fn();
await expect(projectNativeRuntimeRequest({ db: { select } as unknown as Db, binding, event })).resolves.toBeNull();
expect(select).not.toHaveBeenCalled();
expect(event).toEqual(original);
});
it("retains permissions with no provider item identity", async () => {
const event = permissionEvent();
delete event.itemId;
(event.payload.request as Record<string, unknown>).itemId = null;
await expect(projectNativeRuntimeRequest({ db: {} as Db, binding, event })).resolves.toBeNull();
});
it.each([
{ requestKind: "runtime" }, { type: "input" }, { turnId: "stale-turn" }, { itemId: "wrong-item" },
{ requestId: "../../forged" }, { prompt: "" }, { details: [] },
{ choices: [] }, { choices: [{ key: "allow_forever", label: "Always" }] },
{ choices: [{ key: "accept", label: "One" }, { key: "accept", label: "Two" }] },
])("rejects malformed or cross-bound permission data %j", async (override) => {
const event = permissionEvent();
Object.assign(event.payload.request as object, override);
await expect(projectNativeRuntimeRequest({ db: {} as Db, binding, event })).rejects.toThrow(/native_runtime_/);
});
it.each(["runId", "normalizedSessionId", "sourceInstanceId"] as const)("rejects a foreign %s before dispatch", async (field) => {
const event = permissionEvent();
event[field] = "foreign";
await expect(projectNativeRuntimeRequest({ db: {} as Db, binding, event })).rejects.toThrow("native_runtime_request_binding_mismatch");
});
});
@@ -1,5 +1,9 @@
import { createLocalNativeQuestionBridge } from "./local-native-question-bridge.js";
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq, sql } from "drizzle-orm";
@@ -39,6 +43,14 @@ import {
type IssuePostCommitAction,
} from "../issues.js";
import { heartbeatService } from "../heartbeat.js";
import { DurablePrpControlPlane } from "../../vendor/paperclip-runner/index.js";
import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
import { readPendingNativeRuntimeRequest } from "./runtime-request-resolution-authority.js";
import {
queueRunnerPrpRuntimeRequestResolution,
registerRunnerPrpAuthority,
setupRunnerPrpWebSocketServer,
} from "../../realtime/runner-prp-ws.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -201,6 +213,91 @@ describeEmbeddedPostgres("native question bridge", () => {
};
}
function permissionRequestEvent(): PrpEvent {
return { ...runtimeRequestEvent(), payload: { request: {
schema: "paperclip.runtime_request.v2", requestKind: "permission_approval",
requestId: "permission-1", turnId: "turn-1", itemId: "item-1",
type: "permission", status: "pending", prompt: "Allow editing src/example.ts?",
choices: [{ key: "accept", label: "Allow once" }, { key: "decline", label: "Deny" }],
details: { toolCallId: "tool-1" },
origin: { adapter: "acpx-runtime-sidecar", provider: "acpx", method: "session/request_permission" },
} } };
}
it("commits and acknowledges an ACP permission, then queues only an admin's exact turn-bound decision", async () => {
await seed();
const event = permissionRequestEvent();
const projection = vi.fn(async (committed: PrpEvent) => {
await projectNativeRuntimeRequest({ db, binding: binding(), event: committed });
});
const port = new PaperclipControlPlanePort(db, {
companyId, issueId, runId, agentId, sessionId,
completionContractId: binding().completionContractId,
completionContractSha256: binding().completionContractSha256,
sourceInstanceId: runnerInstanceId, controlPlaneSourceInstanceId: "control-1",
}, { onCommittedEvent: projection });
const receipt = await port.appendEvent(event);
expect(receipt).toMatchObject({ disposition: "committed", highestContiguousSourceSeq: 1 });
expect(projection).toHaveBeenCalledOnce();
expect(await db.select().from(issueThreadInteractions)).toHaveLength(0);
const [persisted] = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId));
expect(persisted?.payload).toEqual({ prpEvent: event }); // task-chat's exact source, with only offered decisions
expect(await port.appendEvent(event)).toMatchObject({ disposition: "duplicate" });
const pending = await readPendingNativeRuntimeRequest(db, { companyId, runId, requestId: "permission-1" });
expect(pending).toMatchObject({ requestKind: "permission_approval", turnId: "turn-1", resolverPolicy: "instance_admin" });
expect(await readPendingNativeRuntimeRequest(db, { companyId: randomUUID(), runId, requestId: "permission-1" })).toBeNull();
const stateDirectory = mkdtempSync(join(tmpdir(), "native-permission-authority-"));
const server = createServer();
setupRunnerPrpWebSocketServer(server, { apiUrl: "http://127.0.0.1:3213" });
const authority = new DurablePrpControlPlane({
stateDirectory,
identity: { runnerInstanceId, environmentLeaseId: "lease-1", runId, normalizedSessionId: sessionId, turnId: "turn-1", itemId: "item-1" },
expectedRunnerVersion: "0.3.0", expectedRunnerDigest: `sha256:${"a".repeat(64)}`,
});
const registration = await registerRunnerPrpAuthority({ companyId, runId, authority });
try {
const commandInput = { companyId, runId, pendingRequest: pending!, resolution: { action: "accept" as const },
actor: { type: "user" as const, userId: "admin-1", isInstanceAdmin: true } };
expect(() => queueRunnerPrpRuntimeRequestResolution({ ...commandInput, actor: { ...commandInput.actor, isInstanceAdmin: false } }))
.toThrow("native_runtime_request_resolver_denied");
expect(() => queueRunnerPrpRuntimeRequestResolution({ ...commandInput, companyId: randomUUID() }))
.toThrow("runner_prp_authority_not_active");
const queued = queueRunnerPrpRuntimeRequestResolution(commandInput);
expect(queueRunnerPrpRuntimeRequestResolution(commandInput)).toEqual(queued);
expect(authority.store.state.commands).toHaveLength(1);
expect(authority.store.state.commands[0]).toMatchObject({ type: "request.resolve", payload: {
requestId: "permission-1", requestKind: "permission_approval", turnId: "turn-1",
resolution: { action: "accept" }, resolutionActor: commandInput.actor,
} });
expect(() => queueRunnerPrpRuntimeRequestResolution({ ...commandInput, resolution: { action: "decline" } }))
.toThrow("runtime_request_resolution_conflict");
// Queueing is not delivery: only the runner's post-write receipt closes
// the request. ACP sidecar delivery tests exercise that provider edge.
expect(await readPendingNativeRuntimeRequest(db, { companyId, runId, requestId: "permission-1" })).toEqual(pending);
await port.appendEvent({ ...event, sourceEventId: "permission-delivered", sourceSeq: 2,
eventType: "runtime_request.resolved", payload: { requestId: "permission-1", requestKind: "permission_approval",
turnId: "turn-1", itemId: "item-1", status: "delivered", action: "accept" } });
expect(await readPendingNativeRuntimeRequest(db, { companyId, runId, requestId: "permission-1" })).toBeNull();
} finally {
await registration.release();
await authority.stop();
server.close();
rmSync(stateDirectory, { recursive: true, force: true });
}
});
it.each([
{ requestKind: "runtime" }, { type: "input" }, { turnId: "stale-turn" }, { itemId: "wrong-item" },
{ choices: [] }, { choices: [{ key: "allow_forever", label: "Always" }] },
{ choices: [{ key: "accept", label: "One" }, { key: "accept", label: "Two" }] },
])("rejects a malformed permission projection %j", async (override) => {
await seed();
const event = permissionRequestEvent();
Object.assign(event.payload.request as object, override);
await expect(projectNativeRuntimeRequest({ db, binding: binding(), event })).rejects.toThrow(/native_runtime_/);
});
it("projects an executor question immediately and routes its durable answer into the same live turn", async () => {
await seed();
const event = runtimeRequestEvent();
@@ -200,7 +200,7 @@ async function authorizedNativeRun(
return run ? { ...run, requestId } : null;
}
/** Materialize a canonical runtime input request as the existing task-thread card. */
/** Materialize questions; permission requests use the privileged runtime card. */
export async function projectNativeRuntimeRequest(input: {
db: Db;
binding: Pick<NativeRunStoreBinding, "companyId" | "issueId" | "runId" | "agentId" | "normalizedSessionId" | "runnerSourceInstanceId">;
@@ -218,14 +218,43 @@ export async function projectNativeRuntimeRequest(input: {
if (
!request
|| request.schema !== "paperclip.runtime_request.v2"
|| request.requestKind !== "runtime"
|| request.type !== "input"
|| request.status !== "pending"
|| typeof request.requestId !== "string"
|| !REQUEST_ID_PATTERN.test(request.requestId)
) {
throw new Error("native_runtime_request_invalid");
}
if (request.requestKind === "permission_approval" && request.type === "permission") {
const choices = Array.isArray(request.choices) ? request.choices.map(record) : [];
const supported = new Set(["accept", "accept_for_session", "decline", "cancel"]);
if (
typeof request.turnId !== "string"
|| request.turnId !== input.event.turnId
|| (request.itemId != null && typeof request.itemId !== "string")
|| (request.itemId ?? null) !== (input.event.itemId ?? null)
|| typeof request.prompt !== "string"
|| !request.prompt.trim()
|| request.prompt.length > 4000
|| choices.length === 0
|| choices.length > 4
|| choices.some((choice) => !choice
|| typeof choice.key !== "string" || !supported.has(choice.key)
|| typeof choice.label !== "string" || !choice.label.trim() || choice.label.length > 500)
|| new Set(choices.map((choice) => choice?.key)).size !== choices.length
|| (request.details !== undefined && !record(request.details))
) {
throw new Error("native_runtime_permission_invalid");
}
// The committed run event itself feeds TaskChatProtocolCard. Its decisions
// go through the instance-admin runtime-request route and the exact pending
// turn, not the human-only question-response delivery path. Returning here
// allows the durable coordinator to acknowledge the event without creating
// a second, less-privileged interaction or changing any offered choices.
return null;
}
if (request.requestKind !== "runtime" || request.type !== "input") {
throw new Error("native_runtime_request_invalid");
}
const questionSet = parsePaperclipQuestionSet(request.input);
if (questionSet.questions.some((question) => question.textValidation?.pattern !== undefined)) {
// JavaScript regular expressions have no execution budget. Provider-authored
@@ -12,7 +12,9 @@ import type {
TaskChatProviderActivityFamily,
TaskChatRuntimeRequestDecision,
} from "./task-chat-model";
import type { IssueWorkProduct } from "@paperclipai/shared";
import type { HeartbeatRunEvent, IssueWorkProduct } from "@paperclipai/shared";
import { nativeRunEventsToTranscript } from "../transcript/native-run-events";
import { transcriptToTaskChatItems } from "./transcript-adapter";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
import { RichWorkProductCard } from "./RichWorkProductCard";
import { stateChipFor } from "./RichWorkProductCard";
@@ -539,6 +541,55 @@ describe("TaskChatProtocolCard", () => {
expect(onDecision).toHaveBeenCalledWith({ action: "accept" });
});
it("renders committed ACP permission choices and preserves its exact resolution binding", async () => {
const event: HeartbeatRunEvent = {
id: 1, seq: 1, companyId: "company-1", runId: "run-1", agentId: "agent-1",
eventType: "runtime_request.created", stream: "system", level: "info", color: null, message: null,
createdAt: new Date("2026-09-28T12:00:00Z"),
payload: { prpEvent: {
schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceEventId: "permission-1", sourceSeq: 1,
sourceKind: "runner", sourceInstanceId: "runner-1", runId: "run-1", normalizedSessionId: "session-1",
turnId: "turn-1", itemId: "item-1", eventType: "runtime_request.created", priority: 0,
emittedAt: "2026-09-28T12:00:00Z", payload: { request: {
schema: "paperclip.runtime_request.v2", requestKind: "permission_approval", type: "permission",
requestId: "permission-1", turnId: "turn-1", itemId: "item-1", status: "pending",
prompt: "Allow editing src/example.ts?",
choices: [{ key: "accept", label: "Allow once" }, { key: "decline", label: "Deny" }],
details: { toolCallId: "tool-1" },
origin: { adapter: "acpx-runtime-sidecar", provider: "acpx", method: "session/request_permission" },
} },
} },
};
const item = transcriptToTaskChatItems(nativeRunEventsToTranscript([event]), {
runId: "run-1", agentName: "ACP", running: true,
}).find(candidate => candidate.kind === "protocol" && candidate.surface === "runtime_request");
if (item?.kind !== "protocol" || item.surface !== "runtime_request") throw new Error("permission card missing");
expect(item).toMatchObject({ runId: "run-1", requestId: "permission-1", turnId: "turn-1", requestKind: "permission_approval", status: "pending" });
expect(item.choices.map(choice => choice.key)).toEqual(["accept", "decline"]);
const resolve = vi.fn().mockResolvedValue(undefined);
flushSync(() => root.render(<MemoryRouter><ThemeProvider>
<TaskChatProtocolCard item={item} onRuntimeRequestDecision={resolve} />
</ThemeProvider></MemoryRouter>));
expect(container.textContent).toContain("Allow editing src/example.ts?");
const allow = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Allow once");
expect(allow?.disabled).toBe(false);
expect(container.textContent).not.toContain("Allow for session");
await act(async () => allow?.click());
expect(resolve).toHaveBeenCalledExactlyOnceWith(item, { action: "accept" });
const delivered: HeartbeatRunEvent = { ...event, id: 2, seq: 2, eventType: "runtime_request.resolved", payload: { prpEvent: {
...(event.payload!.prpEvent as Record<string, unknown>), sourceEventId: "permission-delivered", sourceSeq: 2,
eventType: "runtime_request.resolved", payload: { requestId: "permission-1", requestKind: "permission_approval",
turnId: "turn-1", itemId: "item-1", status: "delivered", action: "accept" },
} } };
const settled = transcriptToTaskChatItems(nativeRunEventsToTranscript([event, delivered]), {
runId: "run-1", agentName: "ACP", running: true,
}).find(candidate => candidate.kind === "protocol" && candidate.surface === "runtime_request");
if (settled?.kind !== "protocol" || settled.surface !== "runtime_request") throw new Error("receipt missing");
expect(settled).toMatchObject({ requestId: "permission-1", status: "resolved", resolvedAction: "accept" });
renderCard(root, settled, resolve);
expect(Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Allow once")).toBeUndefined();
});
it("submits structured runtime input through the production card", async () => {
const onDecision = vi.fn().mockResolvedValue(undefined);
renderCard(
@@ -149,7 +149,12 @@ function runtimeRequestEntry(input: {
const requestId = text(request.requestId) ?? text(input.payload.requestId);
if (!requestId) return null;
const suffix = input.eventType.split(".").at(-1);
const rawStatus = text(request.status) ?? suffix;
// The lifecycle event is authoritative. ACP delivery receipts carry
// status:"delivered" in their payload, which must not reopen a resolved
// request as pending just because it is not a UI lifecycle status.
const rawStatus = suffix === "resolved" || suffix === "expired" || suffix === "cancelled"
? suffix
: text(request.status) ?? suffix;
const resolvedAction = text(request.action)
?? text(input.payload.action)
?? input.previous?.resolvedAction