mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The hidden native coordinator already computes a run-scoped semantic tool projection. > - The durable Codex backend now accepts and enforces that projection. > - The server did not include the projection in its `run.prepare` command. > - Codex therefore received no production semantic tools even when the server authorized them. > - This pull request adds the deterministic wire projection and sends it to runnerd. > - The benefit is one fail-closed authorization catalog from the server through Codex. ## Linked Issues or Issue Description Refs #12384 **What existing behavior does this improve?** This improves the existing flagged Paperclip Runner Codex path. **Current behavior** The server creates a run-scoped list of authorized read tools. It does not pass that list to runnerd, so the production Codex session starts with no tools. **Proposed behavior** The server maps the authorized definitions to the versioned runner contract. It computes a cross-language catalog digest. It includes that immutable contract in `run.prepare`. **Reason and benefit** Runnerd and the server now enforce the same catalog identity. Unknown, duplicate, changed, or malformed tool contracts fail before Codex can use them. **Breaking changes** None. Direct adapters are unchanged. A native run with an empty server projection still starts with no dynamic tools. ## What Changed - Add a deterministic semantic-definition to runner-authorization projection. - Match the Rust canonical digest with a shared test vector. - Include the server coordinator projection in the native Codex `run.prepare` command. - Extend the native Codex vertical slice to require and execute a semantic tool. - Verify the production prepare payload in a host-independent server test. ## Verification - `pnpm --filter @paperclipai/paperclip-runner test:typescript` (354 tests pass) - `pnpm --filter @paperclipai/server exec vitest run src/services/native-runtime/native-codex-runner.test.ts` - `pnpm --filter @paperclipai/server typecheck` - `pnpm -r typecheck` - `pnpm build` - The embedded-Postgres vertical slice is present for CI. This local host reports that embedded Postgres is unavailable, so Vitest skips that host-dependent test locally. - Confirmed that the PR changes 7 files against `runner-codex-durable-tools`. - Confirmed that `pnpm-lock.yaml` is unchanged. ## Risks The main risk is a catalog digest mismatch between TypeScript and Rust. Both implementations use canonical JSON. They share the same fixed digest vector. Runnerd also recomputes the digest and rejects a mismatch. The rollout flag and the existing native runtime selection rules remain unchanged. ## Model Used OpenAI Codex with GPT-5 and repository tool use. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { accessSync, chmodSync, constants, mkdirSync, readFileSync } from "node:fs";
|
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import type { AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
|
import type { Db } from "@paperclipai/db";
|
|
|
|
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
|
import {
|
|
createPaperclipRunnerAuthorizedToolSet,
|
|
type PaperclipSemanticToolDefinition,
|
|
} from "../../vendor/paperclip-runner/index.js";
|
|
import { runnerPrpCoordinator } from "./runner-prp-coordinator.js";
|
|
|
|
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
const RUNNER_VERSION = "paperclip-runner-v1";
|
|
|
|
interface NativeRunnerProviderLaunch {
|
|
readonly command: string;
|
|
readonly args: string[];
|
|
readonly providerVersion?: string;
|
|
}
|
|
|
|
interface NativeRunnerPrepareInput {
|
|
readonly cwd: string;
|
|
readonly model: string | null;
|
|
readonly resumeProviderSessionId: string | null;
|
|
readonly completionContract: { revision: string; criterionIds: string[] };
|
|
readonly semanticTools: readonly PaperclipSemanticToolDefinition[];
|
|
readonly providerLaunch?: NativeRunnerProviderLaunch;
|
|
}
|
|
|
|
export function buildNativeRunnerPreparePayload(
|
|
input: NativeRunnerPrepareInput,
|
|
): Record<string, unknown> {
|
|
return {
|
|
provider: {
|
|
provider: "codex",
|
|
driver: "codex_app_server",
|
|
providerVersion: input.providerLaunch?.providerVersion ?? "codex-app-server-v1",
|
|
command: input.providerLaunch?.command ?? "codex",
|
|
args: input.providerLaunch?.args ?? ["app-server"],
|
|
cwd: input.cwd,
|
|
...(input.model ? { model: input.model } : {}),
|
|
...(input.resumeProviderSessionId
|
|
? { providerSessionId: input.resumeProviderSessionId }
|
|
: {}),
|
|
instructions: "",
|
|
approvalPolicy: "never",
|
|
},
|
|
completionContract: input.completionContract,
|
|
authorizedTools: createPaperclipRunnerAuthorizedToolSet(input.semanticTools),
|
|
};
|
|
}
|
|
|
|
function executableName(): string {
|
|
return process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
|
|
}
|
|
|
|
export function resolvePaperclipRunnerBinary(
|
|
configuredPath = process.env.PAPERCLIP_RUNNER_BINARY,
|
|
): string {
|
|
const candidates = [
|
|
configuredPath,
|
|
resolve(moduleDirectory, "../../vendor/paperclip-runner/bin", executableName()),
|
|
resolve(moduleDirectory, "../../../../packages/paperclip-runner/dist/bin", executableName()),
|
|
resolve(
|
|
moduleDirectory,
|
|
"../../../../packages/paperclip-runner/runner/target/release",
|
|
executableName(),
|
|
),
|
|
].filter((candidate): candidate is string => Boolean(candidate));
|
|
if (configuredPath && !isAbsolute(configuredPath)) {
|
|
throw new Error("PAPERCLIP_RUNNER_BINARY must be an absolute path");
|
|
}
|
|
for (const candidate of candidates) {
|
|
try {
|
|
accessSync(candidate, constants.R_OK | (process.platform === "win32" ? 0 : constants.X_OK));
|
|
return candidate;
|
|
} catch {
|
|
// Continue through the fixed production and workspace locations.
|
|
}
|
|
}
|
|
throw new Error(
|
|
"paperclip_runner_binary_missing: build @paperclipai/paperclip-runner or set PAPERCLIP_RUNNER_BINARY",
|
|
);
|
|
}
|
|
|
|
export function buildNativeRunnerArguments(input: {
|
|
connectUrl: string;
|
|
stateDirectory: string;
|
|
runnerInstanceId: string;
|
|
environmentLeaseId: string;
|
|
runId: string;
|
|
normalizedSessionId: string;
|
|
turnId: string;
|
|
itemId: string;
|
|
runnerDigest: string;
|
|
maxRuntimeMs: number;
|
|
}): string[] {
|
|
return [
|
|
"--connect-url", input.connectUrl,
|
|
"--state-dir", input.stateDirectory,
|
|
"--runner-id", input.runnerInstanceId,
|
|
"--environment-lease-id", input.environmentLeaseId,
|
|
"--run-id", input.runId,
|
|
"--session-id", input.normalizedSessionId,
|
|
"--turn-id", input.turnId,
|
|
"--item-id", input.itemId,
|
|
"--runner-version", RUNNER_VERSION,
|
|
"--runner-digest", input.runnerDigest,
|
|
"--max-runtime-ms", String(input.maxRuntimeMs),
|
|
];
|
|
}
|
|
|
|
function privateDirectory(path: string): void {
|
|
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
if (process.platform !== "win32") chmodSync(path, 0o700);
|
|
}
|
|
|
|
function waitForExit(child: ChildProcess): Promise<{
|
|
code: number | null;
|
|
signal: NodeJS.Signals | null;
|
|
}> {
|
|
return new Promise((resolveExit, rejectExit) => {
|
|
child.once("error", rejectExit);
|
|
child.once("exit", (code, signal) => resolveExit({ code, signal }));
|
|
});
|
|
}
|
|
|
|
async function waitForChildExit(exit: Promise<unknown>, timeoutMs: number): Promise<boolean> {
|
|
let timer: NodeJS.Timeout | null = null;
|
|
try {
|
|
return await Promise.race([
|
|
exit.then(() => true),
|
|
new Promise<false>((resolveTimeout) => {
|
|
timer = setTimeout(() => resolveTimeout(false), timeoutMs);
|
|
timer.unref();
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function signalRunnerProcessGroup(child: ChildProcess, signal: NodeJS.Signals): void {
|
|
if (process.platform !== "win32" && child.pid) {
|
|
try {
|
|
process.kill(-child.pid, signal);
|
|
return;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ESRCH") return;
|
|
}
|
|
}
|
|
child.kill(signal);
|
|
}
|
|
|
|
async function stopChild(
|
|
child: ChildProcess,
|
|
exit: Promise<unknown>,
|
|
allowGracefulExit = false,
|
|
): Promise<void> {
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
if (allowGracefulExit && await waitForChildExit(exit, 5_000)) return;
|
|
signalRunnerProcessGroup(child, "SIGTERM");
|
|
if (await waitForChildExit(exit, 5_000)) return;
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
signalRunnerProcessGroup(child, "SIGKILL");
|
|
}
|
|
}
|
|
|
|
export async function executeNativeCodexRunner(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
issueId: string;
|
|
runId: string;
|
|
agentId: string;
|
|
runnerInstanceId: string;
|
|
environmentLeaseId: string;
|
|
normalizedSessionId: string;
|
|
turnId: string;
|
|
itemId: string;
|
|
cwd: string;
|
|
prompt: string;
|
|
model: string | null;
|
|
resumeProviderSessionId: string | null;
|
|
completionContract: { revision: string; criterionIds: string[] };
|
|
timeoutMs: number;
|
|
environment: Record<string, string>;
|
|
/** Internal test seam; production always resolves the packaged binary. */
|
|
runnerBinary?: string;
|
|
/** Internal test seam; production always uses the instance runtime root. */
|
|
runtimeRoot?: string;
|
|
/** Internal conformance seam; production always launches `codex app-server`. */
|
|
providerLaunch?: NativeRunnerProviderLaunch;
|
|
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
onSpawn: (meta: {
|
|
pid: number;
|
|
processGroupId: number | null;
|
|
startedAt: string;
|
|
}) => Promise<void>;
|
|
}): Promise<AdapterExecutionResult> {
|
|
const binary = input.runnerBinary ?? resolvePaperclipRunnerBinary();
|
|
const runnerDigest = `sha256:${createHash("sha256").update(readFileSync(binary)).digest("hex")}`;
|
|
const runtimeRoot = input.runtimeRoot
|
|
? resolve(input.runtimeRoot)
|
|
: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner");
|
|
const runnerStateDirectory = resolve(runtimeRoot, "runner", input.runId);
|
|
privateDirectory(runtimeRoot);
|
|
privateDirectory(resolve(runtimeRoot, "control-plane"));
|
|
privateDirectory(resolve(runtimeRoot, "runner"));
|
|
privateDirectory(runnerStateDirectory);
|
|
|
|
const prepared = await runnerPrpCoordinator(input.db, {
|
|
stateRoot: resolve(runtimeRoot, "control-plane"),
|
|
}).prepare({
|
|
companyId: input.companyId,
|
|
issueId: input.issueId,
|
|
runId: input.runId,
|
|
agentId: input.agentId,
|
|
runnerInstanceId: input.runnerInstanceId,
|
|
environmentLeaseId: input.environmentLeaseId,
|
|
normalizedSessionId: input.normalizedSessionId,
|
|
turnId: input.turnId,
|
|
itemId: input.itemId,
|
|
runnerVersion: RUNNER_VERSION,
|
|
runnerDigest,
|
|
});
|
|
|
|
prepared.queueCommand("run.prepare", buildNativeRunnerPreparePayload({
|
|
cwd: input.cwd,
|
|
model: input.model,
|
|
resumeProviderSessionId: input.resumeProviderSessionId,
|
|
completionContract: input.completionContract,
|
|
semanticTools: prepared.semanticTools,
|
|
...(input.providerLaunch ? { providerLaunch: input.providerLaunch } : {}),
|
|
}), `prepare_${input.runId}`);
|
|
prepared.queueCommand("session.open", {}, `open_${input.runId}`);
|
|
prepared.queueCommand("turn.start", { text: input.prompt }, `turn_${input.runId}`);
|
|
|
|
const child = spawn(binary, buildNativeRunnerArguments({
|
|
connectUrl: prepared.connectUrl,
|
|
stateDirectory: runnerStateDirectory,
|
|
runnerInstanceId: input.runnerInstanceId,
|
|
environmentLeaseId: input.environmentLeaseId,
|
|
runId: input.runId,
|
|
normalizedSessionId: input.normalizedSessionId,
|
|
turnId: input.turnId,
|
|
itemId: input.itemId,
|
|
runnerDigest,
|
|
maxRuntimeMs: input.timeoutMs,
|
|
}), {
|
|
cwd: input.cwd,
|
|
detached: process.platform !== "win32",
|
|
env: {
|
|
...process.env,
|
|
...input.environment,
|
|
PAPERCLIP_RUNNER_BOOTSTRAP_TICKET: prepared.bootstrapTicket,
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const exit = waitForExit(child);
|
|
child.stdout?.on("data", (chunk: Buffer) => {
|
|
void input.onLog("stdout", chunk.toString("utf8"));
|
|
});
|
|
child.stderr?.on("data", (chunk: Buffer) => {
|
|
void input.onLog("stderr", chunk.toString("utf8"));
|
|
});
|
|
|
|
try {
|
|
if (!child.pid) throw new Error("paperclip_runner_process_not_started");
|
|
await input.onSpawn({
|
|
pid: child.pid,
|
|
processGroupId: process.platform === "win32" ? null : child.pid,
|
|
startedAt: new Date().toISOString(),
|
|
});
|
|
const completed = await Promise.race([
|
|
prepared.waitForTerminal(input.timeoutMs),
|
|
exit.then(async ({ code, signal }) => {
|
|
const recovered = await prepared.waitForTerminal(2_000).catch(() => null);
|
|
if (recovered) return recovered;
|
|
throw new Error(
|
|
`paperclip_runner_process_exited: code=${code ?? "null"} signal=${signal ?? "null"}`,
|
|
);
|
|
}),
|
|
]);
|
|
prepared.queueCommand("session.close", {}, `close_${input.runId}`);
|
|
prepared.queueCommand("runner.shutdown", {}, `shutdown_${input.runId}`);
|
|
await stopChild(child, exit, true);
|
|
|
|
const succeeded = completed.terminal.runTerminalState === "succeeded";
|
|
return {
|
|
exitCode: succeeded ? 0 : 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
...(succeeded ? {} : {
|
|
errorCode: "paperclip_runner_provider_failed",
|
|
errorMessage: completed.result.summary,
|
|
}),
|
|
provider: "codex",
|
|
model: input.model,
|
|
sessionParams: {
|
|
sessionId: completed.providerSessionId ?? input.normalizedSessionId,
|
|
},
|
|
sessionDisplayId: completed.providerSessionId ?? input.normalizedSessionId,
|
|
resultJson: {
|
|
nativeRunner: {
|
|
result: completed.result,
|
|
terminal: completed.terminal,
|
|
},
|
|
},
|
|
summary: completed.result.summary,
|
|
};
|
|
} finally {
|
|
await stopChild(child, exit).catch(() => undefined);
|
|
await prepared.release();
|
|
}
|
|
}
|