mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 12:28:10 +02:00
fix(hermes): release credentials and reproduce relocatable runtime assets
This commit is contained in:
1 parent
9d1512a169
commit
fdb94c95ef
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)
|
||||
|
||||
@@ -106,11 +106,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");
|
||||
|
||||
Reference in new issue
Block a user