diff --git a/doc/plans/paperclip-runner-protocol-binding-roster.md b/doc/plans/paperclip-runner-protocol-binding-roster.md index 0724e9c89c0..d6ef760cc15 100644 --- a/doc/plans/paperclip-runner-protocol-binding-roster.md +++ b/doc/plans/paperclip-runner-protocol-binding-roster.md @@ -8,7 +8,7 @@ This roster is the implementation ledger for binding the canonical Paperclip Run - Actions in the Codex semantic catalog: **28** - Actions advertised by the production Paperclip authority: **13** - Actions with a real Paperclip service binding: **13** -- Actions audited through the shared Paperclip-server PRP route: **13 service bindings; route smoke audit pending** +- Actions audited through the shared Paperclip-server PRP route: **1 full real-service vertical slice; 12 additional bound operations unit-audited** - Existing semantic conformance bridge: five route-backed test operations (`report_progress`, `write_document`, confirmation-only `request_human_input`, `set_dependencies`, and `finish_task`). It is test-only and is not a production binding. The generated action metadata currently says `realServiceBinding: unbound` and `prpBindingStatus: audit_pending` for every action. `realBindingStatus: live_codex` means only that a Codex scenario exercised the mock semantic implementation; it must not be interpreted as a production Paperclip binding. @@ -54,3 +54,10 @@ authority still authenticates the one-time bootstrap ticket and durable lease inside PRP. Unknown runs and malformed run IDs fail closed before the protocol handshake. The run ID is a path segment because runnerd deliberately rejects query-bearing WebSocket URLs to avoid URL ambiguity. + +`paperclip-runner-real-server.integration.test.ts` is the first complete proof: +an embedded real Paperclip database and active native heartbeat run are routed +through the shared server endpoint to Rust runnerd and the fake Codex +app-server; Codex calls `get_task_context`, and the result is read from the real +run-bound Paperclip authority. The remaining advertised operations still need +their own route-level cases before the roster can call all 13 fully audited. diff --git a/server/src/services/native-runtime/paperclip-runner-real-server.integration.test.ts b/server/src/services/native-runtime/paperclip-runner-real-server.integration.test.ts new file mode 100644 index 00000000000..aca40ba9c9b --- /dev/null +++ b/server/src/services/native-runtime/paperclip-runner-real-server.integration.test.ts @@ -0,0 +1,130 @@ +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { eq } from "drizzle-orm"; +import { agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { + createRunnerdCodexTransport, + defaultCapabilityRunnerdBinary, +} from "../../vendor/paperclip-runner/index.js"; +import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { + registerRunnerPrpAuthority, + runnerPrpWebSocketInternals, + setupRunnerPrpWebSocketServer, +} from "../../realtime/runner-prp-ws.js"; +import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; + +describe("paperclip-runner real server vertical slice", () => { + let temporary: Awaited>; + const companyId = "00000000-0000-4000-8000-000000000701"; + const agentId = "00000000-0000-4000-8000-000000000702"; + const issueId = "00000000-0000-4000-8000-000000000703"; + const runId = "00000000-0000-4000-8000-000000000704"; + + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("paperclip-runner-real-server-"); + }); + + afterAll(async () => { + runnerPrpWebSocketInternals.resetForTests(); + await temporary.cleanup(); + }); + + it("runs Rust runnerd through Paperclip PRP and reads the real bound task", async () => { + const db = createDb(temporary.connectionString); + await db.insert(companies).values({ id: companyId, name: "Real runner slice", issuePrefix: "RRS" }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Real runner agent", + adapterType: "paperclip_runner", + adapterConfig: { provider: "codex" }, + runtimeConfig: {}, + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + identifier: "RRS-1", + title: "Read me through the real control plane", + status: "in_progress", + workMode: "standard", + assigneeAgentId: agentId, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + status: "running", + runtimeMode: "native", + invocationSource: "assignment", + triggerDetail: "system", + contextSnapshot: { issueId }, + }); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + + const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId }); + const server = createServer(); + await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("Expected a TCP listener."); + setupRunnerPrpWebSocketServer(server, { port: address.port }); + const bundle = createRunnerdCodexTransport({ + runnerBinary: defaultCapabilityRunnerdBinary(), + codexCommand: resolve(import.meta.dirname, "../../../../packages/paperclip-runner/runner/target/debug/fake-codex-app-server"), + codexArgs: [], + prpIdentity: { + runnerInstanceId: "runner-real-server", + environmentLeaseId: "lease-real-server", + runId, + normalizedSessionId: "session-real-server", + turnId: "turn-real-server", + itemId: "item-real-server", + }, + controlPlaneRegistration: (prp) => registerRunnerPrpAuthority({ runId, authority: prp }), + }); + const observedResults: unknown[] = []; + bundle.transport.setServerRequestHandler(async (request) => { + const params = request.params as Record; + const result = await authority.execute({ + tool: String(params.tool), + callId: String(params.callId), + arguments: params.arguments, + }); + observedResults.push(result); + return { + success: true, + contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, result }) }], + }; + }); + + try { + await bundle.transport.request("initialize", {}); + await bundle.transport.request("thread/start", { + cwd: tmpdir(), + dynamicTools: authority.definitions(), + }); + await bundle.transport.request("turn/start", { + input: [{ type: "text", text: "Read your assigned task context." }], + }); + for await (const notification of bundle.transport.notifications()) { + if (notification.method === "turn/completed") break; + } + expect(observedResults).toHaveLength(1); + expect(observedResults[0]).toMatchObject({ + activeTask: { id: issueId, identifier: "RRS-1", title: "Read me through the real control plane" }, + actor: { id: agentId }, + run: { id: runId }, + }); + expect(bundle.evidence().diagnostics).toContain("runnerd authenticated to the durable PRP control plane"); + } finally { + await bundle.transport.close(); + server.closeAllConnections(); + server.close(); + } + }, 30_000); +});