mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 11:13:44 +02:00
Preserve privileged ACP approval projection and delivered lifecycle
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user