mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24ed43935c |
@@ -208,7 +208,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||||
|
|
||||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one.
|
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in session-routes.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from both create paths and from `restoreMuxSessions()` for sessions recovered on server start. Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one.
|
||||||
|
|
||||||
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
|
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { statSync, realpathSync } from 'node:fs';
|
import { statSync, realpathSync } from 'node:fs';
|
||||||
import { Session } from '../session.js';
|
import { Session } from '../session.js';
|
||||||
import { applyWorkspaceHooks } from '../hooks-config.js';
|
|
||||||
import { SseEvent } from '../web/sse-events.js';
|
import { SseEvent } from '../web/sse-events.js';
|
||||||
import { CronJobSchema } from '../web/schemas.js';
|
import { CronJobSchema } from '../web/schemas.js';
|
||||||
import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js';
|
import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js';
|
||||||
@@ -402,15 +401,6 @@ export class CronService {
|
|||||||
// clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own
|
// clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own
|
||||||
// spawn default is what would otherwise apply).
|
// spawn default is what would otherwise apply).
|
||||||
const { geminiConfig, piConfig } = clampCronExternalCliConfigs(mode, ownerGranted);
|
const { geminiConfig, piConfig } = clampCronExternalCliConfigs(mode, ownerGranted);
|
||||||
// Workspace hooks (see applyWorkspaceHooks in hooks-config): cron jobs are
|
|
||||||
// always local (workingDir was stat-validated above) but used to bypass the
|
|
||||||
// shared install-vs-refresh decision, so a job firing in a linked case that
|
|
||||||
// never had an interactive session ran hook-blind — no `stop` for the
|
|
||||||
// completion detection, no tab alert on a blocking dialog. Claude mode only
|
|
||||||
// (nothing else reads `.claude` hooks); best-effort inside the helper.
|
|
||||||
if (mode === 'claude') {
|
|
||||||
await applyWorkspaceHooks(job.workingDir);
|
|
||||||
}
|
|
||||||
session = new Session({
|
session = new Session({
|
||||||
workingDir: job.workingDir,
|
workingDir: job.workingDir,
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
+1
-61
@@ -10,9 +10,8 @@
|
|||||||
* Key exports:
|
* Key exports:
|
||||||
* - `generateHooksConfig()` — returns hooks object for settings.local.json
|
* - `generateHooksConfig()` — returns hooks object for settings.local.json
|
||||||
* - `writeHooksConfig(casePath)` — writes hooks + env config to disk
|
* - `writeHooksConfig(casePath)` — writes hooks + env config to disk
|
||||||
* - `applyWorkspaceHooks(workspace, install?)` — the ONE install-vs-refresh decision
|
|
||||||
* point every claude-session create path routes through (see its doc comment)
|
|
||||||
* - `ensureCodemanHooks(casePath)` — safely installs/updates hooks for a managed case
|
* - `ensureCodemanHooks(casePath)` — safely installs/updates hooks for a managed case
|
||||||
|
* (no production call site yet; see its doc comment before wiring one)
|
||||||
* - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings
|
* - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings
|
||||||
*
|
*
|
||||||
* Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`,
|
* Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`,
|
||||||
@@ -38,7 +37,6 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
import type { HookEventType } from './types.js';
|
import type { HookEventType } from './types.js';
|
||||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||||
import { dataPath } from './config/instance.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
||||||
@@ -749,64 +747,6 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hooks for the workspace a Claude session is about to run in. ONE decision point,
|
|
||||||
* shared by every claude-session create path — the interactive routes, quick-start,
|
|
||||||
* cron fires, legacy scheduled runs, the plan-orchestrator one-shots, and the boot
|
|
||||||
* recovery sweep — so the `workspaceHooksEnabled` setting cannot apply to some of
|
|
||||||
* them only.
|
|
||||||
*
|
|
||||||
* ON (the default): INSTALL Codeman's hooks block (`ensureCodemanHooks`), merging so
|
|
||||||
* a user's own hook entries and every other settings key survive. Hooks used to be
|
|
||||||
* written only when Codeman CREATED the case DIRECTORY, so a linked case or any
|
|
||||||
* pre-existing repo — where most sessions actually run — had none, and every
|
|
||||||
* hook-driven surface was silently dead there (full history on `ensureCodemanHooks`).
|
|
||||||
*
|
|
||||||
* OFF: the older, narrower behavior. A Codeman block that is already there is still
|
|
||||||
* refreshed when stale (COD-91: a pre-secret block 401s once the hook-secret gate
|
|
||||||
* went unconditional), but one is never added, so Codeman leaves the repo alone.
|
|
||||||
*
|
|
||||||
* `install` overrides the setting read: route handlers resolve it through their
|
|
||||||
* ConfigPort (`ctx.getWorkspaceHooksEnabled()`, which tests stub), and the boot sweep
|
|
||||||
* passes `true` after checking the setting once for its whole batch. Every other
|
|
||||||
* caller omits it and the synced setting is read from settings.json here — default ON
|
|
||||||
* when the key is absent or the file unreadable, matching the server's resolver.
|
|
||||||
*
|
|
||||||
* Callers gate on their own context (claude mode only; local — never a remote
|
|
||||||
* workingDir, which is a path on ANOTHER host, and never a docker case that opted
|
|
||||||
* out of hooks). The guards EVERY caller needs live here instead:
|
|
||||||
* - a workspace that does not exist is skipped — `ensureCodemanHooks` mkdir -p's,
|
|
||||||
* so a deleted repo whose tmux session survived would otherwise be resurrected
|
|
||||||
* as an empty directory tree holding only `.claude/settings.local.json`;
|
|
||||||
* - errors are swallowed — a session create must never fail on hooks.
|
|
||||||
*/
|
|
||||||
export async function applyWorkspaceHooks(workspace: string, install?: boolean): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (!existsSync(workspace)) return;
|
|
||||||
const shouldInstall = install ?? (await readWorkspaceHooksEnabled());
|
|
||||||
await (shouldInstall ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace));
|
|
||||||
} catch {
|
|
||||||
// Best-effort by contract (see doc comment): hooks degrade to output-based
|
|
||||||
// idle detection; the create goes ahead.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The synced `workspaceHooksEnabled` app setting, read straight from settings.json
|
|
||||||
* for callers that live outside the web layer (cron, scheduled runs, the plan
|
|
||||||
* orchestrator). Default ON: an absent key means a user who has never seen the
|
|
||||||
* setting, and OFF for them would mean no tab alerts, no Approvals Inbox and no
|
|
||||||
* respawn idle signals in every workspace Codeman did not scaffold itself.
|
|
||||||
*/
|
|
||||||
async function readWorkspaceHooksEnabled(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(await readFile(dataPath('settings.json'), 'utf-8')) as Record<string, unknown>;
|
|
||||||
return parsed.workspaceHooksEnabled !== false;
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
|
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
|
||||||
const STATUSLINE_MARKER = '/api/status-telemetry';
|
const STATUSLINE_MARKER = '/api/status-telemetry';
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import type { TerminalMultiplexer } from './mux-interface.js';
|
|||||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
|
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
|
||||||
import { applyWorkspaceHooks } from './hooks-config.js';
|
|
||||||
import { getErrorMessage, type PlanItem, type ClaudeMode } from './types.js';
|
import { getErrorMessage, type PlanItem, type ClaudeMode } from './types.js';
|
||||||
|
|
||||||
// Re-export for backward compatibility
|
// Re-export for backward compatibility
|
||||||
@@ -430,11 +429,6 @@ export class PlanOrchestrator {
|
|||||||
detail: 'Researching...',
|
detail: 'Researching...',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Workspace hooks for the case this plan targets (see applyWorkspaceHooks in
|
|
||||||
// hooks-config): claude-mode, local workingDir, and the helper itself skips a
|
|
||||||
// vanished dir + swallows failures — the plan run must never fail on hooks.
|
|
||||||
await applyWorkspaceHooks(this.workingDir);
|
|
||||||
|
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: this.workingDir,
|
workingDir: this.workingDir,
|
||||||
mux: this.mux,
|
mux: this.mux,
|
||||||
@@ -597,10 +591,6 @@ export class PlanOrchestrator {
|
|||||||
detail: 'Generating plan...',
|
detail: 'Generating plan...',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Workspace hooks: same rationale as the research one-shot above (idempotent —
|
|
||||||
// the helper short-circuits when the hooks block is already current).
|
|
||||||
await applyWorkspaceHooks(this.workingDir);
|
|
||||||
|
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: this.workingDir,
|
workingDir: this.workingDir,
|
||||||
mux: this.mux,
|
mux: this.mux,
|
||||||
|
|||||||
+22
-6
@@ -3826,7 +3826,6 @@ class CodemanApp {
|
|||||||
// Collapse/expand changes whether the filter is reachable, so re-evaluate it
|
// Collapse/expand changes whether the filter is reachable, so re-evaluate it
|
||||||
// here too — not only at the render tails.
|
// here too — not only at the render tails.
|
||||||
this.applySidebarFilter(this._sidebarFilter);
|
this.applySidebarFilter(this._sidebarFilter);
|
||||||
this.updateSidebarCount();
|
|
||||||
this.updateConnectionLines();
|
this.updateConnectionLines();
|
||||||
// The desktop home rail defers to the sidebar (both dock the session list
|
// The desktop home rail defers to the sidebar (both dock the session list
|
||||||
// flush left), so a layout flip while the welcome screen is up has to
|
// flush left), so a layout flip while the welcome screen is up has to
|
||||||
@@ -3871,9 +3870,22 @@ class CodemanApp {
|
|||||||
this.toggleSessionSidebar();
|
this.toggleSessionSidebar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The count is what is actually ON the list: session rows plus web-tab rows,
|
||||||
|
* minus whatever the sidebar filter is hiding. `this.sessions.size` was the
|
||||||
|
* original source and disagreed with the screen twice over — web tabs render
|
||||||
|
* in the same list but are not sessions (3 sessions + 2 dashboards read "3"
|
||||||
|
* above 5 rows), and a filter hides rows without touching the map. Counting
|
||||||
|
* the rendered rows keeps one source of truth: the list itself.
|
||||||
|
*/
|
||||||
updateSidebarCount() {
|
updateSidebarCount() {
|
||||||
const el = document.getElementById('sessionSidebarCount');
|
const el = document.getElementById('sessionSidebarCount');
|
||||||
if (el) el.textContent = String(this.sessions?.size ?? 0);
|
if (!el) return;
|
||||||
|
const container = this.$('sessionTabs');
|
||||||
|
const count = container
|
||||||
|
? container.querySelectorAll('.session-tab:not(.tab-filtered-out)').length
|
||||||
|
: (this.sessions?.size ?? 0);
|
||||||
|
el.textContent = String(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3906,6 +3918,9 @@ class CodemanApp {
|
|||||||
const haystack = `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`.toLowerCase();
|
const haystack = `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`.toLowerCase();
|
||||||
tab.classList.toggle('tab-filtered-out', !haystack.includes(needle));
|
tab.classList.toggle('tab-filtered-out', !haystack.includes(needle));
|
||||||
}
|
}
|
||||||
|
// The count shows visible rows, so it moves with every filter change —
|
||||||
|
// including keystrokes in the filter box, which call this directly.
|
||||||
|
this.updateSidebarCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -4262,11 +4277,13 @@ class CodemanApp {
|
|||||||
// The full-render path already redraws the connection SVG; this incremental
|
// The full-render path already redraws the connection SVG; this incremental
|
||||||
// one does not, and a badge appearing widens a tab and shifts every tab after
|
// one does not, and a badge appearing widens a tab and shifts every tab after
|
||||||
// it, sliding the lineage arcs off their anchors. Only pay for it when there
|
// it, sliding the lineage arcs off their anchors. Only pay for it when there
|
||||||
// is an arc to keep anchored.
|
// is something anchored to tab rects: lineage arcs, or — in sidebar layout,
|
||||||
if (this._lineageEdgeCount > 0) this.updateConnectionLines();
|
// where lineage is skipped and the edge count stays 0 — the subagent/
|
||||||
|
// ultracode connectors, whose rows a badge changes the HEIGHT of. Same
|
||||||
|
// widening as the strip-scroll listener in session-lineage.js.
|
||||||
|
if (this._lineageEdgeCount > 0 || this.isSessionSidebarActive()) this.updateConnectionLines();
|
||||||
|
|
||||||
this.applySidebarFilter(this._sidebarFilter);
|
this.applySidebarFilter(this._sidebarFilter);
|
||||||
this.updateSidebarCount();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
||||||
@@ -4467,7 +4484,6 @@ class CodemanApp {
|
|||||||
// innerHTML was rebuilt wholesale, so the sidebar filter classes are gone —
|
// innerHTML was rebuilt wholesale, so the sidebar filter classes are gone —
|
||||||
// re-apply them or filtered-out sessions flicker back on every SSE tick.
|
// re-apply them or filtered-out sessions flicker back on every SSE tick.
|
||||||
this.applySidebarFilter(this._sidebarFilter);
|
this.applySidebarFilter(this._sidebarFilter);
|
||||||
this.updateSidebarCount();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up arrow key navigation for session tabs (accessibility)
|
// Set up arrow key navigation for session tabs (accessibility)
|
||||||
|
|||||||
@@ -997,13 +997,16 @@ function computeRewriteScrollLine(input) {
|
|||||||
* never match) and terminated by a known extension (so the end of the path is
|
* never match) and terminated by a known extension (so the end of the path is
|
||||||
* unambiguous — a trailing `)` or `.` after the extension stays out). Longer
|
* unambiguous — a trailing `)` or `.` after the extension stays out). Longer
|
||||||
* extensions come first in each family (`tsx|ts`), so the trailing `\b` cannot
|
* extensions come first in each family (`tsx|ts`), so the trailing `\b` cannot
|
||||||
* be satisfied by the shorter branch mid-word.
|
* be satisfied by the shorter branch mid-word. `/etc` is deliberately NOT a
|
||||||
|
* root: DEFAULT_BLOCKED_TREES (config/attachment-guard.ts) refuses the whole
|
||||||
|
* tree server-side, so every `/etc/...` link was a guaranteed 403 — a link
|
||||||
|
* that renders clickable and then dies is worse than plain text.
|
||||||
*
|
*
|
||||||
* ⚠ Consumers must never share one instance: `lastIndex` is per-object state on
|
* ⚠ Consumers must never share one instance: `lastIndex` is per-object state on
|
||||||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||||||
*/
|
*/
|
||||||
const FILE_PATH_LINK_PATTERN =
|
const FILE_PATH_LINK_PATTERN =
|
||||||
/(\/(?:home|Users|tmp|var|private|etc|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
|
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
|
||||||
|
|
||||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||||
function absoluteFilePathPattern() {
|
function absoluteFilePathPattern() {
|
||||||
@@ -1014,9 +1017,14 @@ function absoluteFilePathPattern() {
|
|||||||
* Extensions the file-preview overlay renders itself. Everything else a link
|
* Extensions the file-preview overlay renders itself. Everything else a link
|
||||||
* points at goes to the tail/log viewer, which is the right home for a growing
|
* points at goes to the tail/log viewer, which is the right home for a growing
|
||||||
* text file and the wrong one for bytes (tailing a PNG shows binary noise).
|
* text file and the wrong one for bytes (tailing a PNG shows binary noise).
|
||||||
|
*
|
||||||
|
* The media entries mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
|
||||||
|
* (src/attachment-registry.ts, the single source) — they diverged once and an
|
||||||
|
* in-workspace `.m4a` opened as binary noise in the log viewer while the same
|
||||||
|
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
|
||||||
*/
|
*/
|
||||||
const FILE_PREVIEW_EXTENSIONS = new Set(
|
const FILE_PREVIEW_EXTENSIONS = new Set(
|
||||||
('png jpg jpeg gif webp bmp svg pdf docx pptx mp4 webm mov mp3 wav').split(' ')
|
('png jpg jpeg gif webp bmp svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
|
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
|
||||||
|
|||||||
@@ -3346,6 +3346,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (attachmentId) {
|
if (attachmentId) {
|
||||||
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
|
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
|
||||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
||||||
|
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
|
||||||
|
// (src/attachment-registry.ts, the single source); the frontend cannot import
|
||||||
|
// it, so test/media-extension-parity.test.ts pins the copies equal.
|
||||||
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
|
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
|
||||||
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
|
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
|
||||||
// Size when we just registered the file ourselves, so a path opened from a
|
// Size when we just registered the file ourselves, so a path opened from a
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ import {
|
|||||||
applyAgentSkill,
|
applyAgentSkill,
|
||||||
refreshUserAgentSkill,
|
refreshUserAgentSkill,
|
||||||
seedAgentSessionPreamble,
|
seedAgentSessionPreamble,
|
||||||
applyWorkspaceHooks,
|
ensureCodemanHooks,
|
||||||
refreshStaleCodemanHooks,
|
refreshStaleCodemanHooks,
|
||||||
} from '../../hooks-config.js';
|
} from '../../hooks-config.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
@@ -626,12 +626,31 @@ async function injectAgentSkill(casePath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workspace hooks: the install-vs-refresh decision core moved to
|
/**
|
||||||
// `applyWorkspaceHooks` in hooks-config.ts (imported above) so the non-route
|
* Hooks for the workspace a Claude session is about to run in. ONE decision point,
|
||||||
// claude create paths — cron fires, legacy scheduled runs, the plan-orchestrator
|
* shared by every create path, so the setting cannot apply to some of them only.
|
||||||
// one-shots, the boot recovery sweep — share the SAME decision instead of
|
*
|
||||||
// bypassing the `workspaceHooksEnabled` setting. Route handlers here resolve the
|
* ON (`workspaceHooksEnabled`, the default): INSTALL Codeman's hooks block, merging
|
||||||
// setting through the ConfigPort (tests stub it) and pass it as the second arg.
|
* so a user's own hook entries and every other settings key survive. Hooks were
|
||||||
|
* previously written only when Codeman CREATED the case DIRECTORY, so a linked case
|
||||||
|
* or any pre-existing repo — where most sessions actually run — had none, and every
|
||||||
|
* hook-driven surface was silently dead there: no tab alert or phone-overview row
|
||||||
|
* when a dialog blocks the pane, no Approvals Inbox item, no push, no definitive
|
||||||
|
* `stop`/`idle_prompt` for respawn, and no `stop`/`blocked` for the wait endpoints.
|
||||||
|
* Measured 2026-08-15 in a linked case: an AskUserQuestion dialog on screen with the
|
||||||
|
* tab reporting a calm `idle`. Claude Code re-reads the file, so a session already
|
||||||
|
* running in that workspace starts firing hooks without a restart (verified live).
|
||||||
|
*
|
||||||
|
* OFF: the older, narrower behavior. A Codeman block that is already there is still
|
||||||
|
* refreshed when stale (COD-91: a pre-secret block 401s once the hook-secret gate
|
||||||
|
* went unconditional), but one is never added, so Codeman leaves the repo alone.
|
||||||
|
*
|
||||||
|
* Best-effort either way: a refusal or a thrown error must never fail the create.
|
||||||
|
*/
|
||||||
|
async function applyWorkspaceHooks(ctx: ConfigPort, workspace: string): Promise<void> {
|
||||||
|
const install = await ctx.getWorkspaceHooksEnabled();
|
||||||
|
await (install ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
export function registerSessionRoutes(
|
export function registerSessionRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
@@ -769,14 +788,7 @@ export function registerSessionRoutes(
|
|||||||
// chip's data feed for everyone. The exporter is benign when the chip is off
|
// chip's data feed for everyone. The exporter is benign when the chip is off
|
||||||
// (the footer just shows session status). isOurs-guarded so a user's own
|
// (the footer just shows session status). isOurs-guarded so a user's own
|
||||||
// statusLine is never touched.
|
// statusLine is never touched.
|
||||||
//
|
if ((body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) {
|
||||||
// Same guard as the hooks call below (499d355): never for a remote attach
|
|
||||||
// (workingDir is a user@host:session pseudo-path — the mkdir inside
|
|
||||||
// applyStatusLineConfig would create it as a junk local dir), and only when
|
|
||||||
// the caller named a workingDir — the process-cwd fallback is $HOME under
|
|
||||||
// installer-created services, and a statusLine materializing in
|
|
||||||
// ~/.claude/settings.local.json was never asked for.
|
|
||||||
if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) {
|
|
||||||
await applyStatusLineConfig(workingDir, true);
|
await applyStatusLineConfig(workingDir, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,7 +799,7 @@ export function registerSessionRoutes(
|
|||||||
// process-cwd fallback is $HOME under installer-created services, and hooks
|
// process-cwd fallback is $HOME under installer-created services, and hooks
|
||||||
// materializing in ~/.claude/settings.local.json was never asked for.
|
// materializing in ~/.claude/settings.local.json was never asked for.
|
||||||
if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') {
|
if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') {
|
||||||
await applyWorkspaceHooks(workingDir, await ctx.getWorkspaceHooksEnabled());
|
await applyWorkspaceHooks(ctx, workingDir);
|
||||||
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
|
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
|
||||||
// .claude rationale as the statusLine above: a create must never remove the
|
// .claude rationale as the statusLine above: a create must never remove the
|
||||||
// skill from under other live sessions in the repo. Marker-guarded, so a
|
// skill from under other live sessions in the repo. Marker-guarded, so a
|
||||||
@@ -2924,7 +2936,7 @@ export function registerSessionRoutes(
|
|||||||
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
|
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
|
||||||
// doesn't exist on the local filesystem.
|
// doesn't exist on the local filesystem.
|
||||||
if (mode === 'claude') {
|
if (mode === 'claude') {
|
||||||
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
|
await applyWorkspaceHooks(ctx, resolvedCasePath);
|
||||||
} else {
|
} else {
|
||||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -2942,11 +2954,16 @@ export function registerSessionRoutes(
|
|||||||
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
||||||
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
|
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
|
||||||
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
|
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
|
||||||
// configured project. Claude mode ONLY — only claude reads `.claude` hooks, so a
|
// configured project. Skipped for external CLIs (they use their own systems).
|
||||||
// shell or external-CLI quick-start must not author a block of its own (the same
|
if (
|
||||||
// rule the existing-case branch above states; this branch used to exclude just
|
docker &&
|
||||||
// the five external CLIs and let `shell` through).
|
docker.hooksEnabled &&
|
||||||
if (docker && docker.hooksEnabled && mode === 'claude') {
|
mode !== 'opencode' &&
|
||||||
|
mode !== 'codex' &&
|
||||||
|
mode !== 'gemini' &&
|
||||||
|
mode !== 'antigravity' &&
|
||||||
|
mode !== 'pi'
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
|
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
|
||||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||||
@@ -2958,7 +2975,7 @@ export function registerSessionRoutes(
|
|||||||
// A settings file with no hooks in it is the same dead-surface case as a
|
// A settings file with no hooks in it is the same dead-surface case as a
|
||||||
// linked case. This branch is already gated on `docker.hooksEnabled`, and
|
// linked case. This branch is already gated on `docker.hooksEnabled`, and
|
||||||
// applyWorkspaceHooks adds the user-level gate on top.
|
// applyWorkspaceHooks adds the user-level gate on top.
|
||||||
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
|
await applyWorkspaceHooks(ctx, resolvedCasePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* non-fatal — the session still runs, hooks may be degraded */
|
/* non-fatal — the session still runs, hooks may be degraded */
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
* symlink pointing at a sensitive target is also caught.
|
* symlink pointing at a sensitive target is also caught.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
const SENSITIVE_PATTERNS: RegExp[] = [
|
const SENSITIVE_PATTERNS: RegExp[] = [
|
||||||
// System account databases.
|
// System account databases.
|
||||||
/^\/etc\/shadow$/,
|
/^\/etc\/shadow$/,
|
||||||
@@ -99,10 +102,26 @@ const SENSITIVE_PATTERNS: RegExp[] = [
|
|||||||
/\/\.codeman[^/]*\/intents\.json$/,
|
/\/\.codeman[^/]*\/intents\.json$/,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude config members that are credential-bearing ONLY under the user's real
|
||||||
|
* home directory: `~/.claude/settings.json` can hold `env.ANTHROPIC_API_KEY`
|
||||||
|
* and `apiKeyHelper` by schema (settings.local.json shares that schema), and
|
||||||
|
* `~/.claude.json` holds account/OAuth-adjacent state. A blanket
|
||||||
|
* `/\.claude\/settings\.json$/` would also block every CASE-level
|
||||||
|
* `.claude/settings.json`, which users legitimately view and edit in the File
|
||||||
|
* Viewer (model override, hooks) — so these are anchored to homedir(), read at
|
||||||
|
* CHECK time inside isSensitivePath, never captured at module load (wrong for
|
||||||
|
* anything that changes HOME later, e.g. per-file test fixtures — same
|
||||||
|
* reasoning as the `.ssh/` note above).
|
||||||
|
*/
|
||||||
|
const HOME_SENSITIVE_MEMBERS = ['.claude.json', '.claude/settings.json', '.claude/settings.local.json'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if the given ABSOLUTE, symlink-resolved path matches the
|
* Returns true if the given ABSOLUTE, symlink-resolved path matches the
|
||||||
* sensitive-file blocklist and must not be served to the browser.
|
* sensitive-file blocklist and must not be served to the browser.
|
||||||
*/
|
*/
|
||||||
export function isSensitivePath(absPath: string): boolean {
|
export function isSensitivePath(absPath: string): boolean {
|
||||||
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
|
if (SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath))) return true;
|
||||||
|
const home = homedir();
|
||||||
|
return HOME_SENSITIVE_MEMBERS.some((member) => absPath === join(home, member));
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-17
@@ -76,7 +76,7 @@ import { RunSummaryTracker } from '../run-summary.js';
|
|||||||
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
||||||
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
||||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||||
import { applyWorkspaceHooks } from '../hooks-config.js';
|
import { ensureCodemanHooks } from '../hooks-config.js';
|
||||||
import { PushSubscriptionStore } from '../push-store.js';
|
import { PushSubscriptionStore } from '../push-store.js';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import { SseStreamManager } from './sse-stream-manager.js';
|
import { SseStreamManager } from './sse-stream-manager.js';
|
||||||
@@ -1819,14 +1819,6 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
let session: Session | null = null;
|
let session: Session | null = null;
|
||||||
try {
|
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.
|
// Create a session for this iteration.
|
||||||
if (isMultiUserMode()) {
|
if (isMultiUserMode()) {
|
||||||
// §6.3: resolve the permission mode with the RUN OWNER (a non-granted user
|
// §6.3: resolve the permission mode with the RUN OWNER (a non-granted user
|
||||||
@@ -2889,11 +2881,6 @@ export class WebServer extends EventEmitter {
|
|||||||
* Skipped entirely when `workspaceHooksEnabled` is OFF: that setting exists so a
|
* 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
|
* user can keep Codeman out of their repos, and a boot-time sweep is the last
|
||||||
* place that should ignore it.
|
* 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> {
|
private async ensureHooksForRecoveredWorkspaces(): Promise<void> {
|
||||||
if (!(await this.getWorkspaceHooksEnabled())) return;
|
if (!(await this.getWorkspaceHooksEnabled())) return;
|
||||||
@@ -2904,9 +2891,7 @@ export class WebServer extends EventEmitter {
|
|||||||
if (session.workingDir) workspaces.add(session.workingDir);
|
if (session.workingDir) workspaces.add(session.workingDir);
|
||||||
}
|
}
|
||||||
for (const workspace of workspaces) {
|
for (const workspace of workspaces) {
|
||||||
// install=true: the setting was already resolved ON above for the whole batch
|
await ensureCodemanHooks(workspace).catch(() => {});
|
||||||
// (OFF skips the sweep wholesale, keeping its documented semantics).
|
|
||||||
await applyWorkspaceHooks(workspace, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,23 @@ describe('terminal link-provider regexes (shipped source)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('the file-path pattern refuses /etc roots (blocked server-side, so the link could only 403)', () => {
|
||||||
|
// `/etc` sits in DEFAULT_BLOCKED_TREES (config/attachment-guard.ts), so an
|
||||||
|
// /etc link is guaranteed dead: it renders clickable, then the preview 403s.
|
||||||
|
// It used to be in the root alternation, which linked exactly those paths.
|
||||||
|
const ext = shippedPattern('FILE_PATH_LINK_PATTERN');
|
||||||
|
const cases = [
|
||||||
|
'see /etc/hosts here',
|
||||||
|
// Extension-bearing, so only the root removal keeps it out.
|
||||||
|
'see /etc/app/config.json here',
|
||||||
|
'cat /etc/nginx/nginx.conf.txt',
|
||||||
|
];
|
||||||
|
for (const line of cases) {
|
||||||
|
ext.lastIndex = 0;
|
||||||
|
expect(ext.exec(line), line).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('terminal-ui builds its path pattern from the shared factory', () => {
|
it('terminal-ui builds its path pattern from the shared factory', () => {
|
||||||
// Structural guard: a local literal here would drift from the response
|
// Structural guard: a local literal here would drift from the response
|
||||||
// viewer's linkifier, which is the divergence the move exists to prevent.
|
// viewer's linkifier, which is the divergence the move exists to prevent.
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Media-extension parity — attachment registry ⇄ frontend copies.
|
||||||
|
*
|
||||||
|
* CLAUDE.md single-sources playable media extensions in
|
||||||
|
* `VIDEO_ATTACHMENT_EXTENSIONS`/`AUDIO_ATTACHMENT_EXTENSIONS`
|
||||||
|
* (src/attachment-registry.ts): the workspace preview and the out-of-workspace
|
||||||
|
* attachment path must agree on what plays. The frontend cannot import that
|
||||||
|
* module, so two hand-maintained copies exist and BOTH have drifted:
|
||||||
|
*
|
||||||
|
* - `FILE_PREVIEW_EXTENSIONS` (constants.js) decides whether a clicked
|
||||||
|
* terminal/chat path opens the preview overlay or the tail/log viewer. It
|
||||||
|
* was missing `m4v ogv ogg oga m4a aac flac opus`, so an in-workspace
|
||||||
|
* `.m4a` routed to the log viewer and rendered as binary noise while the
|
||||||
|
* same file in /tmp played fine.
|
||||||
|
* - `VIDEO_EXTS`/`AUDIO_EXTS` (panels-ui.js) pick the <video>/<audio> markup
|
||||||
|
* for registered attachments; an entry missing there renders a text dump
|
||||||
|
* instead of a player.
|
||||||
|
*
|
||||||
|
* Same technique as test/sse-registry-parity.test.ts: the backend sets are
|
||||||
|
* imported, the frontend copies are extracted from the shipped source as text
|
||||||
|
* (no build-time link exists), and the sets are compared. No port needed.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { AUDIO_ATTACHMENT_EXTENSIONS, VIDEO_ATTACHMENT_EXTENSIONS } from '../src/attachment-registry.js';
|
||||||
|
|
||||||
|
const publicFile = (name: string) =>
|
||||||
|
readFileSync(resolve(import.meta.dirname, '..', 'src', 'web', 'public', name), 'utf8');
|
||||||
|
|
||||||
|
/** `FILE_PREVIEW_EXTENSIONS` is a space-separated string literal in constants.js. */
|
||||||
|
function filePreviewExtensions(): Set<string> {
|
||||||
|
const src = publicFile('constants.js');
|
||||||
|
const m = src.match(/const FILE_PREVIEW_EXTENSIONS = new Set\(\s*\('([^']+)'\)\.split\(' '\)\s*\)/);
|
||||||
|
expect(m, 'FILE_PREVIEW_EXTENSIONS literal not found in constants.js').not.toBeNull();
|
||||||
|
return new Set(m![1].split(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `VIDEO_EXTS`/`AUDIO_EXTS` are quoted-string array Sets in panels-ui.js. */
|
||||||
|
function panelsUiSet(name: string): Set<string> {
|
||||||
|
const src = publicFile('panels-ui.js');
|
||||||
|
const m = src.match(new RegExp(`const ${name} = new Set\\(\\[([^\\]]+)\\]\\)`));
|
||||||
|
expect(m, `${name} literal not found in panels-ui.js`).not.toBeNull();
|
||||||
|
const values = [...m![1].matchAll(/'([^']+)'/g)].map((q) => q[1]);
|
||||||
|
return new Set(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = (s: ReadonlySet<string>) => [...s].sort();
|
||||||
|
|
||||||
|
describe('media extension parity (attachment registry ⇄ frontend)', () => {
|
||||||
|
it('extracts non-trivial sets from every source (guards the parsers)', () => {
|
||||||
|
expect(VIDEO_ATTACHMENT_EXTENSIONS.size).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(AUDIO_ATTACHMENT_EXTENSIONS.size).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(filePreviewExtensions().size).toBeGreaterThan(10);
|
||||||
|
expect(panelsUiSet('VIDEO_EXTS').size).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(panelsUiSet('AUDIO_EXTS').size).toBeGreaterThanOrEqual(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every playable media extension routes to the preview overlay, not the log viewer', () => {
|
||||||
|
const preview = filePreviewExtensions();
|
||||||
|
const missing = [...VIDEO_ATTACHMENT_EXTENSIONS, ...AUDIO_ATTACHMENT_EXTENSIONS].filter((e) => !preview.has(e));
|
||||||
|
expect(
|
||||||
|
missing,
|
||||||
|
`media extensions in attachment-registry.ts but not constants.js FILE_PREVIEW_EXTENSIONS: ${missing.join(', ')}`
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("panels-ui.js VIDEO_EXTS exactly equals the registry's video set", () => {
|
||||||
|
expect(sorted(panelsUiSet('VIDEO_EXTS'))).toEqual(sorted(VIDEO_ATTACHMENT_EXTENSIONS));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("panels-ui.js AUDIO_EXTS exactly equals the registry's audio set", () => {
|
||||||
|
expect(sorted(panelsUiSet('AUDIO_EXTS'))).toEqual(sorted(AUDIO_ATTACHMENT_EXTENSIONS));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -117,6 +117,16 @@ describe('response viewer file-path linkifier', () => {
|
|||||||
expect(root.textContent).toBe('Ratio 3/4 on 2026/08/16, see src/app.ts');
|
expect(root.textContent).toBe('Ratio 3/4 on 2026/08/16, see src/app.ts');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never linkifies /etc paths — the server blocks the whole tree, so the link could only 403', () => {
|
||||||
|
// /etc sits in DEFAULT_BLOCKED_TREES (config/attachment-guard.ts); it used
|
||||||
|
// to be a root in the shared pattern, which made every /etc link a
|
||||||
|
// guaranteed-dead click on both surfaces.
|
||||||
|
const root = linkify('<p>Check /etc/hosts and /etc/app/config.json for the mapping.</p>');
|
||||||
|
|
||||||
|
expect(paths(root)).toHaveLength(0);
|
||||||
|
expect(root.textContent).toBe('Check /etc/hosts and /etc/app/config.json for the mapping.');
|
||||||
|
});
|
||||||
|
|
||||||
it('cannot turn model text into markup', () => {
|
it('cannot turn model text into markup', () => {
|
||||||
// The anchor is built with createElement + textContent, so even a
|
// The anchor is built with createElement + textContent, so even a
|
||||||
// path-shaped payload stays text. (`<` also ends a match, so the linkifier
|
// path-shaped payload stays text. (`<` also ends a match, so the linkifier
|
||||||
|
|||||||
@@ -10,13 +10,6 @@
|
|||||||
*
|
*
|
||||||
* Asserts bytes on disk (the real `ensureCodemanHooks`), not a spy call.
|
* Asserts bytes on disk (the real `ensureCodemanHooks`), not a spy call.
|
||||||
* Uses app.inject(), so no real HTTP port is needed.
|
* Uses app.inject(), so no real HTTP port is needed.
|
||||||
*
|
|
||||||
* Also covers the post-#304 follow-ups: the quick-start existing-case branch, the
|
|
||||||
* docker branch's claude-only gate (a shell quick-start used to author a hooks
|
|
||||||
* block of its own), and the shared decision core `applyWorkspaceHooks` in
|
|
||||||
* hooks-config.ts — the function the non-route create paths (cron, scheduled runs,
|
|
||||||
* plan one-shots, the boot recovery sweep) go through, tested directly here
|
|
||||||
* including the sweep's deleted-workspace guard.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
@@ -29,9 +22,8 @@ import { tmpdir } from 'node:os';
|
|||||||
import { createMockRouteContext } from '../mocks/index.js';
|
import { createMockRouteContext } from '../mocks/index.js';
|
||||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
import { generateHooksConfig, applyWorkspaceHooks } from '../../src/hooks-config.js';
|
import { generateHooksConfig } from '../../src/hooks-config.js';
|
||||||
import { getDataDir } from '../../src/config/instance.js';
|
import { getDataDir } from '../../src/config/instance.js';
|
||||||
import { CASES_DIR } from '../../src/web/route-helpers.js';
|
|
||||||
|
|
||||||
interface HooksFile {
|
interface HooksFile {
|
||||||
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>>;
|
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>>;
|
||||||
@@ -149,14 +141,11 @@ describe('POST /api/sessions workspace hooks', () => {
|
|||||||
|
|
||||||
it('leaves the server cwd alone when workingDir is omitted', async () => {
|
it('leaves the server cwd alone when workingDir is omitted', async () => {
|
||||||
// workingDir falls back to process.cwd(), which is $HOME under installer-created
|
// workingDir falls back to process.cwd(), which is $HOME under installer-created
|
||||||
// services — neither hooks NOR the statusLine exporter (same mkdir-into-cwd
|
// services — hooks must not materialize in ~/.claude/settings.local.json.
|
||||||
// exposure, closed in the #304 follow-ups) may materialize in
|
|
||||||
// ~/.claude/settings.local.json.
|
|
||||||
const cwdSettings = join(process.cwd(), '.claude', 'settings.local.json');
|
const cwdSettings = join(process.cwd(), '.claude', 'settings.local.json');
|
||||||
const before = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
|
const before = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
|
||||||
|
|
||||||
const res = await createSession({ name: 'hooks-no-dir', mode: 'claude', statusLineTelemetry: true });
|
expect((await createSession({ name: 'hooks-no-dir', mode: 'claude' })).statusCode).toBe(200);
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
|
|
||||||
const after = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
|
const after = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
|
||||||
expect(after).toBe(before);
|
expect(after).toBe(before);
|
||||||
@@ -165,8 +154,7 @@ describe('POST /api/sessions workspace hooks', () => {
|
|||||||
it('never writes hooks for a remote attach (workingDir is a user@host pseudo-path)', async () => {
|
it('never writes hooks for a remote attach (workingDir is a user@host pseudo-path)', async () => {
|
||||||
// A claude-mode attachRemoteSession create overwrites workingDir with
|
// A claude-mode attachRemoteSession create overwrites workingDir with
|
||||||
// `user@host:session` — locally a RELATIVE path, so a mkdir would create it
|
// `user@host:session` — locally a RELATIVE path, so a mkdir would create it
|
||||||
// as a junk directory under the server cwd. statusLineTelemetry rides along:
|
// as a junk directory under the server cwd.
|
||||||
// applyStatusLineConfig mkdirs the same way and used to run for remote attaches.
|
|
||||||
await mkdir(getDataDir(), { recursive: true });
|
await mkdir(getDataDir(), { recursive: true });
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(getDataDir(), 'remote-hosts.json'),
|
join(getDataDir(), 'remote-hosts.json'),
|
||||||
@@ -176,7 +164,6 @@ describe('POST /api/sessions workspace hooks', () => {
|
|||||||
const res = await createSession({
|
const res = await createSession({
|
||||||
name: 'hooks-remote',
|
name: 'hooks-remote',
|
||||||
mode: 'claude',
|
mode: 'claude',
|
||||||
statusLineTelemetry: true,
|
|
||||||
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
|
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
@@ -220,157 +207,3 @@ describe('POST /api/sessions workspace hooks', () => {
|
|||||||
expect(Object.keys(written).sort()).toEqual(Object.keys(generateHooksConfig().hooks).sort());
|
expect(Object.keys(written).sort()).toEqual(Object.keys(generateHooksConfig().hooks).sort());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/quick-start workspace hooks', () => {
|
|
||||||
let app: FastifyInstance;
|
|
||||||
|
|
||||||
const quickStart = (payload: Record<string, unknown>) =>
|
|
||||||
app.inject({ method: 'POST', url: '/api/quick-start', payload });
|
|
||||||
|
|
||||||
const hooksFileIn = (dir: string) => join(dir, '.claude', 'settings.local.json');
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
app = Fastify({ logger: false });
|
|
||||||
await app.register(fastifyCookie);
|
|
||||||
registerSessionRoutes(app, createMockRouteContext());
|
|
||||||
installRouteErrorHandler(app);
|
|
||||||
await app.ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await app.close();
|
|
||||||
// Docker fixtures + case dirs must not leak into the next test.
|
|
||||||
await rm(join(getDataDir(), 'docker-hosts.json'), { force: true });
|
|
||||||
await rm(join(getDataDir(), 'docker-cases.json'), { force: true });
|
|
||||||
await rm(CASES_DIR, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('installs hooks into an EXISTING case directory (a linked case / cloned repo)', async () => {
|
|
||||||
// The scaffold branch (writeHooksConfig) only runs when quick-start CREATES the
|
|
||||||
// directory; a pre-existing case takes the applyWorkspaceHooks branch instead.
|
|
||||||
const casePath = join(CASES_DIR, 'existingcase');
|
|
||||||
await mkdir(casePath, { recursive: true });
|
|
||||||
|
|
||||||
const res = await quickStart({ caseName: 'existingcase', mode: 'claude' });
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
|
|
||||||
const raw = await readFile(hooksFileIn(casePath), 'utf-8');
|
|
||||||
expect(raw).toContain('X-Codeman-Hook-Secret');
|
|
||||||
expect(raw).toContain('/api/hook-event');
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Minimal docker host + case fixtures (docker IO is no-op'd under vitest). */
|
|
||||||
const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string) => {
|
|
||||||
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: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath }])
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
it('docker branch scaffolds hooks for a claude session', async () => {
|
|
||||||
// Companion to the shell test below: proves the docker fixture path is live,
|
|
||||||
// so the shell assertion cannot pass vacuously.
|
|
||||||
const ws = await mkdtemp(join(tmpdir(), 'codeman-docker-claude-'));
|
|
||||||
try {
|
|
||||||
await writeDockerFixtures('dockclaude', ws);
|
|
||||||
|
|
||||||
expect((await quickStart({ caseName: 'dockclaude', mode: 'claude' })).statusCode).toBe(200);
|
|
||||||
expect(await readFile(hooksFileIn(ws), 'utf-8')).toContain('X-Codeman-Hook-Secret');
|
|
||||||
} finally {
|
|
||||||
await rm(ws, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('docker branch authors NO hooks block for a shell session', async () => {
|
|
||||||
// The branch used to exclude only the five external CLIs, so a shell
|
|
||||||
// quick-start into a docker case wrote a `.claude` block of its own —
|
|
||||||
// contradicting the existing-case branch's rule that only claude reads it.
|
|
||||||
const ws = await mkdtemp(join(tmpdir(), 'codeman-docker-shell-'));
|
|
||||||
try {
|
|
||||||
await writeDockerFixtures('dockshell', ws);
|
|
||||||
|
|
||||||
expect((await quickStart({ caseName: 'dockshell', mode: 'shell' })).statusCode).toBe(200);
|
|
||||||
expect(existsSync(join(ws, '.claude'))).toBe(false);
|
|
||||||
} finally {
|
|
||||||
await rm(ws, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('applyWorkspaceHooks (the shared decision core in hooks-config)', () => {
|
|
||||||
// The non-route claude create paths — cron fires, legacy scheduled runs, the
|
|
||||||
// plan-orchestrator one-shots, the boot recovery sweep — call this function
|
|
||||||
// directly, so its contract is tested here rather than by spinning those up.
|
|
||||||
let workspace: string;
|
|
||||||
|
|
||||||
const appSettingsPath = () => join(getDataDir(), 'settings.json');
|
|
||||||
const wsSettingsPath = () => join(workspace, '.claude', 'settings.local.json');
|
|
||||||
|
|
||||||
const setWorkspaceHooksSetting = async (enabled: boolean) => {
|
|
||||||
await mkdir(getDataDir(), { recursive: true });
|
|
||||||
await writeFile(appSettingsPath(), JSON.stringify({ workspaceHooksEnabled: enabled }));
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
workspace = await mkdtemp(join(tmpdir(), 'codeman-hooks-core-'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await rm(workspace, { recursive: true, force: true });
|
|
||||||
await rm(appSettingsPath(), { force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('installs hooks with no settings.json at all (absent key = default ON)', async () => {
|
|
||||||
await applyWorkspaceHooks(workspace);
|
|
||||||
|
|
||||||
const raw = await readFile(wsSettingsPath(), 'utf-8');
|
|
||||||
expect(raw).toContain('X-Codeman-Hook-Secret');
|
|
||||||
expect(raw).toContain('curl -sk -X POST');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never ADDS a hooks block when workspaceHooksEnabled is OFF', async () => {
|
|
||||||
await setWorkspaceHooksSetting(false);
|
|
||||||
|
|
||||||
await applyWorkspaceHooks(workspace);
|
|
||||||
expect(existsSync(wsSettingsPath())).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still heals a stale Codeman block when the setting is OFF (COD-91 self-heal)', async () => {
|
|
||||||
await setWorkspaceHooksSetting(false);
|
|
||||||
await mkdir(join(workspace, '.claude'), { recursive: true });
|
|
||||||
await writeFile(wsSettingsPath(), JSON.stringify({ model: 'opus', hooks: staleCodemanHooks() }));
|
|
||||||
|
|
||||||
await applyWorkspaceHooks(workspace);
|
|
||||||
|
|
||||||
const settings: HooksFile = JSON.parse(await readFile(wsSettingsPath(), 'utf-8'));
|
|
||||||
expect(settings.model).toBe('opus');
|
|
||||||
expect(JSON.stringify(settings.hooks)).toContain('X-Codeman-Hook-Secret');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a malformed settings file untouched rather than replacing it', async () => {
|
|
||||||
await mkdir(join(workspace, '.claude'), { recursive: true });
|
|
||||||
await writeFile(wsSettingsPath(), '{ not json');
|
|
||||||
|
|
||||||
await applyWorkspaceHooks(workspace);
|
|
||||||
expect(await readFile(wsSettingsPath(), 'utf-8')).toBe('{ not json');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips a workspace that no longer exists (the boot-sweep resurrection bug)', async () => {
|
|
||||||
// ensureCodemanHooks mkdir -p's, so the sweep used to recreate a DELETED repo
|
|
||||||
// as an empty directory tree holding only .claude/settings.local.json.
|
|
||||||
const gone = join(workspace, 'deleted-repo');
|
|
||||||
|
|
||||||
// install=true mirrors the boot sweep's call shape (setting pre-resolved ON).
|
|
||||||
await applyWorkspaceHooks(gone, true);
|
|
||||||
expect(existsSync(gone)).toBe(false);
|
|
||||||
|
|
||||||
// The setting-driven shape must skip it too.
|
|
||||||
await applyWorkspaceHooks(gone);
|
|
||||||
expect(existsSync(gone)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
* feature), so the "stays attachable" cases matter just as much: over-blocking
|
* feature), so the "stays attachable" cases matter just as much: over-blocking
|
||||||
* breaks the publish skill and the review-card loop.
|
* breaks the publish skill and the review-card loop.
|
||||||
*/
|
*/
|
||||||
|
import { homedir } from 'node:os';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { isSensitivePath } from '../src/web/sensitive-path.js';
|
import { isSensitivePath } from '../src/web/sensitive-path.js';
|
||||||
|
|
||||||
@@ -122,4 +123,34 @@ describe('isSensitivePath', () => {
|
|||||||
expect(isSensitivePath('/srv/app/looks-innocent')).toBe(false);
|
expect(isSensitivePath('/srv/app/looks-innocent')).toBe(false);
|
||||||
expect(isSensitivePath(`${HOME}/.ssh/looks-innocent`)).toBe(true);
|
expect(isSensitivePath(`${HOME}/.ssh/looks-innocent`)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('home-anchored Claude config (credential-bearing by schema)', () => {
|
||||||
|
// ~/.claude/settings.json can hold `env: {ANTHROPIC_API_KEY}` and
|
||||||
|
// `apiKeyHelper` by schema (settings.local.json shares it), and
|
||||||
|
// ~/.claude.json holds account/OAuth-adjacent state. These are anchored to
|
||||||
|
// the REAL homedir, read at CHECK time — test/setup.ts points HOME at a
|
||||||
|
// per-file fixture, so a homedir() captured at module load would be a
|
||||||
|
// different directory than the one this suite resolves.
|
||||||
|
const home = homedir();
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['claude account state', `${home}/.claude.json`],
|
||||||
|
['claude user settings', `${home}/.claude/settings.json`],
|
||||||
|
['claude user local settings', `${home}/.claude/settings.local.json`],
|
||||||
|
])('blocks the %s', (_label, path) => {
|
||||||
|
expect(isSensitivePath(path)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A blanket `/\.claude\/settings\.json$/` would also catch every CASE-level
|
||||||
|
// settings file, which users legitimately view and edit in the File Viewer
|
||||||
|
// (model override, hooks) — the home anchor is what keeps those servable.
|
||||||
|
it.each([
|
||||||
|
['a case-level .claude/settings.json', '/srv/app/.claude/settings.json'],
|
||||||
|
['a case-level .claude/settings.local.json', '/srv/app/.claude/settings.local.json'],
|
||||||
|
['a .claude/settings.json under some OTHER home', `${HOME}/.claude/settings.json`],
|
||||||
|
['a .claude.json under some OTHER home', `${HOME}/.claude.json`],
|
||||||
|
])('keeps %s servable', (_label, path) => {
|
||||||
|
expect(isSensitivePath(path)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -394,15 +394,32 @@ describe('session list layout', () => {
|
|||||||
expect((drawer.win.document.activeElement as HTMLElement).className).toContain('session-tab');
|
expect((drawer.win.document.activeElement as HTMLElement).className).toContain('session-tab');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the live session count in the sidebar header', () => {
|
it('counts the rows actually on the list: web tabs included, filtered rows excluded', () => {
|
||||||
|
// this.sessions.size was the original source and disagreed with the screen
|
||||||
|
// twice over: web tabs render in the same list but are not sessions (3
|
||||||
|
// sessions + 2 dashboards read "3" above 5 rows), and the filter hides
|
||||||
|
// rows without touching the map.
|
||||||
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
|
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
|
||||||
app.sessions = new Map([
|
app.sessions = new Map([
|
||||||
['a', {}],
|
['a', {}],
|
||||||
['b', {}],
|
['b', {}],
|
||||||
['c', {}],
|
|
||||||
]);
|
]);
|
||||||
app.applySessionListLayout();
|
app.applySessionListLayout();
|
||||||
expect(win.document.getElementById('sessionSidebarCount')?.textContent).toBe('3');
|
tabsEl(win).innerHTML = `
|
||||||
|
<div class="session-tab" data-id="a" aria-label="api server" title="/srv/api"></div>
|
||||||
|
<div class="session-tab" data-id="b" aria-label="docs" title="/home/docs"></div>
|
||||||
|
<div class="session-tab session-tab--web" data-webview-id="w" aria-label="Grafana web tab" title="http://x/g"></div>
|
||||||
|
`;
|
||||||
|
app.updateSidebarCount();
|
||||||
|
const count = () => win.document.getElementById('sessionSidebarCount')?.textContent;
|
||||||
|
expect(count()).toBe('3');
|
||||||
|
|
||||||
|
// The count follows the filter — applySidebarFilter is what the filter box
|
||||||
|
// calls per keystroke, so it must move without waiting for a re-render.
|
||||||
|
app.applySidebarFilter('api');
|
||||||
|
expect(count()).toBe('1');
|
||||||
|
app.applySidebarFilter('');
|
||||||
|
expect(count()).toBe('3');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forces tall rows and no wrapping in the sidebar, and leaves the strip rules alone', () => {
|
it('forces tall rows and no wrapping in the sidebar, and leaves the strip rules alone', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user