mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: Approvals Inbox, one cross-session queue for prompts waiting on a human
Permission dialogs, AskUserQuestion questions and idle prompts from every session now land in a server-side inbox (web/approval-inbox.ts, one item per session, claude-mode only) and are answerable in place: a header bell + drawer on desktop, inline answer strips on the phone overview's NEEDS YOU rows, and working push Approve/Deny buttons (previously dead ends, now answered straight from sw.js with no tab open). Pending alerts survive reloads because the frontend seeds from GET /api/approvals on init. Answering sends the digit / Esc / prompt text through the existing tmux input path; option digits are accepted only when they match options parsed from the captured pane frame, and the answer path re-captures the pane first so a dialog that already left the screen refuses with 409 instead of typing into the composer. New elicitation_complete / elicitation_response hook matchers resolve question items the moment they are answered in the terminal; refreshStaleCodemanHooks heals existing cases. Verified end-to-end against a live claude session: a real AskUserQuestion dialog parsed into 5 option buttons and was answered from the drawer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Approvals Inbox store unit tests (src/web/approval-inbox.ts).
|
||||
*
|
||||
* Pure in-memory registry — no ports, no server. Constructs its own
|
||||
* ApprovalInbox instances (never the process singleton) so tests cannot
|
||||
* leak state into the route tests that share the module.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
ApprovalInbox,
|
||||
normalizeCapturedFrame,
|
||||
parseDialogOptions,
|
||||
type ApprovalItem,
|
||||
type ApprovalResolvedInfo,
|
||||
} from '../src/web/approval-inbox.js';
|
||||
|
||||
const PERMISSION_FRAME = [
|
||||
' Do you want to make this edit to foo.ts?',
|
||||
' ❯ 1. Yes',
|
||||
' 2. Yes, allow all edits during this session (shift+tab)',
|
||||
' 3. No, and tell Claude what to do differently (esc)',
|
||||
].join('\n');
|
||||
|
||||
const TWO_OPTION_FRAME = [' Trust the files in this folder?', ' ❯ 1. Yes, proceed', ' 2. No, exit'].join('\n');
|
||||
|
||||
// The live AskUserQuestion shape (measured on Claude Code v2.1.226): a
|
||||
// description row under every option and a ─ separator before "Chat about this".
|
||||
const ASK_USER_QUESTION_FRAME = [
|
||||
' ☐ Color',
|
||||
' Which color do you prefer?',
|
||||
'❯ 1. Red',
|
||||
' Prefer red',
|
||||
' 2. Blue',
|
||||
' Prefer blue',
|
||||
' 3. Green',
|
||||
' Prefer green',
|
||||
' 4. Type something.',
|
||||
'────────────────────────────────────────',
|
||||
' 5. Chat about this',
|
||||
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
||||
].join('\n');
|
||||
|
||||
function collect(inbox: ApprovalInbox) {
|
||||
const pending: ApprovalItem[] = [];
|
||||
const updated: ApprovalItem[] = [];
|
||||
const resolved: ApprovalResolvedInfo[] = [];
|
||||
inbox.onPending = (i) => pending.push(i);
|
||||
inbox.onUpdated = (i) => updated.push(i);
|
||||
inbox.onResolved = (i) => resolved.push(i);
|
||||
return { pending, updated, resolved };
|
||||
}
|
||||
|
||||
describe('parseDialogOptions', () => {
|
||||
it('parses a 3-option permission dialog with the ❯ cursor', () => {
|
||||
const options = parseDialogOptions(PERMISSION_FRAME);
|
||||
expect(options).toEqual([
|
||||
{ n: 1, label: 'Yes' },
|
||||
{ n: 2, label: 'Yes, allow all edits during this session (shift+tab)' },
|
||||
{ n: 3, label: 'No, and tell Claude what to do differently (esc)' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses a 2-option dialog', () => {
|
||||
expect(parseDialogOptions(TWO_OPTION_FRAME)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns undefined when nothing parses', () => {
|
||||
expect(parseDialogOptions('just some terminal output\nwith no menu')).toBeUndefined();
|
||||
expect(parseDialogOptions(undefined)).toBeUndefined();
|
||||
// A single numbered line is not a dialog.
|
||||
expect(parseDialogOptions('1. lonely item')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires consecutive numbering from 1', () => {
|
||||
expect(parseDialogOptions('2. Yes\n3. No')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes the LAST complete block in the frame (dialogs render at the bottom)', () => {
|
||||
const frame = ['1. old option', '2. old option two', 'some output in between', TWO_OPTION_FRAME].join('\n');
|
||||
const options = parseDialogOptions(frame);
|
||||
expect(options?.[0].label).toBe('Yes, proceed');
|
||||
});
|
||||
|
||||
it('caps option labels at 120 chars', () => {
|
||||
const long = 'x'.repeat(300);
|
||||
const options = parseDialogOptions(`1. ${long}\n2. No`);
|
||||
expect(options?.[0].label).toHaveLength(120);
|
||||
});
|
||||
|
||||
it('parses the AskUserQuestion shape (descriptions between options, separator before the last)', () => {
|
||||
const options = parseDialogOptions(ASK_USER_QUESTION_FRAME);
|
||||
expect(options?.map((o) => o.label)).toEqual(['Red', 'Blue', 'Green', 'Type something.', 'Chat about this']);
|
||||
});
|
||||
|
||||
it('a gap of more than 3 lines ends the option block', () => {
|
||||
const frame = ['1. Yes', '2. No', 'a', 'b', 'c', 'd', 'unrelated 3. text'].join('\n');
|
||||
const options = parseDialogOptions(frame);
|
||||
expect(options).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCapturedFrame', () => {
|
||||
it('strips ANSI, right-trims, and drops trailing blank lines', () => {
|
||||
const raw = '\x1b[31mred\x1b[0m \nline2\n\n\n';
|
||||
expect(normalizeCapturedFrame(raw)).toBe('red\nline2');
|
||||
});
|
||||
|
||||
it('keeps only the last 30 lines', () => {
|
||||
const raw = Array.from({ length: 50 }, (_, i) => `line${i}`).join('\n');
|
||||
const out = normalizeCapturedFrame(raw)!;
|
||||
expect(out.split('\n')).toHaveLength(30);
|
||||
expect(out.startsWith('line20')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns undefined for empty/null captures', () => {
|
||||
expect(normalizeCapturedFrame(null)).toBeUndefined();
|
||||
expect(normalizeCapturedFrame('\n\n')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('converts absolute row repaints (formatPaneSnapshot frames) into lines', () => {
|
||||
// The visible tmux capture carries NO newlines — every row is painted at
|
||||
// `ESC[<row>;1H`. Measured against a live dialog frame.
|
||||
const raw = '\x1b[12;1H Which color do you prefer?\x1b[13;1H❯ 1. Red\x1b[14;1H Prefer red\x1b[15;1H 2. Blue';
|
||||
const out = normalizeCapturedFrame(raw)!;
|
||||
expect(out.split('\n')).toEqual([' Which color do you prefer?', '❯ 1. Red', ' Prefer red', ' 2. Blue']);
|
||||
expect(parseDialogOptions(out)).toEqual([
|
||||
{ n: 1, label: 'Red' },
|
||||
{ n: 2, label: 'Blue' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('turns mid-row cursor jumps into spaces instead of gluing words', () => {
|
||||
const out = normalizeCapturedFrame('\x1b[5;1Hstatus:\x1b[5;20Hready');
|
||||
expect(out).toBe('status: ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApprovalInbox', () => {
|
||||
let inbox: ApprovalInbox;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
inbox = new ApprovalInbox();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
inbox.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('notePrompt creates a pending item with parsed options and emits onPending', () => {
|
||||
const { pending } = collect(inbox);
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1-case',
|
||||
kind: 'permission',
|
||||
toolName: 'Edit',
|
||||
capture: () => PERMISSION_FRAME,
|
||||
});
|
||||
expect(item.options).toHaveLength(3);
|
||||
expect(item.context).toContain('Do you want to make this edit');
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(inbox.listPending()).toHaveLength(1);
|
||||
expect(inbox.getById(item.id)?.id).toBe(item.id);
|
||||
expect(inbox.getForSession('s1')?.id).toBe(item.id);
|
||||
});
|
||||
|
||||
it('a new prompt supersedes the session previous item', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
const first = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||
const second = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'question' });
|
||||
expect(inbox.listPending()).toHaveLength(1);
|
||||
expect(inbox.getById(first.id)).toBeUndefined();
|
||||
expect(inbox.getById(second.id)).toBeDefined();
|
||||
expect(resolved).toEqual([expect.objectContaining({ id: first.id, resolution: 'superseded' })]);
|
||||
});
|
||||
|
||||
it('idle prompts never get digit options', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'idle',
|
||||
capture: () => PERMISSION_FRAME,
|
||||
});
|
||||
expect(item.options).toBeUndefined();
|
||||
expect(item.context).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolveForSession with a kinds filter skips other kinds (working-flap guard)', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||
inbox.resolveForSession('s1', 'resolved_in_terminal', ['idle']);
|
||||
expect(inbox.listPending()).toHaveLength(1);
|
||||
|
||||
inbox.notePrompt({ sessionId: 's2', sessionName: 'w2', kind: 'idle' });
|
||||
inbox.resolveForSession('s2', 'resolved_in_terminal', ['idle']);
|
||||
expect(inbox.getForSession('s2')).toBeUndefined();
|
||||
expect(resolved.filter((r) => r.resolution === 'resolved_in_terminal')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('take removes as answered; restore re-inserts unless superseded', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||
const taken = inbox.take(item.id)!;
|
||||
expect(taken.id).toBe(item.id);
|
||||
expect(inbox.take(item.id)).toBeUndefined();
|
||||
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'answered' });
|
||||
|
||||
inbox.restore(taken);
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
|
||||
// A newer prompt wins over a restore.
|
||||
const taken2 = inbox.take(item.id)!;
|
||||
const newer = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'question' });
|
||||
inbox.restore(taken2);
|
||||
expect(inbox.getForSession('s1')?.id).toBe(newer.id);
|
||||
});
|
||||
|
||||
it('dismiss removes without answering', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'question' });
|
||||
expect(inbox.dismiss(item.id)).toBe(true);
|
||||
expect(inbox.dismiss(item.id)).toBe(false);
|
||||
expect(resolved.at(-1)).toMatchObject({ resolution: 'dismissed' });
|
||||
});
|
||||
|
||||
it('items expire after the TTL on read', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||
vi.advanceTimersByTime(13 * 60 * 60 * 1000);
|
||||
expect(inbox.listPending()).toHaveLength(0);
|
||||
expect(resolved.at(-1)).toMatchObject({ resolution: 'expired' });
|
||||
});
|
||||
|
||||
it('re-captures once after a short delay and emits onUpdated', () => {
|
||||
const { updated } = collect(inbox);
|
||||
let frame = 'still painting...';
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => frame,
|
||||
});
|
||||
expect(item.options).toBeUndefined();
|
||||
frame = PERMISSION_FRAME;
|
||||
vi.advanceTimersByTime(700);
|
||||
expect(updated).toHaveLength(1);
|
||||
expect(inbox.getById(item.id)?.options).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('the delayed re-capture never touches a superseded item', () => {
|
||||
let frame = 'first';
|
||||
const first = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission', capture: () => frame });
|
||||
const second = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'question', capture: () => frame });
|
||||
frame = PERMISSION_FRAME;
|
||||
const { updated } = collect(inbox);
|
||||
vi.advanceTimersByTime(700);
|
||||
expect(updated.every((i) => i.id !== first.id)).toBe(true);
|
||||
expect(inbox.getById(second.id)).toBeDefined();
|
||||
});
|
||||
|
||||
describe('verifyStillAnswerable', () => {
|
||||
it('resolves the item and refuses when a parsed dialog left the screen', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
let frame = PERMISSION_FRAME;
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission', capture: () => frame });
|
||||
expect(item.options).toHaveLength(3);
|
||||
frame = 'the dialog is gone, claude is typing';
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(false);
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||
});
|
||||
|
||||
it('refreshes context/options when the dialog is still up', () => {
|
||||
let frame = PERMISSION_FRAME;
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission', capture: () => frame });
|
||||
frame = TWO_OPTION_FRAME;
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||
expect(inbox.getById(item.id)?.options).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('is inconclusive (allows) for items that never parsed options', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => 'unparseable dialog',
|
||||
});
|
||||
expect(item.options).toBeUndefined();
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for unknown ids only as false (missing item refuses)', () => {
|
||||
expect(inbox.verifyStillAnswerable('nope:1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('stop() clears items and silences events', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||
inbox.stop();
|
||||
expect(inbox.listPending()).toHaveLength(0);
|
||||
expect(resolved).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,25 @@ describe('refreshStaleCodemanHooks', () => {
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(healed); // byte-identical: no rewrite
|
||||
});
|
||||
|
||||
it('heals a hooks block that predates the elicitation-closed matchers (Approvals Inbox)', async () => {
|
||||
// A current-at-the-time block from before elicitation_complete/response
|
||||
// existed: secret + markers all present, so ONLY the new-matcher probe can
|
||||
// mark it stale. Build one by healing, then stripping the two matchers.
|
||||
writeFileSync(settingsPath, JSON.stringify({ hooks: staleCodemanHooks() }, null, 2));
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
const healed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
healed.hooks.Notification = (healed.hooks.Notification as Array<{ matcher?: string }>).filter(
|
||||
(n) => n.matcher !== 'elicitation_complete' && n.matcher !== 'elicitation_response'
|
||||
);
|
||||
writeFileSync(settingsPath, JSON.stringify(healed, null, 2));
|
||||
expect(readFileSync(settingsPath, 'utf-8')).not.toContain('elicitation_complete');
|
||||
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
const after = readFileSync(settingsPath, 'utf-8');
|
||||
expect(after).toContain('elicitation_complete');
|
||||
expect(after).toContain('elicitation_response');
|
||||
});
|
||||
|
||||
it('does not touch hooks that are not Codeman’s (no /api/hook-event)', async () => {
|
||||
const foreign = JSON.stringify(
|
||||
{ hooks: { Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'echo hi', timeout: 5 }] }] } },
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('generateHooksConfig', () => {
|
||||
it('should have Notification hooks array', () => {
|
||||
const config = generateHooksConfig();
|
||||
expect(config.hooks.Notification).toBeInstanceOf(Array);
|
||||
expect(config.hooks.Notification).toHaveLength(3);
|
||||
expect(config.hooks.Notification).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should have Stop hooks array', () => {
|
||||
@@ -194,7 +194,7 @@ describe('writeHooksConfig', () => {
|
||||
const settingsPath = join(testDir, '.claude', 'settings.local.json');
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(parsed.hooks).toBeDefined();
|
||||
expect(parsed.hooks.Notification).toHaveLength(3);
|
||||
expect(parsed.hooks.Notification).toHaveLength(5);
|
||||
expect(parsed.hooks.Stop).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -1085,7 +1085,7 @@ describe('Hook Config Generation - Extended', () => {
|
||||
it('should generate valid JSON structure', () => {
|
||||
const config = generateHooksConfig();
|
||||
expect(config.hooks).toBeDefined();
|
||||
expect(config.hooks.Notification).toHaveLength(3);
|
||||
expect(config.hooks.Notification).toHaveLength(5);
|
||||
expect(config.hooks.Stop).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -1096,6 +1096,9 @@ describe('Hook Config Generation - Extended', () => {
|
||||
expect(matchers).toContain('idle_prompt');
|
||||
expect(matchers).toContain('permission_prompt');
|
||||
expect(matchers).toContain('elicitation_dialog');
|
||||
// Approvals Inbox resolution signals (dialog answered in the terminal).
|
||||
expect(matchers).toContain('elicitation_complete');
|
||||
expect(matchers).toContain('elicitation_response');
|
||||
});
|
||||
|
||||
it('should use environment variable placeholders', () => {
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Approvals Inbox route tests (src/web/routes/approval-routes.ts) via app.inject()
|
||||
* — no live port. The hook-event route is registered alongside so items are
|
||||
* created through the REAL ingestion path (sanitize → notePrompt with the
|
||||
* terminal-buffer capture fallback), not by poking the store directly.
|
||||
*
|
||||
* The routes read the process-wide `approvalInbox` singleton, so every test
|
||||
* drains it in afterEach — a leaked pending item would bleed into the next test.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { registerApprovalRoutes } from '../../src/web/routes/approval-routes.js';
|
||||
import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
|
||||
import { approvalInbox } from '../../src/web/approval-inbox.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
|
||||
import { createMockRouteContext, type MockSession } from '../mocks/index.js';
|
||||
|
||||
type MockRouteContext = ReturnType<typeof createMockRouteContext>;
|
||||
|
||||
interface RouteTestHarness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local harness mirroring production's uniform-envelope preSerialization hook
|
||||
* (server.ts), so `{success:false}` bodies carry their conventional 4xx status.
|
||||
* The shared createRouteTestHarness deliberately omits that hook; these routes
|
||||
* signal every guard through returned error envelopes, so the status IS the
|
||||
* behavior under test. Pattern copied from hook-event-routes.test.ts.
|
||||
*/
|
||||
async function createEnvelopeHarness(authUser?: {
|
||||
username: string;
|
||||
role: 'admin' | 'user';
|
||||
}): Promise<RouteTestHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
if (authUser) {
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
|
||||
registerHookEventRoutes(app, ctx as never);
|
||||
registerApprovalRoutes(app, ctx as never);
|
||||
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
const SESSION_ID = 'approval-test-session';
|
||||
|
||||
const PERMISSION_DIALOG = [
|
||||
' Claude needs your permission to use Bash',
|
||||
' ❯ 1. Yes',
|
||||
' 2. Yes, and don’t ask again for this command',
|
||||
' 3. No, and tell Claude what to do differently (esc)',
|
||||
].join('\n');
|
||||
|
||||
async function postHook(harness: RouteTestHarness, event: string, data: Record<string, unknown> = {}): Promise<void> {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: { event, sessionId: SESSION_ID, data },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
async function listApprovals(harness: RouteTestHarness): Promise<Array<Record<string, unknown>>> {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/approvals' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
return res.json().data.approvals;
|
||||
}
|
||||
|
||||
describe('approval routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let session: MockSession;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness();
|
||||
session = harness.ctx.sessions.get(SESSION_ID)!;
|
||||
session.terminalBuffer = PERMISSION_DIALOG;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const item of approvalInbox.listPending()) {
|
||||
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
|
||||
}
|
||||
approvalInbox.onPending = approvalInbox.onUpdated = approvalInbox.onResolved = undefined;
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('a permission_prompt hook creates a pending item with parsed options and context', async () => {
|
||||
await postHook(harness, 'permission_prompt', {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'rm -rf node_modules' },
|
||||
message: 'Claude needs your permission to use Bash',
|
||||
cwd: '/tmp/case',
|
||||
});
|
||||
const approvals = await listApprovals(harness);
|
||||
expect(approvals).toHaveLength(1);
|
||||
expect(approvals[0]).toMatchObject({
|
||||
sessionId: SESSION_ID,
|
||||
kind: 'permission',
|
||||
toolName: 'Bash',
|
||||
toolSummary: 'rm -rf node_modules',
|
||||
message: 'Claude needs your permission to use Bash',
|
||||
});
|
||||
expect(approvals[0].options).toHaveLength(3);
|
||||
expect(String(approvals[0].context)).toContain('permission to use Bash');
|
||||
});
|
||||
|
||||
it('broadcast and push for the prompt carry the approvalId', async () => {
|
||||
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
|
||||
const [item] = await listApprovals(harness);
|
||||
const hookBroadcast = harness.ctx.broadcast.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
|
||||
expect(hookBroadcast?.[1]).toMatchObject({ approvalId: item.id });
|
||||
const push = harness.ctx.sendPushNotifications.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
|
||||
expect(push?.[1]).toMatchObject({ approvalId: item.id });
|
||||
});
|
||||
|
||||
it('answering with a parsed option sends exactly that digit (no Enter)', async () => {
|
||||
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
|
||||
const [item] = await listApprovals(harness);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'option', option: 2 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(session.writeBuffer).toEqual(['2']);
|
||||
expect(await listApprovals(harness)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('approve sends "1", deny sends Esc', async () => {
|
||||
await postHook(harness, 'permission_prompt', {});
|
||||
let [item] = await listApprovals(harness);
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'approve' },
|
||||
});
|
||||
expect(session.writeBuffer).toEqual(['1']);
|
||||
|
||||
session.writeBuffer.length = 0;
|
||||
await postHook(harness, 'permission_prompt', {});
|
||||
[item] = await listApprovals(harness);
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'deny' },
|
||||
});
|
||||
expect(session.writeBuffer).toEqual(['\x1b']);
|
||||
});
|
||||
|
||||
it('a second answer 404s (answered items leave the inbox)', async () => {
|
||||
await postHook(harness, 'permission_prompt', {});
|
||||
const [item] = await listApprovals(harness);
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'approve' },
|
||||
});
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'deny' },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(session.writeBuffer).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('rejects option digits outside the parsed options', async () => {
|
||||
await postHook(harness, 'permission_prompt', {});
|
||||
const [item] = await listApprovals(harness);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'option', option: 7 },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses with 409 when the dialog left the screen since capture', async () => {
|
||||
await postHook(harness, 'permission_prompt', {});
|
||||
const [item] = await listApprovals(harness);
|
||||
// The dialog scrolled away — the re-capture at answer time must refuse.
|
||||
session.terminalBuffer = 'claude is off doing something else now';
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'approve' },
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
expect(await listApprovals(harness)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('idle prompts take a text answer, submitted with \\r; approve/deny are rejected', async () => {
|
||||
session.terminalBuffer = 'claude> waiting at the composer';
|
||||
await postHook(harness, 'idle_prompt', { message: 'Claude is waiting for your input' });
|
||||
const [item] = await listApprovals(harness);
|
||||
expect(item.kind).toBe('idle');
|
||||
|
||||
const bad = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'approve' },
|
||||
});
|
||||
expect(bad.statusCode).toBe(400);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'text', text: 'continue with the plan\nplease' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
// Embedded newlines are flattened; the trailing \r submits.
|
||||
expect(session.writeBuffer).toEqual(['continue with the plan please\r']);
|
||||
});
|
||||
|
||||
it('text answers on dialog items are rejected', async () => {
|
||||
await postHook(harness, 'elicitation_dialog', {});
|
||||
const [item] = await listApprovals(harness);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'text', text: 'hello' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('stop resolves the pending item; elicitation_complete resolves questions', async () => {
|
||||
await postHook(harness, 'elicitation_dialog', {});
|
||||
expect(await listApprovals(harness)).toHaveLength(1);
|
||||
await postHook(harness, 'elicitation_complete', {});
|
||||
expect(await listApprovals(harness)).toHaveLength(0);
|
||||
|
||||
await postHook(harness, 'permission_prompt', {});
|
||||
expect(await listApprovals(harness)).toHaveLength(1);
|
||||
await postHook(harness, 'stop', {});
|
||||
expect(await listApprovals(harness)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a failed write restores the item and reports 422', async () => {
|
||||
await postHook(harness, 'permission_prompt', {});
|
||||
const [item] = await listApprovals(harness);
|
||||
session.failWrites = true;
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'approve' },
|
||||
});
|
||||
expect(res.statusCode).toBe(422);
|
||||
expect(await listApprovals(harness)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('dismiss removes without keystrokes', async () => {
|
||||
await postHook(harness, 'permission_prompt', {});
|
||||
const [item] = await listApprovals(harness);
|
||||
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/dismiss`, payload: {} });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
expect(await listApprovals(harness)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('non-claude sessions never get inbox items', async () => {
|
||||
session.mode = 'codex';
|
||||
await postHook(harness, 'permission_prompt', {});
|
||||
expect(await listApprovals(harness)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('unknown ids 404 on answer and dismiss', async () => {
|
||||
for (const url of ['/api/approvals/nope:1/answer', '/api/approvals/nope:1/dismiss']) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url,
|
||||
payload: url.endsWith('answer') ? { action: 'approve' } : {},
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('approval routes — multi-user scoping', () => {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
saved.CODEMAN_MULTIUSER = process.env.CODEMAN_MULTIUSER;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (saved.CODEMAN_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = saved.CODEMAN_MULTIUSER;
|
||||
for (const item of approvalInbox.listPending()) {
|
||||
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
|
||||
}
|
||||
});
|
||||
|
||||
it("a non-admin neither lists nor answers another user's approvals (404, not 403)", async () => {
|
||||
const harness = await createEnvelopeHarness({ username: 'bob', role: 'user' });
|
||||
const session = harness.ctx.sessions.get(SESSION_ID)!;
|
||||
session.terminalBuffer = PERMISSION_DIALOG;
|
||||
(session as unknown as { owner?: string }).owner = 'alice';
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: { event: 'permission_prompt', sessionId: SESSION_ID, data: {} },
|
||||
});
|
||||
// The item exists in the store...
|
||||
expect(approvalInbox.listPending()).toHaveLength(1);
|
||||
const [item] = approvalInbox.listPending();
|
||||
|
||||
// ...but bob sees an empty list and cannot act on the id.
|
||||
const list = await harness.app.inject({ method: 'GET', url: '/api/approvals' });
|
||||
expect(list.json().data.approvals).toHaveLength(0);
|
||||
const answer = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'approve' },
|
||||
});
|
||||
expect(answer.statusCode).toBe(404);
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
|
||||
await harness.app.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user