All checks were successful
Quality gates / quality (pull_request) Successful in 1m25s
212 lines
12 KiB
JavaScript
212 lines
12 KiB
JavaScript
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 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, 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;
|
|
});
|
|
});
|