review fixes: every claude create path routes through the workspace-hooks decision

Post-#304 follow-ups. The install-vs-refresh decision (workspaceHooksEnabled,
default ON) moved from a session-routes-local helper into hooks-config.ts as
applyWorkspaceHooks(workspace, install?), and the claude session-create sites
that bypassed it now go through it: cron job fires (cron-service), legacy
scheduled-run iterations (runScheduledLoop), and the plan-orchestrator research
and planner one-shots. A cron or scheduled run firing in a linked case that
never had an interactive session ran hook-blind (no stop for completion
detection, no tab alert on a blocking dialog).

The shared core also carries the two guards every caller needs: a workspace
that no longer exists is skipped (ensureCodemanHooks mkdir -p's, so the boot
recovery sweep used to resurrect a deleted repo as an empty tree holding only
.claude/settings.local.json), and all errors are swallowed since a create must
never fail on hooks. Route handlers keep resolving the setting through their
ConfigPort and pass it in; non-route callers omit it and the core reads
settings.json itself (absent key or unreadable file = ON).

Two adjacent gates tightened in session-routes:
- the docker quick-start hooks branch excluded the five external CLIs but let
  `shell` through, contradicting its own rule that only claude reads .claude
  hooks; it is now gated on mode === 'claude'
- the statusLine exporter call in POST /api/sessions got the same
  !remote && body.workingDir guard the hooks call got in 499d355 (it mkdirs the
  same way, so a remote attach created a junk user@host:session dir locally and
  a cwd-fallback create wrote into $HOME)

plan-routes' one-shot deliberately stays out: its workingDir is process.cwd(),
exactly the target 499d355 forbids writing into. restoreMuxSessions stays out
too: the boot sweep already covers recovered workspaces.

Tests: quick-start existing-case install, docker claude-installs/shell-does-not,
and the core directly (default-ON install, OFF add-nothing, OFF still heals a
stale block, malformed file untouched, vanished workspace skipped); the remote
and cwd-fallback regressions now also send statusLineTelemetry:true to pin the
statusLine guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-16 20:27:38 +02:00
parent f07905b193
commit f44d597450
7 changed files with 293 additions and 48 deletions
+17 -2
View File
@@ -76,7 +76,7 @@ import { RunSummaryTracker } from '../run-summary.js';
import { PlanOrchestrator } from '../plan-orchestrator.js';
import { OrchestratorLoop } from '../orchestrator-loop.js';
import { getLifecycleLog } from '../session-lifecycle-log.js';
import { ensureCodemanHooks } from '../hooks-config.js';
import { applyWorkspaceHooks } from '../hooks-config.js';
import { PushSubscriptionStore } from '../push-store.js';
import webpush from 'web-push';
import { SseStreamManager } from './sse-stream-manager.js';
@@ -1819,6 +1819,14 @@ export class WebServer extends EventEmitter {
let session: Session | null = null;
try {
// Workspace hooks for this iteration's session — legacy scheduled runs are
// always claude-mode and always local, and used to bypass the shared decision
// entirely: a scheduled run firing in a linked case that never had an
// interactive session ran hook-blind (see applyWorkspaceHooks in hooks-config;
// it reads the `workspaceHooksEnabled` setting itself, skips a vanished
// workingDir, and swallows failures — a run must never fail on hooks).
await applyWorkspaceHooks(run.workingDir);
// Create a session for this iteration.
if (isMultiUserMode()) {
// §6.3: resolve the permission mode with the RUN OWNER (a non-granted user
@@ -2881,6 +2889,11 @@ export class WebServer extends EventEmitter {
* Skipped entirely when `workspaceHooksEnabled` is OFF: that setting exists so a
* user can keep Codeman out of their repos, and a boot-time sweep is the last
* place that should ignore it.
*
* A workspace that no longer EXISTS is skipped by applyWorkspaceHooks: a tmux
* session can outlive its deleted repo, and `ensureCodemanHooks` mkdir -p's, so
* the sweep used to resurrect the directory as an empty tree holding only
* `.claude/settings.local.json`.
*/
private async ensureHooksForRecoveredWorkspaces(): Promise<void> {
if (!(await this.getWorkspaceHooksEnabled())) return;
@@ -2891,7 +2904,9 @@ export class WebServer extends EventEmitter {
if (session.workingDir) workspaces.add(session.workingDir);
}
for (const workspace of workspaces) {
await ensureCodemanHooks(workspace).catch(() => {});
// install=true: the setting was already resolved ON above for the whole batch
// (OFF skips the sweep wholesale, keeping its documented semantics).
await applyWorkspaceHooks(workspace, true);
}
}