feat(hermes): ship explicit public runtime setup

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 20:17:42 -05:00
1 parent bbadcc18a3
commit b9603a3a8e
16 files changed
+326 -55

No files matched your search

+16 -3
View File
@@ -3,17 +3,17 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, expect, it } from "vitest";
import { resolveCursorProvisioner } from "../commands/runtime.js";
import { resolveCursorProvisioner, resolveHermesProvisioner } from "../commands/runtime.js";
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
async function fixture() {
async function fixture(provider = "cursor") {
const root = await realpath(await mkdtemp(join(tmpdir(), "cursor-public-setup-")));
roots.push(root);
await mkdir(join(root, "dist/vendor/paperclip-runner/cli"), { recursive: true });
await writeFile(join(root, "package.json"), JSON.stringify({ name: "@paperclipai/server" }));
await writeFile(join(root, "dist/index.js"), "throw new Error('must not import server');");
const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-cursor.cjs");
const provisioner = join(root, `dist/vendor/paperclip-runner/cli/provision-${provider}.cjs`);
await writeFile(provisioner, "fixture");
return { root, provisioner, url: pathToFileURL(join(root, "dist/index.js")).href };
}
@@ -36,3 +36,16 @@ it("rejects a provisioner linked outside the installed package", async () => {
await rm(f.provisioner); await symlink(other.provisioner, f.provisioner);
await expect(resolveCursorProvisioner(f.url)).rejects.toThrow("escapes");
});
it("resolves Hermes from the published server without starting it", async () => {
const f = await fixture("hermes");
expect(await resolveHermesProvisioner(f.url)).toBe(f.provisioner);
});
it("Hermes rejects foreign package identities and linked setup code", async () => {
const f = await fixture("hermes"); const other = await fixture("hermes");
await rm(f.provisioner); await symlink(other.provisioner, f.provisioner);
await expect(resolveHermesProvisioner(f.url)).rejects.toThrow("escapes");
await writeFile(join(f.root, "package.json"), JSON.stringify({ name: "untrusted" }));
await expect(resolveHermesProvisioner(f.url)).rejects.toThrow("identity is invalid");
});
+20 -12
View File
@@ -5,22 +5,27 @@ import { fileURLToPath } from "node:url";
import type { Command } from "commander";
/** Resolve the installed public dependency without importing or starting it. */
export async function resolveCursorProvisioner(serverUrl: string): Promise<string> {
async function resolveRuntimeProvisioner(serverUrl: string, provider: "cursor" | "hermes"): Promise<string> {
const label = provider === "hermes" ? "Hermes" : "Cursor";
const url = new URL(serverUrl);
if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Cursor setup requires an installed Paperclip server");
if (url.protocol !== "file:" || url.search || url.hash) throw new Error(`${label} setup requires an installed Paperclip server`);
const entry = await realpath(fileURLToPath(url));
if (!entry.endsWith("/dist/index.js")) throw new Error("Cursor setup requires the published server layout");
if (!entry.endsWith("/dist/index.js")) throw new Error(`${label} setup requires the published server layout`);
const root = resolve(dirname(entry), "..");
const manifest = join(root, "package.json");
const info = await lstat(manifest);
if (!info.isFile() || info.isSymbolicLink() || info.size > 65536 || JSON.parse(await readFile(manifest, "utf8")).name !== "@paperclipai/server") throw new Error("Cursor setup server package identity is invalid");
const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-cursor.cjs");
if (await realpath(provisioner) !== provisioner || !(await lstat(provisioner)).isFile()) throw new Error("Cursor setup entrypoint escapes its server package");
if (!info.isFile() || info.isSymbolicLink() || info.size > 65536 || JSON.parse(await readFile(manifest, "utf8")).name !== "@paperclipai/server") throw new Error(`${label} setup server package identity is invalid`);
const provisioner = join(root, `dist/vendor/paperclip-runner/cli/provision-${provider}.cjs`);
if (await realpath(provisioner) !== provisioner || !(await lstat(provisioner)).isFile()) throw new Error(`${label} setup entrypoint escapes its server package`);
return provisioner;
}
export async function setupCursorRuntime(): Promise<void> {
const provisioner = await resolveCursorProvisioner(import.meta.resolve("@paperclipai/server"));
export const resolveCursorProvisioner = (serverUrl: string) => resolveRuntimeProvisioner(serverUrl, "cursor");
export const resolveHermesProvisioner = (serverUrl: string) => resolveRuntimeProvisioner(serverUrl, "hermes");
async function setupRuntime(provider: "cursor" | "hermes"): Promise<void> {
const label = provider === "hermes" ? "Hermes" : "Cursor";
const provisioner = await resolveRuntimeProvisioner(import.meta.resolve("@paperclipai/server"), provider);
const child = spawn(process.execPath, [provisioner], {
stdio: "inherit", env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8" },
});
@@ -32,19 +37,22 @@ export async function setupCursorRuntime(): Promise<void> {
child.on("error", error => { spawnError = error; });
child.once("close", (code, signal) => {
if (spawnError) reject(spawnError);
else if (code !== 0 || signal) reject(new Error("Cursor setup did not finish. Review its error; an invalid installation is never replaced automatically."));
else if (code !== 0 || signal) reject(new Error(`${label} setup did not finish. Review its error; an invalid installation is never replaced automatically.`));
else accept();
});
});
} finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
}
export const setupCursorRuntime = () => setupRuntime("cursor");
export const setupHermesRuntime = () => setupRuntime("hermes");
export function registerRuntimeCommands(program: Command): void {
program.command("runtime").description("Manage explicitly installed agent runtimes")
.command("setup <provider>")
.description("Install and verify the pinned Cursor runtime for this host (public downloads; no model calls)")
.description("Install and verify a pinned Cursor or Hermes runtime for this host (public downloads; no model calls)")
.action(async (provider: string) => {
if (provider !== "cursor") throw new Error("Supported runtime setup: paperclipai runtime setup cursor");
await setupCursorRuntime();
if (provider !== "cursor" && provider !== "hermes") throw new Error("Supported runtime setup: paperclipai runtime setup <cursor|hermes>");
await setupRuntime(provider);
});
}
@@ -737,3 +737,54 @@ and agent budget admission and one-attempt policy. All 129 affected fixture and
catalog tests, Product E2E TypeScript, two-cell discovery and diff checks passed.
No Bedrock inference, credential refresh or Daytona execution is claimed from
this fixture preparation. Hermes remains pending qualification.
### Managed subscription walkthrough and public setup, 2026-10-08 UTC
The current macOS arm64 Rust/ACPX/native fixtures passed both tests at source
`bbadcc18a3a3279b8150ea61bb8ff60cd5b3acb6`, with the existing reviewed Python
closure. This was deterministic loopback transport evidence, with no paid model
or subscription inference. The owned Rust build cache was removed afterward;
the verified daemon was retained. Local Docker remains stopped.
The isolated current-source Paperclip app was exercised through Dashboard,
Connectors, Connect Grok, and its fresh personal subscription login controller.
The native device-login reached the official Grok consent page. Consent was not
granted before its bounded process exited; the public check never reported ready
and no subscription connection was saved. The real form showed an expired-attempt
error with Start sign-in again and a disabled Connect action. All four sign-ins
started in this disposable company were cancelled through public APIs, and the
owned app supervisor stopped. This is login/failure-path evidence, not a successful
managed subscription or Hermes turn.
Source inspection during clean-consumer preparation exposed a distribution gap:
the public server vendors only the runner's compiled output, while the original
Hermes provisioner/materializer were declared only in the private runner package.
The public CLI now supports `paperclipai runtime setup hermes`. Its self-contained
ESM/CommonJS setup entrypoints and Python materializer are included in compiled
output. Explicit setup installs the reviewed closure into the execution OS user's
cache, verifies complete bytes before publication, and re-verifies existing state
without overwriting an invalid installation. Runtime discovery retains packaged
assets as authoritative and uses the account cache only when assets are absent;
provider HOME overrides cannot redirect it. Source provisioning and public setup
share the pinned download/materialization operation. Python/ACP/ACPX pins and the
native bridge bytes are unchanged. Provisioning scratch and uv downloads remain
owned temporary files and are removed after settlement.
The new setup/layout/cache/CLI checks passed 20 tests. Runner and CLI TypeScript
checks, the runner TypeScript build, generated protocol/profile checks and diff
checks passed. An additional package/model selection invocation passed 25 tests
and failed the standalone-boundary check with five violations. An independent
archive of unchanged `bbadcc18` reproduced exactly those same five violations;
none comes from the setup change. That baseline failure is retained, and neither
a whole-package boundary pass nor live clean-consumer setup is claimed here.
The preceding live Bedrock attempt used the exact Haiku 4.5 inference profile
and a region-bound bearer through managed Connections. AWS rejected inference
because Anthropic use-case details had not been submitted for that account.
Paperclip's original failed grade is retained; the native error text's
rate-limit wording is not the AWS cause. Cost remains unavailable. The owned
Linux qualification host and its temporary access were retired. The private
ledger retains $23 reserved against the $25 cap, not $23 measured spending.
Actual Daytona remains blocked on the previously reported token scopes.
Successful managed subscriptions, actual Daytona and the full original live
interaction/state/routine/distribution criteria remain required before promotion.
+4 -1
View File
@@ -135,7 +135,10 @@ Ordinary remote Cursor startup uses the packaged Linux daemon and verifies every
image asset against that manifest. A mismatched image fails before the provider
starts; install the matching package and image together.
Hermes native integration is documented in [Hermes](docs/hermes.md).
Hermes native integration is documented in [Hermes](docs/hermes.md). Its explicit
public setup command is `paperclipai runtime setup hermes`, with uv 0.12.17 on
PATH. Setup verifies the pinned Python closure in the execution account's cache;
Hermes remains pending live qualification.
Remote Codex sessions relay assigned app tools through the server's configured
gateway. Small catalogs are sent directly. When a catalog would exceed the
+11 -2
View File
@@ -21,12 +21,21 @@ node packages/paperclip-runner/scripts/provision-hermes.mjs \
# Linux amd64 uses the linux-x64 destination instead.
```
For an installed public package, run `paperclipai runtime setup hermes` as the
same OS user that runs Paperclip. The command uses the packaged setup entrypoint
and materializer, downloads public pinned dependencies without model calls, and
verifies all execution bytes before publishing the runtime to
`~/.paperclip/runtimes/hermes/<platform>/<closure-digest>`. It re-verifies an
existing installation and refuses to overwrite invalid state. Setup is explicit;
npm lifecycle hooks and agent turns do not provision Python. Read-only or global
npm installations use this account cache without writing to the package directory.
The materializer emits a complete closure digest. Compare it with the reviewed
platform pin in `hermes-installation.ts`; never adopt an unexpected digest at
platform pin in `hermes-distributions.ts`; never adopt an unexpected digest at
runtime. Runtime launch verifies the installed bytes and host sandbox before
credential staging, then opens a private verified snapshot for execution.
Python uses `-I -B` and the bundled interpreter.
The npm package carries the bridge and provisioner; Python is a separately
The public server package carries the bridge, provisioner and materializer; Python is a separately
provisioned asset, included by `--candidate-providers=hermes` in provider packs.
macOS command tools require `sandbox-exec`. Linux command tools require
+2 -4
View File
@@ -53,9 +53,7 @@
"dist-issue-thread",
"protocol",
"styles.css",
"README.md",
"scripts/provision-hermes.mjs",
"scripts/materialize-hermes.py"
"README.md"
],
"sideEffects": false,
"scripts": {
@@ -77,7 +75,7 @@
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
"test": "pnpm run test:typescript && pnpm run test:rust",
"test:typescript": "pnpm run test:typescript:prep && vitest run",
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs",
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/hermes-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs",
"test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs",
"test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace",
"test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider",
@@ -8,6 +8,12 @@ import { build } from "esbuild";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
export const verifiedProviderEntrypoints = Object.freeze([
Object.freeze({
name: "provision-hermes",
source: resolve(packageRoot, "scripts/setup-hermes-runtime.mjs"),
output: resolve(packageRoot, "dist/cli/provision-hermes.js"),
verifiedOutput: resolve(packageRoot, "dist/cli/provision-hermes.cjs"),
}),
Object.freeze({
name: "provision-cursor",
source: resolve(packageRoot, "scripts/provision-cursor.mjs"),
@@ -59,6 +65,7 @@ export async function bundleVerifiedProviderEntrypoints({ write = true } = {}) {
if (write) {
await mkdir(resolve(packageRoot, "dist/providers"), { recursive: true });
await cp(resolve(packageRoot, "src/providers"), resolve(packageRoot, "dist/providers"), { recursive: true });
await copyFile(resolve(packageRoot, "scripts/materialize-hermes.py"), resolve(packageRoot, "dist/providers/hermes/materialize-hermes.py"));
await copyFile(resolve(packageRoot, "cursor-distributions.json"), resolve(packageRoot, "dist/cursor-distributions.json"));
}
const results = [];
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { stat } from "node:fs/promises";
import { readFile, stat } from "node:fs/promises";
import test from "node:test";
import {
@@ -30,6 +30,7 @@ test("written provider entrypoints satisfy qualified launch permissions", async
return;
}
await bundleVerifiedProviderEntrypoints();
assert.equal(await readFile(new URL("../dist/providers/hermes/materialize-hermes.py", import.meta.url), "utf8"), await readFile(new URL("./materialize-hermes.py", import.meta.url), "utf8"));
for (const entrypoint of verifiedProviderEntrypoints) {
for (const output of [entrypoint.output, entrypoint.verifiedOutput]) {
const mode = (await stat(output)).mode;
@@ -0,0 +1,24 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { hermesRuntimeCachePath } from "../src/drivers/acpx/hermes-runtime-cache.ts";
/** The public server carries the compiled runner, including its setup resources. */
export function hermesProvisionerLayout(moduleUrl) {
const url = new URL(moduleUrl);
if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Hermes setup requires a published provisioner");
const path = fileURLToPath(url);
if (/\/dist\/vendor\/paperclip-runner\/cli\/provision-hermes\.(?:cjs|js)$/.test(path)) {
const root = resolve(dirname(path), "..");
return { root, provider: resolve(root, "providers/hermes"), materializer: resolve(root, "providers/hermes/materialize-hermes.py") };
}
if (/\/dist\/cli\/provision-hermes\.(?:cjs|js)$/.test(path)) {
const root = resolve(dirname(path), "../..");
return { root, provider: resolve(root, "dist/providers/hermes"), materializer: resolve(root, "dist/providers/hermes/materialize-hermes.py") };
}
throw new Error("Hermes setup requires a published provisioner");
}
export function hermesProvisionerDestination(moduleUrl, closureSha256, platform, architecture, home) {
hermesProvisionerLayout(moduleUrl);
return hermesRuntimeCachePath(closureSha256, platform, architecture, home);
}
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { hermesProvisionerDestination, hermesProvisionerLayout } from "./hermes-provisioner-layout.mjs";
test("Hermes setup locates packaged Python materialization resources in both layouts", () => {
for (const [url, root, provider] of [
["file:///consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/cli/provision-hermes.cjs", "/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner", "/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/providers/hermes"],
["file:///consumer/node_modules/@paperclipai/paperclip-runner/dist/cli/provision-hermes.js", "/consumer/node_modules/@paperclipai/paperclip-runner", "/consumer/node_modules/@paperclipai/paperclip-runner/dist/providers/hermes"],
]) {
assert.deepEqual(hermesProvisionerLayout(url), { root, provider, materializer: provider + "/materialize-hermes.py" });
}
});
test("global and local npm setup share the execution account's pinned cache", () => {
for (const prefix of ["/usr/local/lib/node_modules/paperclipai/node_modules", "/consumer/node_modules"]) {
assert.equal(hermesProvisionerDestination(`file://${prefix}/@paperclipai/server/dist/vendor/paperclip-runner/cli/provision-hermes.cjs`, "a".repeat(64), "linux", "x64", "/home/operator"), "/home/operator/.paperclip/runtimes/hermes/linux-x64/" + "a".repeat(64));
}
});
test("unbundled, foreign and parameterized setup paths cannot select cache authority", () => {
for (const url of ["file:///repo/scripts/provision-hermes.mjs", "file:///tmp/provision-hermes.cjs", "https://example.com/dist/cli/provision-hermes.js", "file:///consumer/dist/cli/provision-hermes.cjs?redirect=1"]) assert.throws(() => hermesProvisionerLayout(url), /published provisioner/);
});
@@ -1,31 +1,60 @@
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { access, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { HERMES_CLOSURES } from "../src/drivers/acpx/hermes-distributions.ts";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const provider = await access(join(root, "src/providers/hermes/version.json")).then(() => join(root, "src/providers/hermes"), () => join(root, "dist/providers/hermes"));
const version = JSON.parse(await readFile(join(provider, "version.json"), "utf8"));
const destination = process.argv[2];
if (!destination || !isAbsolute(destination)) throw new Error("Hermes provisioning requires a new absolute destination");
if (!["darwin-arm64", "linux-x64"].includes(`${process.platform}-${process.arch}`)) throw new Error("Hermes platform is not a qualification target");
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
const temporary = await mkdtemp(join(tmpdir(), "paperclip-hermes-provision-"));
try {
const uvVersion = execFileSync("uv", ["--version"], { encoding: "utf8" });
if (!/^uv 0\.12\.17(?:\s|$)/.test(uvVersion)) throw new Error("Hermes provisioning requires uv 0.12.17; install that version before provisioning");
const response = await fetch(`https://api.github.com/repos/NousResearch/hermes-agent/tarball/${version.commit}`, { signal: AbortSignal.timeout(120_000) });
if (!response.ok) throw new Error(`Hermes source download failed: HTTP ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
if (digest(bytes) !== version.archiveSha256) throw new Error("Hermes source digest mismatch");
const archive = join(temporary, "source.tar.gz");
await writeFile(archive, bytes, { mode: 0o600, flag: "wx" });
execFileSync("tar", ["-xzf", archive, "--strip-components=1", "-C", temporary]);
await rm(archive);
if (digest(await readFile(join(temporary, "uv.lock"))) !== version.lockSha256) throw new Error("Hermes dependency lock digest mismatch");
execFileSync("uv", ["sync", "--locked", "--no-dev", "--no-install-project", "--python", version.python,
"--extra", "acp", "--extra", "mcp", "--extra", "anthropic", "--extra", "bedrock", "--extra", "google"], { cwd: temporary, stdio: "inherit" });
execFileSync(join(temporary, ".venv/bin/python"), [join(root, "scripts/materialize-hermes.py"), temporary, destination, provider], { stdio: "inherit" });
} finally { await rm(temporary, { recursive: true, force: true }); }
/** Source provisioning and the public setup command share one pinned operation. */
export async function materializePinnedHermesDistribution({ destination, provider, materializer, verify }) {
if (!destination || !isAbsolute(destination) || resolve(destination) !== destination || destination.includes("\0")) throw new Error("Hermes provisioning requires a new normalized absolute destination");
const expectedClosureSha256 = HERMES_CLOSURES[`${process.platform}-${process.arch}`];
if (!expectedClosureSha256) throw new Error("Hermes platform is not a qualification target");
const version = JSON.parse(await readFile(join(provider, "version.json"), "utf8"));
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
if (await realpath(dirname(destination)) !== dirname(destination)) throw new Error("Hermes provisioning parent must be a real directory");
const existing = await lstat(destination).catch(error => { if (error.code !== "ENOENT") throw error; return null; });
if (existing) throw new Error("Hermes provisioning destination already exists; refusing to overwrite it");
const temporary = await mkdtemp(join(dirname(destination), ".hermes-install-"));
const source = join(temporary, "source");
const prepared = join(temporary, "distribution");
const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8", HOME: join(temporary, "home"),
UV_CACHE_DIR: join(temporary, "uv-cache"), UV_PYTHON_INSTALL_DIR: join(temporary, "uv-python"), UV_NO_CONFIG: "1" };
try {
await mkdir(source, { mode: 0o700 });
await mkdir(env.HOME, { mode: 0o700 });
let uvVersion;
try { uvVersion = execFileSync("uv", ["--version"], { env, encoding: "utf8", timeout: 10000 }); }
catch (error) { if (error.code === "ENOENT") throw new Error("Hermes setup requires uv 0.12.17 on PATH. Install that version and retry setup."); throw error; }
if (!/^uv 0\.12\.17(?:\s|$)/.test(uvVersion)) throw new Error("Hermes provisioning requires uv 0.12.17; install that version before provisioning");
const response = await fetch(`https://api.github.com/repos/NousResearch/hermes-agent/tarball/${version.commit}`, { signal: AbortSignal.timeout(120_000) });
if (!response.ok) throw new Error(`Hermes source download failed: HTTP ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
if (digest(bytes) !== version.archiveSha256) throw new Error("Hermes source digest mismatch");
const archive = join(temporary, "source.tar.gz");
await writeFile(archive, bytes, { mode: 0o600, flag: "wx" });
execFileSync("tar", ["-xzf", archive, "--strip-components=1", "-C", source], { env, timeout: 30000 });
if (digest(await readFile(join(source, "uv.lock"))) !== version.lockSha256) throw new Error("Hermes dependency lock digest mismatch");
execFileSync("uv", ["sync", "--locked", "--no-dev", "--no-install-project", "--python", version.python,
"--extra", "acp", "--extra", "mcp", "--extra", "anthropic", "--extra", "bedrock", "--extra", "google"], { cwd: source, env, stdio: "inherit", timeout: 300000 });
execFileSync(join(source, ".venv/bin/python"), [materializer, source, prepared, provider], { env, stdio: "inherit", timeout: 120000 });
const manifest = JSON.parse(await readFile(join(prepared, "manifest.json"), "utf8"));
if (digest(JSON.stringify(manifest.entries)) !== expectedClosureSha256) throw new Error("Hermes provisioned closure does not match its reviewed pin");
// Public setup checks every execution byte before publication. Native admission
// repeats that verification before the runtime stages credentials.
await verify?.(prepared, expectedClosureSha256);
const conflict = await lstat(destination).catch(error => { if (error.code !== "ENOENT") throw error; return null; });
if (conflict) throw new Error("Hermes destination changed during setup; refusing to overwrite it");
await rename(prepared, destination);
return { destination, closureSha256: expectedClosureSha256, version: version.release };
} finally { await rm(temporary, { recursive: true, force: true }); }
}
if (process.argv[1]?.endsWith("/provision-hermes.mjs") && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
access(join(root, "src/providers/hermes/version.json")).then(() => join(root, "src/providers/hermes"), () => join(root, "dist/providers/hermes"))
.then(provider => materializePinnedHermesDistribution({ destination: process.argv[2], provider, materializer: join(root, "scripts/materialize-hermes.py") }))
.then(result => console.log(JSON.stringify(result))).catch(error => { console.error(error.message); process.exitCode = 1; });
}
@@ -0,0 +1,31 @@
#!/usr/bin/env node
import { lstat, readFile } from "node:fs/promises";
import { join } from "node:path";
import { HERMES_CLOSURES } from "../src/drivers/acpx/hermes-distributions.ts";
import { verifyNativeAcpxInstallation } from "../src/drivers/acpx/installation-integrity.ts";
import { hermesProvisionerDestination, hermesProvisionerLayout } from "./hermes-provisioner-layout.mjs";
import { materializePinnedHermesDistribution } from "./provision-hermes.mjs";
/** Public opt-in only: npm installation and agent execution never invoke setup. */
export async function provisionHermesRuntime() {
if (process.argv.length !== 2) throw new Error("Hermes runtime setup accepts no destination or credential arguments");
const layout = hermesProvisionerLayout(import.meta.url);
const platform = `${process.platform}-${process.arch}`;
const closureSha256 = HERMES_CLOSURES[platform];
if (!closureSha256) throw new Error(`Hermes has no reviewed distribution for ${platform}`);
const destination = hermesProvisionerDestination(import.meta.url, closureSha256, process.platform, process.arch);
const verify = (distributionRoot, expectedClosureSha256) => verifyNativeAcpxInstallation({ distributionRoot, expectedClosureSha256,
manifestPath: join(distributionRoot, "manifest.json"), executable: "python/bin/python3.12", pythonEntrypoint: "entry.py", fixedArguments: [] });
const installed = await lstat(destination).catch(error => { if (error.code !== "ENOENT") throw error; return null; });
if (installed) {
if (!installed.isDirectory() || installed.isSymbolicLink()) throw new Error("Hermes runtime assets must be a real directory");
await verify(destination, closureSha256);
} else {
await materializePinnedHermesDistribution({ destination, provider: layout.provider, materializer: layout.materializer, verify });
}
const version = JSON.parse(await readFile(join(layout.provider, "version.json"), "utf8"));
console.log(`Verified Hermes ${version.release} (${platform}), Python ${version.python}`);
console.log(`Runtime: ${destination}. Run setup as the same OS user that runs Paperclip.`);
}
provisionHermesRuntime().catch(error => { console.error(error.message); process.exitCode = 1; });
@@ -0,0 +1,5 @@
/** Reviewed native closures. Setup cannot adopt a digest from downloaded files. */
export const HERMES_CLOSURES: Readonly<Record<string, string>> = Object.freeze({
"darwin-arm64": "690b3b84a543b04a47a6859969bd613ced61d68776d80b57c85ca424bde31829",
"linux-x64": "6166fadd24dae41b9fdfd994e6c7bc129e9317ed252b7dabdd777b691ab47771",
});
@@ -3,28 +3,27 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { verifyNativeAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js";
import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js";
import { resolveHermesDistributionRoot } from "./hermes-runtime-cache.js";
import { HERMES_CLOSURES } from "./hermes-distributions.js";
export { HERMES_CLOSURES } from "./hermes-distributions.js";
import type { QualifiedAcpxProfile } from "./qualified-profiles.js";
// Pins are produced by explicit provisioning and reviewed alongside the bridge.
// An unbuilt target has no admission pin; it must not inherit a different ABI.
export const HERMES_CLOSURES: Readonly<Record<string, string>> = Object.freeze({
"darwin-arm64": "690b3b84a543b04a47a6859969bd613ced61d68776d80b57c85ca424bde31829",
"linux-x64": "6166fadd24dae41b9fdfd994e6c7bc129e9317ed252b7dabdd777b691ab47771",
});
export async function verifyHermesInstallation(profile: QualifiedAcpxProfile): Promise<VerifiedAcpxInstallation> {
if (profile.agent !== "hermes" || profile.agentServerPackage !== "builtin:hermes-acp" || profile.agentServerVersion !== "1" || profile.agentRuntimePackage !== "native:hermes" || profile.agentRuntimeVersion !== "v2026.9.24") throw new Error("Hermes native profile identity mismatch");
const platform = `${process.platform}-${process.arch}`;
const expectedClosureSha256 = HERMES_CLOSURES[platform];
if (!expectedClosureSha256) throw Object.assign(new Error(`Hermes ${platform} runtime is pending qualification; provision and verify its pinned Python distribution first`), { code: "HERMES_RUNTIME_UNAVAILABLE" });
const distributionRoot = join(resolveRunnerProviderAssetsRoot(import.meta.url, "hermes"), platform);
let installation: VerifiedAcpxInstallation;
try {
const distributionRoot = resolveHermesDistributionRoot(resolveRunnerProviderAssetsRoot(import.meta.url, "hermes"), expectedClosureSha256, process.platform, process.arch);
installation = await verifyNativeAcpxInstallation({
distributionRoot, manifestPath: join(distributionRoot, "manifest.json"), expectedClosureSha256,
executable: "python/bin/python3.12", pythonEntrypoint: "entry.py", fixedArguments: [],
});
} catch (error) {
throw Object.assign(new Error(`Hermes runtime is unavailable or does not match the pinned distribution. Run scripts/provision-hermes.mjs for ${platform} before selecting Hermes.`, { cause: error }), { code: "HERMES_RUNTIME_UNAVAILABLE" });
throw Object.assign(new Error(`Hermes runtime is unavailable or does not match the pinned distribution. Run paperclipai runtime setup hermes as the OS user that runs Paperclip for ${platform}. An invalid installation is never replaced automatically.`, { cause: error }), { code: "HERMES_RUNTIME_UNAVAILABLE" });
}
await verifyHermesCommandSandbox();
return Object.freeze({ ...installation, commandDigest: profile.commandDigest });
@@ -0,0 +1,40 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir, userInfo } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { hermesRuntimeCachePath, resolveHermesDistributionRoot } from "./hermes-runtime-cache.js";
const digest = "1".repeat(64);
const roots: string[] = [];
const root = () => { const path = realpathSync(mkdtempSync(join(tmpdir(), "hermes-runtime-cache-"))); roots.push(path); return path; };
afterEach(() => { for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true }); });
describe("Hermes explicit setup cache", () => {
it("uses the OS account despite a provider HOME override", () => {
const original = process.env.HOME;
try {
process.env.HOME = root();
expect(hermesRuntimeCachePath(digest, "darwin", "arm64")).toBe(join(userInfo().homedir, ".paperclip/runtimes/hermes/darwin-arm64", digest));
} finally { if (original === undefined) delete process.env.HOME; else process.env.HOME = original; }
});
it("uses the cache only when package assets are absent", () => {
const home = root(); const assets = join(root(), "provider-assets/hermes");
expect(resolveHermesDistributionRoot(assets, digest, "linux", "x64", home)).toBe(hermesRuntimeCachePath(digest, "linux", "x64", home));
const packaged = join(assets, "linux-x64"); mkdirSync(packaged, { recursive: true });
expect(resolveHermesDistributionRoot(assets, digest, "linux", "x64", home)).toBe(packaged);
});
it("does not fall back around corrupt or linked packaged assets", () => {
const home = root(); const assets = root(); const target = join(assets, "linux-x64");
writeFileSync(target, "invalid");
expect(() => resolveHermesDistributionRoot(assets, digest, "linux", "x64", home)).toThrow("packaged runtime");
rmSync(target); symlinkSync(root(), target);
expect(() => resolveHermesDistributionRoot(assets, digest, "linux", "x64", home)).toThrow("packaged runtime");
});
it("rejects cache links, foreign targets and malformed identity", () => {
const home = root(); symlinkSync(root(), join(home, ".paperclip"));
expect(() => hermesRuntimeCachePath(digest, "linux", "x64", home)).toThrow("real directories");
expect(() => hermesRuntimeCachePath(digest, "darwin", "x64", root())).toThrow("identity");
expect(() => hermesRuntimeCachePath("unreviewed", "linux", "x64", root())).toThrow("identity");
expect(() => hermesRuntimeCachePath(digest, "linux", "x64", "/tmp/../tmp")).toThrow("identity");
});
});
@@ -0,0 +1,33 @@
import { lstatSync } from "node:fs";
import { userInfo } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
/** The execution account owns setup state; provider HOME overrides have no authority. */
export function hermesRuntimeCachePath(closureSha256: string, platform: string, architecture: string, home = userInfo().homedir): string {
if (!/^[a-f0-9]{64}$/.test(closureSha256) || !["darwin-arm64", "linux-x64"].includes(`${platform}-${architecture}`)
|| !isAbsolute(home) || resolve(home) !== home || home.includes("\0")) throw new Error("Invalid Hermes runtime cache identity");
let directory = home;
for (const part of [".paperclip", "runtimes", "hermes", `${platform}-${architecture}`, closureSha256]) {
directory = join(directory, part);
try {
const stat = lstatSync(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Hermes runtime cache must contain real directories");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
return directory;
}
/** An invalid package asset fails admission; only absence allows the account cache. */
export function resolveHermesDistributionRoot(packageAssetsRoot: string, closureSha256: string, platform: string, architecture: string, home?: string): string {
const packaged = join(packageAssetsRoot, `${platform}-${architecture}`);
try {
const stat = lstatSync(packaged);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Hermes packaged runtime must be a real directory");
return packaged;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
return hermesRuntimeCachePath(closureSha256, platform, architecture, home);
}