mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29:42 +02:00
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:
@@ -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'
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user