fix(files): open file paths agents print, from the terminal and the chat

A path an agent prints was already underlined in the terminal, but clicking
one opened the preview overlay on "File not found": file-content/file-raw
resolve against the session workingDir and refuse anything outside it, and the
paths agents print most (a /tmp capture, Claude's own scratchpad, another
checkout) are outside it by definition. In the response viewer those paths were
not links at all.

- openFilePreview() detects an out-of-workspace path and registers it through
  POST /api/sessions/:id/attachments first, rendering by attachment id. That is
  the surface built for live external files, so the server-side guard is
  unchanged: secret trees blocked, symlinks resolved, extension allowlist. The
  workspace routes keep refusing escapes exactly as before.
- New optional `notify` field on that route. `notify: false` suppresses only the
  attachment:detected broadcast, so a click does not also pop a card announcing
  the file already filling the screen. Default stays true for the CLI and
  publish callers.
- _linkifyFilePaths() links paths in rendered response-viewer markdown. It walks
  text nodes and builds anchors with DOM APIs (the source is model output; never
  a string rebuild of sanitized markup), skips subtrees already inside an <a>,
  and keeps the message text byte-identical so copy-code is unaffected.
- One path pattern in constants.js now feeds both the xterm link provider and
  the chat linkifier, a fresh instance per call since lastIndex is per-object
  state. It picks up /Users and /mnt roots (nothing was clickable on macOS or
  WSL), plus docx/pptx and video/audio extensions.
- .file-preview-overlay moves to z-index 5100, above the response viewer at
  5000. At its old 2000 a path clicked in the chat opened the overlay behind the
  panel it was launched from.

Verified end to end on an isolated instance, desktop and phone viewport: real
clicks in the terminal and the chat both render the image, external md and pdf
render, /etc/hosts is still refused, workspace previews unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-16 17:03:42 +02:00
parent 869a507482
commit 4e2c1b9989
11 changed files with 456 additions and 24 deletions
+67
View File
@@ -2004,6 +2004,17 @@ class CodemanApp {
if (!body || body.dataset.rvBound === '1') return;
body.dataset.rvBound = '1';
body.addEventListener('click', async (ev) => {
// File path (_linkifyFilePaths): open it in the preview overlay, which
// resolves workspace and out-of-workspace paths alike.
const pathLink = ev.target.closest('a.rv-path');
if (pathLink) {
ev.preventDefault();
ev.stopPropagation();
const filePath = pathLink.dataset.path;
if (filePath) this.openFilePreview(filePath, this.activeSessionId);
return;
}
// One-click copy: lift the raw source from the sibling <pre><code>.
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 `<a>` 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'
+39
View File
@@ -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 };
}
+81 -1
View File
@@ -3234,6 +3234,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, PDF, Office documents, Markdown and text 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,13 +3317,34 @@ 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 = `<div class="binary-message">${escapeHtml(externalError)}</div>`;
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();
// 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 = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
} else if (ext === 'pdf') {
+16 -1
View File
@@ -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;
+13 -12
View File
@@ -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,10 @@ 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.
if (previewsInFileViewer(text)) {
self.openFilePreview(text, self.activeSessionId);
return;
}
+10 -2
View File
@@ -1449,7 +1449,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
app.post('/api/sessions/:id/attachments', async (req, reply) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const body = (req.body || {}) as { path?: string };
const body = (req.body || {}) as { path?: string; notify?: boolean };
if (!body.path || typeof body.path !== 'string') {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
@@ -1458,7 +1458,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
try {
const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir });
ctx.broadcast(SseEvent.AttachmentDetected, event);
// `notify: false` registers QUIETLY. The file-preview overlay uses it to
// mint an id for a path the user just clicked (a terminal or response-viewer
// link pointing outside the workspace): it is already opening the file, so
// the attachment card + unread badge would be noise announcing what is
// filling the screen. Default stays true — every other caller (the
// `codeman attach` CLI, codeman-publish) wants the card.
if (body.notify !== false) {
ctx.broadcast(SseEvent.AttachmentDetected, event);
}
return { success: true, data: event };
} catch (err) {
if (err instanceof AttachmentRegistrationError) {