mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(omp): report approvals without claiming Claude's hook set
Review follow-up. omp declared `hooks: 'always'`, which every consumer reads
as Claude's full hook set, so an omp session picked up behaviour it cannot
back. omp goes back to `hooks: 'none'`; `capabilities.approvalHookFile` is now
a separate, narrower capability with its own predicate,
`approvalHookAvailableForMode()` (local, non-adopted sessions only).
- An explicit `stop` wait on omp is a 400 again; `blocked` still works.
- omp inbox items are alert-only (`answerInTerminal`, no `approvalId` on the
push), and only `permission_prompt` / `elicitation_complete` are accepted for
an approval-only session.
- The module is installed from POST /api/quick-start too (new and existing
cases, not Docker or remote) and on reboot-restore, after the launch
pre-flight on both create paths. Claude's settings.local.json block and the
Docker CLAUDE.md no longer land in omp workspaces.
- The generated module passes Bun's `tls: { rejectUnauthorized: false }` for an
https CODEMAN_API_URL (checked against a `--https` server), clears the alert
only when the last pending toolCallId resolves, sends reports in order,
carries omp's policy `reason` as `message`, and imports HookAPI from
`@oh-my-pi/pi-coding-agent/extensibility/hooks`.
- Every path component and the target file are lstat-checked.
- docs/omp-integration.md, verbs.md and the stock.ts comments updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0bbf483a35
commit
27d6e2e30a
14 files changed
+701
-125
No files matched your search
@@ -35,7 +35,7 @@ describe('per-mode capability predicates', () => {
|
||||
['pi', true, false, false],
|
||||
['grok', true, false, false],
|
||||
['deepseek', true, false, true],
|
||||
['omp', true, false, true],
|
||||
['omp', true, false, false],
|
||||
['copilot', true, false, false],
|
||||
] as Array<[SessionMode, boolean, boolean, boolean]>)(
|
||||
'%s: external=%s altScreenStrip=%s hooks=%s',
|
||||
|
||||
+224
-40
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* @fileoverview omp approval-hook wiring.
|
||||
*
|
||||
* Pins the three things that make an omp pane able to report a pending tool approval
|
||||
* at all: the registry says omp HAS hooks, Codeman installs the module that emits them,
|
||||
* and the module maps omp's events onto the names the server already routes on.
|
||||
*
|
||||
* Without these, `hooksAvailableForMode('omp')` is false, /api/approvals never receives
|
||||
* an item, and a dialog on screen raises no tab alert and no notification.
|
||||
* Pins what makes an omp pane able to report a pending tool approval, and what it must
|
||||
* NOT earn by doing so: the registry declares the narrow `approvalHookFile` capability
|
||||
* while `hooks` stays 'none' (so `stop` is still a 400), Codeman installs the module
|
||||
* that emits the events, and the module maps omp's events onto the names the server
|
||||
* already routes on. The route side (both create paths, alert-only inbox items) lives in
|
||||
* test/routes/omp-approval-hook-routes.test.ts and test/routes/approval-routes.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
applyApprovalHookFile,
|
||||
generateOmpApprovalHook,
|
||||
@@ -20,61 +21,76 @@ import {
|
||||
OMP_APPROVAL_HOOK_FILE,
|
||||
} from '../src/hooks-config.js';
|
||||
import { getCli } from '../src/config/cli-registry/registry.js';
|
||||
import { hooksAvailableForMode } from '../src/web/session-wait-registry.js';
|
||||
import {
|
||||
hooksAvailableForMode,
|
||||
approvalHookAvailableForMode,
|
||||
blockedSignalAvailable,
|
||||
resolveWaitSignals,
|
||||
} from '../src/web/session-wait-registry.js';
|
||||
|
||||
const MARKER = 'CODEMAN_OMP_APPROVAL_HOOK_V1';
|
||||
|
||||
describe('omp approval hook — registry', () => {
|
||||
it('declares hooks: always, which is what lets approval items be opened at all', () => {
|
||||
expect(getCli('omp')?.capabilities.hooks).toBe('always');
|
||||
expect(hooksAvailableForMode('omp')).toBe(true);
|
||||
it('keeps hooks: none, because every consumer reads "always" as Claude\'s full hook set', () => {
|
||||
expect(getCli('omp')?.capabilities.hooks).toBe('none');
|
||||
expect(hooksAvailableForMode('omp')).toBe(false);
|
||||
});
|
||||
|
||||
it('reports approvals through the separate approval-hook capability', () => {
|
||||
expect(approvalHookAvailableForMode('omp')).toBe(true);
|
||||
expect(blockedSignalAvailable('omp')).toBe(true);
|
||||
expect(approvalHookAvailableForMode('claude')).toBe(false);
|
||||
});
|
||||
|
||||
it('answers no where Codeman never installed the module (adopted, remote, Docker)', () => {
|
||||
// sessionHookOptions() sets deepSeekBridgeUnreachable for remote and Docker sessions.
|
||||
expect(approvalHookAvailableForMode('omp', { adopted: true })).toBe(false);
|
||||
expect(approvalHookAvailableForMode('omp', { deepSeekBridgeUnreachable: true })).toBe(false);
|
||||
expect(blockedSignalAvailable('omp', { deepSeekBridgeUnreachable: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an explicit stop wait for omp (the 400), and accepts blocked', () => {
|
||||
const stop = resolveWaitSignals('stop', { mode: 'omp' });
|
||||
expect(stop.error).toMatch(/stop/);
|
||||
expect(stop.until).toEqual([]);
|
||||
const blocked = resolveWaitSignals('blocked', { mode: 'omp' });
|
||||
expect(blocked.error).toBeNull();
|
||||
expect(blocked.until).toEqual(['blocked']);
|
||||
// The default set must never hold stop for omp, or a bare wait sits out its timeout.
|
||||
expect(resolveWaitSignals(undefined, { mode: 'omp' }).until).not.toContain('stop');
|
||||
});
|
||||
|
||||
it('refuses blocked for a remote omp session, where no module reports', () => {
|
||||
expect(resolveWaitSignals('blocked', { mode: 'omp', deepSeekBridgeUnreachable: true }).error).toMatch(/blocked/);
|
||||
});
|
||||
|
||||
it('leaves every other hook-less CLI failing the ask', () => {
|
||||
// The rule that keeps this honest: 'always' is earned by a real signal, not by
|
||||
// wanting alerts. opencode/gemini/pi/grok/antigravity have no approval events.
|
||||
// opencode/gemini/pi/grok/antigravity have no approval events.
|
||||
for (const mode of ['opencode', 'gemini', 'pi', 'grok', 'antigravity', 'shell'] as const) {
|
||||
expect(hooksAvailableForMode(mode), mode).toBe(false);
|
||||
expect(approvalHookAvailableForMode(mode), mode).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('names the hook module as registry data, so no call site branches on the id', () => {
|
||||
// The session-create path asks "does this CLI declare approvalHookFile?"; if the
|
||||
// field is absent the hook is never installed and the pane stays silent.
|
||||
// Both create paths ask "does this CLI declare approvalHookFile?"; if the field is
|
||||
// absent the hook is never installed and the pane stays silent.
|
||||
expect(getCli('omp')?.capabilities.approvalHookFile).toBe(OMP_APPROVAL_HOOK_FILE);
|
||||
// claude installs a settings.local.json block instead and must NOT be given a file.
|
||||
expect(getCli('claude')?.capabilities.approvalHookFile).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('omp approval hook — generated module', () => {
|
||||
describe('omp approval hook — generated module (source)', () => {
|
||||
const src = generateOmpApprovalHook();
|
||||
|
||||
it('carries the ownership marker on its first line', () => {
|
||||
expect(src.split('\n')[0]).toContain(MARKER);
|
||||
});
|
||||
|
||||
it('maps omp approval events onto the names the server routes on', () => {
|
||||
// APPROVAL_KIND_BY_EVENT opens an item for permission_prompt and resolves it on
|
||||
// elicitation_complete; using any other name makes the hook a silent no-op.
|
||||
expect(src).toContain('"tool_approval_requested"');
|
||||
expect(src).toContain('"tool_approval_resolved"');
|
||||
expect(src).toContain('"permission_prompt"');
|
||||
expect(src).toContain('"elicitation_complete"');
|
||||
});
|
||||
|
||||
it('posts to the hook-event route with the secret header', () => {
|
||||
expect(src).toContain('/api/hook-event');
|
||||
expect(src).toContain('X-Codeman-Hook-Secret');
|
||||
expect(src).toContain('CODEMAN_SESSION_ID');
|
||||
expect(src).toContain('CODEMAN_API_URL');
|
||||
});
|
||||
|
||||
it('fails open: both handlers catch and nothing is awaited unbounded', () => {
|
||||
expect(src).toContain('catch');
|
||||
expect(src).toContain('AbortSignal.timeout');
|
||||
// `void report(...)` — a rejected report must never surface to the dialog.
|
||||
expect(src).toMatch(/void report\(/);
|
||||
it('imports HookAPI from the subpath omp exports it from, type-only', () => {
|
||||
// omp's package root does not re-export it; erased at runtime either way.
|
||||
expect(src).toContain('import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks";');
|
||||
});
|
||||
|
||||
it('uses a static import, so the module parses without a dynamic loader', () => {
|
||||
@@ -83,6 +99,150 @@ describe('omp approval hook — generated module', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** A fake omp HookAPI: records the handlers the module registers. */
|
||||
function fakeHookApi() {
|
||||
const handlers = new Map<string, (event: Record<string, unknown>) => void>();
|
||||
return {
|
||||
api: { on: (name: string, fn: (event: Record<string, unknown>) => void) => handlers.set(name, fn) },
|
||||
emit: (name: string, event: Record<string, unknown>) => handlers.get(name)?.(event),
|
||||
};
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
describe('omp approval hook — generated module (runtime)', () => {
|
||||
let dir: string;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let counter = 0;
|
||||
const savedEnv = { ...process.env };
|
||||
|
||||
/** Write the module to disk and import a FRESH copy (env is read at load time). */
|
||||
async function loadModule(env: Record<string, string>) {
|
||||
Object.assign(process.env, env);
|
||||
const file = join(dir, `codeman-approval-${counter++}.ts`);
|
||||
writeFileSync(file, generateOmpApprovalHook());
|
||||
const mod = (await import(pathToFileURL(file).href)) as { default: (pi: unknown) => void };
|
||||
const pi = fakeHookApi();
|
||||
mod.default(pi.api);
|
||||
return pi;
|
||||
}
|
||||
|
||||
/** The `{ event, sessionId, data }` bodies posted, in order. */
|
||||
const posted = () =>
|
||||
fetchMock.mock.calls.map(
|
||||
([, init]) => JSON.parse((init as { body: string }).body) as { event: string; data: Record<string, unknown> }
|
||||
);
|
||||
|
||||
/**
|
||||
* Wait until the module's report chain has made exactly `calls` POSTs, then a little
|
||||
* longer so a surplus report would show up here instead of leaking into the next test.
|
||||
*/
|
||||
async function settle(calls: number) {
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(calls));
|
||||
await sleep(50);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(calls);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-omp-hook-mod-'));
|
||||
writeFileSync(join(dir, 'secret'), 's3cret\n');
|
||||
fetchMock = vi.fn(async () => new Response('{}'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
process.env = { ...savedEnv };
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const baseEnv = () => ({
|
||||
CODEMAN_API_URL: 'http://127.0.0.1:3000',
|
||||
CODEMAN_SESSION_ID: 'sess-1',
|
||||
CODEMAN_HOOK_SECRET_FILE: join(dir, 'secret'),
|
||||
});
|
||||
|
||||
it('maps a request to permission_prompt and a resolve to elicitation_complete, with the secret', async () => {
|
||||
const pi = await loadModule(baseEnv());
|
||||
pi.emit('tool_approval_requested', { toolCallId: 'a', toolName: 'bash' });
|
||||
pi.emit('tool_approval_resolved', { toolCallId: 'a', toolName: 'bash', approved: false });
|
||||
await settle(2);
|
||||
|
||||
expect(posted().map((b) => b.event)).toEqual(['permission_prompt', 'elicitation_complete']);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, { headers: Record<string, string> }];
|
||||
expect(url).toBe('http://127.0.0.1:3000/api/hook-event');
|
||||
expect(init.headers['X-Codeman-Hook-Secret']).toBe('s3cret');
|
||||
expect(init).not.toHaveProperty('tls');
|
||||
});
|
||||
|
||||
it('clears the alert only when the LAST pending approval in a batch resolves', async () => {
|
||||
const pi = await loadModule(baseEnv());
|
||||
pi.emit('tool_approval_requested', { toolCallId: 'a', toolName: 'bash' });
|
||||
pi.emit('tool_approval_requested', { toolCallId: 'b', toolName: 'write' });
|
||||
pi.emit('tool_approval_resolved', { toolCallId: 'a', toolName: 'bash', approved: true });
|
||||
await settle(2);
|
||||
expect(posted().map((b) => b.event)).toEqual(['permission_prompt', 'permission_prompt']);
|
||||
|
||||
pi.emit('tool_approval_resolved', { toolCallId: 'b', toolName: 'write', approved: true });
|
||||
await settle(3);
|
||||
expect(posted().map((b) => b.event)).toEqual(['permission_prompt', 'permission_prompt', 'elicitation_complete']);
|
||||
});
|
||||
|
||||
it('keeps request-then-resolve order when the first POST is slow', async () => {
|
||||
// Without the chain, the resolve's POST would start while the prompt's is still in
|
||||
// flight and could land first, leaving the server's alert up.
|
||||
const started: string[] = [];
|
||||
fetchMock.mockImplementation(async (_url: string, init: { body: string }) => {
|
||||
const { event } = JSON.parse(init.body) as { event: string };
|
||||
started.push(event);
|
||||
if (event === 'permission_prompt') await sleep(150);
|
||||
return new Response('{}');
|
||||
});
|
||||
const pi = await loadModule(baseEnv());
|
||||
pi.emit('tool_approval_requested', { toolCallId: 'a', toolName: 'bash' });
|
||||
pi.emit('tool_approval_resolved', { toolCallId: 'a', toolName: 'bash', approved: true });
|
||||
await sleep(60);
|
||||
// The resolve has not even started while the prompt is still in flight.
|
||||
expect(started).toEqual(['permission_prompt']);
|
||||
await settle(2);
|
||||
expect(started).toEqual(['permission_prompt', 'elicitation_complete']);
|
||||
});
|
||||
|
||||
it("sends omp's policy reason as message, and nothing summary-shaped without one", async () => {
|
||||
const pi = await loadModule(baseEnv());
|
||||
pi.emit('tool_approval_requested', { toolCallId: 'a', toolName: 'bash', reason: 'restricted folder' });
|
||||
pi.emit('tool_approval_requested', { toolCallId: 'b', toolName: 'bash' });
|
||||
await settle(2);
|
||||
const [withReason, without] = posted();
|
||||
expect(withReason.data).toEqual({ tool_name: 'bash', message: 'restricted folder' });
|
||||
expect(without.data).toEqual({ tool_name: 'bash' });
|
||||
});
|
||||
|
||||
it('skips certificate verification for an https URL (self-signed loopback)', async () => {
|
||||
const pi = await loadModule({ ...baseEnv(), CODEMAN_API_URL: 'https://127.0.0.1:3000' });
|
||||
pi.emit('tool_approval_requested', { toolCallId: 'a', toolName: 'bash' });
|
||||
await settle(1);
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject({ tls: { rejectUnauthorized: false } });
|
||||
});
|
||||
|
||||
it('fails open: a rejected fetch never throws out of a handler, and later reports still go', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
const pi = await loadModule(baseEnv());
|
||||
expect(() => pi.emit('tool_approval_requested', { toolCallId: 'a', toolName: 'bash' })).not.toThrow();
|
||||
pi.emit('tool_approval_resolved', { toolCallId: 'a', toolName: 'bash', approved: true });
|
||||
await settle(2);
|
||||
});
|
||||
|
||||
it('posts nothing outside a Codeman session', async () => {
|
||||
delete process.env.CODEMAN_API_URL;
|
||||
delete process.env.CODEMAN_SESSION_ID;
|
||||
const pi = await loadModule({});
|
||||
pi.emit('tool_approval_requested', { toolCallId: 'a', toolName: 'bash' });
|
||||
await sleep(100);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('omp approval hook — install', () => {
|
||||
let workspace: string;
|
||||
|
||||
@@ -124,15 +284,39 @@ describe('omp approval hook — install', () => {
|
||||
expect(readFileSync(join(workspace, OMP_APPROVAL_HOOK_DIR, OMP_APPROVAL_HOOK_FILE), 'utf-8')).toBe(foreign);
|
||||
});
|
||||
|
||||
it('refuses to write through a symlinked hooks directory', async () => {
|
||||
// Every component below the workspace, and the file itself: a link at any level
|
||||
// would put the module wherever it points.
|
||||
it.each([
|
||||
['.claude', '.claude'],
|
||||
['.claude/hooks', join('.claude', 'hooks')],
|
||||
['.claude/hooks/pre', OMP_APPROVAL_HOOK_DIR],
|
||||
])('refuses to write through a symlinked %s', async (_label, linkRel) => {
|
||||
const elsewhere = mkdtempSync(join(tmpdir(), 'codeman-omp-hook-target-'));
|
||||
try {
|
||||
mkdirSync(join(workspace, '.claude', 'hooks'), { recursive: true });
|
||||
symlinkSync(elsewhere, join(workspace, OMP_APPROVAL_HOOK_DIR));
|
||||
const link = join(workspace, linkRel);
|
||||
mkdirSync(join(link, '..'), { recursive: true });
|
||||
symlinkSync(elsewhere, link);
|
||||
expect(await applyApprovalHookFile(workspace, OMP_APPROVAL_HOOK_FILE)).toBe('symlink');
|
||||
// Nothing written anywhere under the link target.
|
||||
expect(existsSync(join(elsewhere, 'pre'))).toBe(false);
|
||||
expect(existsSync(join(elsewhere, 'hooks'))).toBe(false);
|
||||
expect(existsSync(join(elsewhere, OMP_APPROVAL_HOOK_FILE))).toBe(false);
|
||||
} finally {
|
||||
rmSync(elsewhere, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a symlinked target file', async () => {
|
||||
const elsewhere = mkdtempSync(join(tmpdir(), 'codeman-omp-hook-target-'));
|
||||
try {
|
||||
const outside = join(elsewhere, 'victim.ts');
|
||||
writeFileSync(outside, `// ${MARKER}\n`);
|
||||
mkdirSync(join(workspace, OMP_APPROVAL_HOOK_DIR), { recursive: true });
|
||||
symlinkSync(outside, join(workspace, OMP_APPROVAL_HOOK_DIR, OMP_APPROVAL_HOOK_FILE));
|
||||
expect(await applyApprovalHookFile(workspace, OMP_APPROVAL_HOOK_FILE)).toBe('symlink');
|
||||
expect(readFileSync(outside, 'utf-8')).toBe(`// ${MARKER}\n`);
|
||||
} finally {
|
||||
rmSync(elsewhere, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ 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 { sessionWaits } from '../../src/web/session-wait-registry.js';
|
||||
import { raiseScreenBlocked } from '../../src/web/screen-blocked.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
|
||||
@@ -522,6 +523,85 @@ describe('approval routes: supervised-hook (DeepSeek) alert-only items', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('approval routes: approval-only hook module (omp) alert-only items', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let session: MockSession;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness();
|
||||
session = harness.ctx.sessions.get(SESSION_ID)!;
|
||||
session.mode = 'omp';
|
||||
// A Claude-shaped frame on purpose: even when the capture parses into options, an
|
||||
// omp item must stay alert-only, because omp's composer takes the keystroke.
|
||||
session.terminalBuffer = PERMISSION_DIALOG;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const item of approvalInbox.listPending()) {
|
||||
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
|
||||
}
|
||||
approvalInbox.onPending = approvalInbox.onUpdated = approvalInbox.onResolved = undefined;
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('creates an answerInTerminal item and omits approvalId from push', async () => {
|
||||
await postHook(harness, 'permission_prompt', { tool_name: 'bash', message: 'restricted folder' });
|
||||
const approvals = await listApprovals(harness);
|
||||
expect(approvals).toHaveLength(1);
|
||||
expect(approvals[0]).toMatchObject({
|
||||
sessionId: SESSION_ID,
|
||||
kind: 'permission',
|
||||
toolName: 'bash',
|
||||
message: 'restricted folder',
|
||||
answerInTerminal: true,
|
||||
});
|
||||
const push = harness.ctx.sendPushNotifications.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
|
||||
expect(push?.[1]).toBeDefined();
|
||||
expect(push?.[1]).not.toHaveProperty('approvalId');
|
||||
});
|
||||
|
||||
it('refuses Approve with a terminal-only error and writes nothing into the pane', async () => {
|
||||
await postHook(harness, 'permission_prompt', { tool_name: 'bash' });
|
||||
const [item] = await listApprovals(harness);
|
||||
for (const payload of [{ action: 'approve' }, { action: 'deny' }, { action: 'option', option: 1 }]) {
|
||||
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/answer`, payload });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/answered in the terminal/i);
|
||||
}
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
expect(await listApprovals(harness)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('resolves the item on elicitation_complete', async () => {
|
||||
await postHook(harness, 'permission_prompt', { tool_name: 'bash' });
|
||||
await postHook(harness, 'elicitation_complete', { tool_name: 'bash', approved: true });
|
||||
expect(await listApprovals(harness)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('wakes a blocked waiter, and never a stop waiter', async () => {
|
||||
const notify = vi.spyOn(sessionWaits, 'notifySignal');
|
||||
await postHook(harness, 'permission_prompt', { tool_name: 'bash' });
|
||||
await postHook(harness, 'stop', {});
|
||||
expect(notify.mock.calls).toEqual([[SESSION_ID, 'blocked']]);
|
||||
});
|
||||
|
||||
it('drops events the module never sends (stop, idle_prompt) instead of opening items', async () => {
|
||||
await postHook(harness, 'idle_prompt', {});
|
||||
expect(await listApprovals(harness)).toHaveLength(0);
|
||||
await postHook(harness, 'permission_prompt', { tool_name: 'bash' });
|
||||
// A forged stop must not clear a real pending dialog either.
|
||||
await postHook(harness, 'stop', {});
|
||||
expect(await listApprovals(harness)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opens nothing for a remote omp session, where no module was installed', async () => {
|
||||
(session as unknown as { remote: unknown }).remote = { host: 'box' };
|
||||
await postHook(harness, 'permission_prompt', { tool_name: 'bash' });
|
||||
expect(await listApprovals(harness)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approval routes: dialogs read off a hook-less screen (codex) are alert-only', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let session: MockSession;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @fileoverview The omp approval hook module on BOTH session create paths.
|
||||
*
|
||||
* `POST /api/quick-start` is how the Run button, the agent skill's `spawn_worker` and
|
||||
* `codeman agent spawn` start omp, so installing only from `POST /api/sessions` left the
|
||||
* usual omp launch silent. These tests pin, on disk:
|
||||
* - both paths write `.claude/hooks/pre/codeman-approval.ts` into a local workspace;
|
||||
* - neither writes Claude's `.claude/settings.local.json` hook block for omp (only
|
||||
* claude reads it) or, for a Docker case, a CLAUDE.md;
|
||||
* - `workspaceHooksEnabled: false` writes nothing;
|
||||
* - a create refused by the launch pre-flight leaves nothing behind.
|
||||
*
|
||||
* The launch pre-flight is mocked so the result does not depend on whether omp is
|
||||
* installed on the machine running the suite. Uses app.inject(), no port.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, safeRmHomeTree } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { OMP_APPROVAL_HOOK_DIR, OMP_APPROVAL_HOOK_FILE } from '../../src/hooks-config.js';
|
||||
import { getDataDir } from '../../src/config/instance.js';
|
||||
import { CASES_DIR } from '../../src/web/route-helpers.js';
|
||||
import { Session } from '../../src/session.js';
|
||||
|
||||
const launch = vi.hoisted(() => ({ error: null as string | null }));
|
||||
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../src/utils/cli-launcher.js')>()),
|
||||
resolveCliLaunchError: vi.fn(async () => launch.error),
|
||||
}));
|
||||
|
||||
const hookFileIn = (dir: string) => join(dir, OMP_APPROVAL_HOOK_DIR, OMP_APPROVAL_HOOK_FILE);
|
||||
const claudeSettingsIn = (dir: string) => join(dir, '.claude', 'settings.local.json');
|
||||
|
||||
/**
|
||||
* A create that SUCCEEDED. The status alone cannot say so: `createErrorResponse()`
|
||||
* envelopes come back as 200 here (no preSerialization hook), so an early refusal would
|
||||
* make every "wrote nothing" assertion below pass vacuously. A success body is the raw
|
||||
* payload (no envelope), so the proof is a real session id in it.
|
||||
*/
|
||||
function expectCreated(res: { statusCode: number; body: string }) {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body) as { success?: boolean; session?: { id?: string }; sessionId?: string };
|
||||
expect(body.success, res.body).not.toBe(false);
|
||||
expect(body.session?.id ?? body.sessionId, res.body).toBeTruthy();
|
||||
}
|
||||
|
||||
describe('omp approval hook on the create paths', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
const useApp = async (workspaceHooksEnabled = true) => {
|
||||
await app?.close();
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
registerSessionRoutes(app, createMockRouteContext({ workspaceHooksEnabled }));
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
launch.error = null;
|
||||
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
|
||||
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
await useApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
vi.restoreAllMocks();
|
||||
await rm(join(getDataDir(), 'docker-hosts.json'), { force: true });
|
||||
await rm(join(getDataDir(), 'docker-cases.json'), { force: true });
|
||||
// CASES_DIR sits under the (redirected) test HOME; safeRmHomeTree refuses anything else.
|
||||
safeRmHomeTree(CASES_DIR);
|
||||
});
|
||||
|
||||
describe('POST /api/sessions', () => {
|
||||
let workingDir: string;
|
||||
const createSession = (payload: Record<string, unknown>) =>
|
||||
app.inject({ method: 'POST', url: '/api/sessions', payload });
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDir = await mkdtemp(join(tmpdir(), 'codeman-omp-create-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('installs the module, and no Claude hook block', async () => {
|
||||
expectCreated(await createSession({ name: 'omp-create', mode: 'omp', workingDir }));
|
||||
expect(existsSync(hookFileIn(workingDir))).toBe(true);
|
||||
expect(existsSync(claudeSettingsIn(workingDir))).toBe(false);
|
||||
});
|
||||
|
||||
it('writes nothing when workspaceHooksEnabled is off', async () => {
|
||||
await useApp(false);
|
||||
expectCreated(await createSession({ name: 'omp-off', mode: 'omp', workingDir }));
|
||||
expect(existsSync(join(workingDir, '.claude'))).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves nothing behind when the launch pre-flight refuses the create', async () => {
|
||||
launch.error = 'omp: command not found';
|
||||
const res = await createSession({ name: 'omp-missing', mode: 'omp', workingDir });
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(existsSync(join(workingDir, '.claude'))).toBe(false);
|
||||
});
|
||||
|
||||
it('gives claude no module', async () => {
|
||||
expectCreated(await createSession({ name: 'claude-create', mode: 'claude', workingDir }));
|
||||
expect(existsSync(hookFileIn(workingDir))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/quick-start', () => {
|
||||
const quickStart = (payload: Record<string, unknown>) =>
|
||||
app.inject({ method: 'POST', url: '/api/quick-start', payload });
|
||||
|
||||
it('installs the module into an EXISTING case, and no Claude hook block', async () => {
|
||||
const casePath = join(CASES_DIR, 'ompexisting');
|
||||
await mkdir(casePath, { recursive: true });
|
||||
expectCreated(await quickStart({ caseName: 'ompexisting', mode: 'omp' }));
|
||||
expect(existsSync(hookFileIn(casePath))).toBe(true);
|
||||
expect(existsSync(claudeSettingsIn(casePath))).toBe(false);
|
||||
});
|
||||
|
||||
it('installs the module into a case it scaffolds, and no Claude hook block', async () => {
|
||||
expectCreated(await quickStart({ caseName: 'ompnew', mode: 'omp' }));
|
||||
const casePath = join(CASES_DIR, 'ompnew');
|
||||
expect(existsSync(hookFileIn(casePath))).toBe(true);
|
||||
expect(existsSync(claudeSettingsIn(casePath))).toBe(false);
|
||||
});
|
||||
|
||||
it('writes nothing into an existing case when workspaceHooksEnabled is off', async () => {
|
||||
await useApp(false);
|
||||
const casePath = join(CASES_DIR, 'ompoff');
|
||||
await mkdir(casePath, { recursive: true });
|
||||
expectCreated(await quickStart({ caseName: 'ompoff', mode: 'omp' }));
|
||||
expect(existsSync(join(casePath, '.claude'))).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves nothing behind when the launch pre-flight refuses', async () => {
|
||||
launch.error = 'omp: command not found';
|
||||
const casePath = join(CASES_DIR, 'ompmissing');
|
||||
await mkdir(casePath, { recursive: true });
|
||||
const res = await quickStart({ caseName: 'ompmissing', mode: 'omp' });
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(existsSync(join(casePath, '.claude'))).toBe(false);
|
||||
});
|
||||
|
||||
it('writes neither the module nor a Claude block (or CLAUDE.md) into a Docker case', async () => {
|
||||
const ws = await mkdtemp(join(tmpdir(), 'codeman-docker-omp-'));
|
||||
try {
|
||||
await mkdir(getDataDir(), { recursive: true });
|
||||
await writeFile(
|
||||
join(getDataDir(), 'docker-hosts.json'),
|
||||
JSON.stringify([{ id: 'd1', label: 'box', image: 'codeman/agent:base' }])
|
||||
);
|
||||
await writeFile(
|
||||
join(getDataDir(), 'docker-cases.json'),
|
||||
JSON.stringify([{ name: 'dockomp', type: 'docker', hostId: 'd1', hostWorkspacePath: ws }])
|
||||
);
|
||||
expectCreated(await quickStart({ caseName: 'dockomp', mode: 'omp' }));
|
||||
expect(existsSync(hookFileIn(ws))).toBe(false);
|
||||
expect(existsSync(claudeSettingsIn(ws))).toBe(false);
|
||||
expect(existsSync(join(ws, 'CLAUDE.md'))).toBe(false);
|
||||
} finally {
|
||||
await rm(ws, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user