mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
feat(runner): add hidden server PRP coordinator (#12176)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Paperclip Runner needs a narrow server trust boundary before an adapter can start it. > - The package has durable runner transport, but the server does not host or authorize that transport. > - Native persistence exists, but no writer connects PRP events to those records. > - A direct adapter must not enter this path by accident. > - This pull request adds a hidden, run-bound PRP server coordinator. > - The benefit is a recoverable server boundary that remains unavailable to normal execution. ## Linked Issues or Issue Description Refs #11962 Refs #12129 Refs #12169 **Subsystem affected** Cross-cutting. The change affects the runner package and server orchestration. **Problem or motivation** The server cannot authenticate runnerd, commit PRP events before ACK, authorize semantic tools, or enter native finalization from a durable runner result. The application must have this hidden boundary before a guarded adapter can use the runner. **Proposed solution** Add an authenticated PRP WebSocket authority and register it only for one exact persisted native Codex run. Bind each connection and event to the company, issue, agent, run, runner, session, turn, item, and verified runner identity. Commit each event before its cumulative ACK. Project only authorized same-task read tools. Rebuild the accepted result and finalization record from durable result and terminal events. **Alternatives considered** The server could expose a broad runner API key or route semantic calls through existing adapter endpoints. Those options grant too much authority and weaken replay recovery. The server could also add the user-facing adapter in this pull request. That option would mix rollout selection with the transport trust boundary and make legacy compatibility harder to review. **Roadmap alignment** This work supports the shipped enforced-outcomes, governed-tool, and self-healing-run milestones. It does not add a new roadmap surface. ## What Changed - Add the durable PRP server authority with one-use bootstrap tickets, reconnect leases, encrypted frames, bounded state, cumulative ACKs, and idempotent commands. - Add `/api/runner/v1/connect/:runId`. Derive its `ws://` or `wss://` URL from the configured Paperclip API URL. - Register one authority only after the coordinator verifies the complete native Codex run binding. - Commit validated PRP events to `heartbeat_run_events` before ACK. Reject source gaps and conflicting replays. - Rebuild accepted results and finalization records from durable result and terminal events. Enforce finalization owner leases and retry times. - Project five same-task read operations. Recheck run, agent, task, and company authority for each call. - Keep the route hidden. No adapter selects this coordinator, and no code starts runnerd. - Vendor the compiled runner TypeScript runtime into the server package while keeping the workspace package development-only for the server. - Document the package, database writer, run-log payload, and credential exclusions. ## Verification - Run `pnpm --filter @paperclipai/paperclip-runner check:all`. All TypeScript protocol checks and 69 Vitest tests pass, including commit-before-ACK crash recovery. All 43 Rust unit tests and 13 Rust integration tests pass. Conformance and replay parity pass. - Run the focused server WebSocket, coordinator, package-build, and startup-wiring suites. All 26 tests pass, including a clean-checkout reproduction with the runner `dist` directory absent. - Run `pnpm -r typecheck`. - Run `pnpm test:run`. - Run `pnpm build`. - Confirm that the diff contains 19 files. Confirm that it contains no workflow or `pnpm-lock.yaml` change. ## Risks - The server installs the WebSocket route at startup. An unregistered or malformed run path fails closed and creates no native record. - Bootstrap tickets are one use. The private state directory uses mode `0700`, and the state file uses mode `0600`. The file stores derived authentication verifiers and never stores raw tickets or lease tokens. - The journal has explicit frame, command, event-window, and file-size bounds. A bound violation closes the runner connection or rejects the command. - A runner event reaches the database before its ACK. A crash between event commit and ACK causes a byte-equivalent replay, not a second logical effect. - The coordinator accepts only an existing queued or running native Codex row with exact company, task, agent, runner, session, and completion-contract ownership. - Existing direct adapters do not call this service. They keep their current execution, transcript, result, and finalization paths. - The server has no production dependency on the private runner package. Its build copies the compiled runtime into `server/dist`; the workspace link is development-only. This adds no external package and does not change the lockfile. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with GPT-5. The exact deployment ID and context-window size are not exposed. The model used agentic reasoning, repository tools, code execution, and test execution. ## 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] I have considered and documented any risks above - [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
This commit is contained in:
1 parent
86fe9339e1
commit
9964b034bb
19 files changed
+4129
-15
No files matched your search
@@ -0,0 +1,93 @@
|
||||
import { createServer } from "node:http";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import type { DurablePrpControlPlane } from "@paperclipai/paperclip-runner";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
registerRunnerPrpAuthority,
|
||||
runnerPrpWebSocketInternals,
|
||||
setupRunnerPrpWebSocketServer,
|
||||
} from "./runner-prp-ws.js";
|
||||
|
||||
describe("runner PRP websocket route", () => {
|
||||
afterEach(() => runnerPrpWebSocketInternals.resetForTests());
|
||||
|
||||
it("routes only the registered run and releases it by generation", async () => {
|
||||
const server = createServer();
|
||||
setupRunnerPrpWebSocketServer(server, { apiUrl: "http://127.0.0.1:3210" });
|
||||
const handleUpgrade = vi.fn();
|
||||
const runId = "00000000-0000-4000-8000-000000000777";
|
||||
const registration = await registerRunnerPrpAuthority({
|
||||
companyId: "company-1",
|
||||
runId,
|
||||
authority: { handleUpgrade } as unknown as DurablePrpControlPlane,
|
||||
});
|
||||
|
||||
expect(registration.connectUrl).toBe(
|
||||
`ws://127.0.0.1:3210/api/runner/v1/connect/${runId}`,
|
||||
);
|
||||
expect(
|
||||
runnerPrpWebSocketInternals.activeRegistration({
|
||||
companyId: "company-1",
|
||||
runId,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const socket = new PassThrough();
|
||||
const request = { url: `/api/runner/v1/connect/${runId}`, headers: {} };
|
||||
server.emit("upgrade", request, socket, Buffer.alloc(0));
|
||||
expect(request).toMatchObject({ paperclipWebSocketHandled: true });
|
||||
expect(handleUpgrade).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: `/api/runner/v1/connect/${runId}` }),
|
||||
socket,
|
||||
`/api/runner/v1/connect/${runId}`,
|
||||
expect.any(Buffer),
|
||||
);
|
||||
|
||||
await registration.release();
|
||||
expect(
|
||||
runnerPrpWebSocketInternals.activeRegistration({
|
||||
companyId: "company-1",
|
||||
runId,
|
||||
}),
|
||||
).toBe(false);
|
||||
server.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/api/runner/v1/connect/not-a-run", "400 Bad Request"],
|
||||
[
|
||||
"/api/runner/v1/connect/00000000-0000-4000-8000-000000000778",
|
||||
"404 Not Found",
|
||||
],
|
||||
])("fails closed for %s", (path, expectedStatus) => {
|
||||
const server = createServer();
|
||||
setupRunnerPrpWebSocketServer(server, { apiUrl: "http://127.0.0.1:3211" });
|
||||
const socket = new PassThrough();
|
||||
const writes: Buffer[] = [];
|
||||
socket.on("data", (chunk) => writes.push(Buffer.from(chunk)));
|
||||
server.emit("upgrade", { url: path, headers: {} }, socket, Buffer.alloc(0));
|
||||
expect(Buffer.concat(writes).toString("utf8")).toContain(expectedStatus);
|
||||
server.close();
|
||||
});
|
||||
|
||||
it("does not replace an active registration", async () => {
|
||||
const server = createServer();
|
||||
setupRunnerPrpWebSocketServer(server, { apiUrl: "http://127.0.0.1:3212" });
|
||||
const runId = "00000000-0000-4000-8000-000000000779";
|
||||
const authority = {
|
||||
handleUpgrade: vi.fn(),
|
||||
} as unknown as DurablePrpControlPlane;
|
||||
const first = await registerRunnerPrpAuthority({
|
||||
companyId: "company-1",
|
||||
runId,
|
||||
authority,
|
||||
});
|
||||
await expect(
|
||||
registerRunnerPrpAuthority({ companyId: "company-2", runId, authority }),
|
||||
).rejects.toThrow("runner_prp_authority_already_registered");
|
||||
await first.release();
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { IncomingMessage, Server } from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
|
||||
import type { DurablePrpControlPlane } from "../vendor/paperclip-runner/index.js";
|
||||
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
const CONNECT_PATH_PREFIX = "/api/runner/v1/connect/";
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
interface RegisteredAuthority {
|
||||
readonly companyId: string;
|
||||
readonly authority: DurablePrpControlPlane;
|
||||
readonly generation: symbol;
|
||||
}
|
||||
|
||||
interface RunnerPrpUpgradeRequest extends IncomingMessage {
|
||||
paperclipWebSocketHandled?: boolean;
|
||||
}
|
||||
|
||||
const registrations = new Map<string, RegisteredAuthority>();
|
||||
let loopbackOrigin: string | null = null;
|
||||
|
||||
function rejectUpgrade(
|
||||
socket: Duplex,
|
||||
status: "400 Bad Request" | "404 Not Found",
|
||||
): void {
|
||||
if (socket.destroyed) return;
|
||||
try {
|
||||
socket.end(
|
||||
`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ errorName: error instanceof Error ? error.name : typeof error },
|
||||
"failed to reject runner PRP websocket upgrade",
|
||||
);
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function setupRunnerPrpWebSocketServer(
|
||||
server: Server,
|
||||
options: { readonly apiUrl: string },
|
||||
): void {
|
||||
const apiUrl = new URL(options.apiUrl);
|
||||
if (!["http:", "https:"].includes(apiUrl.protocol)) {
|
||||
throw new Error("runner_prp_websocket_api_url_invalid");
|
||||
}
|
||||
apiUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
apiUrl.username = "";
|
||||
apiUrl.password = "";
|
||||
apiUrl.pathname = "";
|
||||
apiUrl.search = "";
|
||||
apiUrl.hash = "";
|
||||
loopbackOrigin = apiUrl.toString().replace(/\/$/, "");
|
||||
server.on(
|
||||
"upgrade",
|
||||
(request: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||
const url = new URL(request.url ?? "/", "http://paperclip.invalid");
|
||||
if (!url.pathname.startsWith(CONNECT_PATH_PREFIX)) return;
|
||||
|
||||
const ownedRequest = request as RunnerPrpUpgradeRequest;
|
||||
if (ownedRequest.paperclipWebSocketHandled) return;
|
||||
ownedRequest.paperclipWebSocketHandled = true;
|
||||
socket.on("error", (error) => {
|
||||
logger.warn(
|
||||
{ errorName: error.name },
|
||||
"runner PRP websocket upgrade socket failed",
|
||||
);
|
||||
});
|
||||
|
||||
const runId = url.pathname.slice(CONNECT_PATH_PREFIX.length);
|
||||
if (!UUID_PATTERN.test(runId)) {
|
||||
rejectUpgrade(socket, "400 Bad Request");
|
||||
return;
|
||||
}
|
||||
const registration = registrations.get(runId);
|
||||
if (!registration) {
|
||||
rejectUpgrade(socket, "404 Not Found");
|
||||
return;
|
||||
}
|
||||
registration.authority.handleUpgrade(request, socket, url.pathname, head);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function registerRunnerPrpAuthority(input: {
|
||||
readonly companyId: string;
|
||||
readonly runId: string;
|
||||
readonly authority: DurablePrpControlPlane;
|
||||
}): Promise<{ readonly connectUrl: string; release(): Promise<void> }> {
|
||||
if (loopbackOrigin === null) {
|
||||
throw new Error("runner_prp_websocket_server_not_configured");
|
||||
}
|
||||
if (!UUID_PATTERN.test(input.runId) || input.companyId.length === 0) {
|
||||
throw new Error("runner_prp_authority_binding_invalid");
|
||||
}
|
||||
if (registrations.has(input.runId)) {
|
||||
throw new Error("runner_prp_authority_already_registered");
|
||||
}
|
||||
const generation = Symbol(input.runId);
|
||||
registrations.set(input.runId, {
|
||||
companyId: input.companyId,
|
||||
authority: input.authority,
|
||||
generation,
|
||||
});
|
||||
return {
|
||||
connectUrl: `${loopbackOrigin}${CONNECT_PATH_PREFIX}${input.runId}`,
|
||||
release: async () => {
|
||||
if (registrations.get(input.runId)?.generation === generation) {
|
||||
registrations.delete(input.runId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const runnerPrpWebSocketInternals = {
|
||||
connectPathPrefix: CONNECT_PATH_PREFIX,
|
||||
activeRegistration(input: {
|
||||
readonly companyId: string;
|
||||
readonly runId: string;
|
||||
}): boolean {
|
||||
return registrations.get(input.runId)?.companyId === input.companyId;
|
||||
},
|
||||
resetForTests(): void {
|
||||
registrations.clear();
|
||||
loopbackOrigin = null;
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user