From b9603a3a8e262084a1c18dc8a55ff333eca08115 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 7 Oct 2026 20:17:42 -0500 Subject: [PATCH] feat(hermes): ship explicit public runtime setup Co-Authored-By: Paperclip --- cli/src/__tests__/runtime-setup.test.ts | 19 ++++- cli/src/commands/runtime.ts | 32 +++++--- doc/plans/2026-10-06-hermes-native-runner.md | 51 ++++++++++++ packages/paperclip-runner/README.md | 5 +- packages/paperclip-runner/docs/hermes.md | 13 ++- packages/paperclip-runner/package.json | 6 +- .../build-verified-provider-entrypoints.mjs | 7 ++ ...ild-verified-provider-entrypoints.test.mjs | 3 +- .../scripts/hermes-provisioner-layout.mjs | 24 ++++++ .../hermes-provisioner-layout.test.mjs | 20 +++++ .../scripts/provision-hermes.mjs | 81 +++++++++++++------ .../scripts/setup-hermes-runtime.mjs | 31 +++++++ .../src/drivers/acpx/hermes-distributions.ts | 5 ++ .../src/drivers/acpx/hermes-installation.ts | 11 ++- .../drivers/acpx/hermes-runtime-cache.test.ts | 40 +++++++++ .../src/drivers/acpx/hermes-runtime-cache.ts | 33 ++++++++ 16 files changed, 326 insertions(+), 55 deletions(-) create mode 100644 packages/paperclip-runner/scripts/hermes-provisioner-layout.mjs create mode 100644 packages/paperclip-runner/scripts/hermes-provisioner-layout.test.mjs create mode 100644 packages/paperclip-runner/scripts/setup-hermes-runtime.mjs create mode 100644 packages/paperclip-runner/src/drivers/acpx/hermes-distributions.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/hermes-runtime-cache.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/hermes-runtime-cache.ts diff --git a/cli/src/__tests__/runtime-setup.test.ts b/cli/src/__tests__/runtime-setup.test.ts index 0c588ff6e51..11bb5d4a359 100644 --- a/cli/src/__tests__/runtime-setup.test.ts +++ b/cli/src/__tests__/runtime-setup.test.ts @@ -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"); +}); diff --git a/cli/src/commands/runtime.ts b/cli/src/commands/runtime.ts index 5afdc54be8e..7fe8aabf654 100644 --- a/cli/src/commands/runtime.ts +++ b/cli/src/commands/runtime.ts @@ -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 { +async function resolveRuntimeProvisioner(serverUrl: string, provider: "cursor" | "hermes"): Promise { + 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 { - 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 { + 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 { 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 ") - .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 "); + await setupRuntime(provider); }); } diff --git a/doc/plans/2026-10-06-hermes-native-runner.md b/doc/plans/2026-10-06-hermes-native-runner.md index d7e6f73a318..54a354e9268 100644 --- a/doc/plans/2026-10-06-hermes-native-runner.md +++ b/doc/plans/2026-10-06-hermes-native-runner.md @@ -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. diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 677336c3a41..cd2ef3fef5d 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -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 diff --git a/packages/paperclip-runner/docs/hermes.md b/packages/paperclip-runner/docs/hermes.md index 0cb8050c62a..97899b73189 100644 --- a/packages/paperclip-runner/docs/hermes.md +++ b/packages/paperclip-runner/docs/hermes.md @@ -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//`. 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 diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 18297374580..cc112f4a8bf 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -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", diff --git a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs index 605795b1a83..3f61685f386 100644 --- a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs +++ b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs @@ -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 = []; diff --git a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs index 223af558178..54352181f1c 100644 --- a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs +++ b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs @@ -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; diff --git a/packages/paperclip-runner/scripts/hermes-provisioner-layout.mjs b/packages/paperclip-runner/scripts/hermes-provisioner-layout.mjs new file mode 100644 index 00000000000..b6e31e9afbd --- /dev/null +++ b/packages/paperclip-runner/scripts/hermes-provisioner-layout.mjs @@ -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); +} diff --git a/packages/paperclip-runner/scripts/hermes-provisioner-layout.test.mjs b/packages/paperclip-runner/scripts/hermes-provisioner-layout.test.mjs new file mode 100644 index 00000000000..b835ece6984 --- /dev/null +++ b/packages/paperclip-runner/scripts/hermes-provisioner-layout.test.mjs @@ -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/); +}); diff --git a/packages/paperclip-runner/scripts/provision-hermes.mjs b/packages/paperclip-runner/scripts/provision-hermes.mjs index 049d9aee51f..fa046677e3f 100644 --- a/packages/paperclip-runner/scripts/provision-hermes.mjs +++ b/packages/paperclip-runner/scripts/provision-hermes.mjs @@ -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; }); +} diff --git a/packages/paperclip-runner/scripts/setup-hermes-runtime.mjs b/packages/paperclip-runner/scripts/setup-hermes-runtime.mjs new file mode 100644 index 00000000000..5ecfe57096f --- /dev/null +++ b/packages/paperclip-runner/scripts/setup-hermes-runtime.mjs @@ -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; }); diff --git a/packages/paperclip-runner/src/drivers/acpx/hermes-distributions.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-distributions.ts new file mode 100644 index 00000000000..80aa87e29ae --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-distributions.ts @@ -0,0 +1,5 @@ +/** Reviewed native closures. Setup cannot adopt a digest from downloaded files. */ +export const HERMES_CLOSURES: Readonly> = Object.freeze({ + "darwin-arm64": "690b3b84a543b04a47a6859969bd613ced61d68776d80b57c85ca424bde31829", + "linux-x64": "6166fadd24dae41b9fdfd994e6c7bc129e9317ed252b7dabdd777b691ab47771", +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts index 40674c4f1fc..8376aaca0ce 100644 --- a/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts @@ -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> = Object.freeze({ - "darwin-arm64": "690b3b84a543b04a47a6859969bd613ced61d68776d80b57c85ca424bde31829", - "linux-x64": "6166fadd24dae41b9fdfd994e6c7bc129e9317ed252b7dabdd777b691ab47771", -}); export async function verifyHermesInstallation(profile: QualifiedAcpxProfile): Promise { 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 }); diff --git a/packages/paperclip-runner/src/drivers/acpx/hermes-runtime-cache.test.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-runtime-cache.test.ts new file mode 100644 index 00000000000..4a7e42926dc --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-runtime-cache.test.ts @@ -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"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/hermes-runtime-cache.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-runtime-cache.ts new file mode 100644 index 00000000000..e6cb9b3be4a --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-runtime-cache.ts @@ -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); +}