mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
feat(hermes): ship explicit public runtime setup
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
bbadcc18a3
commit
b9603a3a8e
16 files changed
+326
-55
No files matched your search
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user