diff --git a/doc/plans/2026-10-06-hermes-native-runner.md b/doc/plans/2026-10-06-hermes-native-runner.md deleted file mode 100644 index f03e564c20..0000000000 --- a/doc/plans/2026-10-06-hermes-native-runner.md +++ /dev/null @@ -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. diff --git a/packages/paperclip-runner/scripts/materialize-hermes.py b/packages/paperclip-runner/scripts/materialize-hermes.py index ee8aff7bb5..cd2bdc3d04 100644 --- a/packages/paperclip-runner/scripts/materialize-hermes.py +++ b/packages/paperclip-runner/scripts/materialize-hermes.py @@ -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 diff --git a/packages/paperclip-runner/scripts/qualify-hermes-runnerd.test.mjs b/packages/paperclip-runner/scripts/qualify-hermes-runnerd.test.mjs deleted file mode 100644 index 62b2598b48..0000000000 --- a/packages/paperclip-runner/scripts/qualify-hermes-runnerd.test.mjs +++ /dev/null @@ -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'); -}); diff --git a/packages/paperclip-runner/scripts/qualify-hermes-runtime.test.mjs b/packages/paperclip-runner/scripts/qualify-hermes-runtime.test.mjs deleted file mode 100644 index 13713cff31..0000000000 --- a/packages/paperclip-runner/scripts/qualify-hermes-runtime.test.mjs +++ /dev/null @@ -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"); -}); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index 75a098ce4a..71d8f00d2f 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -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, }, }); diff --git a/packages/paperclip-runner/src/contracts/types.ts b/packages/paperclip-runner/src/contracts/types.ts index c9a2701e15..1c85562683 100644 --- a/packages/paperclip-runner/src/contracts/types.ts +++ b/packages/paperclip-runner/src/contracts/types.ts @@ -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"; diff --git a/packages/paperclip-runner/src/drivers/acpx/hermes-credentials.test.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-credentials.test.ts new file mode 100644 index 0000000000..3d5bee53ac --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-credentials.test.ts @@ -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" }); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/hermes-credentials.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-credentials.ts index d095d8f590..535a5287c1 100644 --- a/packages/paperclip-runner/src/drivers/acpx/hermes-credentials.ts +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-credentials.ts @@ -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) => { + 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; }, diff --git a/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts index fd81e80984..9118d1396f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts @@ -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> = Object.freeze({ - "darwin-arm64": "898f2e80e11320b3abb68b7d521776fd71b015102c3caf8b2159f6726f35d746", - "linux-x64": "619c2cf33f52f3db4aa0c8c7005b104c0562ba903f79fae0e46a835fd8cfd70d", + "darwin-arm64": "4c89b24335e1869a9faba6996a4e82979337ee5f850d792ab41a838e79afdce3", + "linux-x64": "f9919bd2e812e86e81ecd964d6e1961bb68f96e2154f816c554f31c4f1d78211", }); export async function verifyHermesInstallation(profile: QualifiedAcpxProfile): Promise { if (profile.agent !== "hermes" || profile.agentServerPackage !== "builtin:hermes-acp" || profile.agentServerVersion !== "1" || profile.agentRuntimePackage !== "native:hermes" || profile.agentRuntimeVersion !== "v2026.9.24") throw new Error("Hermes native profile identity mismatch"); diff --git a/packages/paperclip-runner/src/drivers/acpx/hermes-state.test.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-state.test.ts index e1a8043b6c..c209e59b05 100644 --- a/packages/paperclip-runner/src/drivers/acpx/hermes-state.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-state.test.ts @@ -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"]); + }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/hermes-state.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-state.ts index 9f31b350a7..0c212f6f13 100644 --- a/packages/paperclip-runner/src/drivers/acpx/hermes-state.ts +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-state.ts @@ -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> { 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"); diff --git a/packages/paperclip-runner/src/providers/hermes/bridge.py b/packages/paperclip-runner/src/providers/hermes/bridge.py index 4e1de0597e..b892dd8272 100644 --- a/packages/paperclip-runner/src/providers/hermes/bridge.py +++ b/packages/paperclip-runner/src/providers/hermes/bridge.py @@ -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", "") diff --git a/packages/paperclip-runner/src/providers/hermes/test_bridge.py b/packages/paperclip-runner/src/providers/hermes/test_bridge.py index 103b2794c2..e9b5063eeb 100644 --- a/packages/paperclip-runner/src/providers/hermes/test_bridge.py +++ b/packages/paperclip-runner/src/providers/hermes/test_bridge.py @@ -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) diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 8c9f221e10..7a7cb425d1 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -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");