diff --git a/CLAUDE.md b/CLAUDE.md index a65f2017e..078b8bfd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -230,6 +230,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments) +**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND the response viewer's `_linkifyFilePaths()`; a fresh instance per call, since `lastIndex` is per-object state. The chat linkifier walks TEXT NODES with DOM APIs (the source is model output; never rebuild sanitized markup as a string) and skips subtrees already inside an ``. ⚠️ **An out-of-workspace path is served through the ATTACHMENT routes, not the file routes** — `file-content`/`file-raw` are workspace-confined and 404 exactly the paths agents print most (a `/tmp` capture, Claude's scratchpad), so `openFilePreview()` registers such a path via `POST /api/sessions/:id/attachments` with **`notify: false`** (suppresses only the `attachment:detected` broadcast — same guard, same routes; without it every click also popped a card announcing the file already on screen) and renders by id. The click is an explicit action on the explicit, Origin-guarded route, which is what distinguishes it from the force-confined magic-link scanner. ⚠️ **Media extensions are single-sourced** (`VIDEO_ATTACHMENT_EXTENSIONS`/`AUDIO_ATTACHMENT_EXTENSIONS` in `attachment-registry.ts`, imported by `file-content`'s classification) so a clip plays the same in or out of the workspace; a player needs all THREE of allowlist + a real `MIME_TYPES` entry (octet-stream renders a dead player) + the range-aware body. ⚠️ **`TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS`** (never a second list): if the viewer would edit it inside the workspace, it can be read outside. Widening READ must never widen RUN, so `html`/`htm` joined `svg` in `serveRawFile`'s download-only branch, other text goes out as inert `text/plain`+`nosniff`, and `~/.codeman*/state.json` joined `isSensitivePath` (it persists `envOverrides`, which can hold `GEMINI_API_KEY`). ⚠️ The terminal sends an **out-of-workspace** path to the preview instead of the log viewer (that one spawns `tail -f` and reaches only workspace + `/var/log` + `~/logs`); in-workspace text keeps the tail viewer and `file-stream-manager`'s allowlist is untouched. The image-watcher keeps its own narrow detection list, so none of this cards every file an agent writes. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) + **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) **File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md` @@ -296,7 +298,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` that stops delivering does not always error, so `onerror` never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads. ⚠️ The 15s server keepalive was an SSE **comment** (`:keepalive`), and comments are **invisible to `EventSource` by spec**, so there was nothing a client could observe: it is now the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), which is exactly why the frame had to change type. ⚠️ Staleness is judged **only while the status is `connected`** and the device is online; that guard is the loop breaker, since a forced `connectSSE()` leaves `connected` immediately and cannot re-fire while a reconnect is in flight. ⚠️ The liveness stamp is applied inside `addListener` itself, so every registered handler (the `_SSE_HANDLER_MAP` wrappers AND the directly-registered ones) feeds it from one place; the heartbeat's own listener is a no-op that exists **only** to be registered, since `EventSource` drops named events nobody listens for. ⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s". -**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), local echo overlay (7). +**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), local echo overlay (7). **Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min). diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 4967662a6..9d8da4e90 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -122,6 +122,24 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough **Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups _identical_ in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals. **Codex generated artifacts** (COD-166/#150, `generated-artifact-attachments.ts`): codex-mode sessions ALSO scan (ANSI-stripped) output for `Saved to: file:///…` lines and surface those files as attachment cards with a relaxed trust policy — the allow decision runs on the **realpath-resolved** path against `os.homedir()`-anchored `~/.codex` marker dirs (symlink escapes fall back to force-confinement); gated to `mode === 'codex'` only (`source` is a REQUIRED param through the listener-deps chain — a dropped arg here silently kills the feature). Image thumbnails pass through jpg/jpeg/gif/webp. +### File-path links (terminal + response viewer) + +A file path an agent prints is a link on both surfaces it can appear on, and clicking it opens the file-preview overlay. Three things make that work and each has bitten: + +**One pattern, two consumers.** `FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` live in `constants.js`; the xterm link provider (`registerFilePathLinkProvider`, terminal-ui.js) and the response viewer's `_linkifyFilePaths()` (app.js) both build a fresh instance from it. ⚠️ Fresh per call, never one shared object: `lastIndex` is per-object state on a `/g` regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (`3/4`) or a date can't match and trailing punctuation stays out. Roots include `Users` and `mnt`, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in `test/link-provider-regex.test.ts`. + +**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. `test/response-viewer-file-links.test.ts` pins both properties. + +**Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, which is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term. + +⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow); nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path. + +**Text reuses the edit-mode allowlist; markup stays download-only.** `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (`config/file-editing.ts`) rather than a second curated list that would drift from it: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The justification for widening is that the agent in the session can already `cat` any of these and the picker already previews them, so the suffix was never the confidentiality gate; the path guard is (sensitive-file blocklist, `/root` and `/etc` trees, realpath first). ⚠️ Two consequences had to be handled at the same time: `~/.codeman*/state.json` joined `isSensitivePath` (it persists `SessionState.envOverrides`, and the env allowlist admits key-shaped names like `GEMINI_API_KEY`, so it can hold a live credential), and `html`/`htm` joined `svg` in `serveRawFile`'s **download-only** branch so that widening what can be READ never widens what can RUN on our own origin. Text with no dedicated MIME entry goes out as inert `text/plain; charset=utf-8` + `nosniff`, matching the picker. The by-id text preview is bounded like the workspace one: a `Range` request for the first 512KB (a real partial read, not a discarded 50MB download) plus a 500-line cap, with the footer saying so. + +**Media is single-sourced across the two preview paths.** `VIDEO_ATTACHMENT_EXTENSIONS` / `AUDIO_ATTACHMENT_EXTENSIONS` live in `attachment-registry.ts` and are imported by `file-content`'s media classification, so a clip plays identically whether it is in the workspace or reached by id from outside it. They diverged first: the workspace path had its own inline sets and the registry allowlist had no media at all, so a video an agent wrote to `/tmp` was refused as an unsupported type while the same file inside the repo played. ⚠️ Three things have to line up for a player rather than a dead frame: the extension in the allowlist, a **real MIME entry** in `MIME_TYPES` (a `
.
       const copyBtn = ev.target.closest('.rv-copy-btn');
       if (copyBtn) {
@@ -2074,10 +2085,66 @@ class CodemanApp {
     const renderedText = document.createElement('div');
     renderedText.className = 'rv-text';
     renderedText.innerHTML = this._renderMarkdown(text);
+    this._linkifyFilePaths(renderedText);
     div.appendChild(renderedText);
     return div;
   }
 
+  /**
+   * Make absolute file paths in a rendered message clickable.
+   *
+   * The terminal's link provider never sees these: the response viewer is
+   * markdown, and a path the agent wrote as prose or inline code renders as
+   * inert text — so the file it just produced (a screenshot, a report) was one
+   * copy-paste away from being viewable instead of one click. Same pattern the
+   * terminal uses (constants.js), same destination (the file-preview overlay).
+   *
+   * Walks TEXT NODES and builds anchors with DOM APIs — never innerHTML, and
+   * never a string rebuild of already-sanitized markup: the source is model
+   * output. Subtrees already inside an `` are skipped so an autolinked URL
+   * is never re-cut, and the anchor's textContent is the path verbatim, so
+   * "copy code" still yields exactly what the agent printed.
+   */
+  _linkifyFilePaths(root) {
+    if (!root || typeof document === 'undefined') return;
+    // Guarded: a stale cached constants.js must degrade to plain text, not throw
+    // out of the middle of rendering a message.
+    if (typeof absoluteFilePathPattern !== 'function') return;
+    const pattern = absoluteFilePathPattern();
+
+    // Collect first: replacing a node while the walker is positioned on it
+    // invalidates the traversal.
+    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+    const targets = [];
+    for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+      if (node.parentElement?.closest('a')) continue;
+      pattern.lastIndex = 0;
+      if (pattern.test(node.nodeValue || '')) targets.push(node);
+    }
+
+    for (const node of targets) {
+      const value = node.nodeValue;
+      const frag = document.createDocumentFragment();
+      let cursor = 0;
+      let match;
+      pattern.lastIndex = 0;
+      while ((match = pattern.exec(value)) !== null) {
+        const path = match[1];
+        if (match.index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, match.index)));
+        const link = document.createElement('a');
+        link.className = 'rv-path';
+        link.href = '#';
+        link.dataset.path = path;
+        link.title = path;
+        link.textContent = path;
+        frag.appendChild(link);
+        cursor = match.index + path.length;
+      }
+      if (cursor < value.length) frag.appendChild(document.createTextNode(value.slice(cursor)));
+      node.parentNode?.replaceChild(frag, node);
+    }
+  }
+
   _getResponseViewerAgentLabel() {
     const mode = this.sessions.get(this.activeSessionId)?.mode;
     return mode === 'codex'
diff --git a/src/web/public/constants.js b/src/web/public/constants.js
index 56f3852a5..0e5e0b6ed 100644
--- a/src/web/public/constants.js
+++ b/src/web/public/constants.js
@@ -893,6 +893,45 @@ function computeRewriteScrollLine(input) {
   return Math.max(0, (input?.baseY || 0) - linesFromBottom);
 }
 
+/**
+ * Absolute file paths in agent output, as ONE pattern with two consumers: the
+ * xterm link provider (terminal-ui.js) and the response viewer's markdown
+ * linkifier (app.js). They used to be able to drift, and a path that is
+ * clickable in the terminal but inert in the chat reads as a bug, not a policy.
+ *
+ * Anchored on a known absolute root (so an ordinary fraction or a date can
+ * 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
+ * extensions come first in each family (`tsx|ts`), so the trailing `\b` cannot
+ * be satisfied by the shorter branch mid-word.
+ *
+ * ⚠ Consumers must never share one instance: `lastIndex` is per-object state on
+ * a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
+ */
+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;
+
+/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
+function absoluteFilePathPattern() {
+  return new RegExp(FILE_PATH_LINK_PATTERN.source, 'g');
+}
+
+/**
+ * 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
+ * text file and the wrong one for bytes (tailing a PNG shows binary noise).
+ */
+const FILE_PREVIEW_EXTENSIONS = new Set(
+  ('png jpg jpeg gif webp bmp svg pdf docx pptx mp4 webm mov mp3 wav').split(' ')
+);
+
+/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
+function previewsInFileViewer(filePath) {
+  const ext = String(filePath || '').split('.').pop().toLowerCase();
+  return FILE_PREVIEW_EXTENSIONS.has(ext);
+}
+
 if (typeof window !== 'undefined') {
   window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
+  window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
 }
diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js
index fbec74645..2750e94d8 100644
--- a/src/web/public/panels-ui.js
+++ b/src/web/public/panels-ui.js
@@ -15,6 +15,11 @@
 
 const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
 const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
+// Bounds for the by-id text preview, mirroring what the workspace text preview
+// already does server-side (500 lines). The byte cap rides a Range request, so
+// a huge log is a partial read rather than a download the viewer throws away.
+const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
+const TEXT_PREVIEW_MAX_LINES = 500;
 const AWAY_DIGEST_SECTIONS = [
   ['needsAttention', 'Needs Attention'],
   ['completed', 'Completed'],
@@ -3234,6 +3239,65 @@ Object.assign(CodemanApp.prototype, {
     if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false');
   },
 
+  /**
+   * Whether a path is absolute and provably OUTSIDE this session's workspace.
+   *
+   * `file-content` / `file-raw` resolve every path against `workingDir` and
+   * refuse anything that escapes it, so an absolute path elsewhere on the host
+   * (an agent's `/tmp` scratchpad capture, a screenshot, another checkout) can
+   * only ever 404 there — it has to go through the attachment routes instead.
+   *
+   * A string compare is enough for ROUTING; the real containment decision stays
+   * server-side (realpath + guard) on whichever route the request lands on. An
+   * unknown workingDir answers false, leaving the historical path untouched.
+   */
+  _isExternalPreviewPath(filePath, sessionId) {
+    if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
+    const workingDir = this.sessions.get(sessionId)?.workingDir;
+    if (!workingDir) return false;
+    const root = workingDir.endsWith('/') ? workingDir : `${workingDir}/`;
+    return filePath !== workingDir && !filePath.startsWith(root);
+  },
+
+  /**
+   * Register an out-of-workspace path as a live external attachment and return
+   * its id, so the preview can render it through the by-id attachment routes.
+   *
+   * `notify: false` keeps this quiet: the caller is already opening the file in
+   * the overlay, so the usual attachment card + unread badge would be noise on
+   * top of the thing the user just asked to see. The server still enforces the
+   * full attachment guard (blocked secret trees, extension allowlist, symlinks
+   * resolved), so a refusal here is a policy answer worth showing verbatim.
+   *
+   * @returns {Promise<{attachmentId?: string, size?: number, error?: string}>}
+   */
+  async _registerExternalPreview(filePath, sessionId) {
+    try {
+      const res = await fetch(`/api/sessions/${sessionId}/attachments`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ path: filePath, notify: false }),
+      });
+      const result = await res.json().catch(() => null);
+      if (res.ok && result?.success && result.data?.attachmentId) {
+        return { attachmentId: result.data.attachmentId, size: result.data.size || 0 };
+      }
+      const reason = result?.error || `Cannot open this file (HTTP ${res.status})`;
+      // The registry's type answer is a policy term, not an explanation, and the
+      // user just clicked a file they can see on disk. Say what IS previewable
+      // from outside the workspace instead.
+      if (/unsupported/i.test(reason)) {
+        const ext = (filePath.split('.').pop() || '').toLowerCase();
+        return {
+          error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents and text files only).`,
+        };
+      }
+      return { error: reason };
+    } catch (err) {
+      return { error: err.message || 'Cannot open this file' };
+    }
+  },
+
   async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
     if (!sessionId || !filePath) return;
 
@@ -3258,25 +3322,70 @@ Object.assign(CodemanApp.prototype, {
 
     const ext = (filePath.split('.').pop() || '').toLowerCase();
 
+    // Out-of-workspace path: mint an attachment id up front. Every branch below
+    // talks to a workspace-confined route, so without this the image/PDF ones
+    // render a broken frame and the text one reports a bare "File not found"
+    // for a file that is sitting right there on disk.
+    let externalError = '';
+    let externalSize = 0;
+    if (!attachmentId && this._isExternalPreviewPath(filePath, sessionId)) {
+      const external = await this._registerExternalPreview(filePath, sessionId);
+      attachmentId = external.attachmentId || null;
+      externalError = external.error || '';
+      externalSize = external.size || 0;
+    }
+    if (!attachmentId && externalError) {
+      footerEl.textContent = '';
+      bodyEl.innerHTML = `
${escapeHtml(externalError)}
`; + return; + } + // Registered attachment: render straight from its by-id routes — images and // PDFs inline, Office docs via the server-converted PDF preview, text fetched // raw. (Workspace-path previews fall through to the file-content endpoint.) if (attachmentId) { const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`; const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']); - footerEl.textContent = ext.toUpperCase(); + const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']); + 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 + // link reads like a workspace preview instead of a bare "PNG". History + // cards arrive with an id and no size and keep the short form. + footerEl.textContent = externalSize ? `${this.formatFileSize(externalSize)} • ${ext}` : ext.toUpperCase(); if (IMAGE_EXTS.has(ext)) { bodyEl.innerHTML = `${escapeHtml(filePath)}`; + } else if (VIDEO_EXTS.has(ext)) { + // Same markup as the workspace branch below, including playsinline: iOS + // otherwise hijacks playback into its own fullscreen player, which + // leaves this overlay behind it with no way back but its close button. + // The attachment raw route is range-aware, so the scrub bar works. + bodyEl.innerHTML = ``; + } else if (AUDIO_EXTS.has(ext)) { + bodyEl.innerHTML = ``; } else if (ext === 'pdf') { bodyEl.innerHTML = ``; } else if (ext === 'docx' || ext === 'pptx') { bodyEl.innerHTML = ``; } else { try { - const res = await fetch(`${base}/raw`); + // Bounded like the workspace text preview: a Range for the first + // chunk (the route is range-aware, so this is a real partial read, + // not a 50MB download thrown away) and a line cap on top. An agent's + // log can be enormous, and rendering all of it into one
 is how
+          // you lock up the tab on the file you wanted to glance at.
+          const res = await fetch(`${base}/raw`, { headers: { Range: `bytes=0-${TEXT_PREVIEW_MAX_BYTES - 1}` } });
           if (!res.ok) throw new Error('Failed to load attachment');
           const text = await res.text();
-          bodyEl.innerHTML = `
${escapeHtml(text)}
`; + const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES; + const lines = text.split('\n'); + const clippedByLines = lines.length > TEXT_PREVIEW_MAX_LINES; + const shown = clippedByLines ? lines.slice(0, TEXT_PREVIEW_MAX_LINES).join('\n') : text; + bodyEl.innerHTML = `
${escapeHtml(shown)}
`; + this.filePreviewContent = shown; + if (clippedByLines || clippedByBytes) { + const note = clippedByLines ? `showing first ${TEXT_PREVIEW_MAX_LINES} lines` : 'showing the start of the file'; + footerEl.textContent = `${footerEl.textContent} (${note})`; + } } catch (err) { bodyEl.innerHTML = `
Error: ${escapeHtml(err.message)}
`; } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 5df82884a..0b9b8395b 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -9855,13 +9855,18 @@ kbd { /* ========== File Preview Overlay ========== */ +/* Above the response viewer (5000) and its backdrop (4999): a file path in the + chat opens this overlay, and at the old 2000 it rendered BEHIND the panel it + was launched from — the click looked dead. Same relationship the path picker + and its preview already have (10020 / 10030). Still below the toast and + picker band (10000+), so a "Saved" toast keeps landing on top. */ .file-preview-overlay { position: fixed; inset: 0; background: var(--modal-backdrop); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); - z-index: 2000; + z-index: 5100; display: none; align-items: center; justify-content: center; @@ -12413,6 +12418,16 @@ kbd { border-bottom-color: var(--accent); } +/* File paths linkified out of the message text. Monospace so a path still reads + as a path in prose, and break-all because these are long and the viewer is + narrow on a phone. Colour/underline come from the .rv-text a rule above. */ +.rv-text a.rv-path { + font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, monospace; + font-size: 0.92em; + word-break: break-all; + cursor: pointer; +} + /* Tables — scroll wrapper keeps table proper while allowing horizontal overflow */ .rv-table-wrap { margin: 1em 0; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 2c36011a7..e119f03d4 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1423,19 +1423,19 @@ Object.assign(CodemanApp.prototype, { // the whole tab on hover. Non-empty token + bounded reps is O(n). const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g; - // Pattern 2: Paths with common extensions. - // Image/PDF extensions are included so pasted-attachment paths - // (`.claude-images/paste-*.png`) are clickable; they open the file preview - // rather than the log viewer (see addLink). - const extPattern = - /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js|png|jpe?g|gif|webp|bmp|svg|pdf))\b/g; + // Pattern 2: Paths with common extensions. Image/PDF/media extensions are + // included so pasted-attachment paths (`.claude-images/paste-*.png`) and + // screenshots an agent just wrote are clickable; those open the file + // preview rather than the log viewer (see addLink). + // + // The literal lives in constants.js because the response viewer linkifies + // the SAME paths out of markdown — one definition, two consumers. A fresh + // instance per call: `lastIndex` is per-object state. + const extPattern = absoluteFilePathPattern(); // Pattern 3: Bash() tool output const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g; - /** Extensions that should open the image/document preview, not the log viewer. */ - const PREVIEW_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'pdf']); - const addLink = (filePath, matchIndex) => { const startCol = lineText.indexOf(filePath, matchIndex); if (startCol === -1) return; @@ -1454,9 +1454,19 @@ Object.assign(CodemanApp.prototype, { }, activate(event, text) { // Tailing a PNG in the log viewer shows binary noise; the file preview - // already renders images and PDFs inline. - const ext = (text.split('.').pop() || '').toLowerCase(); - if (PREVIEW_EXTS.has(ext)) { + // already renders images, PDFs, documents and media inline — and it + // now reaches files outside the workspace too, which is where an + // agent's screenshots and scratchpad captures actually land. + // + // Text goes to the log viewer, which follows a file that is still + // being written — but ONLY where it can actually read: it spawns + // `tail -f` and allows the workspace, /var/log and ~/logs, so an + // out-of-workspace path there answered "Path must be within + // working directory or allowed log directories" while the SAME + // path clicked in the response viewer previewed fine. The preview + // reads those through the guarded attachment routes, so external + // paths route there and the two surfaces agree. + if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) { self.openFilePreview(text, self.activeSessionId); return; } diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index ede34a58e..ccc4108ee 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -23,12 +23,15 @@ import type { import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { fileStreamManager } from '../../file-stream-manager.js'; import { + AUDIO_ATTACHMENT_EXTENSIONS, AttachmentRegistrationError, attachmentRecordToEvent, attachmentRegistry, buildFileThumbnailRoute, isSupportedAttachmentExtension, registerExternalAttachment, + TEXT_ATTACHMENT_EXTENSIONS, + VIDEO_ATTACHMENT_EXTENSIONS, type AttachmentRecord, } from '../../attachment-registry.js'; import { generateFirstPageThumbnail } from '../../document-thumbnailer.js'; @@ -67,6 +70,22 @@ const MIME_TYPES: Record = { webp: 'image/webp', ico: 'image/x-icon', bmp: 'image/bmp', + // Media needs a real type, not the octet-stream fallback: a
is invalid markup that would swallow the outer link's click. + // ⚠️ The URL's tail MUST be a string the pattern matches on its own + // (`/tmp/...` here): with an unmatchable tail this test passes with the + // inside-anchor guard deleted, i.e. it pins nothing. + const root = linkify('

https://example.com/tmp/shot.png

'); + + expect(paths(root)).toHaveLength(0); + expect(root.querySelectorAll('a')).toHaveLength(1); + expect(root.querySelector('a')!.getAttribute('href')).toBe('https://example.com/tmp/shot.png'); + }); + + it('leaves text with no path untouched', () => { + const root = linkify('

Ratio 3/4 on 2026/08/16, see src/app.ts

'); + + expect(paths(root)).toHaveLength(0); + expect(root.textContent).toBe('Ratio 3/4 on 2026/08/16, see src/app.ts'); + }); + + it('cannot turn model text into markup', () => { + // The anchor is built with createElement + textContent, so even a + // path-shaped payload stays text. (`<` also ends a match, so the linkifier + // never spans into it in the first place.) + const root = linkify('

/tmp/x.png<img src=x onerror=alert(1)>.png

'); + + expect(root.querySelector('img')).toBeNull(); + expect(root.textContent).toContain('.png'); + for (const link of paths(root)) { + expect(link.innerHTML).toBe(link.textContent); + } + }); + + it('is wired into message rendering and the click delegate', () => { + // The linkifier is only reachable through these two call sites; losing + // either leaves inert paths (no linkify) or dead links (no handler). + expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)'); + expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/); + expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, this\.activeSessionId\)/); + }); +}); diff --git a/test/routes/file-routes-attachment-path-guard.test.ts b/test/routes/file-routes-attachment-path-guard.test.ts index 8a8543863..5fe204d97 100644 --- a/test/routes/file-routes-attachment-path-guard.test.ts +++ b/test/routes/file-routes-attachment-path-guard.test.ts @@ -56,6 +56,7 @@ import { registerExternalAttachment, type AttachmentRecord, } from '../../src/attachment-registry.js'; +import { SseEvent } from '../../src/web/sse-events.js'; const mockedStat = vi.mocked(fs.stat); const mockedRealpathSync = vi.mocked(realpathSync); @@ -355,4 +356,250 @@ describe('file-routes attachment path guard (COD-53)', () => { attachmentRegistry.clearSession('test-session-mlc'); }); }); + + // ===== Media (click-to-preview parity with the workspace preview) ===== + // A video an agent writes inside the workspace plays with a working scrub + // bar; the same file in /tmp used to be refused as an unsupported type. Both + // now go through the same extension sets, and the raw route has to answer + // with a real media Content-Type and a range, or the player renders and then + // does nothing. + describe('media attachments', () => { + it('registers a video and serves it as seekable video/mp4', async () => { + const content = Buffer.from('MP4DATA-0123456789'); + mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never); + mockedCreateReadStream.mockReturnValue(Readable.from([content.subarray(4, 10)]) as never); + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/tmp/captures/demo.mp4', notify: false }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.attachmentType).toBe('video'); + + const rawRes = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`, + headers: { range: 'bytes=4-9' }, + }); + expect(rawRes.statusCode).toBe(206); + expect(rawRes.headers['content-type']).toBe('video/mp4'); + expect(rawRes.headers['content-range']).toBe(`bytes 4-9/${content.length}`); + expect(rawRes.headers['accept-ranges']).toBe('bytes'); + }); + + it('registers audio with an audio type and its real MIME', async () => { + const content = Buffer.from('ID3AUDIO'); + mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never); + mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never); + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/tmp/captures/take.mp3', notify: false }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.attachmentType).toBe('audio'); + + const rawRes = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`, + }); + expect(rawRes.statusCode).toBe(200); + expect(rawRes.headers['content-type']).toBe('audio/mpeg'); + }); + + it('answers no thumbnail for media instead of spawning a converter', async () => { + // generateFirstPageThumbnail has no media branch; the card falls back to + // its type label. This pins that the route reports that cleanly. + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/tmp/captures/clip.webm', notify: false }, + }); + const { attachmentId } = JSON.parse(res.body).data; + + const thumbRes = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/attachments/${attachmentId}/thumbnail`, + }); + expect(thumbRes.statusCode).toBe(204); + }); + + it('still refuses media in a blocked tree', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/root/private/recording.mp4', notify: false }, + }); + expect(res.statusCode).toBe(403); + }); + }); + + // ===== Text family (code, config and logs outside the workspace) ===== + // The agent in the session can already `cat` these, so refusing the click + // bought no confidentiality. The gate that matters is the path guard, which + // still runs, and markup must not become executable just because it is now + // readable. + describe('text attachments', () => { + it.each([ + ['/tmp/run.log', 'log'], + ['/tmp/data.json', 'json'], + ['/tmp/conf/app.yaml', 'yaml'], + ['/tmp/src/index.ts', 'ts'], + ['/tmp/export.csv', 'csv'], + ])('registers %s as a text attachment', async (path, extension) => { + mockedStat.mockResolvedValue({ size: 40, isFile: () => true, mtimeMs: 5 } as never); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path, notify: false }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.extension).toBe(extension); + expect(body.data.attachmentType).toBe('text'); + }); + + it('serves a text file with no dedicated MIME as inert text/plain', async () => { + const content = Buffer.from('boot ok\nstarted\n'); + mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never); + mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never); + + const reg = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/tmp/run.log', notify: false }, + }); + const rawRes = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/attachments/${JSON.parse(reg.body).data.attachmentId}/raw`, + }); + + expect(rawRes.statusCode).toBe(200); + expect(rawRes.headers['content-type']).toBe('text/plain; charset=utf-8'); + expect(rawRes.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('keeps HTML download-only so readable never means executable', async () => { + // Serving markup with a renderable type on our own origin is stored XSS. + // The preview reads it through fetch(), which ignores the disposition, so + // a clicked .html still shows its source. + const content = Buffer.from(''); + mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never); + mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never); + + const reg = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/tmp/report.html', notify: false }, + }); + const rawRes = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/attachments/${JSON.parse(reg.body).data.attachmentId}/raw`, + }); + + expect(rawRes.headers['content-type']).toBe('application/octet-stream'); + expect(String(rawRes.headers['content-disposition'])).toContain('attachment'); + }); + + it('answers a byte range for text so a huge log is a partial read', async () => { + const content = Buffer.from('0123456789abcdef'); + mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never); + mockedCreateReadStream.mockReturnValue(Readable.from([content.subarray(0, 8)]) as never); + + const reg = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/tmp/big.log', notify: false }, + }); + const rawRes = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/attachments/${JSON.parse(reg.body).data.attachmentId}/raw`, + headers: { range: 'bytes=0-7' }, + }); + + expect(rawRes.statusCode).toBe(206); + expect(rawRes.headers['content-range']).toBe(`bytes 0-7/${content.length}`); + }); + + it.each([ + ['/home/someone/.config/gh/hosts.yml', 'forge token'], + ['/home/someone/project/.env.json', 'dotenv'], + ['/home/someone/.codeman/state.json', 'codeman state (can hold envOverrides secrets)'], + ['/home/someone/deploy/credentials.yaml', 'generic credentials'], + ['/etc/codeman/dump.log', 'blocked tree'], + ])('still refuses %s (%s) now that text is servable', async (path) => { + mockedStat.mockResolvedValue({ size: 40, isFile: () => true, mtimeMs: 5 } as never); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path, notify: false }, + }); + + expect(res.statusCode).toBe(403); + }); + + it('still refuses a type outside the family', async () => { + mockedStat.mockResolvedValue({ size: 40, isFile: () => true, mtimeMs: 5 } as never); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/tmp/drawing.svg', notify: false }, + }); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/unsupported/i); + }); + }); + + // ===== Quiet registration (click-to-preview) ===== + // The file-preview overlay registers a clicked out-of-workspace path to mint + // an id it can render by. It is already putting the file on screen, so the + // usual attachment card + unread badge would announce what the user is + // looking at. `notify: false` suppresses ONLY the broadcast — the guard, the + // registry entry and the by-id routes are identical either way. + describe('quiet registration', () => { + const outside = '/tmp/claude-1000/scratchpad/probe-run-native.png'; + + it('broadcasts by default, so the CLI and publish paths keep their card', async () => { + mockedStat.mockResolvedValue({ size: 128, isFile: () => true, mtimeMs: 5 } as never); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: outside }, + }); + + expect(res.statusCode).toBe(200); + expect(harness.ctx.broadcast).toHaveBeenCalledWith(SseEvent.AttachmentDetected, expect.anything()); + }); + + it('registers and serves a clicked path without broadcasting when notify is false', async () => { + const content = Buffer.from('PNGDATA'); + mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never); + mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never); + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: outside, notify: false }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.fileName).toBe('probe-run-native.png'); + expect(harness.ctx.broadcast).not.toHaveBeenCalled(); + + // The preview renders from this route, so the id has to be live. + const rawRes = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`, + }); + expect(rawRes.statusCode).toBe(200); + expect(rawRes.headers['content-type']).toBe('image/png'); + }); + }); }); diff --git a/test/sensitive-path.test.ts b/test/sensitive-path.test.ts index 264d9966b..793299254 100644 --- a/test/sensitive-path.test.ts +++ b/test/sensitive-path.test.ts @@ -73,6 +73,20 @@ describe('isSensitivePath', () => { ['codeman hook secret', `${HOME}/.codeman/hook-secret`], ['codeman user table', `${HOME}/.codeman/users.json`], ['codeman hook secret on a named instance', `${HOME}/.codeman-beta/hook-secret`], + // state.json persists SessionState.envOverrides, and the env allowlist + // admits key-shaped names (GEMINI_API_KEY, CLAUDE_CODE_*), so it can hold + // a live credential. Named once .json became previewable from outside the + // workspace. + ['codeman state file', `${HOME}/.codeman/state.json`], + ['codeman state file on a named instance', `${HOME}/.codeman-beta/state.json`], + ['codeman state sibling (same payload)', `${HOME}/.codeman/state-inner.json`], + // settings.json holds voiceSettings.apiKey by schema; push-keys.json holds + // the VAPID PRIVATE key; intents.json is 0600 because captured prompts can + // contain secrets and is deliberately kept out of /api/search. + ['codeman settings (Deepgram key)', `${HOME}/.codeman/settings.json`], + ['codeman push keys (VAPID private)', `${HOME}/.codeman/push-keys.json`], + ['codeman intent profiles', `${HOME}/.codeman/intents.json`], + ['codeman intents on a named instance', `${HOME}/.codeman-beta/intents.json`], ]; it.each(blocked)('blocks the %s', (_label, path) => { @@ -88,6 +102,7 @@ describe('isSensitivePath', () => { // The publish skill and the review-card loop attach from these trees, so // only their named secret members are blocked, never the whole tree. ['a codeman screenshot', `${HOME}/.codeman/screenshots/shot.png`], + ['a codeman lifecycle log', `${HOME}/.codeman/session-lifecycle.jsonl`], ['a claude transcript', `${HOME}/.claude/projects/proj/session.jsonl`], ['a claude team inbox', `${HOME}/.claude/teams/alpha/inboxes/bob.json`], // isUnderTree-style separator awareness: a sibling name that merely starts