Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
421 lines
17 KiB
JavaScript
421 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Smoke test for ADR-0030 Phase 5 — TOTP approval gate.
|
|
*
|
|
* Runs against the LOCAL godcrm_test DB (per ADR-0009 isolation rules).
|
|
* Does NOT hit PROD or DEV. Does NOT spawn the real `claude --print`
|
|
* binary — case 10 uses a stub via RUN_CLAUDE_SCRIPT_OVERRIDE.
|
|
*
|
|
* Cases (10 total):
|
|
* 1. generateApprovalCode — shape + hash matches.
|
|
* 2. timingSafeHashEqual — equal hashes pass; mismatched / malformed fail.
|
|
* 3. persistApprovalRequest → readApprovalState round-trip + run_state flip.
|
|
* 4. resolveApproval('approved') → run_state='preparing', resolved_at set.
|
|
* 5. resolveApproval('denied') → run_state='failed', terminal='approval_denied'.
|
|
* 6. resolveApproval('expired') → run_state='failed', terminal='approval_timeout'.
|
|
* 7. recordAttempt — 5 wrong attempts auto-deny (state='denied').
|
|
* 8. awaitApproval — already-approved short-circuits immediately.
|
|
* 9. awaitApproval — TTL elapsed → outcome='expired', state persisted.
|
|
* 10. End-to-end: dispatcher tick with RUN_REQUIRE_APPROVAL='true' and stub
|
|
* claude → ticket reaches 'awaiting_approval'; in-flight resolveApproval
|
|
* unblocks the gate; final run_state='succeeded' + audit log carries
|
|
* 'approval_required' and 'approval_granted' entries.
|
|
*/
|
|
|
|
// ─── Force test DB + live phase BEFORE any module import ───────────
|
|
process.env.POSTGRES_HOST = 'localhost';
|
|
process.env.POSTGRES_DB = 'godcrm_test';
|
|
process.env.POSTGRES_USER = 'godcrm';
|
|
process.env.POSTGRES_PASSWORD = 'godcrm_dev_2026';
|
|
process.env.POSTGRES_PORT = '5432';
|
|
process.env.AGENT_RUN_DISPATCHER_ENABLED = 'false'; // we drive ticks manually
|
|
process.env.RUN_DISPATCHER_PHASE = 'live';
|
|
process.env.RUN_REQUIRE_APPROVAL = 'true';
|
|
process.env.NODE_ENV = 'test';
|
|
delete process.env.BUSINESS_CRM_IS_PROD;
|
|
|
|
import { promises as fs } from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
|
|
const approval = await import(
|
|
'../backend/services/agent-run-dispatcher/approval-gate.js'
|
|
);
|
|
const {
|
|
generateApprovalCode,
|
|
hashCode,
|
|
timingSafeHashEqual,
|
|
persistApprovalRequest,
|
|
readApprovalState,
|
|
recordAttempt,
|
|
resolveApproval,
|
|
awaitApproval,
|
|
APPROVAL_OUTCOMES,
|
|
} = approval;
|
|
|
|
const { runTick } = await import(
|
|
'../backend/services/agent-run-dispatcher/index.js'
|
|
);
|
|
const { destroyWorkspace } = await import(
|
|
'../backend/services/agent-run-dispatcher/workspace-manager.js'
|
|
);
|
|
const { dbGet, dbRun } = await import('../backend/database/connection.js');
|
|
|
|
const TICKETS_TABLE_ID = 1708;
|
|
const AGENTS_TABLE_ID = 1784;
|
|
const STATE_BACKLOG = 24275;
|
|
const SMOKE_TAG = 'smoke-adr0030-p5';
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
const insertedRowIds = []; // { table_id, id }
|
|
const stubFiles = [];
|
|
const e2eTicketIds = []; // tickets that may have created workspaces
|
|
|
|
function assert(label, cond, extra = '') {
|
|
if (cond) { console.log(` PASS ${label}`); pass++; }
|
|
else { console.log(` FAIL ${label} ${extra}`); fail++; }
|
|
}
|
|
|
|
function genBaseId() {
|
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
let s = '';
|
|
for (let i = 0; i < 8; i++) s += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
return s;
|
|
}
|
|
|
|
async function insertRow(tableId, data) {
|
|
const row = await dbGet(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at)
|
|
VALUES ($1, $2, $3::jsonb, NOW(), NOW())
|
|
RETURNING id`,
|
|
[tableId, genBaseId(), JSON.stringify(data)]
|
|
);
|
|
insertedRowIds.push({ table_id: tableId, id: row.id });
|
|
return row.id;
|
|
}
|
|
|
|
async function getTicketData(ticketId) {
|
|
const row = await dbGet(
|
|
`SELECT data FROM table_rows WHERE table_id = $1 AND id = $2`,
|
|
[TICKETS_TABLE_ID, ticketId]
|
|
);
|
|
if (!row?.data) return null;
|
|
return typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
|
|
}
|
|
|
|
async function makeTicketAwaitingApproval({ overrideExpires = null } = {}) {
|
|
const agentId = await insertRow(AGENTS_TABLE_ID, {
|
|
name: 'Smoke Agent P5',
|
|
system_prompt: 'Be brief.',
|
|
smoke_tag: SMOKE_TAG,
|
|
});
|
|
const ticketId = await insertRow(TICKETS_TABLE_ID, {
|
|
state: STATE_BACKLOG,
|
|
assigned_to: String(agentId),
|
|
title: 'P5 smoke ticket',
|
|
what: 'P5 smoke ticket',
|
|
smoke_tag: SMOKE_TAG,
|
|
run_state: 'preparing',
|
|
run_attempt: 1,
|
|
});
|
|
const { code, code_hash, expires_at, generated_at } = generateApprovalCode();
|
|
const finalExpires = overrideExpires || expires_at;
|
|
await persistApprovalRequest(ticketId, {
|
|
code_hash,
|
|
expires_at: finalExpires,
|
|
generated_at,
|
|
});
|
|
return { agentId, ticketId, code, code_hash, expires_at: finalExpires };
|
|
}
|
|
|
|
async function writeStubScript(name, body) {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'p5-stub-'));
|
|
const file = path.join(dir, name);
|
|
await fs.writeFile(file, body, { mode: 0o755 });
|
|
await fs.chmod(file, 0o755);
|
|
stubFiles.push(file);
|
|
return file;
|
|
}
|
|
|
|
async function cleanup() {
|
|
delete process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
|
|
|
|
// Clean any workspaces we may have created.
|
|
for (const id of e2eTicketIds) {
|
|
try { await destroyWorkspace(id); } catch { /* best-effort */ }
|
|
}
|
|
|
|
if (insertedRowIds.length > 0) {
|
|
const ids = insertedRowIds.map((r) => r.id);
|
|
try {
|
|
await dbRun(`DELETE FROM table_rows WHERE id = ANY($1::int[])`, [ids]);
|
|
} catch { /* best-effort */ }
|
|
}
|
|
// Catch orphans by smoke_tag.
|
|
await dbRun(
|
|
`DELETE FROM table_rows WHERE data->>'smoke_tag' = $1`,
|
|
[SMOKE_TAG]
|
|
).catch(() => {});
|
|
|
|
for (const f of stubFiles) {
|
|
try {
|
|
await fs.rm(path.dirname(f), { recursive: true, force: true });
|
|
} catch { /* best-effort */ }
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log('ADR-0030 Phase 5 smoke test (godcrm_test) — start');
|
|
console.log(` RUN_REQUIRE_APPROVAL=${process.env.RUN_REQUIRE_APPROVAL}`);
|
|
console.log(` RUN_DISPATCHER_PHASE=${process.env.RUN_DISPATCHER_PHASE}`);
|
|
|
|
// Pre-flight scrub.
|
|
await dbRun(
|
|
`DELETE FROM table_rows WHERE data->>'smoke_tag' = $1`,
|
|
[SMOKE_TAG]
|
|
);
|
|
|
|
// ── Case 1: generateApprovalCode shape
|
|
console.log('\nCase 1: generateApprovalCode shape + hash');
|
|
{
|
|
const a = generateApprovalCode();
|
|
assert('code is 6 digits', /^\d{6}$/.test(a.code), `got ${a.code}`);
|
|
assert('code_hash is 64 hex chars', /^[0-9a-f]{64}$/.test(a.code_hash));
|
|
assert('hash matches code', a.code_hash === hashCode(a.code));
|
|
assert('expires_at parseable', !Number.isNaN(Date.parse(a.expires_at)));
|
|
assert('generated_at parseable', !Number.isNaN(Date.parse(a.generated_at)));
|
|
const ttlMs = Date.parse(a.expires_at) - Date.parse(a.generated_at);
|
|
assert('ttl ~10 min', ttlMs > 9 * 60_000 && ttlMs < 11 * 60_000, `ttlMs=${ttlMs}`);
|
|
// Sanity: two consecutive codes should almost always differ.
|
|
const b = generateApprovalCode();
|
|
assert('two consecutive codes differ', a.code !== b.code);
|
|
}
|
|
|
|
// ── Case 2: timingSafeHashEqual edge cases
|
|
console.log('\nCase 2: timingSafeHashEqual edge cases');
|
|
{
|
|
const h = hashCode('123456');
|
|
assert('equal hashes match', timingSafeHashEqual(h, h));
|
|
assert('different hashes mismatch', !timingSafeHashEqual(h, hashCode('654321')));
|
|
assert('non-hex returns false', !timingSafeHashEqual(h, 'z'.repeat(64)));
|
|
assert('short string returns false', !timingSafeHashEqual(h, 'abc'));
|
|
assert('null returns false', !timingSafeHashEqual(h, null));
|
|
assert('non-string returns false', !timingSafeHashEqual(h, 12345));
|
|
}
|
|
|
|
// ── Case 3: persistApprovalRequest → readApprovalState round-trip
|
|
console.log('\nCase 3: persist + read round-trip + run_state flip');
|
|
{
|
|
const { ticketId, code_hash, expires_at } = await makeTicketAwaitingApproval();
|
|
const view = await readApprovalState(ticketId);
|
|
assert('view present', view.present);
|
|
assert('view.state === pending', view.state === 'pending');
|
|
assert('view.attempts === 0', view.attempts === 0);
|
|
assert('view.code_hash matches', view.code_hash === code_hash);
|
|
assert('view.expires_at matches', view.expires_at === expires_at);
|
|
assert('view.run_state === awaiting_approval',
|
|
view.run_state === 'awaiting_approval', `got ${view.run_state}`);
|
|
assert('view.expired === false', view.expired === false);
|
|
|
|
const data = await getTicketData(ticketId);
|
|
assert('ticket.run_state === awaiting_approval',
|
|
data.run_state === 'awaiting_approval', `got ${data?.run_state}`);
|
|
assert('ticket.run_approval object present', !!data.run_approval);
|
|
assert('ticket.run_approval.code_hash redacted from logs (still on row)',
|
|
data.run_approval.code_hash === code_hash);
|
|
}
|
|
|
|
// ── Case 4: resolveApproval('approved') → run_state='preparing'
|
|
console.log('\nCase 4: resolveApproval(approved)');
|
|
{
|
|
const { ticketId } = await makeTicketAwaitingApproval();
|
|
await resolveApproval(ticketId, { state: 'approved', resolved_by: 1 });
|
|
const data = await getTicketData(ticketId);
|
|
assert('run_state === preparing', data.run_state === 'preparing', `got ${data?.run_state}`);
|
|
assert('run_approval.state === approved', data.run_approval?.state === 'approved');
|
|
assert('run_approval.resolved_at set', !!data.run_approval?.resolved_at);
|
|
assert('run_approval.resolved_by === 1', data.run_approval?.resolved_by === 1);
|
|
assert('run_terminal_reason NOT set', !data.run_terminal_reason);
|
|
}
|
|
|
|
// ── Case 5: resolveApproval('denied') → run_state='failed', terminal='approval_denied'
|
|
console.log('\nCase 5: resolveApproval(denied)');
|
|
{
|
|
const { ticketId } = await makeTicketAwaitingApproval();
|
|
await resolveApproval(ticketId, { state: 'denied', resolved_by: 1, reason: 'explicit_deny' });
|
|
const data = await getTicketData(ticketId);
|
|
assert('run_state === failed', data.run_state === 'failed', `got ${data?.run_state}`);
|
|
assert('run_terminal_reason === approval_denied',
|
|
data.run_terminal_reason === 'approval_denied',
|
|
`got ${data?.run_terminal_reason}`);
|
|
assert('run_finished_at set', !!data.run_finished_at);
|
|
assert('run_approval.state === denied', data.run_approval?.state === 'denied');
|
|
}
|
|
|
|
// ── Case 6: resolveApproval('expired') → run_state='failed', terminal='approval_timeout'
|
|
console.log('\nCase 6: resolveApproval(expired)');
|
|
{
|
|
const { ticketId } = await makeTicketAwaitingApproval();
|
|
await resolveApproval(ticketId, { state: 'expired', reason: 'ttl_elapsed' });
|
|
const data = await getTicketData(ticketId);
|
|
assert('run_state === failed', data.run_state === 'failed', `got ${data?.run_state}`);
|
|
assert('run_terminal_reason === approval_timeout',
|
|
data.run_terminal_reason === 'approval_timeout',
|
|
`got ${data?.run_terminal_reason}`);
|
|
assert('run_approval.state === expired', data.run_approval?.state === 'expired');
|
|
}
|
|
|
|
// ── Case 7: recordAttempt — 5 wrong attempts auto-deny
|
|
console.log('\nCase 7: recordAttempt 5x wrong → auto-deny');
|
|
{
|
|
const { ticketId } = await makeTicketAwaitingApproval();
|
|
let last;
|
|
for (let i = 1; i <= 5; i++) {
|
|
last = await recordAttempt(ticketId, false);
|
|
}
|
|
assert('attempts === 5', last.attempts === 5, JSON.stringify(last));
|
|
assert('state === denied (auto)', last.state === 'denied', JSON.stringify(last));
|
|
assert('attempts_remaining === 0', last.attempts_remaining === 0);
|
|
const data = await getTicketData(ticketId);
|
|
assert('run_state flipped to failed after auto-deny',
|
|
data.run_state === 'failed', `got ${data?.run_state}`);
|
|
assert('terminal_reason === approval_denied (auto)',
|
|
data.run_terminal_reason === 'approval_denied');
|
|
}
|
|
|
|
// ── Case 8: awaitApproval — already-approved short-circuit
|
|
console.log('\nCase 8: awaitApproval short-circuits on already-approved');
|
|
{
|
|
const { ticketId } = await makeTicketAwaitingApproval();
|
|
await resolveApproval(ticketId, { state: 'approved', resolved_by: 1 });
|
|
const t0 = Date.now();
|
|
const result = await awaitApproval(ticketId, { pollMs: 500, timeoutMs: 30_000 });
|
|
const elapsed = Date.now() - t0;
|
|
assert('outcome === approved', result.outcome === APPROVAL_OUTCOMES.APPROVED);
|
|
assert('returns within 1s (no poll wait)', elapsed < 1000, `elapsed=${elapsed}ms`);
|
|
}
|
|
|
|
// ── Case 9: awaitApproval — TTL elapsed → expired, state persisted
|
|
console.log('\nCase 9: awaitApproval TTL elapsed → expired');
|
|
{
|
|
const expiredAt = new Date(Date.now() - 60_000).toISOString();
|
|
const { ticketId } = await makeTicketAwaitingApproval({ overrideExpires: expiredAt });
|
|
const t0 = Date.now();
|
|
const result = await awaitApproval(ticketId, { pollMs: 500, timeoutMs: 30_000 });
|
|
const elapsed = Date.now() - t0;
|
|
assert('outcome === expired', result.outcome === APPROVAL_OUTCOMES.EXPIRED, JSON.stringify(result));
|
|
assert('returns fast (TTL already past)', elapsed < 1500, `elapsed=${elapsed}ms`);
|
|
const data = await getTicketData(ticketId);
|
|
assert('run_approval.state persisted as expired',
|
|
data.run_approval?.state === 'expired', `got ${data?.run_approval?.state}`);
|
|
assert('run_state flipped to failed',
|
|
data.run_state === 'failed', `got ${data?.run_state}`);
|
|
assert('terminal_reason === approval_timeout',
|
|
data.run_terminal_reason === 'approval_timeout');
|
|
}
|
|
|
|
// ── Case 10: end-to-end dispatcher tick with stub claude + in-flight approve
|
|
console.log('\nCase 10: end-to-end tick — gate fires, in-flight approve, run completes');
|
|
{
|
|
const stub = await writeStubScript('stub-p5.sh', `#!/usr/bin/env bash
|
|
PROMPT=$(cat)
|
|
echo '{"type":"info","message":"p5_stub_started"}'
|
|
echo '{"type":"output","content":"P5 stub output content.","status":"success","exit":0}'
|
|
echo '{"type":"result","status":"success","exit":0}'
|
|
exit 0
|
|
`);
|
|
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = stub;
|
|
|
|
const agentId = await insertRow(AGENTS_TABLE_ID, {
|
|
name: 'Smoke Agent P5 E2E',
|
|
system_prompt: 'Be brief.',
|
|
smoke_tag: SMOKE_TAG,
|
|
});
|
|
const ticketId = await insertRow(TICKETS_TABLE_ID, {
|
|
state: STATE_BACKLOG,
|
|
assigned_to: String(agentId),
|
|
title: 'P5 e2e smoke ticket',
|
|
what: 'P5 e2e smoke ticket',
|
|
smoke_tag: SMOKE_TAG,
|
|
run_state: 'idle',
|
|
});
|
|
e2eTicketIds.push(ticketId);
|
|
|
|
// Kick the tick — runs in background; we'll resolve the gate from outside.
|
|
const tickPromise = runTick({ source: 'smoke_p5_case_10' });
|
|
|
|
// Poll until ticket reaches awaiting_approval (gate persisted).
|
|
let gateSeen = false;
|
|
const pollDeadline = Date.now() + 30_000;
|
|
while (Date.now() < pollDeadline) {
|
|
const view = await readApprovalState(ticketId);
|
|
if (view.present && view.run_state === 'awaiting_approval') {
|
|
gateSeen = true;
|
|
break;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
assert('gate fired (run_state hit awaiting_approval)', gateSeen,
|
|
'never observed awaiting_approval within 30s');
|
|
|
|
if (gateSeen) {
|
|
// Simulate operator approve (bypassing API since we already trust the
|
|
// function — case 4 covers approve mechanics).
|
|
await resolveApproval(ticketId, { state: 'approved', resolved_by: 1 });
|
|
}
|
|
|
|
// Now the tick should finish.
|
|
const stats = await tickPromise;
|
|
assert('tick reports awaiting_approval >= 1',
|
|
stats.awaiting_approval >= 1, JSON.stringify(stats));
|
|
assert('tick reports approval_approved >= 1',
|
|
stats.approval_approved >= 1, JSON.stringify(stats));
|
|
assert('tick reports live_succeeded >= 1',
|
|
stats.live_succeeded >= 1, JSON.stringify(stats));
|
|
|
|
const data = await getTicketData(ticketId);
|
|
assert('final run_state === succeeded',
|
|
data.run_state === 'succeeded', `got ${data?.run_state}`);
|
|
assert('run_terminal_reason === completed',
|
|
data.run_terminal_reason === 'completed', `got ${data?.run_terminal_reason}`);
|
|
|
|
const audit = data.run_audit_log || [];
|
|
const approvalReq = audit.find((e) => e.reason === 'approval_required');
|
|
const approvalGranted = audit.find((e) => e.reason === 'approval_granted');
|
|
const completed = audit.find((e) => e.reason === 'completed');
|
|
assert('audit contains approval_required entry', !!approvalReq, JSON.stringify(audit));
|
|
assert('audit contains approval_granted entry', !!approvalGranted, JSON.stringify(audit));
|
|
assert('audit contains completed entry', !!completed, JSON.stringify(audit));
|
|
|
|
// Spot-check the approval_required entry has expires_at, no plaintext code.
|
|
if (approvalReq) {
|
|
assert('approval_required carries expires_at', !!approvalReq.expires_at);
|
|
assert('approval_required does NOT carry plaintext code',
|
|
!('code' in approvalReq) && !approvalReq.code,
|
|
JSON.stringify(approvalReq));
|
|
}
|
|
|
|
// Workspace must be cleaned up.
|
|
let dirGone = false;
|
|
try {
|
|
await fs.access(`/root/workspaces/T-${ticketId}`);
|
|
} catch {
|
|
dirGone = true;
|
|
}
|
|
assert('workspace dir removed after run', dirGone);
|
|
}
|
|
|
|
console.log(`\n${pass + fail} assertions: ${pass} pass / ${fail} fail`);
|
|
if (fail === 0) console.log('all assertions passed');
|
|
}
|
|
|
|
main()
|
|
.catch((err) => {
|
|
console.error('SMOKE FAILED with exception:', err);
|
|
fail++;
|
|
})
|
|
.finally(async () => {
|
|
try { await cleanup(); } catch (err) { console.error('cleanup error:', err); }
|
|
process.exit(fail === 0 ? 0 : 1);
|
|
});
|