perf(runner): cache verified native executable digests (#18390)

## Thinking Path

> - Paperclip manages AI agents and their task execution.
> - Runner checks qualified native programs before it starts a provider.
> - Installation checks and command opening can hash the same program
again.
> - A new sidecar process repeats this work on a later warm task.
> - This pull request stores the computed digest in a private filesystem
cache.
> - Every cache hit still checks the independently selected trusted
digest and current file metadata.
> - An unchanged program can start without another full native file
read.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
Repeated **qualified ACPX** native executable admission in Runner.
Claude, ACPX-Codex, and Grok use this verifier in both Core-owned and
plugin-backed launches. The ordinary native Codex app-server launcher is
a different path and is not changed. A retained provider that opens no
new native command has no saved hash work here. The measured Claude task
path does not require a comment backlog.

**Current behavior**
Installation verification and final command opening each read and hash
the native executable. The qualified Linux x64 Claude file is
241,667,256 bytes. A new verification and command lease reads
483,334,512 native bytes. Another Node process repeats both reads.

**Proposed behavior**
Store the actual computed SHA-256 digest by device, inode, size,
nanosecond modification time, and nanosecond change time. Reuse it
across Runner and sidecar processes. Compare each hit with the current
build-owned digest. Hash the file on a miss or changed identity. This
replaces the earlier Claude-only deferral design in this same PR.

**Reason and benefit**
The first verification and command lease reads the native file once.
Later processes read none of its native bytes while its identity stays
the same. Related: #16372 established qualified executable admission.
This change is independent of other preparation PRs. It does not change
provider pins or profile versions.

## What Changed

- Add a shared filesystem digest cache under the absolute OS temporary
root at `paperclip-runner-<uid>/executable-digests-v1`.
- Use private directories and files, versioned hash-only filenames,
bounded JSON entries, and atomic temporary-file replacement. Cache files
contain a version and the computed digest. They contain no credentials
or source paths.
- Fall back to full hashing when the cache is missing, unreadable,
malformed, or unusable. Keep current expected-digest selection outside
the cache.
- Keep cache data outside task homes. Neither `HOME` nor task cwd
selects the path. The OS temporary-root selection honors absolute
platform `TMPDIR`, `TMP`, or `TEMP` overrides. A relative root disables
caching. Hosts must keep that root outside task workspaces. Temp cleanup
causes a later rehash.
- Keep the no-follow executable open, regular-file and size checks,
execute permission, descriptor ownership, before/after identity checks,
and preparation-to-launch identity comparison. Cache publication occurs
before the final identity checks.
- Use this path for standalone installation checks and final command
opening. Remove the earlier pending-digest type and Claude-only launch
helper. Runtime host and profile selection keep their original
structure.
- Test changed content, all fingerprint fields, symlinks, mode changes,
inode replacement, mutation during lookup/publication, concurrent cache
writers, wrong trusted pins, and cache failures.
- Include a deliberate trust-boundary test: a process running as the
same OS user can plant a matching cached digest for changed bytes. It
opens a test descriptor and executes nothing.

In plain language: Runner remembers the hash for the same file. It still
checks which file it opened and which hash the build requires. The cache
trusts the OS user who can write it.

## Verification

From `packages/paperclip-runner`:

- `../../node_modules/.bin/vitest run
src/drivers/acpx/executable-digest-cache.test.ts
src/drivers/acpx/installation-integrity.test.ts
src/drivers/acpx/runtime-host.test.ts` — 177 passed, 6 platform-specific
skips.
- `../../node_modules/.bin/tsc -p tsconfig.json --noEmit` — passed.
- `git diff --check` and the added-line secret, PII, private-address,
and code-comment scan — passed.

The offline comparison uses baseline
`45a3a92e6dc52d20923b9aa34a22fee2ec5443f9` and treatment
`c4356df12032448de90516ae329d8f939af10984`. Each sample starts a
different Node process. Each normal sample runs the actual qualified
installation verifier and opens a command lease. Seven samples per case
follow one warmup. The cache is removed before each cold case. The next
process reuses its entry. Baseline order alternates around the treatment
pair. Command digest and package paths match in every pair.

| Verifier plus command opening | Baseline | Cold cache | Later process
hit |
| --- | ---: | ---: | ---: |
| Full native reads | 2 | 1 | 0 |
| Native bytes read | 483,334,512 | 241,667,256 | 0 |
| Local median, ms | 1041.26 | 602.70 | 130.17 |
| Local range, ms | 994.20–1133.11 | 580.27–999.83 | 121.96–179.45 |
| Median whole child process, ms | 1379.96 | 988.75 | 523.01 |

Raw verifier-plus-open samples in ms:

- Baseline: 1133.11, 1074.40, 1061.99, 1041.26, 994.20, 1036.12,
1037.70.
- Cold: 647.13, 646.33, 999.83, 584.83, 580.27, 583.37, 602.70.
- Later process hit: 179.45, 138.37, 134.39, 130.17, 121.96, 122.33,
126.68.

Separate direct-opener fault probes use a disposable copy of that same
native file:

| Change | Native bytes read | Result | Local open, ms |
| --- | ---: | --- | ---: |
| First copied file | 241,667,256 | Accepted | 526.14 |
| Next process, different HOME and task cwd | 0 | Accepted | 24.91 |
| Changed timestamps | 241,667,256 | Accepted after hashing | 531.40 |
| New inode, identical bytes | 241,667,256 | Accepted after hashing |
536.70 |
| Corrupt byte, same size and exactly restored mtime | 241,667,256 |
Rejected | Whole process: 841.36 |
| Different independent trusted pin | 0 | Rejected | Whole process:
374.76 |

The corruption probe keeps `mtimeNs` exactly equal before and after, at
`1000000000000000000`. Its changed `ctimeNs` invalidates the entry.
Cache expiry is not required. The changed-pin probe rejects the old
actual digest without a native read.

The cached image is
`sha256:99ce80690a0c13328f41fd84156cc73e1ac562eaed3120fd41d04d7acdfb325d`.
The Node binary is `/opt/paperclip/runner/libexec/node`, version
`v24.20.0`. The artifact is Claude SDK `0.3.286`, with expected digest
`sha256:fe503f65c6289d59c23e5b21ae44f03583f997dd33a2cbfc75ab4f96fb8fc73f`.
The container used a read-only root, a disposable writable tmpfs, and
disabled network. No provider process, model request, credential, or
deployment was used.

**These elapsed values are diagnostics from Linux x64 CPU emulation on
an ARM64 Docker host. They are not production latency or TTFT
estimates.** The exact byte counts, cross-process reuse, output
equality, and invalidation results are the portable evidence.

The saved phase 0 exports have no directly comparable native executable
hashing span. Their broad `runner.transport.attach` spans include other
work. A production comparison within 20% is therefore unavailable for
this seam. The earlier local 919 ms control was also an emulation
diagnostic; it is not a production baseline.

The initial Docker attempt was denied access to the local socket by the
sandbox. The same offline command then ran with local socket permission.
An initial fault probe restored mtime through a `Date`, which did not
prove exact nanosecond equality. Its raw report is retained as
historical; the final probe sets and verifies an exact mtime before and
after corruption. The prior Claude-only implementation and its checks
and measurements remain historical evidence. They do not qualify this
replacement.

The superseded home-directory cache head had one browser recovery test
timeout in its GitHub workflow. It expected a saved title after session
recovery. The failure is retained. No browser code was changed; the
current head reruns CI after moving the cache outside task homes.

No full local CI suite was run, as requested. GitHub CI supplies full
regression coverage. Current head
`c4356df12032448de90516ae329d8f939af10984` passed the complete [GitHub
workflow](https://github.com/paperclipai/paperclip/actions/runs/41145223331),
including all browser shards. Its latest checks have 56 successes and 2
intended skips, including both security scans started when the PR became
ready. Standard Greptile reviewed this exact head at 5/5 with no open
findings. Apex then reviewed the same head at 5/5 under the documented
OS-user trust policy. It reported no new findings. There are no
unresolved review threads. The PR is ready for review and remains
unmerged.

## Risks

- This deliberately weakens protection against the same OS user. That
user can write the cache and can plant a chosen digest for a file
fingerprint. A cache hit trusts that record; metadata identity is not a
fresh content hash. The explicit fault test documents this accepted
policy.
- A read-only administrator-owned Runner can use this user-writable
cache. The cache has no signature, secret, or daemon. It does not
require a Launcher or ROM change.
- Other users cannot use normal cache permissions to write these
entries. Cache errors fall back to hashing. Removing the cache directory
restores full hashing. Old fingerprints leave small unused entries until
the cache is removed or OS temp cleanup evicts it. Temp eviction costs
one later full hash.
- A changed file normally rehashes. A digest that differs from the
independent current pin still fails admission. Existing command,
package, credential, process-lifetime, and final dispatch checks remain.
- No schema, public protocol, package dependency, profile version, or
provider pin changes.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, code editing, test
execution, and tool use. This session does not expose a more precise
serving model ID or context-window capacity.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used with the details available to this
session
- [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 described the issue in-PR following the enhancement
template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket ID
- [x] The targeted local tests pass; the full local suite was not run as
requested
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented risks
- [x] All Paperclip CI gates are green for the replacement head
- [x] Standard Greptile is 5/5 with no open findings for the replacement
head
- [x] Apex is 5/5 on the current head with no unresolved findings
- [x] I will address review findings before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-30 17:07:32 -07:00
1 parent 87ae20ae65
commit 493e229d93
4 files changed
+286 -25

No files matched your search

@@ -98,6 +98,35 @@ digest, and refuse the rest. A profile identity does not establish hosted
harness qualification. [`rules/acpx-profile-identity.mdc`](../../../rules/acpx-profile-identity.mdc) harness qualification. [`rules/acpx-profile-identity.mdc`](../../../rules/acpx-profile-identity.mdc)
summarizes these practices for contributors. summarizes these practices for contributors.
Qualified ACPX native executables use Runner's persistent digest cache. The cache
stores a computed SHA-256 digest under a key made from device, inode, size,
nanosecond modification time, and nanosecond change time. Each check compares
that digest with the independently selected build-owned pin. A changed file
identity, missing entry, or unusable cache causes a full descriptor read and
hash. A hit reuses the earlier digest; metadata identity is not a fresh content
hash. Claude, Codex, and Grok use the same native executable admission path.
Cache entries live in the operating-system temporary directory under
`paperclip-runner-<uid>/executable-digests-v1`, outside task workspaces such as
`/home/agent`. Neither `HOME` nor the task working directory selects this path.
Node's `os.tmpdir()` selects the root and honors the platform's absolute
`TMPDIR`, `TMP`, or `TEMP` override. Hosts must keep that root outside task
workspaces; a relative or unusable root disables caching. Temporary-directory
eviction removes the optimization until a later full hash fills it again. The
directory and files are private to that user. Writes use a temporary file and
atomic rename, so later Runner or sidecar processes can reuse complete entries.
Cache errors fall back to hashing. The cache stores no credentials or file paths.
Every admission still opens the executable without following its final symlink,
checks its regular-file type, bounds, execute permission, and before/after file
identity, and retains the descriptor for launch. Package qualification and the
preparation-to-launch identity check remain. The cache deliberately trusts the
operating-system user: a process with that user's access can plant a digest for
a chosen fingerprint. It is not authenticated with a secret or signature.
An administrator-owned read-only Runner can therefore use a user-writable cache,
but this provides weaker protection against that same user than hashing every
executable on each admission. Removing the cache restores full hashing.
## External controller checkpoints ## External controller checkpoints
The optional controller persistence callback stores recovery control state, not The optional controller persistence callback stores recovery control state, not
@@ -0,0 +1,160 @@
import { createHash } from "node:crypto";
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createExecutableDigestCache, executableDigestCache, type ExecutableFileIdentity } from "../../executable-digest-cache.js";
import { openVerifiedRuntimeExecutable } from "./installation-integrity.js";
const temporary: string[] = [];
const identity: ExecutableFileIdentity = { device: "1", inode: "2", size: "3", modifiedNanoseconds: "4", changedNanoseconds: "5" };
const digest = `sha256:${"a".repeat(64)}`;
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })));
});
async function fixture() {
const root = await mkdtemp(join(tmpdir(), "runner-executable-digest-cache-"));
temporary.push(root);
const directory = join(root, "cache");
const cache = createExecutableDigestCache(directory);
return { root, directory, cache };
}
describe("persistent executable digest cache", () => {
it("stores the actual digest in a private, versioned entry and survives a new cache instance", async () => {
const f = await fixture();
expect(await f.cache.read(identity)).toBeNull();
await f.cache.write(identity, digest);
expect(await createExecutableDigestCache(f.directory).read(identity)).toBe(digest);
const [name] = await readdir(f.directory);
expect(name).toMatch(/^v1-[a-f0-9]{64}\.json$/);
expect(JSON.parse(await readFile(join(f.directory, name), "utf8"))).toEqual({ version: 1, digest });
expect((await lstat(f.directory)).mode & 0o777).toBe(0o700);
expect((await lstat(join(f.directory, name))).mode & 0o777).toBe(0o600);
});
it.each(Object.keys(identity) as (keyof ExecutableFileIdentity)[])("invalidates a changed %s", async key => {
const f = await fixture();
await f.cache.write(identity, digest);
expect(await f.cache.read({ ...identity, [key]: "changed" })).toBeNull();
});
it.each(["malformed", "version", "digest", "oversized", "permissions", "symlink"] as const)(
"treats an unusable %s entry as a miss", async kind => {
const f = await fixture();
await f.cache.write(identity, digest);
const [name] = await readdir(f.directory), file = join(f.directory, name);
if (kind === "malformed") await writeFile(file, "{");
else if (kind === "version") await writeFile(file, JSON.stringify({ version: 2, digest }));
else if (kind === "digest") await writeFile(file, JSON.stringify({ version: 1, digest: "expected" }));
else if (kind === "oversized") await writeFile(file, "x".repeat(1025));
else if (kind === "permissions") await chmod(file, 0o644);
else { await rename(file, `${file}.target`); await symlink(`${file}.target`, file); }
expect(await f.cache.read(identity)).toBeNull();
},
);
it("falls back when the cache directory cannot be used and leaves existing permissions unchanged", async () => {
const f = await fixture();
await writeFile(f.directory, "not a directory");
await expect(f.cache.write(identity, digest)).resolves.toBeUndefined();
expect(await f.cache.read(identity)).toBeNull();
await rm(f.directory);
await mkdir(f.directory, { mode: 0o755 });
await f.cache.write(identity, digest);
expect(await f.cache.read(identity)).toBeNull();
expect((await lstat(f.directory)).mode & 0o777).toBe(0o755);
});
it("does not resolve a relative cache directory through the task working directory", async () => {
const cache = createExecutableDigestCache("relative-task-cache");
await cache.write(identity, digest);
expect(await cache.read(identity)).toBeNull();
});
it("publishes complete entries under concurrent writers without leftover temporary files", async () => {
const f = await fixture();
await Promise.all(Array.from({ length: 8 }, () => f.cache.write(identity, digest)));
expect(await f.cache.read(identity)).toBe(digest);
expect(await readdir(f.directory)).toHaveLength(1);
});
});
describe.skipIf(!["linux", "darwin"].includes(process.platform))("descriptor admission with cached digests", () => {
async function executable() {
const f = await fixture(), path = join(f.root, "native"), bytes = Buffer.from("qualified native fixture");
await writeFile(path, bytes, { mode: 0o700 });
vi.spyOn(executableDigestCache, "read").mockImplementation(f.cache.read);
vi.spyOn(executableDigestCache, "write").mockImplementation(f.cache.write);
return { ...f, path, digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}` };
}
it("hashes a miss, reuses the actual digest, and still rejects a different independent pin", async () => {
const f = await executable();
await (await openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).handle.close();
expect(executableDigestCache.write).toHaveBeenCalledOnce();
await (await openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).handle.close();
expect(executableDigestCache.write).toHaveBeenCalledOnce();
await expect(openVerifiedRuntimeExecutable(f.path, digest, "fixture")).rejects.toThrow("digest mismatch");
});
it("rehashes changed contents and rejects corruption despite a previous valid entry", async () => {
const f = await executable();
await (await openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).handle.close();
await writeFile(f.path, "corrupted native fixture");
await expect(openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).rejects.toThrow("digest mismatch");
});
it.each(["permissions", "symlink", "replacement"] as const)("retains admission checks after cached %s changes", async kind => {
const f = await executable();
await (await openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).handle.close();
if (kind === "permissions") {
await chmod(f.path, 0o600);
await expect(openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).rejects.toThrow("bounded executable file");
} else if (kind === "symlink") {
await rename(f.path, `${f.path}.original`); await symlink(`${f.path}.original`, f.path);
await expect(openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).rejects.toThrow("real regular file");
} else {
await rename(f.path, `${f.path}.original`); await writeFile(f.path, "new invalid executable", { mode: 0o700 });
await expect(openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).rejects.toThrow("digest mismatch");
}
});
it("rejects mutation while a cache hit is being read", async () => {
const f = await executable();
await (await openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).handle.close();
vi.mocked(executableDigestCache.read).mockImplementationOnce(async () => {
await writeFile(f.path, "mutation during lookup");
return f.digest;
});
await expect(openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).rejects.toThrow("changed while it was verified");
});
it("checks executable identity after publishing a cold cache entry", async () => {
const f = await executable();
vi.mocked(executableDigestCache.write).mockImplementationOnce(async (identity, digest) => {
await f.cache.write(identity, digest);
await writeFile(f.path, "mutation during cache publication");
});
await expect(openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).rejects.toThrow("changed while it was verified");
});
it("falls back to hashing when the cache is unavailable", async () => {
const f = await executable();
await writeFile(f.directory, "unavailable cache");
await (await openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).handle.close();
await writeFile(f.path, "invalid native fixture");
await expect(openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).rejects.toThrow("digest mismatch");
});
it("trusts a digest planted by the same operating-system user for current metadata", async () => {
const f = await executable();
await writeFile(f.path, "content changed by the same user");
const metadata = await lstat(f.path, { bigint: true });
await f.cache.write({ device: metadata.dev.toString(), inode: metadata.ino.toString(), size: metadata.size.toString(),
modifiedNanoseconds: metadata.mtimeNs.toString(), changedNanoseconds: metadata.ctimeNs.toString() }, f.digest);
await (await openVerifiedRuntimeExecutable(f.path, f.digest, "fixture")).handle.close();
});
});
@@ -2,6 +2,7 @@ import { MAX_ACPX_RUNTIME_EXECUTABLE_BYTES, ACPX_PRIVATE_SNAPSHOT_ENV, createAcp
import { createNativeAcpxDistributionSnapshot, NATIVE_ACPX_BOOTSTRAP_NAME, readNativeAcpxDistributionEntries, type NativeAcpxDistributionInput } from "./native-distribution-integrity.js"; import { createNativeAcpxDistributionSnapshot, NATIVE_ACPX_BOOTSTRAP_NAME, readNativeAcpxDistributionEntries, type NativeAcpxDistributionInput } from "./native-distribution-integrity.js";
export type { NativeAcpxDistributionInput } from "./native-distribution-integrity.js"; export type { NativeAcpxDistributionInput } from "./native-distribution-integrity.js";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { executableDigestCache, type ExecutableFileIdentity } from "../../executable-digest-cache.js";
import { import {
spawn as spawnChildProcess, spawn as spawnChildProcess,
type ChildProcess, type ChildProcess,
@@ -606,13 +607,7 @@ export async function awaitVerifiedAcpxProviderExit(
await exitProof; await exitProof;
} }
interface VerifiedAcpxCommandIdentity { type VerifiedAcpxCommandIdentity = ExecutableFileIdentity;
device: string;
inode: string;
size: string;
modifiedNanoseconds: string;
changedNanoseconds: string;
}
interface VerifiedAcpxRuntimeExecutable { interface VerifiedAcpxRuntimeExecutable {
path: string; path: string;
@@ -1204,7 +1199,7 @@ async function inspectCommand(
} }
} }
async function openVerifiedRuntimeExecutable( export async function openVerifiedRuntimeExecutable(
executablePath: string, executablePath: string,
expectedDigest: string, expectedDigest: string,
agent: string, agent: string,
@@ -1246,23 +1241,31 @@ async function openVerifiedRuntimeExecutable(
`ACPX ${agent} runtime executable must be a bounded executable file`, `ACPX ${agent} runtime executable must be a bounded executable file`,
); );
} }
const hash = createHash("sha256"); const cachedDigest = await executableDigestCache.read(fileIdentity(before));
const buffer = Buffer.alloc(1024 * 1024); let digest = cachedDigest;
let position = 0; let position = 0;
try { if (digest === null) {
while (position < Number(before.size)) { const hash = createHash("sha256");
const { bytesRead } = await handle.read( const buffer = Buffer.alloc(1024 * 1024);
buffer, try {
0, while (position < Number(before.size)) {
Math.min(buffer.length, Number(before.size) - position), const { bytesRead } = await handle.read(
position, buffer,
); 0,
if (bytesRead === 0) break; Math.min(buffer.length, Number(before.size) - position),
hash.update(buffer.subarray(0, bytesRead)); position,
position += bytesRead; );
if (bytesRead === 0) break;
hash.update(buffer.subarray(0, bytesRead));
position += bytesRead;
}
} finally {
buffer.fill(0);
} }
} finally { digest = `sha256:${hash.digest("hex")}`;
buffer.fill(0); }
if (cachedDigest === null && position === Number(before.size) && digest === expectedDigest) {
await executableDigestCache.write(fileIdentity(before), digest);
} }
const after = await handle.stat({ bigint: true }); const after = await handle.stat({ bigint: true });
const lexicalAfter = await lstat(executablePath, { bigint: true }).catch( const lexicalAfter = await lstat(executablePath, { bigint: true }).catch(
@@ -1271,7 +1274,7 @@ async function openVerifiedRuntimeExecutable(
const beforeIdentity = fileIdentity(before); const beforeIdentity = fileIdentity(before);
const afterIdentity = fileIdentity(after); const afterIdentity = fileIdentity(after);
if ( if (
position !== Number(before.size) || (cachedDigest === null && position !== Number(before.size)) ||
lexicalAfter === null || lexicalAfter === null ||
lexicalAfter.isSymbolicLink() || lexicalAfter.isSymbolicLink() ||
!lexicalAfter.isFile() || !lexicalAfter.isFile() ||
@@ -1283,7 +1286,6 @@ async function openVerifiedRuntimeExecutable(
`ACPX ${agent} runtime executable changed while it was verified`, `ACPX ${agent} runtime executable changed while it was verified`,
); );
} }
const digest = `sha256:${hash.digest("hex")}`;
if (digest !== expectedDigest) { if (digest !== expectedDigest) {
throw new Error(`ACPX ${agent} runtime executable digest mismatch`); throw new Error(`ACPX ${agent} runtime executable digest mismatch`);
} }
@@ -0,0 +1,70 @@
import { createHash, randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
import { tmpdir, userInfo } from "node:os";
import { isAbsolute, join } from "node:path";
export interface ExecutableFileIdentity {
device: string;
inode: string;
size: string;
modifiedNanoseconds: string;
changedNanoseconds: string;
}
export function createExecutableDigestCache(directory?: string) {
async function location(identity: ExecutableFileIdentity, create: boolean): Promise<string> {
const user = userInfo();
const temporaryRoot = tmpdir();
if (!isAbsolute(directory ?? temporaryRoot)) throw new Error("Executable digest cache directory is unavailable");
const base = join(temporaryRoot, `paperclip-runner-${user.uid}`);
const directories = directory ? [directory] : [base, join(base, "executable-digests-v1")];
for (const [index, path] of directories.entries()) {
if (create) await mkdir(path, { mode: 0o700 }).catch(error => {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
});
const metadata = await lstat(path);
if (!metadata.isDirectory() || metadata.isSymbolicLink() || metadata.uid !== user.uid ||
(metadata.mode & (index === directories.length - 1 ? 0o077 : 0o022)) !== 0) {
throw new Error("Executable digest cache directory is unavailable");
}
}
const key = createHash("sha256").update(JSON.stringify([1, identity.device, identity.inode, identity.size,
identity.modifiedNanoseconds, identity.changedNanoseconds])).digest("hex");
return join(directories.at(-1)!, `v1-${key}.json`);
}
return {
async read(identity: ExecutableFileIdentity): Promise<string | null> {
try {
const path = await location(identity, false);
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const metadata = await handle.stat();
if (!metadata.isFile() || metadata.uid !== userInfo().uid || (metadata.mode & 0o077) !== 0 ||
metadata.size < 1 || metadata.size > 1024) return null;
const entry: unknown = JSON.parse(await handle.readFile("utf8"));
if (typeof entry !== "object" || entry === null || !("version" in entry) || entry.version !== 1 ||
!("digest" in entry) || typeof entry.digest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(entry.digest)) return null;
return entry.digest;
} finally { await handle.close(); }
} catch { return null; }
},
async write(identity: ExecutableFileIdentity, digest: string): Promise<void> {
let temporary: string | undefined;
try {
if (!/^sha256:[a-f0-9]{64}$/.test(digest)) return;
const path = await location(identity, true);
temporary = `${path}.${randomUUID()}.tmp`;
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try { await handle.writeFile(JSON.stringify({ version: 1, digest })); }
finally { await handle.close(); }
await rename(temporary, path);
} catch { } finally {
if (temporary) await unlink(temporary).catch(() => undefined);
}
},
};
}
export const executableDigestCache = createExecutableDigestCache();