feat: workspaceHooksEnabled setting as the opt-out for workspace hook installs

Installing hooks into any workspace a Claude session runs in is the right
default, but it takes a decision away from a user who deliberately removed
them: nothing on disk distinguishes "removed on purpose" from "never had any",
so they would come back on the next session create.

Adds the synced workspaceHooksEnabled setting (App Settings -> Agents & CLIs ->
Claude), default ON. OFF restores the older behavior exactly: a Codeman hooks
block that is already present is still refreshed when stale (COD-91), but one
is never added.

Every create path routes through one applyWorkspaceHooks() helper so the gate
cannot apply to some paths only, and the boot-time recovery sweep honours it too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-16 07:05:10 +02:00
parent 98fa8c00d1
commit f485085174
9 changed files with 149 additions and 26 deletions
+4
View File
@@ -19,6 +19,7 @@ export function createMockRouteContext(options?: {
sessionId?: string;
agentSkillEnabled?: boolean;
claudeVoiceEnabled?: boolean;
workspaceHooksEnabled?: boolean;
}) {
const sessionId = options?.sessionId ?? 'test-session-1';
const session = createMockSession(sessionId);
@@ -96,6 +97,9 @@ export function createMockRouteContext(options?: {
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false),
// Default OFF mirrors the shipped setting: no test opens a voice relay by accident.
getClaudeVoiceEnabled: vi.fn(async () => options?.claudeVoiceEnabled ?? false),
// Default ON mirrors the shipped setting, so a route test sees what a user sees.
// Writes land in the test's temp working dir, never in a real repo.
getWorkspaceHooksEnabled: vi.fn(async () => options?.workspaceHooksEnabled ?? true),
getDefaultClaudeMdPath: vi.fn(async () => undefined),
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
getLightSessionsState: vi.fn(() => {
@@ -22,6 +22,7 @@ import { tmpdir } from 'node:os';
import { createMockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { generateHooksConfig } from '../../src/hooks-config.js';
interface HooksFile {
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>>;
@@ -29,6 +30,32 @@ interface HooksFile {
model?: unknown;
}
/**
* A faithful PRE-SECRET Codeman hooks block (what a case created before COD-54
* contains): it targets /api/hook-event, so it is recognisably ours, but carries
* no X-Codeman-Hook-Secret header and no -k. Used to prove the self-heal still
* runs with the setting OFF.
*/
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('POST /api/sessions workspace hooks', () => {
let app: FastifyInstance;
let workingDir: string;
@@ -39,6 +66,16 @@ describe('POST /api/sessions workspace hooks', () => {
const createSession = (payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/sessions', payload });
/** Rebuild the app with the `workspaceHooksEnabled` gate in a given position. */
const useApp = async (workspaceHooksEnabled: boolean) => {
await app?.close();
app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerSessionRoutes(app, createMockRouteContext({ workspaceHooksEnabled }));
installRouteErrorHandler(app);
await app.ready();
};
beforeEach(async () => {
workingDir = await mkdtemp(join(tmpdir(), 'codeman-workspace-hooks-'));
app = Fastify({ logger: false });
@@ -108,4 +145,33 @@ describe('POST /api/sessions workspace hooks', () => {
expect((await createSession({ name: 'hooks-malformed', mode: 'claude', workingDir })).statusCode).toBe(200);
expect(await readFile(settingsPath(), 'utf-8')).toBe('{ not json');
});
it('adds nothing when workspaceHooksEnabled is OFF', async () => {
await useApp(false);
expect((await createSession({ name: 'hooks-off', mode: 'claude', workingDir })).statusCode).toBe(200);
expect(existsSync(settingsPath())).toBe(false);
});
it('still heals a stale Codeman block when workspaceHooksEnabled is OFF', async () => {
// The setting turns off ADDING hooks, not the COD-91 self-heal: a pre-secret
// block 401s against the now-unconditional hook-secret gate, so a workspace that
// already opted in must not be left with hooks that silently fail.
await useApp(false);
await mkdir(join(workingDir, '.claude'), { recursive: true });
await writeFile(settingsPath(), JSON.stringify({ model: 'opus', hooks: staleCodemanHooks() }));
expect((await createSession({ name: 'hooks-off-stale', mode: 'claude', workingDir })).statusCode).toBe(200);
const settings = await readSettings();
expect(settings.model).toBe('opus');
expect(JSON.stringify(settings.hooks)).toContain('X-Codeman-Hook-Secret');
});
it('writes the hooks the generator produces, so the two cannot drift', async () => {
expect((await createSession({ name: 'hooks-parity', mode: 'claude', workingDir })).statusCode).toBe(200);
const written = (await readSettings()).hooks ?? {};
expect(Object.keys(written).sort()).toEqual(Object.keys(generateHooksConfig().hooks).sort());
});
});