fix(files): open the files agents print, wherever they wrote them - #306
Merged
Conversation
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>
A clip an agent wrote inside the workspace played with a working scrub bar, while the same file in /tmp was refused as an unsupported type. The workspace preview classified media with its own inline extension sets and the attachment allowlist had no media at all, so the two paths disagreed about what a video is. - VIDEO_ATTACHMENT_EXTENSIONS and AUDIO_ATTACHMENT_EXTENSIONS now live in attachment-registry.ts and are imported by file-content's classification, so both paths answer the same. mp4/webm/mov/m4v/ogv and mp3/wav/ogg/oga/m4a/aac/flac/opus join the attachment allowlist. - Real MIME types for those extensions. Without one the raw route falls back to application/octet-stream, which a <video> refuses to decode: the player renders and then does nothing. - getAttachmentType() gained the video and audio members of AttachmentDetectedType. Attachment cards have no per-type CSS and their thumbnail falls back to the type label, since the thumbnailer has no media branch and answers 204 rather than spawning a converter. - The preview overlay's by-id branch renders <video>/<audio> with the same markup as the workspace branch, playsinline included. Serving was already range-aware, so seeking works. The image-watcher keeps its own narrow detection list (png/pdf/docx/pptx), so this does not start popping cards for every video an agent writes. Text types that are not md or txt (.json, .log, code files) remain out of the allowlist by choice and still report what is previewable instead. Verified on an isolated instance: an external mp4 and mp3 both play, seek, and report the right duration, matching the in-workspace clip exactly, and a click on an external mp4 in the terminal opens the player with no attachment card. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…routing them at a viewer that cannot read them A .json/.log/.yaml/code path outside the session workspace was refused as an unsupported type, and clicking one in the terminal made it worse: text goes to the log viewer, which spawns `tail -f` and allows only the workspace, /var/log and ~/logs, so it answered "Path must be within working directory or allowed log directories" while the same path clicked in the response viewer previewed fine. Two surfaces, two answers, for a file the session can already cat. - TEXT_ATTACHMENT_EXTENSIONS IS EDITABLE_EXTENSIONS (config/file-editing.ts), not a second curated list that would drift from it. The rule reads: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The suffix was never the confidentiality gate here, the path guard is (sensitive-file blocklist, /root and /etc trees, realpath before the check), and it still runs on every registration. - Widening what can be READ must not widen what can RUN. html/htm join svg in serveRawFile's download-only branch, so markup is never served with a renderable type on our own origin; other text goes out as inert text/plain; charset=utf-8 with nosniff, matching what the path picker does. The preview reads through fetch(), which ignores the disposition, so a clicked .html still shows its source. - ~/.codeman*/state.json joins isSensitivePath. It persists SessionState.envOverrides and the env allowlist admits key-shaped names (GEMINI_API_KEY, CLAUDE_CODE_*), so it can hold a live credential. Same treatment as hook-secret and users.json, and the rest of the tree stays attachable. - The terminal sends an out-of-workspace path to the preview instead of the log viewer. In-workspace text keeps the tail viewer, which is the point of it, and file-stream-manager's allowlist is untouched: no `tail -f` on arbitrary host paths. - 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. Verified on an isolated instance: a 1.1MB external log opens in ~1.8s showing 500 lines with "showing first 500 lines" in the footer; json, yaml and code preview; an .html carrying a script tag renders as source and does not execute; .svg is still refused; a terminal click on an external .yaml opens the preview with no log viewer and no attachment card; an in-workspace .log still opens the streaming tail viewer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nside-anchor test bite Widening the servable extensions to EDITABLE_EXTENSIONS made ~/.codeman JSON previewable for the first time, and the blocklist named only state.json. But settings.json holds a credential BY SCHEMA (voiceSettings.apiKey), push-keys.json holds the VAPID PRIVATE key, and intents.json is written 0600 precisely because captured prompts can carry secrets — all three were one authenticated click away once an agent printed the path. Blocked alongside state.json, whose rule now also catches state-* siblings. The never-re-cuts-inside-an-anchor test used an unmatchable URL tail, so it passed with the guard deleted; the fixture now carries a matchable /tmp path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
CreatureSurvive pushed a commit
to CreatureSurvive/Codeman
that referenced
this pull request
Aug 17, 2026
Five post-merge review items from PRs Ark0N#306 (clickable file paths) and Ark0N#307 (session sidebar): - constants.js FILE_PREVIEW_EXTENSIONS gains the media extensions it was missing vs the single-source sets in attachment-registry.ts (m4v ogv ogg oga m4a aac flac opus), so an in-workspace .m4a opens the preview player instead of the log viewer; new test/media-extension-parity.test.ts pins all three copies (constants.js, panels-ui.js, attachment-registry.ts) against each other. - FILE_PATH_LINK_PATTERN drops `etc` from its root alternation: /etc is unconditionally in DEFAULT_BLOCKED_TREES, so every /etc link 403'd. Negative cases added to the link-provider and response-viewer tests. - updateSidebarCount() counts the rows actually on the sidebar list (session rows + web-tab rows, minus filtered-out ones) instead of this.sessions.size, and applySidebarFilter() refreshes it so the count follows the filter box per keystroke. - The incremental-render connection-line gate now also fires in sidebar layout (this._lineageEdgeCount is permanently 0 there), matching the strip-scroll listener widened in Ark0N#307, so a badge changing row heights redraws subagent/ultracode connectors. - isSensitivePath() blocks ~/.claude.json, ~/.claude/settings.json and ~/.claude/settings.local.json (credential-bearing by schema), anchored to homedir() read at check time so case-level .claude/settings*.json files stay servable in the File Viewer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
When an agent writes a file and prints its path, Codeman could not open it.
In the terminal the path was already underlined and clickable, but clicking one opened the file-preview overlay on "File not found". The preview goes through
file-content/file-raw, which resolve every path against the session'sworkingDirand refuse anything that escapes it. The paths agents print most often are outside it by definition: a screenshot in/tmp, a capture in Claude Code's own scratchpad dir, a file in another checkout. So the failure hit exactly the case people click.In the response viewer (the panel that shows the agent's last response and the conversation) those paths were not links at all. It renders markdown, so a path written as prose or in backticks arrived as inert text, and the terminal's link provider never sees it.
What changed
Out-of-workspace paths now open.
openFilePreview()detects a path outside the session workspace and registers it throughPOST /api/sessions/:id/attachmentsfirst, then renders it by attachment id. That route is the surface built for live external file references, so nothing new was opened up: the same server-side guard applies (secret trees blocked, symlinks resolved before the check, extension allowlist decides what is servable). The workspace-confined routes keep refusing escapes exactly as before.New optional
notifyfield on that route.notify: falsesuppresses only theattachment:detectedbroadcast. Without it, every click also popped an attachment card and bumped an unread badge to announce the file that was already filling the screen. The default staystrue, so thecodeman attachCLI and the publish paths keep their card.Paths in the response viewer are links.
_linkifyFilePaths()walks the rendered message's text nodes and wraps matches in an anchor that opens the same preview. It builds the anchors with DOM APIs rather than rebuilding already-sanitized markup as a string (the source is model output), skips subtrees that are already inside an<a>so an autolinked URL is never re-cut, and keeps the message text byte-identical so the copy-code button still yields what the agent printed.One path pattern, two consumers. The regex moved to
constants.jsand now feeds both the xterm link provider and the chat linkifier, with a fresh instance per call becauselastIndexis per-object state on a/gregex. A path that is clickable in the terminal but inert in the chat reads as a bug rather than a policy, and one definition is what prevents that. The move also picked up/Usersand/mntroots (nothing was clickable at all on macOS or WSL) plus docx, pptx and video/audio extensions.Z-index fix this exposed.
.file-preview-overlaywas at 2000, below the response viewer at 5000, so a path clicked in the chat opened the overlay behind the panel that launched it. It is now 5100, still under the toast and path-picker band at 10000 so a "Saved" toast keeps landing on top.Video and audio play from outside the workspace too (second commit). A clip an agent wrote inside the workspace played with a working scrub bar while the same file in
/tmpwas refused as an unsupported type, because the workspace preview classified media with its own inline extension sets and the attachment allowlist had no media at all. Those sets now live inattachment-registry.tsand are imported by the workspace classification, so both paths answer the same, and mp4/webm/mov/m4v/ogv plus mp3/wav/ogg/oga/m4a/aac/flac/opus joined the allowlist. Three things had to line up for a player rather than a dead frame: the extension, a realMIME_TYPESentry (a<video>refuses to decodeapplication/octet-stream, which presents as a player that renders and then does nothing), and the range-aware body that was already there, which is what makes seeking work.Text files too, and the terminal stops routing them at a viewer that cannot read them (third commit). A
.json/.log/.yaml/code path outside the workspace was refused as an unsupported type, and clicking one in the terminal was worse: text goes to the log viewer, which spawnstail -fand allows only the workspace,/var/logand~/logs, so it answered "Path must be within working directory or allowed log directories" while the same path in the chat previewed fine.TEXT_ATTACHMENT_EXTENSIONSisEDITABLE_EXTENSIONS(config/file-editing.ts) rather than a second curated list that would drift from it. The rule reads: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The justification is that a session can alreadycatany of these and the path picker already previews them, so the suffix was never the confidentiality gate; the path guard is, and it still runs on every registration.Widening what can be READ must not widen what can RUN, so three things came with it:
html/htmjoinsvgin the download-only branch, so markup is never served with a renderable type on our own origin. Other text goes out as inerttext/plain; charset=utf-8withnosniff, matching what the path picker already does. The preview reads throughfetch(), which ignores the disposition, so a clicked.htmlstill shows its source.~/.codeman*/state.jsonjoins the sensitive-path blocklist. It persistsSessionState.envOverrides, and the env allowlist admits key-shaped names (GEMINI_API_KEY,CLAUDE_CODE_*), so that file can hold a live credential. Same treatment ashook-secretandusers.json; the rest of the tree stays attachable.file-stream-manager's allowlist is untouched: notail -fon arbitrary host paths.Text previews are bounded like the workspace one: a
Rangerequest for the first 512KB (a real partial read, not a discarded 50MB download) plus a 500-line cap, with the footer saying so..svgand.bmpfrom outside the workspace still say what can be previewed instead of failing with a policy term. Inside the workspace everything previews as before.Not changed
file-stream-manager's allowlist: the log viewer still refuses totail -fanything outside the workspace,/var/logand~/logs.codeman://attachmagic-link scanner stays force-confined to the workspace. A click is an explicit user action on the explicit, Origin-guarded registration route, which is what makes crossing the workspace boundary appropriate there and not in a scanner reading attacker-influenceable terminal output.Testing
npm run test:cigreen (257 files, 5112 tests). New and extended suites:test/response-viewer-file-links.test.ts(new): the linkifier over a jsdom document, pinning that the message text is untouched, that an existing anchor is never re-cut, and that model text cannot become markup.test/link-provider-regex.test.ts: the linear-time guard now covers the shared pattern, plus a structural check that terminal-ui builds from the factory instead of a local literal that could drift.test/routes/file-routes-attachment-path-guard.test.ts: quiet registration broadcasts nothing while still registering and serving, the default still broadcasts, media registers with the right type and serves a 206 withvideo/mp4and aContent-Range, media answers 204 for a thumbnail rather than spawning a converter, text registers as text and serves inerttext/plain, HTML stays download-only, and~/.config/gh/hosts.yml,.env.json,~/.codeman/state.json,credentials.yamland the/etctree are all still refused now that text is servable.test/sensitive-path.test.ts: the newstate.jsonentries, including on a named instance.Verified end to end against an isolated instance (its own data dir and tmux socket), on a desktop viewport and an iPhone 13 viewport:
/attachments/att_.../raw).mdrenders as text, external.pdfin the iframe/etc/hostsstill refused with "Access to this file is blocked"1.1 MB • log (showing first 500 lines); json, yaml and code preview as text.htmlcarrying<script>window.__PWNED=1</script>renders as source and does not execute.yamlopens the preview with no log viewer and no attachment card, while an in-workspace.logstill opens the streaming tail viewer