Skip to content

fix(files): open the files agents print, wherever they wrote them - #306

Merged
Ark0N merged 4 commits into
masterfrom
feat/file-path-links
Aug 16, 2026
Merged

fix(files): open the files agents print, wherever they wrote them#306
Ark0N merged 4 commits into
masterfrom
feat/file-path-links

Conversation

@Ark0N

@Ark0NArk0N commented Aug 16, 2026

Copy link
Copy Markdown
Owner

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's workingDir and 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 through POST /api/sessions/:id/attachments first, 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 notify field on that route.notify: false suppresses only the attachment:detected broadcast. 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 stays true, so the codeman attach CLI 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.js and now feeds both the xterm link provider and the chat linkifier, with a fresh instance per call because lastIndex is per-object state on a /g regex. 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 /Users and /mnt roots (nothing was clickable at all on macOS or WSL) plus docx, pptx and video/audio extensions.

Z-index fix this exposed..file-preview-overlay was 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 /tmp was 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 in attachment-registry.ts and 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 real MIME_TYPES entry (a <video> refuses to decode application/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 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 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 already cat any 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/htm join svg in the 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 already does. The preview reads through fetch(), which ignores the disposition, so a clicked .html still shows its source.
  • ~/.codeman*/state.json joins the sensitive-path blocklist. It persists SessionState.envOverrides, and the env allowlist admits key-shaped names (GEMINI_API_KEY, CLAUDE_CODE_*), so that file can hold a live credential. Same treatment as hook-secret and users.json; 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.

Text previews are 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.

.svg and .bmp from 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

  • No change to the workspace file routes or their confinement.
  • The image-watcher keeps its own narrow detection list (png/pdf/docx/pptx), so adding media to the attachment allowlist does not start popping cards for every video an agent writes.
  • file-stream-manager's allowlist: the log viewer still refuses to tail -f anything outside the workspace, /var/log and ~/logs.
  • The passive codeman://attach magic-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:ci green (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 with video/mp4 and a Content-Range, media answers 204 for a thumbnail rather than spawning a converter, text registers as text and serves inert text/plain, HTML stays download-only, and ~/.config/gh/hosts.yml, .env.json, ~/.codeman/state.json, credentials.yaml and the /etc tree are all still refused now that text is servable.
  • test/sensitive-path.test.ts: the new state.json entries, 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:

  • real click on the path in the terminal renders the PNG (decoded 64x64, served from /attachments/att_.../raw)
  • real click on the path in the chat does the same, with the overlay above the panel
  • external .md renders as text, external .pdf in the iframe
  • /etc/hosts still refused with "Access to this file is blocked"
  • workspace-relative, workspace-absolute and workspace-image previews unchanged
  • an external mp4 and mp3 play, seek and report the right duration, identical to the same clip inside the workspace, and a terminal click on an external mp4 opens the player with no attachment card
  • a 1.1MB external log opens in ~1.8s showing 500 lines, footer 1.1 MB • log (showing first 500 lines); json, yaml and code preview as text
  • an external .html carrying <script>window.__PWNED=1</script> renders as source and does not execute
  • a terminal click on an external .yaml opens the preview with no log viewer and no attachment card, while an in-workspace .log still opens the streaming tail viewer

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>
@Ark0NArk0N changed the title fix(files): open file paths agents print, from the terminal and the chatfix(files): open the files agents print, wherever they wrote themAug 16, 2026
…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>
@Ark0N
Ark0N merged commit aaf2290 into masterAug 16, 2026
2 checks passed
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Ark0N@claude