Quality gates / quality (pull_request) Successful in 2m38s
Deterministic escalation only fired on a narrow set of literal phrases, so real reports like "there is blood when I wipe" or "I have tarry stools" reached the chat provider without the fixed medical guidance. Expanding the suite found 18 of 18 checked clinical phrasings missing. - domain: cover bleeding reported by wiping/toilet paper/anatomy/mixed-in phrasing, black-tarry-dark-red stool, reported temperature and high temperature, abdominal-scoped severe pain in every natural word order, and past-tense/slang inability to pass gas. Near-miss wording that must stay quiet (paper cut, blood pressure, black beans, dark red wine, a normal temperature, knee or back pain, passed gas, no gas in the car) is pinned. - domain: a symptom reported in any truthy shape (1, "yes", "true") now counts as reported. Reading only the literal boolean `true` silently cleared a symptom, which is the one direction this boundary may never fail in. - hermes-agent-service: the gateway ledger sanitizer used the same strict check, so a browser-supplied ledger entry could have its symptom cleared before the escalation check. - tests/escalation-regression.test.js: per-flag positive and negative language coverage, model output can neither set nor clear a symptom/urgency/discomfort/note, and the escalation copy is byte-identical for every flag and under a missing, throwing, or malicious provider. Registered in the default unit gate. - scripts/record_release_demo.mjs: the recording now types the new bleeding and tarry phrasing, and seeds a fictional ledger whose note says "stool was tarry yesterday", then asserts in the browser that exactly one new Timmy bubble carries the app's own copy and that the turn never reaches Hermes. Three new captions are required release gates. Boundary unchanged: no new inference, no diagnosis, no image claim. Escalation only ever gets broader on user-reported symptoms.
245 lines
14 KiB
JavaScript
245 lines
14 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 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('ledger context keeps a symptom reported in any truthy shape instead of silently clearing it', async () => {
|
|
const calls = [];
|
|
const service = createHermesAgentService({
|
|
config: configured(),
|
|
randomToken: () => 'ledger-cookie',
|
|
runTurn: async input => { calls.push(input); return { reply: 'should not be reached', sessionId: 'private-session' }; },
|
|
});
|
|
await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' });
|
|
|
|
// Issue #21: a browser-supplied ledger whose symptom is reported but not the literal boolean
|
|
// `true` used to be filtered out of the sanitized context, which cleared the symptom before the
|
|
// escalation check ever saw it.
|
|
const truthy = await service.chat({ origin, cookieToken: 'ledger-cookie', payload: {
|
|
message: 'What do my logs show?',
|
|
ledger: [{ bristolType: 4, color: 'brown', note: '', symptoms: { blood: 'yes' } }],
|
|
} });
|
|
assert.equal(calls.length, 0, 'a reported symptom must be intercepted before Hermes');
|
|
assert.equal(truthy.safetyOverride, true);
|
|
assert.match(truthy.reply, /medical help/i);
|
|
|
|
const cleared = await service.chat({ origin, cookieToken: 'ledger-cookie', payload: {
|
|
message: 'What do my logs show?',
|
|
ledger: [{ bristolType: 4, color: 'brown', note: '', symptoms: { blood: 'no', fever: false } }],
|
|
} });
|
|
assert.equal(cleared.safetyOverride, undefined);
|
|
assert.equal(calls.length, 1);
|
|
assert.match(calls[0].prompt, /"symptoms":\{\}/);
|
|
});
|
|
|
|
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;
|
|
});
|
|
});
|