diff --git a/server/src/services/native-runtime/native-permission-projection.test.ts b/server/src/services/native-runtime/native-permission-projection.test.ts new file mode 100644 index 0000000000..718a57ba0a --- /dev/null +++ b/server/src/services/native-runtime/native-permission-projection.test.ts @@ -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).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"); + }); +}); diff --git a/server/src/services/native-runtime/native-question-bridge.test.ts b/server/src/services/native-runtime/native-question-bridge.test.ts index 75b9ba4abf..3943cc1fbe 100644 --- a/server/src/services/native-runtime/native-question-bridge.test.ts +++ b/server/src/services/native-runtime/native-question-bridge.test.ts @@ -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(); diff --git a/server/src/services/native-runtime/native-question-bridge.ts b/server/src/services/native-runtime/native-question-bridge.ts index 4d77db841a..12286bf71c 100644 --- a/server/src/services/native-runtime/native-question-bridge.ts +++ b/server/src/services/native-runtime/native-question-bridge.ts @@ -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; @@ -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 diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx index b251462d59..9af1b59b91 100644 --- a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx @@ -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( + + )); + 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), 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( diff --git a/ui/src/components/transcript/native-run-events.ts b/ui/src/components/transcript/native-run-events.ts index 2f39dbdbc0..fcc4bb13e9 100644 --- a/ui/src/components/transcript/native-run-events.ts +++ b/ui/src/components/transcript/native-run-events.ts @@ -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