mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
186 lines
7.3 KiB
TypeScript
186 lines
7.3 KiB
TypeScript
/**
|
||
* COD-91 — `refreshStaleCodemanHooks` self-heal.
|
||
*
|
||
* Making the hook-event secret unconditionally required (PR #127) would silently 401 the
|
||
* hook curls baked into cases created before the secret header existed (COD-54). Those
|
||
* curls live in `.claude/settings.local.json` and `writeHooksConfig` only runs at case
|
||
* CREATION, so existing cases never refresh. `refreshStaleCodemanHooks` regenerates the
|
||
* hooks block on session spawn — but ONLY when the case already holds Codeman's own
|
||
* pre-secret hook curls, never clobbering a user's customizations.
|
||
*
|
||
* Pure filesystem logic against a temp dir — no port / server / tmux.
|
||
*/
|
||
|
||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { tmpdir } from 'node:os';
|
||
import { refreshStaleCodemanHooks } from '../src/hooks-config.js';
|
||
|
||
const SECRET_HEADER = 'X-Codeman-Hook-Secret';
|
||
|
||
// A faithful pre-secret Codeman hook curl (what cases created before COD-54 contain):
|
||
// targets /api/hook-event, but with NO X-Codeman-Hook-Secret header.
|
||
function staleCodemanHooks() {
|
||
return {
|
||
Stop: [
|
||
{
|
||
matcher: '',
|
||
hooks: [
|
||
{
|
||
type: 'command',
|
||
command:
|
||
"HOOK_DATA=$(cat 2>/dev/null || echo '{}'); " +
|
||
'printf \'{"event":"stop","sessionId":"%s","data":%s}\' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ' +
|
||
'curl -s -X POST "$CODEMAN_API_URL/api/hook-event" -H \'Content-Type: application/json\' --data @- 2>/dev/null || true',
|
||
timeout: 5,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
describe('refreshStaleCodemanHooks', () => {
|
||
let dir: string;
|
||
let settingsPath: string;
|
||
|
||
beforeEach(() => {
|
||
dir = mkdtempSync(join(tmpdir(), 'codeman-selfheal-'));
|
||
mkdirSync(join(dir, '.claude'), { recursive: true });
|
||
settingsPath = join(dir, '.claude', 'settings.local.json');
|
||
});
|
||
|
||
afterEach(() => {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('adds the secret header to a stale Codeman hooks block and preserves other keys', async () => {
|
||
writeFileSync(
|
||
settingsPath,
|
||
JSON.stringify({ env: { CLAUDE_CODE_FOO: '1' }, model: 'opus', hooks: staleCodemanHooks() }, null, 2)
|
||
);
|
||
await refreshStaleCodemanHooks(dir);
|
||
|
||
const after = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||
expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER);
|
||
expect(JSON.stringify(after.hooks)).toContain('CODEMAN_HOOK_SECRET_FILE');
|
||
// sibling keys untouched
|
||
expect(after.env).toEqual({ CLAUDE_CODE_FOO: '1' });
|
||
expect(after.model).toBe('opus');
|
||
});
|
||
|
||
it('leaves a hooks block that already carries the secret unchanged', async () => {
|
||
// Seed with a current block by healing a stale one first, then re-heal: second pass must no-op.
|
||
writeFileSync(settingsPath, JSON.stringify({ hooks: staleCodemanHooks() }, null, 2));
|
||
await refreshStaleCodemanHooks(dir);
|
||
const healed = readFileSync(settingsPath, 'utf-8');
|
||
expect(healed).toContain(SECRET_HEADER);
|
||
|
||
await refreshStaleCodemanHooks(dir);
|
||
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 }] }] } },
|
||
null,
|
||
2
|
||
);
|
||
writeFileSync(settingsPath, foreign);
|
||
await refreshStaleCodemanHooks(dir);
|
||
expect(readFileSync(settingsPath, 'utf-8')).toBe(foreign);
|
||
});
|
||
|
||
it('preserves user handlers and events in a mixed stale configuration', async () => {
|
||
const hooks = staleCodemanHooks();
|
||
hooks.Stop[0].hooks.push({
|
||
type: 'command',
|
||
command: './notify-user.sh',
|
||
timeout: 10,
|
||
});
|
||
const customPostToolUse = {
|
||
matcher: 'Write',
|
||
hooks: [{ type: 'command', command: './format.sh' }],
|
||
};
|
||
const customEvent = [
|
||
{
|
||
hooks: [{ type: 'command', command: './audit.sh' }],
|
||
},
|
||
];
|
||
writeFileSync(
|
||
settingsPath,
|
||
JSON.stringify(
|
||
{
|
||
hooks: {
|
||
...hooks,
|
||
PostToolUse: [customPostToolUse],
|
||
CustomEvent: customEvent,
|
||
},
|
||
},
|
||
null,
|
||
2
|
||
)
|
||
);
|
||
|
||
await refreshStaleCodemanHooks(dir);
|
||
|
||
const after = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||
expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER);
|
||
expect(JSON.stringify(after.hooks)).toContain('CODEMAN_BACKGROUND_REWAKE_V3');
|
||
expect(JSON.stringify(after.hooks.Stop)).toContain('./notify-user.sh');
|
||
expect(after.hooks.PostToolUse).toEqual(expect.arrayContaining([customPostToolUse]));
|
||
expect(after.hooks.CustomEvent).toEqual(customEvent);
|
||
});
|
||
|
||
// A case can be current on the secret AND the background-wake hook and still carry
|
||
// the `-k`-less curl shape, which exits 60 against a self-signed HTTPS API and is
|
||
// swallowed by `|| true` — every hook event dead, silently. The refresh must treat
|
||
// that as a third stale shape.
|
||
it('heals a current-looking block whose hook curls lack -k (HTTPS self-signed installs)', async () => {
|
||
const { generateHooksConfig } = await import('../src/hooks-config.js');
|
||
const flagless = JSON.parse(JSON.stringify(generateHooksConfig()).replaceAll('curl -sk ', 'curl -s '));
|
||
writeFileSync(settingsPath, JSON.stringify({ hooks: flagless.hooks }, null, 2));
|
||
|
||
await refreshStaleCodemanHooks(dir);
|
||
|
||
const after = readFileSync(settingsPath, 'utf-8');
|
||
expect(after).toContain('curl -sk -X POST');
|
||
expect(after).not.toContain('curl -s -X POST');
|
||
// and the pass is convergent: a second refresh must not rewrite
|
||
await refreshStaleCodemanHooks(dir);
|
||
expect(readFileSync(settingsPath, 'utf-8')).toBe(after);
|
||
});
|
||
|
||
it('is a no-op when settings.local.json is absent (does not create one)', async () => {
|
||
await refreshStaleCodemanHooks(dir);
|
||
expect(existsSync(settingsPath)).toBe(false);
|
||
});
|
||
|
||
it('leaves a malformed settings file untouched', async () => {
|
||
const garbage = '{ not valid json';
|
||
writeFileSync(settingsPath, garbage);
|
||
await refreshStaleCodemanHooks(dir);
|
||
expect(readFileSync(settingsPath, 'utf-8')).toBe(garbage);
|
||
});
|
||
});
|