import test from 'node:test'; import assert from 'node:assert/strict'; import { AgentGatewayError, buildHermesEnvironment, createHermesAgentService, parseHermesCliOutput, resolveHermesAgentConfig, runHermesCliTurn, } from '../src/hermes-agent-service.js'; const origin = 'http://127.0.0.1:4173'; const configured = () => resolveHermesAgentConfig({ TIMMY_AGENT_ENABLED: 'true', TIMMY_AGENT_ACCESS_TOKEN: 'test-agent-access-code-2026', TIMMY_PUBLIC_ORIGIN: origin, TIMMY_AGENT_WORKDIR: '/tmp/timmy-agent-workspace', TIMMY_AGENT_TIMEOUT_MS: '45000', }); async function rejectsStatus(fn, status) { await assert.rejects(fn, error => error instanceof AgentGatewayError && error.status === status); } test('Hermes agent is disabled by default and public status exposes no secrets', () => { const config = resolveHermesAgentConfig({}); assert.equal(config.enabled, false); assert.equal(config.configured, false); assert.deepEqual(config.publicStatus(), { enabled: false, configured: false, authenticated: false, mode: 'local-fallback' }); assert.doesNotMatch(JSON.stringify(config.publicStatus()), /token|workdir|command/i); }); test('enabled agent requires a strong access token, exact origin, and absolute workdir', () => { for (const env of [ { TIMMY_AGENT_ENABLED: 'true', TIMMY_PUBLIC_ORIGIN: origin, TIMMY_AGENT_WORKDIR: '/tmp/agent' }, { TIMMY_AGENT_ENABLED: 'true', TIMMY_AGENT_ACCESS_TOKEN: 'short', TIMMY_PUBLIC_ORIGIN: origin, TIMMY_AGENT_WORKDIR: '/tmp/agent' }, { TIMMY_AGENT_ENABLED: 'true', TIMMY_AGENT_ACCESS_TOKEN: 'test-agent-access-code-2026', TIMMY_AGENT_WORKDIR: '/tmp/agent' }, { TIMMY_AGENT_ENABLED: 'true', TIMMY_AGENT_ACCESS_TOKEN: 'test-agent-access-code-2026', TIMMY_PUBLIC_ORIGIN: origin, TIMMY_AGENT_WORKDIR: 'relative' }, ]) assert.equal(resolveHermesAgentConfig(env).configured, false); assert.equal(configured().configured, true); }); test('unlock is exact-origin, rate bounded, and returns only an opaque browser credential', async () => { let serial = 0; const service = createHermesAgentService({ config: configured(), randomToken: () => `opaque-${++serial}` }); await rejectsStatus(() => service.unlock({ origin: 'https://evil.example', accessCode: 'test-agent-access-code-2026' }), 403); await rejectsStatus(() => service.unlock({ origin, accessCode: 'wrong-access-code-value' }), 401); const result = await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }); assert.equal(result.cookieToken, 'opaque-1'); assert.deepEqual(result.public, { enabled: true, configured: true, authenticated: true, mode: 'hermes-agent' }); assert.doesNotMatch(JSON.stringify(result), /test-agent-access|session_id|workdir/i); }); test('unlock brute-force attempts are bounded before a correct code is accepted', async () => { const service = createHermesAgentService({ config: { ...configured(), maxUnlockAttemptsPerMinute: 3 }, randomToken: () => 'unused-cookie' }); for (let attempt = 0; attempt < 3; attempt += 1) await rejectsStatus(() => service.unlock({ origin, accessCode: `wrong-access-code-${attempt}` }), 401); await rejectsStatus(() => service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }), 429); }); test('opaque browser credential generation never evicts an active conversation on collision', async () => { const tokens = ['collision-cookie', 'collision-cookie', 'fresh-cookie']; const service = createHermesAgentService({ config: configured(), randomToken: () => tokens.shift(), runTurn: async () => ({ reply: 'ok', sessionId: 'private-session' }) }); const first = await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }); await service.chat({ origin, cookieToken: first.cookieToken, payload: { message: 'first turn', ledger: [] } }); const second = await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }); assert.equal(first.cookieToken, 'collision-cookie'); assert.equal(second.cookieToken, 'fresh-cookie'); assert.equal(service.status(first.cookieToken).authenticated, true); }); test('session capacity never evicts an active authenticated user', async () => { let clock = 0; const tokens = ['one-cookie', 'two-cookie', 'three-cookie']; const service = createHermesAgentService({ config: { ...configured(), maxSessions: 1 }, randomToken: () => tokens.shift(), now: () => clock }); const first = await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }); await rejectsStatus(() => service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }), 429); assert.equal(service.status(first.cookieToken).authenticated, true); clock = 24 * 60 * 60 * 1000 + 1; const replacement = await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }); assert.equal(replacement.cookieToken, 'two-cookie'); }); test('chat rejects unauthenticated, cross-origin, oversized, and browser-controlled agent policy', async () => { const service = createHermesAgentService({ config: configured(), randomToken: () => 'browser-cookie', runTurn: async () => ({ reply: 'ok', sessionId: 'private' }) }); await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }); await rejectsStatus(() => service.chat({ origin, cookieToken: '', payload: { message: 'hello', ledger: [] } }), 401); await rejectsStatus(() => service.chat({ origin: 'https://evil.example', cookieToken: 'browser-cookie', payload: { message: 'hello', ledger: [] } }), 403); await rejectsStatus(() => service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'x'.repeat(4001), ledger: [] } }), 413); for (const forbidden of ['sessionId', 'toolsets', 'model', 'provider', 'systemPrompt']) { await rejectsStatus(() => service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'hello', ledger: [], [forbidden]: 'attacker-controlled' } }), 400); } }); test('chat keeps Hermes session IDs server-side, resumes continuity, and strips photos from ledger context', async () => { const calls = []; const service = createHermesAgentService({ config: configured(), randomToken: () => 'browser-cookie', runTurn: async input => { calls.push(input); return calls.length === 1 ? { reply: 'I see one confirmed log.', sessionId: 'hermes-private-session' } : { reply: 'We were discussing that confirmed log.', sessionId: 'hermes-private-session' }; }, }); await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }); const ledger = [{ id: 'one', occurredAt: '2026-08-20T12:00:00Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'normal', photoDataUrl: 'data:image/jpeg;base64,PRIVATE' }]; const first = await service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'What changed?', ledger } }); const second = await service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'What were we discussing?', ledger } }); assert.equal(calls[0].hermesSessionId, null); assert.equal(calls[1].hermesSessionId, 'hermes-private-session'); assert.match(calls[0].prompt, /observable patterns/i); assert.match(calls[0].prompt, /confirmed ledger context/i); assert.doesNotMatch(calls[0].prompt, /PRIVATE|photoDataUrl/); assert.deepEqual(first, { reply: 'I see one confirmed log.', connected: true }); assert.deepEqual(second, { reply: 'We were discussing that confirmed log.', connected: true }); assert.doesNotMatch(JSON.stringify([first, second]), /hermes-private-session/); }); test('authoritative chat service intercepts urgent message and ledger symptoms before Hermes', async () => { const calls = []; const service = createHermesAgentService({ config: configured(), randomToken: () => 'safety-cookie', runTurn: async input => { calls.push(input); return { reply: 'unsafe upstream reply', sessionId: 'unsafe-session' }; }, }); await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }); const messageResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'I have rectal bleeding', ledger: [] } }); const pastTenseVomitingResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'I threw up', ledger: [] } }); const slangVomitingResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'I barfed', ledger: [] } }); const possessiveIdiomResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'She tosses her cookies', ledger: [] } }); const thirdPersonVomitingResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'She hurls', ledger: [] } }); const ledgerResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'What does my journal show?', ledger: [{ bristolType: 4, color: 'brown', note: '', symptoms: { cannotPassGas: true } }], } }); const noteResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'Summarize this entry', ledger: [{ bristolType: 4, color: 'brown', note: 'unable to pass gas', symptoms: {} }], } }); assert.equal(calls.length, 0); for (const result of [messageResult, pastTenseVomitingResult, slangVomitingResult, possessiveIdiomResult, thirdPersonVomitingResult, ledgerResult, noteResult]) { assert.equal(result.safetyOverride, true); assert.match(result.reply, /medical help/i); assert.doesNotMatch(result.reply, /unsafe upstream/i); } }); test('Hermes CLI parser accepts quiet output, strips control sequences, and rejects leaked metadata', () => { const parsed = parseHermesCliOutput('\u001b[32msession_id: 20260820_abcd\u001b[0m\nA concise answer.'); assert.deepEqual(parsed, { sessionId: '20260820_abcd', reply: 'A concise answer.' }); assert.throws(() => parseHermesCliOutput('answer only'), /session metadata/i); assert.throws(() => parseHermesCliOutput('session_id: private\nsession_id: leaked'), /unsafe/i); }); test('Hermes CLI adapter combines stderr session metadata with stdout answer', async () => { let captured; const result = await runHermesCliTurn({ prompt: 'hello', hermesSessionId: null, config: configured(), execImpl: async (command, args, options) => { captured = { command, args, options }; return { stdout: 'bounded answer\n', stderr: '\u001b[33mwarning\u001b[0m\nsession_id: 20260820_fixture\n' }; }, }); assert.deepEqual(result, { sessionId: '20260820_fixture', reply: 'bounded answer' }); assert.equal(captured.command, 'hermes'); assert.equal(captured.args.includes('--in'), true); assert.equal(captured.options.cwd, '/tmp/timmy-agent-workspace'); }); test('Hermes child receives only an explicit non-secret environment allowlist', () => { const childEnv = buildHermesEnvironment({ HOME: '/root', PATH: '/usr/bin', HERMES_HOME: '/root/.hermes/profiles/timmyapp', LANG: 'C.UTF-8', TIMMY_AGENT_ACCESS_TOKEN: 'test-must-not-reach-child', GITEA_TOKEN: 'test-must-not-reach-child', OPENAI_API_KEY: 'test-must-not-reach-child', RANDOM_PRIVATE_VALUE: 'test-must-not-reach-child', }); assert.deepEqual(childEnv, { HOME: '/root', PATH: '/usr/bin', HERMES_HOME: '/root/.hermes/profiles/timmyapp', LANG: 'C.UTF-8', TIMMY_AGENT_BROWSER_REQUEST: '1', }); }); test('upstream failures and concurrency fail closed without leaking process detail', async () => { let release; const runTurn = () => new Promise(resolve => { release = resolve; }); const service = createHermesAgentService({ config: configured(), randomToken: () => 'browser-cookie', runTurn }); await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }); const active = service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'first', ledger: [] } }); await new Promise(resolve => setImmediate(resolve)); await rejectsStatus(() => service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'second', ledger: [] } }), 429); release({ reply: 'done', sessionId: 'private-session' }); await active; const broken = createHermesAgentService({ config: configured(), randomToken: () => 'broken-cookie', runTurn: async () => { throw new Error('spawn /root/.hermes/auth.json SECRET'); } }); await broken.unlock({ origin, accessCode: 'test-agent-access-code-2026' }); await assert.rejects(() => broken.chat({ origin, cookieToken: 'broken-cookie', payload: { message: 'hello', ledger: [] } }), error => { assert.equal(error.status, 503); assert.match(error.message, /temporarily unavailable/i); assert.doesNotMatch(error.message, /auth\.json|SECRET|spawn/); return true; }); });