From 3f81880de9d03c1d8a018cda9c94e6fb47865be2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:31:44 -0700 Subject: [PATCH 1/5] fix(cli): support stable Hermes 0.22 releases, ported onto the seats package #2582 rebased onto main. #2739 moved the Hermes adapter into packages/seats/src/adapters/hermes.js, so the PR's four commits are carried as one change there: - accept stable Hermes >=0.22.2 <0.23.0 and name `hermes update` on refusal (SUPPORTED_HERMES_VERSION_RANGE replaces TESTED_HERMES_VERSION); - write the OpenRouter key into the profile's .env, which 0.22.5's gateway reads instead of the spawn env (syncProfileEnv); - pin the workspace with --in, since 0.22.5 rejects --no-restore-cwd; - OS-neutral install guidance. The cli wrapper re-exports the new names. Tests, 0.22.5 fixtures and the BYO guide are the PR's, unchanged; main had not touched them since its base. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu --- cli/__tests__/adapters.hermes.test.mjs | 172 ++++++++++++++++-- .../fixtures/hermes-0.22.5/first-turn.jsonl | 3 + .../fixtures/hermes-0.22.5/resume.jsonl | 3 + cli/src/lib/adapters/hermes.js | 2 +- docs-site/bring-your-own/hermes.mdx | 4 +- packages/seats/src/adapters/hermes.js | 77 +++++++- 6 files changed, 236 insertions(+), 25 deletions(-) create mode 100644 cli/__tests__/fixtures/hermes-0.22.5/first-turn.jsonl create mode 100644 cli/__tests__/fixtures/hermes-0.22.5/resume.jsonl diff --git a/cli/__tests__/adapters.hermes.test.mjs b/cli/__tests__/adapters.hermes.test.mjs index ca644e5f6f..51549aa32b 100644 --- a/cli/__tests__/adapters.hermes.test.mjs +++ b/cli/__tests__/adapters.hermes.test.mjs @@ -1,25 +1,27 @@ /** - * Fixtures are stdout captured from NousResearch/hermes-agent tag v0.22.2 - * (commit d923a5eb487a) on 2026-10-21. The real CLI was run twice with + * Fixtures are stdout captured from NousResearch/hermes-agent tags v0.22.2 + * (commit d923a5eb487a) and v0.22.5 (commit bf0203a794a0). The real CLI was run twice with * --query-file - --format stream-json --provider openrouter --model test/model, * using an isolated HERMES_HOME and a local OpenAI-compatible test endpoint * answering PONG. The second capture passed --resume with the first session ID. + * On 0.22.5 --in replaced --no-restore-cwd for the gateway transport; the + * init event now has an empty session_id, while the result carries the ID. * No provider credential or request body is stored in these fixtures. */ import { EventEmitter } from 'events'; -import { mkdtemp, readFile, rm } from 'fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import { PassThrough } from 'stream'; import { jest } from '@jest/globals'; -import hermes, { TESTED_HERMES_VERSION, readHermesJsonl } from '../src/lib/adapters/hermes.js'; +import hermes, { SUPPORTED_HERMES_VERSION_RANGE, readHermesJsonl } from '../src/lib/adapters/hermes.js'; import { validateEnvironmentSpec } from '../src/lib/environment.js'; -const fixture = async (name) => readFile( - new URL(`./fixtures/hermes-0.22.2/${name}.jsonl`, import.meta.url), 'utf8', +const fixture = async (name, version = '0.22.2') => readFile( + new URL(`./fixtures/hermes-${version}/${name}.jsonl`, import.meta.url), 'utf8', ); -const versionProbe = (version = TESTED_HERMES_VERSION) => jest.fn(() => ({ +const versionProbe = (version = '0.22.5') => jest.fn(() => ({ status: 0, stdout: `Hermes Agent v${version} (local build)\n`, })); @@ -58,7 +60,7 @@ test('real 0.22.2 first and resumed JSONL return one final reply and a stable se const calls = []; const context = { cwd: home, agentName: 'pa1-test', env: { PATH: '/bin', OPENROUTER_API_KEY: 'fixture-only' }, - _hermesHome: join(home, 'profiles', 'pa1'), _spawnSyncImpl: versionProbe(), + _hermesHome: join(home, 'profiles', 'pa1'), _spawnSyncImpl: versionProbe('0.22.2'), }; const firstResult = await hermes.spawn('Reply PONG', { environment: { model: 'test-model' }, ...context, _spawnImpl: recordedSpawn(first, (...call) => calls.push(call)), @@ -79,6 +81,8 @@ test('real 0.22.2 first and resumed JSONL return one final reply and a stable se ])); expect(args.join(' ')).not.toContain('fixture-only'); expect(args.join(' ')).not.toContain('Reply'); + expect(args).toEqual(expect.arrayContaining(['--in', home])); + expect(args).not.toContain('--no-restore-cwd'); expect(options.stdio).toEqual(['pipe', 'pipe', 'pipe']); expect(options.env.HERMES_HOME).toBe(join(home, 'profiles', 'pa1')); } @@ -87,21 +91,78 @@ test('real 0.22.2 first and resumed JSONL return one final reply and a stable se }); }); -test('refuses any Hermes version other than 0.22.2 before starting a turn', async () => { +test('real 0.22.5 JSONL keeps the resumed session in the terminal result', async () => { + const first = await fixture('first-turn', '0.22.5'); + const resumed = await fixture('resume', '0.22.5'); + const firstEvents = first.trim().split('\n').map(JSON.parse); + const resumeEvents = resumed.trim().split('\n').map(JSON.parse); + expect(firstEvents.map((event) => event.type)).toEqual(['system', 'text', 'result']); + expect(resumeEvents.map((event) => event.type)).toEqual(['system', 'text', 'result']); + expect(firstEvents[0].session_id).toBe(''); + expect(firstEvents[2].session_id).toBeTruthy(); + expect(resumeEvents[2].session_id).toBe(firstEvents[2].session_id); + + await withHome(async (home) => { + const calls = []; + const context = { + environment: { model: 'test-model' }, cwd: home, agentName: 'pa1-test', env: { OPENROUTER_API_KEY: 'fixture-only' }, + _hermesHome: join(home, 'profiles', 'pa1'), _spawnSyncImpl: versionProbe('0.22.5'), + }; + const firstResult = await hermes.spawn('Reply PONG', { + ...context, _spawnImpl: recordedSpawn(first, (...call) => calls.push(call)), + }); + expect(firstResult).toEqual({ text: 'PONG', newSessionId: firstEvents[2].session_id }); + const resumeResult = await hermes.spawn('Reply again', { + ...context, sessionId: firstResult.newSessionId, + _spawnImpl: recordedSpawn(resumed, (...call) => calls.push(call)), + }); + expect(resumeResult).toEqual({ text: 'PONG', newSessionId: firstResult.newSessionId }); + expect(calls[0][1]).toEqual(expect.arrayContaining(['--in', home])); + expect(calls[1][1]).toEqual(expect.arrayContaining(['--in', home, '--resume', firstResult.newSessionId])); + }); +}); + +test('accepts stable Hermes 0.22 releases from 0.22.2 onward', async () => { + await withHome(async (home) => { + const recording = await fixture('first-turn'); + for (const version of ['0.22.2', '0.22.3', '0.22.5']) { + // eslint-disable-next-line no-await-in-loop + await expect(hermes.spawn('hello', { environment: { model: 'test-model' }, + cwd: home, _hermesHome: home, env: { OPENROUTER_API_KEY: 'fixture-only' }, + _spawnSyncImpl: versionProbe(version), _spawnImpl: recordedSpawn(recording), + })).resolves.toMatchObject({ text: 'PONG' }); + } + }); +}); + +test('refuses Hermes outside the supported stable range before starting a turn', async () => { await withHome(async (home) => { const child = jest.fn(); - for (const version of ['0.22.3', '0.22.1', '0.22.2+1.gabcdef0']) { - // A post-release checkout may retain the 0.22.2 prefix but is not the pin. + for (const version of ['0.22.1', '0.23.0', '1.0.0', '0.22.2+1.gabcdef0', '0.22.6-rc.1']) { + // Development builds and prereleases are outside the tested stable-release contract. // eslint-disable-next-line no-await-in-loop await expect(hermes.spawn('hello', { environment: { model: 'test-model' }, cwd: home, _hermesHome: home, env: { OPENROUTER_API_KEY: 'fixture-only' }, _spawnSyncImpl: versionProbe(version), _spawnImpl: child, - })).rejects.toThrow(`Hermes ${version} is unsupported; this adapter requires 0.22.2`); + })).rejects.toThrow(`Hermes ${version} is unsupported; this adapter supports ${SUPPORTED_HERMES_VERSION_RANGE}. Run hermes update`); } expect(child).not.toHaveBeenCalled(); }); }); +test('missing Hermes on Windows names the supported range and platform-neutral install guide', async () => { + await withHome(async (home) => { + const child = jest.fn(); + const missing = hermes.spawn('hello', { environment: { model: 'test-model' }, + cwd: home, _hermesHome: home, + _spawnSyncImpl: jest.fn(() => ({ error: { code: 'ENOENT' } })), _spawnImpl: child, + }); + await expect(missing).rejects.toThrow(`Hermes Agent ${SUPPORTED_HERMES_VERSION_RANGE} is not on PATH; install for your OS: https://hermes-agent.nousresearch.com/docs/getting-started/installation`); + await expect(missing).rejects.not.toThrow(/curl|bash/); + expect(child).not.toHaveBeenCalled(); + }); +}); + test('retries a failed cold-home version probe before the first turn', async () => { await withHome(async (home) => { const probe = jest.fn() @@ -259,3 +320,90 @@ test('rejects invalid JSONL and a process that exits before its terminal result' })).rejects.toThrow('without a complete JSONL result'); }); }); + +describe('profile .env: the key reaches a 0.22.5 detached gateway', () => { + const KEY = 'sk-or-v1-profileenvsentinel0123456789'; + const modeOf = async (path) => (await stat(path)).mode & 0o777; + const turn = async (home, env, observed = []) => hermes.spawn('hello', { + environment: { model: 'test-model' }, cwd: home, _hermesHome: join(home, 'profile'), env, + _spawnSyncImpl: versionProbe(), onSpawn: (event) => observed.push(event), + _spawnImpl: recordedSpawn(await fixture('first-turn', '0.22.5'), (...call) => observed.push(call)), + }); + + test('creates the profile .env at 0600 in a 0700 home, and leaves it for the gateway', async () => { + await withHome(async (home) => { + const observed = []; + await turn(home, { OPENROUTER_API_KEY: KEY, OPENROUTER_BASE_URL: 'https://openrouter.example/api/v1' }, observed); + const profile = join(home, 'profile'); + expect(await readFile(join(profile, '.env'), 'utf8')) + .toBe(`OPENROUTER_API_KEY=${KEY}\nOPENROUTER_BASE_URL=https://openrouter.example/api/v1\n`); + expect(await modeOf(join(profile, '.env'))).toBe(0o600); + expect(await modeOf(profile)).toBe(0o700); + expect((await readdir(profile)).filter((name) => name.endsWith('.tmp'))).toEqual([]); + expect(observed.length).toBeGreaterThan(0); + for (const event of observed) { + const { env, ...rest } = Array.isArray(event) ? { ...event[2], args: event[1] } : event; + expect(JSON.stringify(rest)).not.toContain(KEY); + } + }); + }); + + test('keeps the user\'s own lines and overwrites only the keys the wrapper owns', async () => { + await withHome(async (home) => { + const profile = join(home, 'profile'); + await mkdir(profile, { recursive: true }); + await writeFile(join(profile, '.env'), [ + '# my profile', 'TELEGRAM_BOT_TOKEN=user-owned', 'OPENROUTER_API_KEY=stale-key', + 'export OPENROUTER_BASE_URL=https://stale.example', 'OPENROUTER_API_KEY_BACKUP=user-backup', '', + ].join('\n'), { mode: 0o600 }); + await turn(home, { OPENROUTER_API_KEY: KEY, OPENROUTER_BASE_URL: 'https://openrouter.example/api/v1' }); + expect(await readFile(join(profile, '.env'), 'utf8')).toBe([ + '# my profile', 'TELEGRAM_BOT_TOKEN=user-owned', 'OPENROUTER_API_KEY_BACKUP=user-backup', + `OPENROUTER_API_KEY=${KEY}`, 'OPENROUTER_BASE_URL=https://openrouter.example/api/v1', '', + ].join('\n')); + }); + }); + + test('an unset base URL leaves the user\'s base URL line alone', async () => { + await withHome(async (home) => { + const profile = join(home, 'profile'); + await mkdir(profile, { recursive: true }); + await writeFile(join(profile, '.env'), 'OPENROUTER_BASE_URL=https://mine.example\n', { mode: 0o600 }); + await turn(home, { OPENROUTER_API_KEY: KEY }); + expect(await readFile(join(profile, '.env'), 'utf8')) + .toBe(`OPENROUTER_BASE_URL=https://mine.example\nOPENROUTER_API_KEY=${KEY}\n`); + }); + }); + + test('a rewrite never loosens the mode, and tightens a loose file or home', async () => { + await withHome(async (home) => { + const profile = join(home, 'profile'); + await turn(home, { OPENROUTER_API_KEY: KEY }); + await turn(home, { OPENROUTER_API_KEY: `${KEY}rotated` }); + expect(await modeOf(join(profile, '.env'))).toBe(0o600); + expect(await readFile(join(profile, '.env'), 'utf8')).toBe(`OPENROUTER_API_KEY=${KEY}rotated\n`); + + await chmod(join(profile, '.env'), 0o644); + await chmod(profile, 0o755); + await turn(home, { OPENROUTER_API_KEY: `${KEY}rotated` }); + expect(await modeOf(join(profile, '.env'))).toBe(0o600); + expect(await modeOf(profile)).toBe(0o700); + }); + }); + + test('neither a refused value nor a failed write puts the key in the error', async () => { + await withHome(async (home) => { + const multiline = `${KEY}\nINJECTED=1`; + const refused = turn(home, { OPENROUTER_API_KEY: multiline }); + await expect(refused).rejects.toThrow('OPENROUTER_API_KEY contains characters'); + await refused.catch((error) => expect(`${error.message}${error.stack}`).not.toContain(KEY)); + await expect(readFile(join(home, 'profile', '.env'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + + await mkdir(join(home, 'profile', '.env'), { recursive: true }); + const failed = turn(home, { OPENROUTER_API_KEY: KEY }); + await expect(failed).rejects.toThrow('Hermes profile .env'); + await failed.catch((error) => expect(`${error.message}${error.stack}`).not.toContain(KEY)); + expect((await readdir(join(home, 'profile'))).filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); + }); +}); diff --git a/cli/__tests__/fixtures/hermes-0.22.5/first-turn.jsonl b/cli/__tests__/fixtures/hermes-0.22.5/first-turn.jsonl new file mode 100644 index 0000000000..3a36199c95 --- /dev/null +++ b/cli/__tests__/fixtures/hermes-0.22.5/first-turn.jsonl @@ -0,0 +1,3 @@ +{"type": "system", "subtype": "init", "model": "test/model", "session_id": ""} +{"type": "text", "text": "PONG"} +{"type": "result", "session_id": "local-c7632bb30a7a0c641ab65371a42c6c164424559fc947e3dbcc26d26c42a99757", "exit_code": 0, "text": "PONG", "tokens": {"input": 100, "output": 20, "total": 120, "cache_read": 0, "cache_write": 0}} diff --git a/cli/__tests__/fixtures/hermes-0.22.5/resume.jsonl b/cli/__tests__/fixtures/hermes-0.22.5/resume.jsonl new file mode 100644 index 0000000000..38cb54b0d6 --- /dev/null +++ b/cli/__tests__/fixtures/hermes-0.22.5/resume.jsonl @@ -0,0 +1,3 @@ +{"type": "system", "subtype": "init", "model": "test/model", "session_id": ""} +{"type": "text", "text": "PONG"} +{"type": "result", "session_id": "local-c7632bb30a7a0c641ab65371a42c6c164424559fc947e3dbcc26d26c42a99757", "exit_code": 0, "text": "PONG", "tokens": {"input": 200, "output": 40, "total": 240, "cache_read": 0, "cache_write": 0}} diff --git a/cli/src/lib/adapters/hermes.js b/cli/src/lib/adapters/hermes.js index 8181bbe1f2..b930a1cca4 100644 --- a/cli/src/lib/adapters/hermes.js +++ b/cli/src/lib/adapters/hermes.js @@ -5,7 +5,7 @@ import { buildMemoryPreamble } from '../memory-bridge.js'; import { effectiveSandboxTrust } from '../environment.js'; import { seatModelRequired } from './seat-model.js'; -export { TESTED_HERMES_VERSION, readHermesJsonl } from '@commonlyai/seats/adapters/hermes'; +export { SUPPORTED_HERMES_VERSION_RANGE, readHermesJsonl, syncProfileEnv } from '@commonlyai/seats/adapters/hermes'; // Commonly's policy for the generic Hermes adapter: its memory preamble, its // legacy sandbox-trust names, the fleet seat-model switch, and the profile diff --git a/docs-site/bring-your-own/hermes.mdx b/docs-site/bring-your-own/hermes.mdx index 7aaf23c5ea..f5d0912161 100644 --- a/docs-site/bring-your-own/hermes.mdx +++ b/docs-site/bring-your-own/hermes.mdx @@ -3,11 +3,11 @@ title: Hermes description: CLI attach in a private pod --- -Hermes connects through the CLI listener in an invite-only pod. The adapter requires Hermes Agent 0.22.2 and a key in `OPENROUTER_API_KEY`. Use an OpenRouter key, or set `OPENROUTER_BASE_URL` to an OpenRouter-compatible endpoint and supply its key. Hermes cannot run as a public or sandbox-declared seat. +Hermes connects through the CLI listener in an invite-only pod. The adapter supports stable Hermes Agent releases `>=0.22.2 <0.23.0` and needs a key in `OPENROUTER_API_KEY`. Use an OpenRouter key, or set `OPENROUTER_BASE_URL` to an OpenRouter-compatible endpoint and supply its key. Hermes cannot run as a public or sandbox-declared seat. You need Node.js 20 or later. -Install the [Commonly CLI](https://www.npmjs.com/package/@commonlyai/cli) and sign in with `commonly login`. With Hermes 0.22.2 installed and `OPENROUTER_API_KEY` set on your computer, attach the agent in your private pod: +Install the [Commonly CLI](https://www.npmjs.com/package/@commonlyai/cli) and sign in with `commonly login`. Run `hermes update` if your installed Hermes is outside the supported range. With `OPENROUTER_API_KEY` set on your computer, attach the agent in your private pod: ```bash commonly agent attach hermes --pod --name diff --git a/packages/seats/src/adapters/hermes.js b/packages/seats/src/adapters/hermes.js index e54973f88f..c5fb175363 100644 --- a/packages/seats/src/adapters/hermes.js +++ b/packages/seats/src/adapters/hermes.js @@ -1,7 +1,7 @@ -/** Hermes 0.22.2 local CLI wrapper. Hermes owns provider I/O; this file only spawns it. */ +/** Hermes 0.22 local CLI wrapper. Hermes owns provider I/O; this file only spawns it. */ import { spawn as childSpawn, spawnSync } from 'child_process'; -import { createHash } from 'crypto'; -import { mkdir } from 'fs/promises'; +import { createHash, randomBytes } from 'crypto'; +import { chmod, mkdir, readFile, rename, rm, writeFile } from 'fs/promises'; import { homedir } from 'os'; import { join } from 'path'; import { StringDecoder } from 'string_decoder'; @@ -9,11 +9,64 @@ import { observeSpawn, controlsFromArguments } from '../runtime-observation.js'; import { requireSeatModel } from '../seat-model.js'; import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; -export const TESTED_HERMES_VERSION = '0.22.2'; +export const SUPPORTED_HERMES_VERSION_RANGE = '>=0.22.2 <0.23.0'; +const supportsHermesVersion = (version) => { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version || ''); + if (!match) return false; + return Number(match[1]) === 0 && Number(match[2]) === 22 && Number(match[3]) >= 2; +}; const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; const VERSION_TIMEOUT_MS = 15000; const MAX_STDERR_CHARS = 4096; const MAX_LINE_CHARS = 1024 * 1024; +// Keys the wrapper owns in the profile's .env. Every other line is the user's. +const PROFILE_ENV_KEYS = ['OPENROUTER_API_KEY', 'OPENROUTER_BASE_URL']; +const PLAIN_ENV_VALUE = /^[A-Za-z0-9_\-.:/+=@%~]+$/; + +const definedKey = (line) => { + const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line); + return match ? match[1] : null; +}; + +// From 0.22.5 the detached gateway serves a profiles/ home with a child +// env scrubbed of credentials and overlaid with that profile's own .env +// (tools/environments/local.py, served_profile_child_env), so a key in the +// spawn env never reaches the model. Hermes documents /.env as where +// a profile's API keys live (website/docs/user-guide/profiles.md). The file +// stays after the turn: the gateway outlives the spawn and rereads it. +export const syncProfileEnv = async (home, env) => { + const owned = PROFILE_ENV_KEYS.filter((key) => env[key]); + for (const key of owned) { + if (!PLAIN_ENV_VALUE.test(env[key])) { + throw new Error(`${key} contains characters a Hermes .env line cannot carry unquoted; refusing to write it`); + } + } + const path = join(home, '.env'); + let current = ''; + try { + current = await readFile(path, 'utf8'); + } catch (error) { + if (error?.code !== 'ENOENT') throw new Error(`Cannot read the Hermes profile .env (${error?.code || 'read failed'})`); + } + const kept = current.split(/\r?\n/).filter((line, index, lines) => ( + !owned.includes(definedKey(line)) && !(index === lines.length - 1 && line === '') + )); + const next = `${[...kept, ...owned.map((key) => `${key}=${env[key]}`)].join('\n')}\n`; + await chmod(home, 0o700); + if (next === current) { + await chmod(path, 0o600); + return; + } + const temp = join(home, `.env.${process.pid}.${randomBytes(6).toString('hex')}.tmp`); + try { + await writeFile(temp, next, { mode: 0o600, flag: 'wx' }); + await chmod(temp, 0o600); + await rename(temp, path); + } catch (error) { + await rm(temp, { force: true }); + throw new Error(`Cannot write the Hermes profile .env (${error?.code || 'write failed'})`); + } +}; const readVersion = (env, spawnSyncImpl = spawnSync) => { let probe; @@ -24,8 +77,8 @@ const readVersion = (env, spawnSyncImpl = spawnSync) => { if (probe.error?.code === 'ENOENT') return null; if (probe.error || probe.status !== 0) continue; const version = String(probe.stdout || '').match(/Hermes Agent v([^\s]+)/i)?.[1]; - if (version !== TESTED_HERMES_VERSION) { - throw new Error(`Hermes ${version || 'unknown'} is unsupported; this adapter requires ${TESTED_HERMES_VERSION}`); + if (!supportsHermesVersion(version)) { + throw new Error(`Hermes ${version || 'unknown'} is unsupported; this adapter supports ${SUPPORTED_HERMES_VERSION_RANGE}. Run hermes update to install a supported release`); } return version; } @@ -182,13 +235,17 @@ export const createHermesAdapter = ({ await mkdir(home, { recursive: true, mode: 0o700 }); const env = { ...(ctx.env || process.env), HERMES_HOME: home }; if (!readVersion(env, ctx._spawnSyncImpl)) { - throw new Error('Hermes Agent 0.22.2 is not on PATH'); + throw new Error(`Hermes Agent ${SUPPORTED_HERMES_VERSION_RANGE} is not on PATH; install for your OS: https://hermes-agent.nousresearch.com/docs/getting-started/installation`); } if (!env.OPENROUTER_API_KEY) { throw new Error('Supply the operator OPENROUTER_API_KEY through Secret Manager before running Hermes'); } + await syncProfileEnv(home, env); + const cwd = ctx.cwd || process.cwd(); + // 0.22.5's gateway rejects --no-restore-cwd; --in pins the workspace on + // both fresh and resumed turns and is also supported by 0.22.2. const args = ['chat', '--oneshot', '--query-file', '-', '--format', 'stream-json', - '--provider', 'openrouter', '--source', 'tool', '--no-restore-cwd']; + '--provider', 'openrouter', '--source', 'tool', '--in', cwd]; if (seatModel) args.push('--model', seatModel); if (ctx.sessionId) args.push('--resume', ctx.sessionId); const fullPrompt = memoryPreamble(prompt, ctx.memoryLongTerm, { @@ -198,8 +255,8 @@ export const createHermesAdapter = ({ const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0 ? Math.min(requestedTimeout, DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS; return runHermes({ - args, prompt: fullPrompt, cwd: ctx.cwd, env, timeoutMs, - spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args, { modelFlag: '--model', valueFlags: ['--query-file', '--format', '--provider', '--source', '--resume'] }), + args, prompt: fullPrompt, cwd, env, timeoutMs, + spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args, { modelFlag: '--model', valueFlags: ['--query-file', '--format', '--provider', '--source', '--in', '--resume'] }), credentials: spawnCredentials(ctx, ['OPENROUTER_API_KEY']), }); }, From 8f04122a6703015b70f2686e32cc1ce654ca2e9a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:27:08 -0700 Subject: [PATCH 2/5] fix(cli): accept Hermes 0.22.5 and later below 0.23 (TASK-444) 0.22.2 is the release the pre-port adapter walked with --no-restore-cwd, and 0.22.5 rejects that flag. The adapter passes --in, which only the 0.22.5 walk has run, so the floor moves to 0.22.5 rather than keying the flag by version. 0.22.3 and 0.22.4 were never walked and are refused. Scout copy and the Hermes docs name the new range. The refusal already names the found version and 'hermes update'. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- backend/config/native-agents/scout.ts | 2 +- cli/__tests__/adapters.hermes.test.mjs | 56 +++++--------------------- docs-site/bring-your-own/hermes.mdx | 2 +- packages/seats/src/adapters/hermes.js | 7 ++-- 4 files changed, 15 insertions(+), 52 deletions(-) diff --git a/backend/config/native-agents/scout.ts b/backend/config/native-agents/scout.ts index 0c18feeae1..24850db617 100644 --- a/backend/config/native-agents/scout.ts +++ b/backend/config/native-agents/scout.ts @@ -126,7 +126,7 @@ export const scoutApp = { // on our LiteLLM. The adapter itself passes --provider openrouter and needs // OPENROUTER_API_KEY, which the walk did not use, so no provider is named as walked. + ' * Hermes connects from the command line, not from this page. It needs Hermes ' - + '0.22.2 exactly, and an OpenRouter API key (OPENROUTER_API_KEY) in the environment ' + + '0.22.5 or newer below 0.23 (run `hermes update` if yours is older), and an OpenRouter API key (OPENROUTER_API_KEY) in the environment ' + 'of the computer it runs on: the adapter sends its model calls through OpenRouter. ' + 'Run `commonly agent attach hermes --pod --name `, then `commonly ' + 'agent run ` to answer @mentions. The pod id is the last part of its URL. ' diff --git a/cli/__tests__/adapters.hermes.test.mjs b/cli/__tests__/adapters.hermes.test.mjs index 51549aa32b..08e05dbf74 100644 --- a/cli/__tests__/adapters.hermes.test.mjs +++ b/cli/__tests__/adapters.hermes.test.mjs @@ -17,7 +17,7 @@ import { jest } from '@jest/globals'; import hermes, { SUPPORTED_HERMES_VERSION_RANGE, readHermesJsonl } from '../src/lib/adapters/hermes.js'; import { validateEnvironmentSpec } from '../src/lib/environment.js'; -const fixture = async (name, version = '0.22.2') => readFile( +const fixture = async (name, version = '0.22.5') => readFile( new URL(`./fixtures/hermes-${version}/${name}.jsonl`, import.meta.url), 'utf8', ); @@ -47,48 +47,10 @@ const withHome = async (run) => { try { return await run(home); } finally { await rm(home, { recursive: true, force: true }); } }; -test('real 0.22.2 first and resumed JSONL return one final reply and a stable session', async () => { - const first = await fixture('first-turn'); - const resumed = await fixture('resume'); - const firstEvents = first.trim().split('\n').map(JSON.parse); - const resumeEvents = resumed.trim().split('\n').map(JSON.parse); - expect(firstEvents.map((event) => event.type)).toEqual(['system', 'text', 'result']); - expect(resumeEvents.map((event) => event.type)).toEqual(['system', 'text', 'result']); - expect(resumeEvents[0].session_id).toBe(firstEvents[0].session_id); - - await withHome(async (home) => { - const calls = []; - const context = { - cwd: home, agentName: 'pa1-test', env: { PATH: '/bin', OPENROUTER_API_KEY: 'fixture-only' }, - _hermesHome: join(home, 'profiles', 'pa1'), _spawnSyncImpl: versionProbe('0.22.2'), - }; - const firstResult = await hermes.spawn('Reply PONG', { environment: { model: 'test-model' }, - ...context, _spawnImpl: recordedSpawn(first, (...call) => calls.push(call)), - }); - expect(firstResult).toEqual({ text: 'PONG', newSessionId: firstEvents[0].session_id }); - - const resumeResult = await hermes.spawn('Reply again', { environment: { model: 'test-model' }, - ...context, sessionId: firstResult.newSessionId, - _spawnImpl: recordedSpawn(resumed, (...call) => calls.push(call)), - }); - expect(resumeResult).toEqual({ text: 'PONG', newSessionId: firstResult.newSessionId }); - expect(calls).toHaveLength(2); - for (const [command, args, options] of calls) { - expect(command).toBe('hermes'); - expect(args).toEqual(expect.arrayContaining([ - 'chat', '--oneshot', '--query-file', '-', '--format', 'stream-json', - '--provider', 'openrouter', '--source', 'tool', - ])); - expect(args.join(' ')).not.toContain('fixture-only'); - expect(args.join(' ')).not.toContain('Reply'); - expect(args).toEqual(expect.arrayContaining(['--in', home])); - expect(args).not.toContain('--no-restore-cwd'); - expect(options.stdio).toEqual(['pipe', 'pipe', 'pipe']); - expect(options.env.HERMES_HOME).toBe(join(home, 'profiles', 'pa1')); - } - expect(calls[1][1]).toEqual(expect.arrayContaining(['--resume', firstResult.newSessionId])); - expect(calls[0][1]).not.toContain('--resume'); - }); +test('the walked 0.22.2 capture still parses, though the adapter refuses that release', async () => { + const events = (await fixture('first-turn', '0.22.2')).trim().split('\n').map(JSON.parse); + expect(events.map((event) => event.type)).toEqual(['system', 'text', 'result']); + expect(events[2].session_id).toBeTruthy(); }); test('real 0.22.5 JSONL keeps the resumed session in the terminal result', async () => { @@ -122,10 +84,10 @@ test('real 0.22.5 JSONL keeps the resumed session in the terminal result', async }); }); -test('accepts stable Hermes 0.22 releases from 0.22.2 onward', async () => { +test('accepts stable Hermes 0.22 releases from 0.22.5 onward', async () => { await withHome(async (home) => { const recording = await fixture('first-turn'); - for (const version of ['0.22.2', '0.22.3', '0.22.5']) { + for (const version of ['0.22.5', '0.22.9']) { // eslint-disable-next-line no-await-in-loop await expect(hermes.spawn('hello', { environment: { model: 'test-model' }, cwd: home, _hermesHome: home, env: { OPENROUTER_API_KEY: 'fixture-only' }, @@ -138,7 +100,7 @@ test('accepts stable Hermes 0.22 releases from 0.22.2 onward', async () => { test('refuses Hermes outside the supported stable range before starting a turn', async () => { await withHome(async (home) => { const child = jest.fn(); - for (const version of ['0.22.1', '0.23.0', '1.0.0', '0.22.2+1.gabcdef0', '0.22.6-rc.1']) { + for (const version of ['0.22.2', '0.22.3', '0.22.4', '0.22.1', '0.23.0', '1.0.0', '0.22.5+1.gabcdef0', '0.22.6-rc.1']) { // Development builds and prereleases are outside the tested stable-release contract. // eslint-disable-next-line no-await-in-loop await expect(hermes.spawn('hello', { environment: { model: 'test-model' }, @@ -167,7 +129,7 @@ test('retries a failed cold-home version probe before the first turn', async () await withHome(async (home) => { const probe = jest.fn() .mockReturnValueOnce({ status: null, signal: 'SIGTERM', error: { code: 'ETIMEDOUT' } }) - .mockReturnValueOnce({ status: 0, stdout: 'Hermes Agent v0.22.2 (local build)\n' }); + .mockReturnValueOnce({ status: 0, stdout: 'Hermes Agent v0.22.5 (local build)\n' }); const recording = await fixture('first-turn'); await expect(hermes.spawn('hello', { environment: { model: 'test-model' }, cwd: home, _hermesHome: home, env: { OPENROUTER_API_KEY: 'fixture-only' }, diff --git a/docs-site/bring-your-own/hermes.mdx b/docs-site/bring-your-own/hermes.mdx index f5d0912161..a271c064d9 100644 --- a/docs-site/bring-your-own/hermes.mdx +++ b/docs-site/bring-your-own/hermes.mdx @@ -3,7 +3,7 @@ title: Hermes description: CLI attach in a private pod --- -Hermes connects through the CLI listener in an invite-only pod. The adapter supports stable Hermes Agent releases `>=0.22.2 <0.23.0` and needs a key in `OPENROUTER_API_KEY`. Use an OpenRouter key, or set `OPENROUTER_BASE_URL` to an OpenRouter-compatible endpoint and supply its key. Hermes cannot run as a public or sandbox-declared seat. +Hermes connects through the CLI listener in an invite-only pod. The adapter supports stable Hermes Agent releases `>=0.22.5 <0.23.0` and needs a key in `OPENROUTER_API_KEY`. Use an OpenRouter key, or set `OPENROUTER_BASE_URL` to an OpenRouter-compatible endpoint and supply its key. Hermes cannot run as a public or sandbox-declared seat. You need Node.js 20 or later. diff --git a/packages/seats/src/adapters/hermes.js b/packages/seats/src/adapters/hermes.js index c5fb175363..59321f394d 100644 --- a/packages/seats/src/adapters/hermes.js +++ b/packages/seats/src/adapters/hermes.js @@ -9,11 +9,11 @@ import { observeSpawn, controlsFromArguments } from '../runtime-observation.js'; import { requireSeatModel } from '../seat-model.js'; import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; -export const SUPPORTED_HERMES_VERSION_RANGE = '>=0.22.2 <0.23.0'; +export const SUPPORTED_HERMES_VERSION_RANGE = '>=0.22.5 <0.23.0'; const supportsHermesVersion = (version) => { const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version || ''); if (!match) return false; - return Number(match[1]) === 0 && Number(match[2]) === 22 && Number(match[3]) >= 2; + return Number(match[1]) === 0 && Number(match[2]) === 22 && Number(match[3]) >= 5; }; const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; const VERSION_TIMEOUT_MS = 15000; @@ -243,7 +243,8 @@ export const createHermesAdapter = ({ await syncProfileEnv(home, env); const cwd = ctx.cwd || process.cwd(); // 0.22.5's gateway rejects --no-restore-cwd; --in pins the workspace on - // both fresh and resumed turns and is also supported by 0.22.2. + // both fresh and resumed turns. 0.22.2-0.22.4 are refused above, so no + // version-keyed flag is needed. const args = ['chat', '--oneshot', '--query-file', '-', '--format', 'stream-json', '--provider', 'openrouter', '--source', 'tool', '--in', cwd]; if (seatModel) args.push('--model', seatModel); From 5280ce0c9566eecc6308babe5e437f4cd1ff5432 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:29:29 -0700 Subject: [PATCH 3/5] docs(hermes): name the install step and the older-than-floor case (TASK-444) Lumen's HOLD on 8f04122a: 'outside the supported range' gives no help to a user on 0.23 or later, since hermes update cannot take them back to 0.22. The page and the refusal now say 'older than 0.22.5', and the page links Hermes's install guide, which the old line had dropped. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- docs-site/bring-your-own/hermes.mdx | 2 +- packages/seats/src/adapters/hermes.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-site/bring-your-own/hermes.mdx b/docs-site/bring-your-own/hermes.mdx index a271c064d9..62af537f94 100644 --- a/docs-site/bring-your-own/hermes.mdx +++ b/docs-site/bring-your-own/hermes.mdx @@ -7,7 +7,7 @@ Hermes connects through the CLI listener in an invite-only pod. The adapter supp You need Node.js 20 or later. -Install the [Commonly CLI](https://www.npmjs.com/package/@commonlyai/cli) and sign in with `commonly login`. Run `hermes update` if your installed Hermes is outside the supported range. With `OPENROUTER_API_KEY` set on your computer, attach the agent in your private pod: +Install [Hermes Agent](https://hermes-agent.nousresearch.com/docs/getting-started/installation) 0.22.5 or later, below 0.23. If yours is older than 0.22.5, run `hermes update`. Then install the [Commonly CLI](https://www.npmjs.com/package/@commonlyai/cli) and sign in with `commonly login`. With `OPENROUTER_API_KEY` set on your computer, attach the agent in your private pod: ```bash commonly agent attach hermes --pod --name diff --git a/packages/seats/src/adapters/hermes.js b/packages/seats/src/adapters/hermes.js index 59321f394d..642210f10d 100644 --- a/packages/seats/src/adapters/hermes.js +++ b/packages/seats/src/adapters/hermes.js @@ -78,7 +78,7 @@ const readVersion = (env, spawnSyncImpl = spawnSync) => { if (probe.error || probe.status !== 0) continue; const version = String(probe.stdout || '').match(/Hermes Agent v([^\s]+)/i)?.[1]; if (!supportsHermesVersion(version)) { - throw new Error(`Hermes ${version || 'unknown'} is unsupported; this adapter supports ${SUPPORTED_HERMES_VERSION_RANGE}. Run hermes update to install a supported release`); + throw new Error(`Hermes ${version || 'unknown'} is unsupported; this adapter supports ${SUPPORTED_HERMES_VERSION_RANGE}. Run hermes update if yours is older than 0.22.5; releases 0.23 and later are not supported yet`); } return version; } From b6ab210f4de611cd422939d98878c4e2b31fa537 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:49:43 -0700 Subject: [PATCH 4/5] test(scout): pin the Hermes 0.22.5 range in the scout knowledge test scout.ts now says "0.22.5 or newer below 0.23", so the assertion for the old exact 0.22.2 pin fails. Assert the new range text; the attach step and the OpenRouter key assertions are unchanged. Written by Forge, a Commonly agent (TASK-444). Co-Authored-By: Claude Haiku 5.5 Co-authored-by: forge Agent-Model: claude/claude-haiku-5-5/default --- .../__tests__/unit/config/nativeAgents.scoutKnowledge.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js index 75efac5428..e24eabf630 100644 --- a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js +++ b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js @@ -96,7 +96,7 @@ describe('Scout onboarding knowledge', () => { expect(prompt).toMatch(/OpenCode or dsh through their guides, and Hermes from the command line/); expect(prompt).toMatch(/Do not claim any other tool connects\./); expect(prompt).toContain('commonly agent attach hermes --pod --name '); - expect(prompt).toMatch(/It needs Hermes 0\.22\.2 exactly/); + expect(prompt).toMatch(/It needs Hermes 0\.22\.5 or newer below 0\.23/); // The adapter fixes --provider openrouter and reads OPENROUTER_API_KEY from the host. expect(prompt).toMatch(/an OpenRouter API key \(OPENROUTER_API_KEY\) in the environment/); expect(prompt).toMatch(/do not say OpenRouter, or any model provider, was the one it was walked on/); From d6ab22d331f6b138f16c0c3f16323a6717b5f6ee Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:10:39 -0700 Subject: [PATCH 5/5] test(hermes): refuse 0.23.5 and 1.22.5 in the version guard test The refusal list covered 0.23.0 and 1.0.0 but not a later 0.23 patch or a 1.x with a 0.22 minor, so a guard that matched only the minor or major would still pass. Add one of each. Written by Forge, a Commonly agent (TASK-444). Co-Authored-By: Claude Haiku 5.5 Co-authored-by: forge Agent-Model: claude/claude-haiku-5-5/default --- cli/__tests__/adapters.hermes.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/__tests__/adapters.hermes.test.mjs b/cli/__tests__/adapters.hermes.test.mjs index 08e05dbf74..f98441d19d 100644 --- a/cli/__tests__/adapters.hermes.test.mjs +++ b/cli/__tests__/adapters.hermes.test.mjs @@ -100,7 +100,7 @@ test('accepts stable Hermes 0.22 releases from 0.22.5 onward', async () => { test('refuses Hermes outside the supported stable range before starting a turn', async () => { await withHome(async (home) => { const child = jest.fn(); - for (const version of ['0.22.2', '0.22.3', '0.22.4', '0.22.1', '0.23.0', '1.0.0', '0.22.5+1.gabcdef0', '0.22.6-rc.1']) { + for (const version of ['0.22.2', '0.22.3', '0.22.4', '0.22.1', '0.23.0', '0.23.5', '1.0.0', '1.22.5', '0.22.5+1.gabcdef0', '0.22.6-rc.1']) { // Development builds and prereleases are outside the tested stable-release contract. // eslint-disable-next-line no-await-in-loop await expect(hermes.spawn('hello', { environment: { model: 'test-model' },