Files
PaperClipAI/server/src/services/native-runtime/native-codex-runner.test.ts
T
DottaandClaude f25fee3532 fix(native): hash runner artifacts and run archive tar off the event loop (#20722)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - One server process prepares native runs, renews every controller
lease on timers, and serves every request
> - On a run path, the server hashed the 31 MB runner binary with
`readFileSync` (more than once per run), hashed provider packs and
harness backups synchronously, and ran `tar` with `execFileSync`
(timeouts up to 60 s, one with no timeout)
> - On a slow disk, each of these calls freezes the whole process. A 103
s stall on a hosted instance lapsed every lease. The last event before
that stall was immediately before a synchronous read and hash of the
runner binary (see #20694 and #20709)
> - This pull request moves those reads, hashes and archive commands off
the event loop. It computes the runner binary digest once per boot
> - The benefit is that a slow disk delays only the run that waits for
it. Turns also start faster, because the runner binary is no longer
re-read for every run

## Linked Issues or Issue Description

Refs #20694, #20709

**What happened?**

On a hosted instance, the server process stopped for 103 s without a
restart. Every controller lease lapsed, and two tasks needed a manual
Retry. The last run event before the gap was immediately before
`matchesControllerRunnerArtifact`, which does
`createHash("sha256").update(readFileSync(controllerRunnerBinary))`.
Four workspace restores were running at the same time, and file I/O was
very slow (a 43 s archive hash, 20 s snapshot decrypts). The trigger is
not proven. But each synchronous call below blocks the event loop for as
long as the disk is slow.

**Expected behavior**

File hashing and archive commands on a run path must not block the event
loop. A slow disk must delay only the run that waits for it.

**Steps to reproduce**

1. Make a remote runner binary slow to read (the test uses a FIFO whose
writer waits), or make `tar` slow (the test puts a `tar` wrapper that
sleeps first on `PATH`).
2. Call `resolveRemotePaperclipRunnerBinary`,
`stageRemoteRunnerDirectory`, or `syncRemoteRunnerDirectoryOut` while a
10 ms timer runs.
3. Before this change, the timer stops for the whole slow step (814 ms,
1,764 ms and 3,379 ms). After this change, it keeps firing.

## What Changed

- `server/src/services/native-runtime/off-loop-file-work.ts` (new):
  - `sha256FileStreamed`
- `cachedFileSha256`: a streamed digest, reused while the file's device,
inode, size, mtime and ctime do not change
- `binaryTargetFromHeader`: reads 64 KiB; reads the rest of the file
only when the header cannot answer, so the result equals
`runnerBinaryTarget(readFileSync(path))`
  - `sha256DirectoryTreeStreamed`
- `execFileBuffered`: `execFile` with stdin input, `maxBuffer`,
`timeout` and `env`. It rejects on a non-zero exit, a timeout or a
`maxBuffer` overflow, like `execFileSync`.
- `native-session-executor.ts`:
- `matchesControllerRunnerArtifact` uses `cachedFileSha256`. Each boot
reads the binary once, not once or twice for every run.
  - The remote Claude daemon target is read from the binary header.
- The bundled remote runner binary is checked by
`verifiedBundledRemoteRunnerBinary`: the same checks and errors, with a
cached digest.
- Provider-pack upload verification uses the new
`verifyRemoteProviderPackManifest`. It runs the same checks, in the same
order, with the same errors. One generator drives both the sync and the
streamed readers.
- Harness backup build and verify on run paths use streamed digests
(`buildNativeHarnessBackupManifestStreamed`,
`verifyNativeHarnessBackupStreamed`). The digest format does not change.
- `stageRemoteRunnerDirectory` (3 calls),
`assertSafeRemoteCheckpointArchive` (3 calls) and the checkpoint extract
use `execFileBuffered`, with the same arguments, timeouts and buffer
limits. `stageRemoteRunnerFile` uses `readFile`.
- `packages/paperclip-runner/src/live/runner-binary.ts`: the default
daemon lookup (`resolvePackagedRunnerBinary`, used by the local and
remote resolvers) classifies the daemon with `runnerBinaryFileTarget`.
It reads the 64 KiB header, not the whole file.
- `packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts`:
the release-manifest checks now live in one helper,
`bundledRemoteRunnerRelease`. The sync `bundledRemoteRunnerBinary` and
the server's streamed check both use it.
- `native-codex-runner.ts`:
  - `resolveRemotePaperclipRunnerBinary` reads only the header.
  - The local `runnerDigest` uses `cachedFileSha256`.
  - New `verifiedBundledRemoteRunnerBinary`.

**Ordering and atomicity.** I checked each converted call for code that
relied on the sync call being atomic with respect to other runs:
- **Checkpoint copy-back.** It extracts into a private `mkdtemp`
directory. The replace sequence (`existsSync`, `rename`, `rename`, `rm`)
stays synchronous, so no other run can see a half-installed checkpoint.
This function already awaited a remote command before the extract, so
this change adds no new interleaving with the target.
- **Harness backup verify then restore.** The verify was sync, but the
restore after it was already async. The window between them existed
before. One owner serializes the turns of a session, so no other turn
publishes a backup for that session while a turn verifies it.
- **Staging and provider-pack hashing.** These read build-owned,
immutable trees, or the private staging copies of a run.

## Verification

- Red/green: `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/off-loop-file-work.test.ts`
- With `native-session-executor.ts` and `native-codex-runner.ts` from
`origin/master`: 3 failed. The longest timer gaps were 814 ms (runner
binary target read), 1,764 ms (directory staging tar) and 3,379 ms
(checkpoint list, size check and extract).
- With this change: 6 passed. The gaps stay under 400 ms. The same file
also checks the digest cache, the header fallback, and the
`execFileSync`-like errors (exit code, timeout kill,
`ERR_CHILD_PROCESS_STDIO_MAXBUFFER`).
- `native-session-executor.test.ts`:
- The streamed provider-pack verifier returns the same manifest as the
sync reader, and rejects a changed asset tree with the same error.
  - The streamed harness backup manifest equals the sync one.
- The streamed verify agrees with the sync verify, for a valid backup
and for a corrupt one.
- Run together: `native-session-executor`, `off-loop-file-work`,
`native-codex-runner`, `setup-readiness`, `antigravity-connection-probe`
(1,653 passed). `run-completion.test.ts` passed all 153 tests. Its
`afterAll` embedded-Postgres cleanup timed out locally, which is not
related to this change; CI runs it.
- `pnpm --filter @paperclipai/server exec tsc --noEmit` passes.

## Risks

- **Turn start.** This change adds no wait before `turn.start`; it
removes work. The first run of a boot streams the runner binary once.
Later runs reuse the digest after one `stat`.
- **Cache staleness.** The cache keys on device, inode, size, mtime and
ctime. An in-place rewrite changes mtime and ctime, and a replaced file
changes its inode, so a changed binary is hashed again.
- **stderr.** `execFileSync` wrote a child's stderr to the server's
stderr. `execFileBuffered` captures it. Tar warnings no longer appear in
server logs; errors and exit codes are unchanged.
- **Persisted state.** No persisted shape changes. Old saved state and
new code (and the reverse) read the same manifests and digests.
- **Not converted in this PR:** small metadata syscalls (`existsSync`,
`lstatSync`, `readdirSync`, small JSON reads and writes, renames) in the
native state machines. Some of these rely on running synchronously. Also
not converted: the sync harness backup verify inside the sync
restart-authority checks, and the sync stamp verify in
`native-harness-backup-storage.ts`. Both are listed for a follow-up.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used through Claude
Code with tool use, shell, and code 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 (code
comments carry the contract)
- [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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 00:27:12 -06:00

238 lines
14 KiB
TypeScript

import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as runner from "../../vendor/paperclip-runner/index.js";
import type { PaperclipSemanticToolDefinition } from "../../vendor/paperclip-runner/index.js";
import {
buildNativeRunnerArguments,
buildNativeRunnerPreparePayload,
resolvePaperclipRunnerBinary,
assertPaperclipRunnerBinaryConfiguration,
resolveRemotePaperclipRunnerBinary,
} from "./native-codex-runner.js";
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
describe("installed runner selection", () => {
it("uses the runner's platform-aware packaged selection", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-runner-selection-"));
const binary = join(root, "paperclip-runnerd");
try {
await writeFile(binary, "test executable", { mode: 0o700 });
const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockReturnValue(binary);
expect(resolvePaperclipRunnerBinary("")).toBe(binary);
expect(select).toHaveBeenCalledOnce();
expect(resolvePaperclipRunnerBinary(join(root, "missing"))).toBe(binary);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("keeps an explicit usable override and rejects a relative override", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-runner-selection-"));
const binary = join(root, "configured-runnerd");
try {
await writeFile(binary, "test executable", { mode: 0o700 });
const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockImplementation(() => {
throw new Error("must not inspect package when override is usable");
});
expect(resolvePaperclipRunnerBinary(binary)).toBe(binary);
expect(select).not.toHaveBeenCalled();
expect(() => resolvePaperclipRunnerBinary("relative-runnerd")).toThrow("absolute path");
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
describe("selected remote daemon target", () => {
function header(target: string) {
const bytes = Buffer.alloc(64);
if (target.startsWith("darwin")) {
bytes.writeUInt32LE(0xfeedfacf); bytes.writeUInt32LE(target.endsWith("arm64") ? 0x0100000c : 0x01000007, 4);
} else {
bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(target.endsWith("arm64") ? 183 : 62, 18);
}
return bytes;
}
const remote = (stdout: string, overrides = {}) => ({ execute: vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout, stderr: "", pid: null, startedAt: null, ...overrides })) });
it.each([
["Linux", "aarch64", "linux", "arm64"], ["Linux", "x86_64", "linux", "x64"],
["Darwin", "arm64", "darwin", "arm64"], ["Darwin", "x86_64", "darwin", "x64"],
] as const)("uses the packaged %s/%s daemon independent of the controller", async (os, arch, platform, architecture) => {
const root = await mkdtemp(join(tmpdir(), "paperclip-ssh-daemon-"));
const binary = join(root, "paperclip-runnerd");
try {
await writeFile(binary, header(`${platform}-${architecture}`));
const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinaryAsync").mockResolvedValue(binary);
const target = remote(`${os}\n${arch}\n`);
expect(await resolveRemotePaperclipRunnerBinary({ runner: target, remoteCwd: "/selected-workspace" })).toBe(binary);
expect(select).toHaveBeenCalledExactlyOnceWith(platform, architecture);
expect(target.execute).toHaveBeenCalledExactlyOnceWith({ command: "sh", args: ["-c", "uname -s; uname -m"], cwd: "/selected-workspace", bypassSession: true, timeoutMs: 10_000 });
} finally { await rm(root, { recursive: true, force: true }); }
});
it.each(["arm64", "x64"].flatMap(controllerArch => ["arm64", "x64"].map(targetArch => ({ controllerArch, targetArch }))))(
"selects the actual Linux $targetArch sandbox bytes on a Mac $controllerArch controller", async ({ controllerArch, targetArch }) => {
const root = await mkdtemp(join(tmpdir(), "paperclip-sandbox-daemon-"));
const binary = join(root, "paperclip-runnerd");
try {
vi.stubGlobal("process", { ...process, platform: "darwin", arch: controllerArch });
await writeFile(binary, header(`linux-${targetArch}`));
const controllerBinary = join(root, "controller-runnerd");
await writeFile(controllerBinary, header(`darwin-${controllerArch}`), { mode: 0o700 });
vi.stubEnv("PAPERCLIP_RUNNER_BINARY", controllerBinary);
const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinaryAsync").mockResolvedValue(binary);
const target = remote(`Linux\n${targetArch === "arm64" ? "aarch64" : "x86_64"}\n`);
expect(await resolveRemotePaperclipRunnerBinary({ runner: target, remoteCwd: "/sandbox-workspace" })).toBe(binary);
expect(select).toHaveBeenCalledExactlyOnceWith("linux", targetArch);
expect(target.execute).toHaveBeenCalledExactlyOnceWith({ command: "sh", args: ["-c", "uname -s; uname -m"], cwd: "/sandbox-workspace", bypassSession: true, timeoutMs: 10_000 });
} finally { await rm(root, { recursive: true, force: true }); }
},
);
it.each(["linux", "darwin"].flatMap(platform => ["arm64", "x64"].map(architecture => ({ platform, architecture }))))(
"prefers the supervisor's fresh $platform/$architecture override for the same-host target", async ({ platform, architecture }) => {
const root = await mkdtemp(join(tmpdir(), "paperclip-same-host-daemon-"));
try {
vi.stubGlobal("process", { ...process, platform, arch: architecture });
const fresh = join(root, "fresh-runnerd"), stale = join(root, "stale-packaged-runnerd");
await writeFile(fresh, Buffer.concat([header(`${platform}-${architecture}`), Buffer.from("fresh")]), { mode: 0o700 });
await writeFile(stale, Buffer.concat([header(`${platform}-${architecture}`), Buffer.from("stale")]), { mode: 0o700 });
vi.stubEnv("PAPERCLIP_RUNNER_BINARY", fresh);
const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinaryAsync").mockResolvedValue(stale);
const target = remote(`${platform === "linux" ? "Linux" : "Darwin"}\n${architecture === "arm64" ? "arm64" : "x86_64"}\n`);
expect(await resolveRemotePaperclipRunnerBinary({ runner: target, remoteCwd: "/workspace" })).toBe(fresh);
expect(select).not.toHaveBeenCalled();
} finally { await rm(root, { recursive: true, force: true }); }
},
);
it.each(["missing", "relative", "malformed", "wrong-architecture"])("retains same-host override policy and target validation (%s)", async scenario => {
const root = await mkdtemp(join(tmpdir(), "paperclip-same-host-daemon-"));
try {
vi.stubGlobal("process", { ...process, platform: "linux", arch: "x64" });
const override = join(root, "configured-runnerd"), fallback = join(root, "packaged-runnerd");
if (scenario !== "missing") await writeFile(override, scenario === "malformed" ? Buffer.alloc(64) : header("linux-arm64"), { mode: 0o700 });
await writeFile(fallback, header("linux-x64"), { mode: 0o700 });
vi.stubEnv("PAPERCLIP_RUNNER_BINARY", scenario === "relative" ? "relative-runnerd" : override);
vi.spyOn(runner, "defaultCapabilityRunnerdBinaryAsync").mockResolvedValue(fallback);
const request = resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\nx86_64\n"), remoteCwd: "/workspace" });
if (scenario === "missing") expect(await request).toBe(fallback);
else await expect(request).rejects.toThrow(scenario === "relative" ? "PAPERCLIP_RUNNER_BINARY must be an absolute path" : "runner_remote_artifact_platform_mismatch");
} finally { await rm(root, { recursive: true, force: true }); }
});
it("ignores a relative controller override when the actual target differs", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-cross-host-daemon-"));
try {
vi.stubGlobal("process", { ...process, platform: "darwin", arch: "arm64" });
vi.stubEnv("PAPERCLIP_RUNNER_BINARY", "relative-controller-runnerd");
const binary = join(root, "linux-runnerd");
await writeFile(binary, header("linux-arm64"));
const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinaryAsync").mockResolvedValue(binary);
expect(await resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\naarch64\n"), remoteCwd: "/workspace" })).toBe(binary);
expect(select).toHaveBeenCalledExactlyOnceWith("linux", "arm64");
} finally { await rm(root, { recursive: true, force: true }); }
});
it.each(["matching", "wrong-architecture", "malformed", "missing"])("verifies an explicit controller-owned remote artifact (%s)", async scenario => {
const root = await mkdtemp(join(tmpdir(), "paperclip-ssh-daemon-"));
const binary = join(root, "configured-runnerd");
try {
vi.stubGlobal("process", { ...process, platform: "linux", arch: "arm64" });
vi.stubEnv("PAPERCLIP_RUNNER_BINARY", "invalid-relative-controller-override");
if (scenario !== "missing") await writeFile(binary, scenario === "malformed" ? Buffer.alloc(64) : header(scenario === "matching" ? "linux-arm64" : "linux-x64"));
const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinaryAsync");
const request = resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\naarch64\n"), remoteCwd: "/workspace", configuredPath: binary });
if (scenario === "matching") expect(await request).toBe(binary);
else await expect(request).rejects.toThrow(scenario === "missing" ? "runner_remote_artifact_unavailable" : "runner_remote_artifact_platform_mismatch");
expect(select).not.toHaveBeenCalled();
} finally { await rm(root, { recursive: true, force: true }); }
});
it("keeps missing package targets actionable without a controller or legacy fallback", async () => {
const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinaryAsync").mockRejectedValue(new Error("runner_remote_artifact_unavailable: install the matching release"));
await expect(resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\naarch64\n"), remoteCwd: "/workspace" })).rejects.toThrow("runner_remote_artifact_unavailable");
expect(select).toHaveBeenCalledExactlyOnceWith("linux", "arm64");
});
it.each([
["Linux\naarch64\n", { exitCode: 1 }], ["Linux\naarch64\n", { timedOut: true }],
["Linux\nriscv64\n", {}], ["FreeBSD\nx86_64\n", {}], ["", {}],
])("fails a missing, failed or unsupported platform probe (%s)", async (stdout, overrides) => {
const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinaryAsync");
await expect(resolveRemotePaperclipRunnerBinary({ runner: remote(stdout as string, overrides), remoteCwd: "/workspace" })).rejects.toThrow("runner_remote_artifact_platform_mismatch");
expect(select).not.toHaveBeenCalled();
});
});
describe("buildNativeRunnerArguments", () => {
it("binds every durable identity without exposing the bootstrap ticket", () => {
const args = buildNativeRunnerArguments({
connectUrl: "ws://127.0.0.1:3000/api/runner/v1/connect/run-1",
stateDirectory: "/tmp/runner-state",
runnerInstanceId: "runner-1",
environmentLeaseId: "lease-1",
runId: "run-1",
normalizedSessionId: "session-1",
turnId: "turn-1",
itemId: "item-1",
runnerDigest: `sha256:${"a".repeat(64)}`,
maxRuntimeMs: 60_000,
});
expect(args).toContain("--connect-url");
expect(args).toContain("--runner-digest");
expect(args.join(" ")).not.toContain("bootstrap");
});
});
const tool: PaperclipSemanticToolDefinition = {
name: "get_task_context",
description: "Read the active task context.",
inputSchema: { type: "object" },
outputSchema: { type: "object" },
annotations: {
semanticContract: "paperclip.semantic-action.v1",
version: 1,
placement: "always",
effect: "read",
requiredClaims: [],
},
};
describe("buildNativeRunnerPreparePayload", () => {
it("binds the coordinator tool projection to run.prepare", () => {
expect(buildNativeRunnerPreparePayload({
cwd: "/workspace",
model: "test-model",
resumeProviderSessionId: "thread-1",
completionContract: { revision: "1", criterionIds: ["objective"] },
semanticTools: [tool],
providerLaunch: {
command: "/bin/fake-codex",
args: ["app-server"],
providerVersion: "fake-1",
},
})).toMatchObject({
provider: {
kind: "codex",
provider: "codex",
driver: "codex_app_server",
providerSessionId: "thread-1",
},
authorizedTools: {
schema: "paperclip.runner.authorized-tools.v1",
schemaVersion: 1,
catalogDigest:
"sha256:4e0332535c9e2ff1f5e43089517ee1b46654bfc9cb2ed51efbea4be50db21009",
operations: [{ operationId: "get_task_context", version: 1 }],
},
});
});
});
it("validates only local Runner configuration without requiring or probing an artifact", () => {
expect(() => assertPaperclipRunnerBinaryConfiguration("relative-runnerd")).toThrow("PAPERCLIP_RUNNER_BINARY must be an absolute path");
vi.stubEnv("PAPERCLIP_RUNNER_BINARY", "relative-controller-runnerd");
expect(() => assertPaperclipRunnerBinaryConfiguration()).toThrow("PAPERCLIP_RUNNER_BINARY must be an absolute path");
expect(() => resolvePaperclipRunnerBinary()).toThrow("PAPERCLIP_RUNNER_BINARY must be an absolute path");
expect(() => assertPaperclipRunnerBinaryConfiguration("")).not.toThrow();
expect(() => assertPaperclipRunnerBinaryConfiguration(join(tmpdir(), "not-present-configuration-only-runnerd"))).not.toThrow();
});