fix(hermes): release credentials and reproduce relocatable runtime assets

This commit is contained in:
Dotta committed 2026-10-07 10:29:19 -05:00
1 parent f5ef997aeb
commit 5ca8dc587c
14 files changed
+120 -469

No files matched your search

@@ -1,146 +0,0 @@
# Hermes native runner implementation
Status (updated 2026-10-07): implementation candidate; **not qualified**.
Branch: `codex/hermes-native-runner`.
## Accepted outcome
Run the pinned Hermes ACP agent through the existing Paperclip Runner/ACPX
boundary. Use existing Connections for models, credentials, subscriptions and
custom endpoints. Preserve incremental reasoning/text/tools, attachments,
native questions, explicit active steering, controller-owned queued work,
strict session recovery, per-agent memory/learned skills and Paperclip routines.
Keep the existing Hermes local/gateway adapters compatible.
## Delivery sequence
- [x] Built-in provider, reproducible provisioning, verified launch and shared connection projections.
- [x] Streaming, multimodal input, questions, steering, cancellation and strict restore implementation.
- [x] Managed memory/skills, per-turn lifecycle and real routine-service binding.
- [x] UI/configuration/contracts, documentation and package distribution.
- [x] Focused TS/Python tests and production-path macOS execution with a deterministic model server.
- [x] Complete final Rust/regression checks and resolve or classify failures.
- [ ] Browser acceptance, Linux/Daytona execution and connection-method qualification.
- [ ] Required repository checks and reviewable PR.
## Qualification evidence
Source baseline: Hermes `v2026.9.24`, commit
`f97608f178d1ffeca59860195ab7da295f7c8e5f`; ACPX `0.13.1`.
The native Hermes process has run through the real production runner against a
deterministic no-auth local HTTP model server. This proves the transport and
native callback path; it does not qualify a paid model, subscription, browser
journey or remote environment. Do not enable the production profile merely
because fixtures pass.
## Implemented scope
- Pinned Python 3.12.14, ACP SDK 0.9.0, MCP and provider extras from upstream's
lockfile; uv 0.12.17 provisioning; byte-verified relocatable runtime; packaged
bridge/provisioner and candidate provider-pack support. Both platform closures
reproduce from fresh provisioning.
- `hermes_runner` projection through existing Connections, pools, account
selection, ephemeral credential staging, refresh ownership and session
compatibility. API, subscription, custom protocol and Bedrock projections
have focused tests. Provider authentication is still unqualified live.
- Native execution v6 with backward parsing for v1–v5, authorized typed image
and text attachments through TypeScript/Rust/sidecar, and bounded frames.
Ordinary semantic-result limits remain unchanged.
- Native incremental reasoning/text/tool events, question forms, acknowledged
active steering, controller-owned queued work, cancellation, strict history
restore and compaction-head tracking. No SQLite polling or gateway daemon.
- Per-conversation runtime state, managed agent memory/learned skills,
protected assigned skills, native tool middleware and child-process policy.
macOS uses sandbox-exec; Linux requires working bubblewrap namespaces and
rejects unsupported hosts before credential staging.
- Real self-assigned routine create/update/pause/resume with the existing
service, revision checks, run-bound idempotency and activity publication.
Native cron and gateway messaging are disabled.
- Pending Hermes choice in existing runner configuration; existing connection,
model, permission and transcript components. Ten Product E2E candidate cells
(five local, five Daytona) are registered but have not been run live.
## Evidence and outstanding release gates
The production-path native fixtures pass on macOS arm64: incremental reasoning
and text, image bytes at the selected endpoint, an actual terminal command,
native clarification, assigned MCP round trip, active steering, cancellation,
memory collection, process restart and missing-history rejection. The Rust PRP
fixture also verifies authorized image delivery and semantic task completion.
These tests use real Hermes and a simulated model endpoint.
The branch was rebased onto `03cf6a6ecb0caf5e6f9c4e6af87dc723e5e3bca2`.
Hermes uses the new shared ACP profile manifest. The shared extension fix also
updates Cursor's ACPX patch attestation and profile identity to revision 15;
the Cursor usage, delegation and model-selection package contracts pass.
Post-rebase checks:
| Check | Result |
| --- | --- |
| `pnpm -r typecheck` | Pass |
| `pnpm build` | Pass |
| Rust workspace suite | 651 passing test executions; two ignored |
| Runner ACPX/native contracts and control plane | 1,008 passed; seven skipped |
| Native server input, execution and file handoff | 639 passed |
| Connection projection and routine authority | 38 passed (14 connection, 24 authority) |
| ACP package contracts and provider-pack argument checks | 35 passed |
| Product E2E catalog/fixture support | 74 passed; live journeys not run |
| Native Hermes production-path fixtures | Two passed; deterministic model server |
| UI token gates | Pass |
Python bridge tests pass (15); the Python bridge bytes did not change in the
rebase. Transport coverage includes the unchanged ordinary semantic-result
bound and attachment-sized encrypted frames.
Before the rebase, full repository `pnpm test:run` ran with 15,612 passing, two failing and 91
skipped tests. Both failures pass on targeted reruns: the managed listener
failure was a port collision, and the complete 28-test legacy OpenClaw
comment-wake file passes. That wake file also passes against the original
source baseline. The aggregate invocation itself was not green; no product
change was made to hide either failure.
The clean npm consumer passes its contract checks and independently provisions
the same Hermes runtime hash. Both native fixtures then pass from that
installed package (not workspace imports). The candidate provider-pack
materializer also verifies the copied runtime. Both native execution targets
are still pending real model and product qualification.
Reproduced runtime closure SHA-256:
| Target | Closure digest |
| --- | --- |
| macOS arm64 | `898f2e80e11320b3abb68b7d521776fd71b015102c3caf8b2159f6726f35d746` |
| Linux amd64 | `619c2cf33f52f3db4aa0c8c7005b104c0562ba903f79fae0e46a835fd8cfd70d` |
Release blockers remain explicit:
1. Supply an authorized existing Connection/account for paid-model and
subscription qualification. No provider or Daytona credentials were present
in this execution context; the account-selection question is pending.
2. Qualify a Linux amd64 host with the required sandbox support, then actual
Daytona. Docker's emulated Linux container rejected namespace setup. The
implementation does not bypass protected-path or process isolation to pass.
3. Run the complete browser journeys and per-method connection matrix,
including refresh/revocation/concurrent ownership, permission prompts,
questions across reconnect, steering/queue/stop, remote recovery,
cross-task learned skills, routine firing and cost attribution.
4. Obtain a green aggregate CI run and review the implementation before
promoting the candidate. Default production selection remains disabled.
## Review handoff
The routine service binding is a separate eight-file commit on
`codex/hermes-routines`. The native integration is stacked on it on
`codex/hermes-native-runner`, within the 100-file review limit. Changes are
committed locally. The generated root lockfile is excluded as required by the
repository; the Daytona Dockerfile pins the twice-reproduced resolved lock.
Automatic approval review rejected the push and draft-PR creation because they
would publish externally beyond the authorized local implementation. No branch
was pushed and no PR was created. Publication requires user approval. The
Greptile workflow has not run; no review or CI success is claimed.
No Paperclip issue/run API context was supplied to this local Codex task, so
the implementation record stays in this repository rather than being attached
as an issue work product.
@@ -7,6 +7,8 @@ import json
import os
import shutil
import sys
import sysconfig
import subprocess
from pathlib import Path
source, destination, provider = map(Path, sys.argv[1:])
@@ -23,6 +25,23 @@ def copy_tree(origin, target, excluded=()):
copy_tree(Path(sys.base_prefix), destination / "python", ("include", "share"))
# uv rewrites sysconfig to its installation home. That path differs between
# local consumers, root image builds and the Daytona runtime user. Normalize
# this build metadata so the verified closure is relocatable and reproducible.
prefixes = {sys.base_prefix, str(Path(sys.base_prefix).resolve()), sysconfig.get_config_var("prefix")}
for metadata in (destination / "python/lib/python3.12").glob("_sysconfigdata_*.py"):
text = metadata.read_text()
for prefix in sorted((p for p in prefixes if isinstance(p, str)), key=len, reverse=True):
text = text.replace(prefix, "/paperclip-hermes/python")
metadata.write_text(text)
if sys.platform == "darwin":
# uv also rewrites the Mach-O install name. Restore a relative identity and
# a deterministic ad-hoc signature after changing its load command.
library = destination / "python/lib/libpython3.12.dylib"
if not shutil.which("install_name_tool") or not shutil.which("codesign"):
raise ValueError("Hermes provisioning requires macOS Command Line Tools (install_name_tool and codesign)")
subprocess.run(["install_name_tool", "-id", "@rpath/libpython3.12.dylib", str(library)], check=True)
subprocess.run(["codesign", "--force", "--sign", "-", "--identifier", "paperclip.hermes.libpython3.12", "--timestamp=none", str(library)], check=True)
site = destination / "python/lib/python3.12/site-packages"
site.mkdir(parents=True, exist_ok=True)
# Do not retain editable-install locators, virtualenv hooks, or provenance files
@@ -1,123 +0,0 @@
// Production TS -> Rust PRP -> sidecar -> ACPX -> pinned Hermes -> deterministic
// HTTP model fixture. This is not live provider or browser qualification.
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { once } from 'node:events';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createRunnerdNativeSessionBackend } from '../dist/backends/codex-native-backend.js';
import { createRunnerdCodexTransport } from '../dist/live/runnerd-codex-transport.js';
import { resolveQualifiedAcpxProfile } from '../dist/drivers/acpx/qualified-profiles.js';
import { parseNativeExecutionInput } from '../dist/contracts/native-execution.js';
import { PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION, nativeRuntimePromptDigest, canonicalNativeRuntimeContextDigest } from '../dist/contracts/runtime-context.js';
const sha256 = text => createHash('sha256').update(text).digest('hex');
const imageData = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aA1sAAAAASUVORK5CYII=';
const completion = {
reportedWorkDisposition: 'done', summary: 'Native Rust path completed.',
completionClaim: { contractRevision: '1', objectiveSatisfied: true,
criteria: [{ criterionId: 'objective', status: 'satisfied', evidenceRefs: [] }], remainingWork: [] },
evidence: [], verification: [{ commandOrCheck: 'native transport fixture', status: 'passed' }], attentionRequests: [], artifacts: [],
};
test('pinned Hermes streams images and semantic completion through Rust PRP and the production sidecar', {
skip: process.env.PAPERCLIP_HERMES_QUALIFY !== '1', timeout: 180_000,
}, async t => {
const root = await mkdtemp(join(tmpdir(), 'paperclip-hermes-runnerd-'));
const workspace = join(root, 'workspace');
const instructions = join(root, 'instructions');
const agentFiles = join(root, 'agent-files');
for (const directory of [workspace, instructions, agentFiles, join(root, 'runtime')]) await mkdir(directory, { mode: 0o700 });
const entry = 'You are a deterministic native transport test agent.';
await writeFile(join(instructions, 'AGENTS.md'), entry);
const requests = [];
const server = createServer(async (req, res) => {
const chunks = []; for await (const chunk of req) chunks.push(chunk);
const body = JSON.parse(Buffer.concat(chunks).toString() || '{}');
requests.push(body);
assert.equal(req.headers.authorization, undefined);
if (req.url !== '/v1/chat/completions') { res.writeHead(404).end(); return; }
if (!body.stream) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ id: 'title', object: 'chat.completion', model: 'hermes-fixture', choices: [{ index: 0, message: { role: 'assistant', content: 'Fixture' }, finish_reason: 'stop' }] }));
return;
}
res.writeHead(200, { 'content-type': 'text/event-stream' });
const send = (delta, finish_reason = null) => res.write(`data: ${JSON.stringify({ id: 'native-prp', object: 'chat.completion.chunk', choices: [{ index: 0, delta, finish_reason }] })}\n\n`);
if (!body.messages.some(message => message.role === 'tool')) {
send({ role: 'assistant', reasoning_content: 'Checking the native runner path.' });
await new Promise(resolve => setTimeout(resolve, 80));
const finish = body.tools.find(tool => tool.function.name.endsWith('paperclip_finish'));
assert.ok(finish, 'Paperclip completion authority was not exposed to the native model');
send({ tool_calls: [{ index: 0, id: 'native-prp-finish', type: 'function', function: { name: finish.function.name, arguments: JSON.stringify(completion) } }] });
send({}, 'tool_calls');
} else {
send({ role: 'assistant', content: 'Native Rust ' });
await new Promise(resolve => setTimeout(resolve, 80));
send({ content: 'path completed.' });
send({}, 'stop');
}
res.end('data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}\n\ndata: [DONE]\n\n');
});
let session, bundle;
t.after(async () => {
await session?.close({ reason: 'fixture cleanup' });
await bundle?.transport.close();
server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
if (process.env.PAPERCLIP_KEEP_HERMES_FIXTURE === '1') t.diagnostic(`Fixture evidence: ${root}`);
else await rm(root, { recursive: true, force: true });
});
server.listen(0, '127.0.0.1'); await once(server, 'listening');
const context = {
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
instructions: { entryPath: 'AGENTS.md', bundle: { schema: 'paperclip.runtime-asset.v1', digest: sha256(entry), manifestDigest: sha256(entry), rootPath: instructions, fileCount: 1, totalBytes: Buffer.byteLength(entry) },
workingCopy: { kind: 'agent_files', rootPath: agentFiles, entryPath: 'AGENTS.md' } },
skills: [], mcp: { assignmentSetId: 'none', digest: sha256('none'), bindingId: null },
};
const runtimeContext = { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) };
const identity = { runId: 'hermes-prp-run', sessionId: 'hermes-prp-session', companyId: 'hermes-prp-company', issueId: 'hermes-prp-issue', agentId: 'hermes-prp-agent' };
const resolved = resolveQualifiedAcpxProfile('hermes', 'hermes-fixture');
const profile = Object.fromEntries(['driverKind', 'protocolVersion', 'acpxVersion', 'agent', 'agentProfileVersion', 'agentServerPackage', 'agentServerVersion', 'agentRuntimePackage', 'agentRuntimeVersion', 'commandDigest'].map(key => [key, resolved[key]]));
const input = parseNativeExecutionInput({
schema: 'paperclip.native-execution-input.v6', executionMode: 'default', planningContext: null,
binding: { runId: identity.runId, companyId: identity.companyId, issueId: identity.issueId, agentId: identity.agentId, executionWorkspaceId: 'hermes-prp-workspace' },
session: { normalizedSessionId: identity.sessionId, driverKind: 'acpx_runtime', protocolVersion: 1 },
task: { identifier: 'HERMES-PRP', title: 'Native transport fixture', description: null, prompt: 'Complete the native transport fixture.', workMode: 'standard' },
workspace: { cwd: workspace, repoUrl: null, repoRef: null, branchName: null },
provider: { kind: 'acpx', agent: 'hermes', model: 'hermes-fixture', permissionMode: 'approve-all', profile, connectionFingerprint: '1'.repeat(64) },
completionContract: { id: 'hermes-prp-contract', sha256: sha256('fixture'), schemaVersion: 'paperclip.completion-contract.v1', contract: { revision: '1', objective: 'Native transport fixture', criteria: [{ id: 'objective', requirement: 'Complete the native transport fixture.' }] } },
runtimeContext, interactionResponses: [], credentialBindings: [],
attachments: [{ schema: 'paperclip.user_attachment.v1', kind: 'image', name: 'pixel.png', mediaType: 'image/png', data: imageData }],
});
bundle = createRunnerdCodexTransport({
provider: 'acpx', acpxAgent: 'hermes', acpxCandidateProfile: 'hermes', acpxPermissionMode: 'approve-all',
stateDirectory: join(root, 'runner'), acpxRuntimeDirectory: join(root, 'runtime'), runtimeContext,
lifecyclePolicy: { mode: 'per_turn', idleTimeoutMs: null }, turnStartTimeoutMs: 90_000,
onDiagnostic: message => t.diagnostic(message),
environment: { PATH: process.env.PATH, PAPERCLIP_PROVIDER_TRACE_PATH: join(root, 'provider-trace.jsonl'), PAPERCLIP_HERMES_CONNECTION_FINGERPRINT: input.provider.connectionFingerprint,
PAPERCLIP_HERMES_CONFIG_JSON: JSON.stringify({ model: { provider: 'custom:paperclip', default: 'hermes-fixture' },
providers: { paperclip: { base_url: `http://127.0.0.1:${server.address().port}/v1`, transport: 'chat_completions', default_model: 'hermes-fixture', api_key: 'no-key-required' } },
paperclip_auth: { protocol: 'chat', style: 'none' } }),
},
prpIdentity: { runnerInstanceId: 'hermes-prp-runner', environmentLeaseId: 'hermes-prp-lease', runId: identity.runId, normalizedSessionId: identity.sessionId, turnId: 'hermes-prp-turn', itemId: 'hermes-prp-item' },
});
const backend = createRunnerdNativeSessionBackend(input, { runnerInstanceId: 'hermes-prp-runner', transportFactory: () => bundle.transport });
session = await backend.openSession({ identity, workingDirectory: workspace });
await session.startTurn({ message: { role: 'user', text: input.task.prompt, attachments: input.attachments } });
const events = [];
for await (const event of session.events()) {
events.push(event);
if (['run.terminal', 'turn.completed', 'turn.failed', 'turn.cancelled', 'turn.interrupted'].includes(event.eventType)) break;
}
assert.ok(events.some(event => event.eventType === 'turn.completed'), JSON.stringify(events));
assert.ok(events.some(event => JSON.stringify(event.payload).includes('Checking the native runner path.')), 'Reasoning did not cross PRP');
assert.ok(events.some(event => JSON.stringify(event.payload).includes('Native Rust')), 'Text did not cross PRP');
assert.ok(requests.some(body => body.messages?.some(message => Array.isArray(message.content) && message.content.some(part => part.type === 'image_url' && part.image_url.url === `data:image/png;base64,${imageData}`))), 'Images did not cross TS/Rust/sidecar');
const snapshot = await session.snapshot();
assert.equal(snapshot.semanticResult?.reportedWorkDisposition, 'done');
assert.equal(snapshot.terminal?.runTerminalState, 'succeeded');
assert.equal(bundle.evidence().acpxAgent, 'hermes');
});
@@ -1,188 +0,0 @@
// Pinned native Hermes + production ACPX host, deterministic model fixture.
// This is transport proof, not live model or product qualification.
// PAPERCLIP_HERMES_QUALIFY=1 node --test scripts/qualify-hermes-runtime.test.mjs
import assert from "node:assert/strict";
import { once } from "node:events";
import { mkdir, mkdtemp, rm, readFile, readdir } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { AcpxRuntimeHost } from "../dist/drivers/acpx/runtime-host.js";
import { openCodexAcpxRuntime } from "../dist/drivers/acpx/codex-runtime-adapter.js";
import { acpxProfileClientCapabilities } from "../dist/drivers/acpx/profile-extensions.js";
import { acpxProviderSessionIdentity } from "../dist/drivers/acpx/recovery-identity.js";
test("pinned Hermes streams through the production ACPX host using a no-auth local connection", {
skip: process.env.PAPERCLIP_HERMES_QUALIFY !== "1", timeout: 180_000,
}, async (t) => {
const root = await mkdtemp(join(tmpdir(), "paperclip-hermes-transport-"));
const workspace = join(root, "workspace");
const agentFiles = join(root, "agent-files");
await mkdir(workspace); await mkdir(agentFiles); await mkdir(join(root, "runtime"));
let host;
const requests = [];
const bridgeCalls = [];
const server = createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = JSON.parse(Buffer.concat(chunks).toString() || "{}");
requests.push({ path: req.url, authorization: req.headers.authorization, body });
if (req.url !== "/v1/chat/completions") { res.writeHead(404).end(); return; }
if (!body.stream) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ id: "fixture", object: "chat.completion", model: "hermes-fixture", choices: [{ index: 0, message: { role: "assistant", content: "Fixture title" }, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } }));
return;
}
res.writeHead(200, { "content-type": "text/event-stream" });
const lastUser = body.messages.findLastIndex(message => message.role === "user");
const userText = JSON.stringify(body.messages[lastUser]?.content);
if ((userText.includes("NATIVE_STEER_FIXTURE") && !JSON.stringify(body.messages).includes("NATIVE_STEER_ACCEPTED")) || userText.includes("NATIVE_STOP_FIXTURE")) {
for (let index = 0; index < 150 && !res.destroyed; index++) {
res.write(`data: ${JSON.stringify({ id: "control-fixture", choices: [{ index: 0, delta: { reasoning_content: "Working. " }, finish_reason: null }] })}\n\n`);
await new Promise(resolve => setTimeout(resolve, 30));
}
if (!res.destroyed) res.end('data: {"id":"control-fixture","choices":[{"index":0,"delta":{"content":"Control fixture complete."},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n');
return;
}
const toolDone = body.messages.slice(lastUser + 1).some(message => message.role === "tool");
if (!toolDone && (userText.includes("NATIVE_TOOL_FIXTURE") || userText.includes("NATIVE_QUESTION_FIXTURE") || userText.includes("NATIVE_MCP_FIXTURE") || userText.includes("NATIVE_MEMORY_FIXTURE"))) {
const question = userText.includes("NATIVE_QUESTION_FIXTURE");
const bridge = userText.includes("NATIVE_MCP_FIXTURE");
const memory = userText.includes("NATIVE_MEMORY_FIXTURE");
const name = question ? "clarify" : memory ? "memory" : bridge ? body.tools.find(tool => tool.function?.name.endsWith("fixture_probe"))?.function.name ?? "missing_assigned_tool" : "terminal";
const args = question ? { question: "Choose the fixture color", choices: ["Cobalt", "Amber"] }
: memory ? { action: "add", target: "memory", content: "NATIVE_PERSISTED_MEMORY: Project uses Cobalt." }
: bridge ? { marker: "assigned-tool-roundtrip" }
: { command: "printf 'native-hermes-command\\n' > command-proof.txt", timeout: 10 };
res.write(`data: ${JSON.stringify({ id: "tool-fixture", object: "chat.completion.chunk", model: "hermes-fixture", created: 1, choices: [{ index: 0, delta: { role: "assistant", tool_calls: [{ index: 0, id: `native-${name}`, type: "function", function: { name, arguments: JSON.stringify(args) } }] }, finish_reason: null }] })}\n\n`);
res.end(`data: ${JSON.stringify({ id: "tool-fixture", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } })}\n\ndata: [DONE]\n\n`);
return;
}
for (const [delta, finish_reason] of [
[{ role: "assistant", reasoning_content: "Fixture reasoning." }, null],
[{ content: "Hermes " }, null], [{ content: "transport works." }, null], [{}, "stop"],
]) {
res.write(`data: ${JSON.stringify({ id: "fixture", object: "chat.completion.chunk", model: "hermes-fixture", created: 1, choices: [{ index: 0, delta, finish_reason }] })}\n\n`);
await new Promise(resolve => setTimeout(resolve, 40));
}
res.end('data: {"id":"fixture","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}\n\ndata: [DONE]\n\n');
});
t.after(async () => {
if (host) await host.close({ reason: "qualification cleanup" });
server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
await rm(root, { recursive: true, force: true });
});
server.listen(0, "127.0.0.1"); await once(server, "listening");
const endpoint = `http://127.0.0.1:${server.address().port}/v1`;
const options = {
runtimeDirectory: join(root, "runtime"), normalizedSessionId: "hermes-transport-test", workingDirectory: workspace,
agent: "hermes", model: "hermes-fixture", permissionMode: "approve-all", providerPolicy: { readOnly: false },
clientCapabilities: acpxProfileClientCapabilities("hermes"),
systemInstructions: "This is a transport fixture. Reply briefly.",
semanticTools: {
tools: [{ name: "fixture_probe", description: "Return the supplied marker.", inputSchema: { type: "object", properties: { marker: { type: "string" } }, required: ["marker"], additionalProperties: false } }],
handler: async call => { bridgeCalls.push({ tool: call.tool, arguments: call.arguments }); return { marker: "assigned-tool-returned" }; },
},
runtimeContext: { instructions: { workingCopy: { kind: "agent_files", rootPath: agentFiles, entryPath: "AGENTS.md" } }, skills: [], mcp: {} },
environment: { PATH: process.env.PATH,
PAPERCLIP_HERMES_CONNECTION_FINGERPRINT: "1".repeat(64),
PAPERCLIP_HERMES_CONFIG_JSON: JSON.stringify({ model: { provider: "custom:paperclip", default: "hermes-fixture" },
providers: { paperclip: { base_url: endpoint, transport: "chat_completions", default_model: "hermes-fixture", api_key: "no-key-required" } },
paperclip_auth: { protocol: "chat", style: "none" } }),
}, signal: AbortSignal.timeout(150_000),
};
const dependencies = { openRuntime: openCodexAcpxRuntime, reportRetainedCleanupFailure: failure => t.diagnostic(String(failure.error)) };
host = await AcpxRuntimeHost.open(options, dependencies);
const turn = host.startTurn({ text: "Reply with the transport marker.", requestId: "transport-turn" });
const events = [];
let completed = false;
turn.result.then(() => { completed = true; });
for await (const event of turn.events) events.push({ event, beforeCompletion: !completed });
assert.equal((await turn.result).status, "completed");
const text = events.filter(({ event }) => event.type === "text_delta" && event.stream !== "thought");
assert.equal(text.map(({ event }) => event.text).join(""), "Hermes transport works.");
assert.ok(text.some(row => row.beforeCompletion));
assert.ok(requests.some(row => row.body.model === "hermes-fixture"));
assert.ok(requests.every(row => row.authorization === undefined), `No-auth connection sent credentials: ${JSON.stringify(requests.map(row => ({ path: row.path, authenticated: row.authorization !== undefined })))}`);
assert.ok(events.some(({ event }) => event.type === "text_delta" && event.stream === "thought"));
const expectedIdentity = acpxProviderSessionIdentity(host.identity(), host.binding());
await host.close({ reason: "per-turn process checkpoint" });
host = await AcpxRuntimeHost.open({ ...options, expectedIdentity }, dependencies);
const imageData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aA1sAAAAASUVORK5CYII=";
const resumed = host.startTurn({ text: "Continue the previous conversation and inspect this image.", requestId: "resumed-turn",
attachments: [{ schema: "paperclip.user_attachment.v1", kind: "image", name: "pixel.png", mediaType: "image/png", data: imageData }] });
for await (const _event of resumed.events) { /* drain bounded events */ }
assert.equal((await resumed.result).status, "completed");
assert.ok(requests.some(row => row.body.messages?.filter(message => message.role === "user").length >= 2), "Restore lost the previous conversation");
assert.ok(requests.some(row => row.body.messages?.some(message => Array.isArray(message.content) && message.content.some(part => part.type === "image_url" && part.image_url.url === `data:image/png;base64,${imageData}`))), `The selected model did not receive the authorized image bytes: ${JSON.stringify(requests.filter(row => row.body.stream).map(row => row.body.messages.filter(message => message.role === "user")))}`);
const tools = host.startTurn({ text: "NATIVE_TOOL_FIXTURE: Run the supplied command.", requestId: "tools-turn" });
const toolEvents = [];
for await (const event of tools.events) toolEvents.push(event);
assert.equal((await tools.result).status, "completed");
assert.equal(await readFile(join(workspace, "command-proof.txt"), "utf8").catch(() => null), "native-hermes-command\n",
`Native command failed: ${JSON.stringify(requests.flatMap(row => row.body.messages?.filter(message => message.role === "tool") ?? []))}`);
assert.ok(toolEvents.some(event => event.type === "tool_call" || event.type === "tool_call_update"), "Native tool events did not reach ACPX");
let questions = 0;
const questionTurn = host.startTurn({ text: "NATIVE_QUESTION_FIXTURE: Ask the native question.", requestId: "question-turn",
onExtensionRequest: async (method, params) => {
assert.equal(method, "_hermes/ask_questions");
assert.equal(params.version, 1);
questions++;
const question = params.input.questions[0];
assert.ok(question.options[0].label.startsWith("Cobalt"));
return { outcome: "answered", answers: { [question.id]: { selectedOptionIds: ["o0"] } } };
},
});
for await (const _event of questionTurn.events) { /* drain */ }
assert.equal((await questionTurn.result).status, "completed");
assert.equal(questions, 1, `Native clarify failed: ${JSON.stringify(requests.at(-1)?.body.messages?.filter(message => message.role === "tool"))}`);
assert.ok(requests.some(row => row.body.messages?.some(message => message.role === "tool" && String(message.content).includes("Cobalt"))), "The native callback did not receive the submitted answer");
for (const kind of ["MCP", "MEMORY"]) {
const turn = host.startTurn({ text: `NATIVE_${kind}_FIXTURE: Use the native tool.`, requestId: `${kind.toLowerCase()}-turn` });
for await (const _event of turn.events) { /* drain */ }
assert.equal((await turn.result).status, "completed");
}
assert.deepEqual(bridgeCalls, [{ tool: "fixture_probe", arguments: { marker: "assigned-tool-roundtrip" } }]);
assert.equal(host.steeringCapability()?.steering, true);
assert.equal(host.steeringCapability()?.queuedFollowUp, false);
const steering = host.startTurn({ text: "NATIVE_STEER_FIXTURE: Continue working until redirected.", requestId: "steering-turn" });
let steered = false;
for await (const event of steering.events) {
if (!steered && event.type === "text_delta") {
await host.steerActiveTurn("NATIVE_STEER_ACCEPTED: Answer with the transport marker instead.");
steered = true;
}
}
assert.equal(steered, true);
assert.equal((await steering.result).status, "completed");
assert.ok(requests.some(row => JSON.stringify(row.body.messages ?? []).includes("NATIVE_STEER_ACCEPTED")), "Accepted steering did not reach the native turn");
await assert.rejects(host.steerActiveTurn("stale correction"), /active turn/);
const stop = host.startTurn({ text: "NATIVE_STOP_FIXTURE: Continue working until cancelled.", requestId: "stop-turn" });
let stopped = false;
for await (const event of stop.events) {
if (!stopped && event.type === "text_delta") {
stopped = true;
await host.interruptActiveTurn("qualification stop");
}
}
assert.equal(stopped, true);
assert.equal((await stop.result).status, "cancelled");
const restoreIdentity = acpxProviderSessionIdentity(host.identity(), host.binding());
await host.close({ reason: "verify missing history" }); host = undefined;
assert.match(await readFile(join(agentFiles, "hermes/memories/MEMORY.md"), "utf8"), /NATIVE_PERSISTED_MEMORY/);
const nativeDatabases = (await readdir(join(root, "runtime"), { recursive: true })).filter(path => path.endsWith("state.db"));
assert.equal(nativeDatabases.length, 1);
await rm(join(root, "runtime", nativeDatabases[0]));
let refused = false;
try {
host = await AcpxRuntimeHost.open({ ...options, expectedIdentity: restoreIdentity }, dependencies);
const missing = host.startTurn({ text: "Continue the lost conversation.", requestId: "missing-history" });
for await (const _event of missing.events) { /* drain */ }
refused = (await missing.result).status !== "completed";
} catch (error) {
assert.match(String(error), /restore|history|session|resume/i);
refused = true;
}
assert.equal(refused, true, "Missing native history was silently replaced with a new conversation");
});
@@ -586,7 +586,7 @@ describe("qualified ACPX runtime sidecar", () => {
ok: false,
error: {
code: "acpx_sidecar_command_failed",
message: "ACPX agent must be claude, codex, grok, cursor, copilot, or pi",
message: "ACPX agent must be claude, codex, grok, cursor, copilot, pi, or hermes",
retryable: false,
},
});
@@ -1,3 +1,5 @@
import type { NativeUserAttachment } from "./user-attachments.js";
export interface NativeRunIdentity {
runId: string;
sessionId: string;
@@ -48,7 +50,7 @@ export interface NativeSessionCapabilities {
export interface NativeUserMessage {
role: "user";
text: string;
attachments?: import("./user-attachments.js").NativeUserAttachment[];
attachments?: NativeUserAttachment[];
}
import type { TypedEventFamilyCapability } from "../provider-events.js";
@@ -0,0 +1,32 @@
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { stageManagedHermesCredential } from "./hermes-credentials.js";
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
const environment = { PAPERCLIP_HERMES_AUTH_JSON_SECRET: JSON.stringify({ version: 1, providers: { "openai-codex": { access_token: "fixture-token" } } }) };
describe("Hermes credential cleanup after verified provider exit", () => {
it.each(["refresh", "collection"])("scrubs credentials and releases ownership even when %s fails", async failure => {
const home = await mkdtemp(join(tmpdir(), "hermes-credential-cleanup-")); roots.push(home);
let failCollection = failure === "collection";
const lease = await stageManagedHermesCredential({ agentHomeDirectory: home, environment,
beforeRelease: async () => { if (failCollection) throw new Error("learned state rejected"); } });
await mkdir(join(home, "logs"));
await writeFile(join(home, "logs/native.log"), "private diagnostic fixture");
if (failure === "refresh") await chmod(join(home, "auth.json"), 0o644);
await expect(lease.close()).rejects.toThrow("Hermes state save or credential cleanup failed");
await expect(readFile(join(home, "auth.json"))).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(join(home, "logs/native.log"))).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(join(home, ".paperclip-auth-cleanup-required"))).rejects.toMatchObject({ code: "ENOENT" });
failCollection = false;
await lease.close();
const next = await stageManagedHermesCredential({ agentHomeDirectory: home, environment, retainRefresh: () => false });
await lease.close();
expect(await readFile(join(home, "auth.json"), "utf8")).toContain("fixture-token");
await next.close();
await expect(readFile(join(home, "auth.json"))).rejects.toMatchObject({ code: "ENOENT" });
});
});
@@ -41,9 +41,14 @@ export async function stageManagedHermesCredential(input: {
if (closed) return Promise.resolve();
if (closing) return closing;
closing = (async () => {
const errors: unknown[] = [];
const attempt = async (operation: () => Promise<void>) => {
try { await operation(); } catch (error) { errors.push(error); }
};
// The runtime host calls close only after verified provider exit. Retain
// a private refresh handoff for the controller's existing Hermes merge
// predicate, then scrub auth.json before releasing the process fence.
await attempt(async () => {
if (inline && (input.retainRefresh?.() ?? true)) {
const auth = await open(join(input.agentHomeDirectory, "auth.json"), constants.O_RDONLY | constants.O_NOFOLLOW).catch((error) => {
if (error.code === "ENOENT") return null;
@@ -71,15 +76,18 @@ export async function stageManagedHermesCredential(input: {
} finally { await auth.close(); }
}
}
});
// Hermes diagnostics can contain account/authentication fields. They are
// disposable, unlike session history, and must not outlive this owner.
// close is invoked only after the provider has been contained.
for (const name of ["logs", "auth.json.corrupt", "auth.lock"]) {
await rm(join(input.agentHomeDirectory, name), { recursive: true, force: true });
await attempt(() => rm(join(input.agentHomeDirectory, name), { recursive: true, force: true }));
}
await input.beforeRelease?.();
await lease.close();
closed = true;
await attempt(async () => { await input.beforeRelease?.(); });
// Persistence errors must remain visible, but cannot retain credentials
// or their ownership fence after the provider has verifiably exited.
await attempt(async () => { await lease.close(); closed = true; });
if (errors.length) throw new AggregateError(errors, "Hermes state save or credential cleanup failed");
})().finally(() => { closing = null; });
return closing;
},
@@ -8,8 +8,8 @@ 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": "898f2e80e11320b3abb68b7d521776fd71b015102c3caf8b2159f6726f35d746",
"linux-x64": "619c2cf33f52f3db4aa0c8c7005b104c0562ba903f79fae0e46a835fd8cfd70d",
"darwin-arm64": "4c89b24335e1869a9faba6996a4e82979337ee5f850d792ab41a838e79afdce3",
"linux-x64": "f9919bd2e812e86e81ecd964d6e1961bb68f96e2154f816c554f31c4f1d78211",
});
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");
@@ -1,4 +1,4 @@
import { mkdtemp, mkdir, readFile, rm, symlink, link, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, readdir, rm, symlink, link, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -46,4 +46,19 @@ describe("Hermes managed learned state", () => {
const f = await fixture();
await expect(stageHermesAgentState(f.home, null)).rejects.toThrow(/managed agent-file storage/);
});
it("discards unfinished transfers during staging and collection without learning their contents", async () => {
const f = await fixture();
const state = await stageHermesAgentState(f.home, f.context);
const leftover = ".paperclip-hermes-transfer-00000000-0000-4000-8000-000000000001.tmp";
await writeFile(join(f.home, "memories/MEMORY.md"), "Complete memory");
await writeFile(join(f.home, "memories", leftover), "Incomplete memory");
await writeFile(join(f.agent, "hermes/memories/MEMORY.md.paperclip-state.tmp"), "Old incomplete transfer");
await state.collect();
expect(await readdir(join(f.agent, "hermes/memories"))).toEqual(["MEMORY.md"]);
expect(await readFile(join(f.agent, "hermes/memories/MEMORY.md"), "utf8")).toBe("Complete memory");
await writeFile(join(f.agent, "hermes/memories", leftover), "Incomplete saved transfer");
await stageHermesAgentState(f.home, f.context);
expect(await readdir(join(f.home, "memories"))).toEqual(["MEMORY.md"]);
expect(await readdir(join(f.agent, "hermes/memories"))).toEqual(["MEMORY.md"]);
});
});
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { lstat, mkdir, open, readdir, rename, rm } from "node:fs/promises";
import { dirname, join, resolve, relative } from "node:path";
@@ -6,6 +7,7 @@ import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-conte
/** Only these native files belong to an agent. Sessions, auth and configuration
* belong to the isolated conversation home and never cross this boundary. */
const DIRECTORIES = ["memories", "skills"] as const;
const TEMPORARY_NAME = /^\.paperclip-hermes-transfer-[a-f0-9-]{36}\.tmp$/;
const MAX_FILE_BYTES = 256 * 1024 * 1024;
const MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
@@ -48,6 +50,11 @@ async function inventory(root: string): Promise<Map<string, number>> {
if (stat.isDirectory() && !stat.isSymbolicLink()) await walk(path);
else {
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > MAX_FILE_BYTES) throw new Error("Hermes learned state must contain bounded regular files");
// Reserved transfer files may survive a crash. Validate before
// removing them so symlinks/hardlinks still fail closed.
if (TEMPORARY_NAME.test(entry.name) || entry.name.endsWith(".paperclip-state.tmp")) {
await rm(path); continue;
}
total += stat.size;
if (total > MAX_TOTAL_BYTES) throw new Error("Hermes learned state exceeds managed storage limits");
files.set(relative(root, path), stat.size);
@@ -68,7 +75,7 @@ async function mirrorDirectory(source: string, destination: string) {
let directory = destination;
for (const part of parts) { directory = join(directory, part); await ensureDirectory(directory); }
const input = await open(join(source, name), constants.O_RDONLY | constants.O_NOFOLLOW);
const temporary = `${target}.paperclip-state.tmp`;
const temporary = join(dirname(target), `.paperclip-hermes-transfer-${randomUUID()}.tmp`);
try {
const before = await input.stat({ bigint: true });
if (!before.isFile() || before.nlink !== 1n || before.size !== BigInt(size)) throw new Error("Hermes state changed during collection");
@@ -6,6 +6,7 @@ are adapted to the runner; inference, tools and conversation history are Hermes.
from __future__ import annotations
import asyncio
from urllib.parse import urlsplit, urlunsplit
import concurrent.futures
import json
import math
@@ -187,7 +188,16 @@ class ManagedSessionManager(SessionManager):
if kwargs.get("model") not in (None, model["default"]):
raise ValueError("Hermes restored model differs from the selected connection")
for argument, field in (("requested_provider", "provider"), ("base_url", "base_url"), ("api_mode", "api_mode")):
if kwargs.get(argument) is not None and kwargs[argument] != runtime.get(field):
actual, expected = kwargs.get(argument), runtime.get(field)
if argument == "base_url" and isinstance(actual, str) and isinstance(expected, str):
# SDK clients normalize a base URL by appending a slash.
# Compare only that normalization; query, host and protocol
# remain exact and Paperclip pins the connection fingerprint.
def normalize(url):
parts = urlsplit(url)
return urlunsplit(parts._replace(path=parts.path.rstrip("/")))
actual, expected = normalize(actual), normalize(expected)
if actual is not None and actual != expected:
raise ValueError("Hermes restored route differs from the selected connection")
agent = super()._make_agent(**kwargs)
agent.ephemeral_system_prompt = os.environ.get("PAPERCLIP_HERMES_SYSTEM_INSTRUCTIONS", "")
@@ -103,6 +103,20 @@ class Restore(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "compacted session history is missing"):
manager._restore("parent")
def test_restore_accepts_sdk_base_url_normalization_but_rejects_routing_changes(self):
manager = ManagedSessionManager()
config = {"model": {"provider": "custom:paperclip", "default": "exact-model"}}
route = {"provider": "custom:paperclip", "base_url": "https://example.test/v1?route=a", "api_mode": "chat_completions"}
agent = SimpleNamespace()
with patch("hermes_cli.config.load_config", return_value=config), patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=route), patch("acp_adapter.session.SessionManager._make_agent", return_value=agent) as native:
manager._make_agent(model="exact-model", base_url="https://example.test/v1/?route=a", api_mode="chat_completions")
native.assert_called_once()
for change in [{"model": "other"}, {"base_url": "https://example.test/v2?route=a"}, {"base_url": "https://example.test/v1?route=b"}, {"requested_provider": "other"}, {"api_mode": "responses"}]:
native.reset_mock()
with self.assertRaises(ValueError):
manager._make_agent(**{"model": "exact-model", "base_url": route["base_url"], "api_mode": "chat_completions", **change})
native.assert_not_called()
def test_failed_native_flush_cannot_be_reported_as_durable(self):
db = Mock()
manager = ManagedSessionManager(db=db)
@@ -97,11 +97,12 @@ describe("Paperclip Runner Codex configuration", () => {
expect(html).toContain('ACP agents');
expect(html).toContain("ACP agent");
expect(html).toContain('aria-label="ACP agent"');
expect(html.match(/role="option"[^>]*data-disabled=""/g)).toHaveLength(2);
expect(html.match(/role="option"[^>]*data-disabled=""/g)).toHaveLength(3);
expect(html).toContain('Cursor');
expect(html).not.toContain('Cursor — qualification pending');
expect(html).toContain('GitHub Copilot — qualification pending');
expect(html).toContain('Pi — qualification pending');
expect(html).toContain('Hermes — qualification pending');
expect(html).not.toContain("Codex via ACPX");
expect(html).not.toContain("ACPX Codex");
expect(html).not.toContain("Pi via ACPX");