The Attach button, the Compose and paste-dialog pickers, terminal drop and the
clipboard now take documents beside images and videos: the office formats
pdf/docx/pptx/xlsx/zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml.
A document is admitted by its name's extension (the MIME a phone attaches to a
.md is anyone's guess), buffered under the same 50MB image cap, and judged on
its bytes by the same fail-closed sniffer: a magic-number signature for the
binary formats, UTF-8 with no NUL byte for text. It is saved under a sanitized
stem of its name, so Q3 report.pdf is findable as paste-<ts>-<hex>-Q3-report.pdf.
sh/py/js/html are refused by name with a clear 400. That is a scope choice, not
a security property: file-raw already serves non-media text inert, so this keeps
the server from storing what it would serve or an agent could run, and html and
svg are the ones deliberately kept out. One client classifier (_promptAttachKind)
now returns image, video or document, and the server's dispatch mirrors it: an
image MIME wins whatever the name says, a document extension takes the document
branch, and a foreign extension with no image MIME is refused only after the
image sniff fails, so nothing the image path accepted by its bytes is lost.
Conflicts resolved against #605 (and #602's test mock):
- CLAUDE.md Prompt uploads: #606's rewrite is kept and #605's bounded
workspace probe sentence follows the remote (SSH) refusal.
- test/routes/session-routes.test.ts: the remote-pane-history and
bounded-path-probe mocks are kept beside #606's _resetPasteRateBuckets
import, and both PRs' paste-image tests are kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Multi-user: a non-admin's folder must resolve inside their own userSpacePath
or the session's workingDir (folderAllowedForUser, the path picker's rule),
checked on the resolved folder before it is read and again on each served
file after symlink resolution; 403 otherwise.
- listFolder probes the local folder with probePathKind({ pastCap: true })
first (unknown -> 503 via describeUnknownPath, never 404), then async
fs.realpath; resolveFolderFile uses probePathKind instead of fs.stat.
- The guard runs on the realpath before readdir.
- FOLDER_PATH_LINK_PATTERN ends a folder at ':' (quadratic `://` rescan on
colon runs; PATH=/a:/b linked as one folder).
- Load counters drop stale preview / folder-grid responses; closing the grid
invalidates a listing still in flight.
- validFolderFileName refuses a leading dot; the remote listing matches
extensions in any case.
- Docs: folder grid in the z-index lists; multi-user rule and bounded probe in
the invariants and security docs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Folder paths in the terminal and the response viewer are now links
(rooted or ~/ without an extension, relative with a trailing slash); a
click opens a grid of the folder's supported files, newest first, with
subfolder chips and a parent button. A tile opens the file preview,
which pages through the folder with ‹ › / ←/→; the preview of any file
gets a "Show folder" button.
Server: POST /api/sessions/:id/folders grants + lists a folder (local
fs or one ssh round trip for a remote case) under the attachment guard;
GET …/folders/:folderId/files/:name/(raw|preview|thumbnail) serves one
file by bare name through resolveServableAttachmentPath, so browser
requests still never carry a path. Grants live in their own small
registry so a big folder cannot evict the session's attachments. The
attachment raw route's body is now the shared serveServableRaw helper.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Terminal and response-viewer links only knew rooted absolute paths. Agent
output is full of the other forms: Claude Code echoes an attached image as
`~/repos/x/garage.png`, scripts print `builds/captures/x.png`, and Claude
Code wraps tool output a few columns short of the edge, so a long path
arrives as `.../captures/2` + `026-10-10/x.png` and only its prefix linked.
- constants.js: RELATIVE_FILE_PATH_LINK_PATTERN + findFilePathLinks() (one
entry point for both consumers; never inside an http(s) URL),
resolveLinkedFilePath() (relative -> session workingDir, ~/ as is),
terminalPathLine() (glues a path a program broke over lines; guarded so
ordinary short lines are never joined).
- terminal-ui.js: provider uses them, no overlapping links, opens the
resolved path. Touch selection keeps terminalLogicalLine.
- app.js: response viewer links the same forms; only its own links are
resolved, a markdown document's rebased links stay as they were.
- Server: registerExternalAttachment accepts ~/ (local homedir(); remote
probe expands $HOME on the host). Guards run on the resolved path.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A phone-only toolbar Attach button hands the agent a photo or a video with the
keyboard down. It owns its own file input, positioned over the button so iOS
anchors the picker popover to it; the picked paths land on the prompt as unsent
text, so Compose adopts them together with anything already typed and Use
terminal keyboard hands a single-line draft back the other way.
Videos take a second path on the same route: ?kind=video streams the file to
disk under MAX_PASTE_VIDEO_BYTES (1GB, env CODEMAN_MAX_PASTE_VIDEO_BYTES)
instead of buffering it, sniffs the container from the first bytes as they
arrive, drains a mislabeled body unwritten and unlinks on any failure. It draws
on its own 3/min per-(IP, session) rate bucket beside the image path's 30/min:
at 1GB each, 30/min would be 30GB of disk a minute. Without the flag the route
is byte-identical to the image-only version, same 50MB cap and same bucket.
One client classifier, _promptAttachKind (MIME first, then the name's
extension), drives every pick and paste surface: the Attach button, terminal
drop, the tile grid's drop, the Compose and paste-dialog pickers and the
clipboard handlers. A pick that lands while the tile grid owns the terminal
goes to the PTY rather than the parked main-terminal overlay.
A pasted image landed in <workspace>/.claude-images/, a name that belongs to another tool, in a folder nothing ignored, so it showed up in git status of every case that ever received a paste. Uploads now go to a flat <workspace>/.codeman-uploads/ that carries a .gitignore of `*` (written once, never over a file already there): in the workspace because that is the only path identical for a local agent and a container, flat because a nested .codeman/ is the data dir itself when the workspace is the home directory.
The directory names live in paste-image-gc.ts alone. The old folder receives nothing but stays readable for one release: the hourly sweep and the delete cleanup go through uploadDirs(), the image watcher's ignore filter reads the names. uploadDirs() lists only real directories, none that is, contains or sits inside the data dir, and nothing for a remote session, since both consumers delete. The route refuses a remote (SSH) session before touching disk: the file would land on this host under the remote path, where the agent cannot read it.
test/paste-image-dir-shared.test.ts binds port 0 and leaves the port guard's legacy list.
Decided in #553.
Five post-merge review items from PRs #306 (clickable file paths) and
#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 #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>
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>
Three separate truncations, each cutting a clickable link short so it opened the
wrong target (or nothing at all).
1. A single `&` ended the match. It is a query-parameter separator, so every real
query string was cut: a WordPress edit link resolved to `?post=1479` and opened
the post list instead of the editor, and Claude Code's own `/login` URL was not
usable at all. `&` is now part of a URL; `&&` stays a boundary, since that is
the shell operator and never appears inside one. A lone trailing `&` is still
trimmed as punctuation.
2. Links longer than the terminal is wide were cut at the row boundary. xterm
calls the link provider once per visible ROW and translateToString returns only
that row, despite a comment here claiming it handled wrapping. The provider now
stitches the continuation rows back into one logical line and maps match offsets
back to (x, y), so a link can span rows.
Two kinds of continuation exist and handling only the first is not enough. A
SOFT wrap is the emulator running out of columns, which flags the next row
`isWrapped`. A HARD wrap is the program wrapping its own output and emitting a
real newline, which flags nothing: Ink does this, which is why the /login URL
was cut at the window edge and why the clickable part grew when the window was
widened. A row that fills the full width is now treated as continuing into the
next, that being the only trace a hard wrap leaves behind. Bounded to 12 rows so
a screenful of wide output cannot make every hover re-scan the viewport.
3. Image and PDF paths were not matched at all. `.claude-images/paste-*.png`, what
Codeman writes for a pasted screenshot, rendered as plain text. Those extensions
are now linked and open the file preview, which renders images inline, rather
than the log viewer, which would show binary noise.
Verified in a real terminal: a 450-char /login URL hard-wrapped across 5 rows with
zero isWrapped flags in the buffer (so a genuine hard wrap, not the soft case)
links intact, as do soft-wrapped URLs and a wrapped attachment path. Regression
cases added to link-provider-regex.test.ts, which extracts the patterns from the
shipped source so they cannot drift. Its existing ReDoS guard still passes, which
matters because this changes a pattern that once froze the tab on hover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cmdPattern's empty-matchable unbounded arg group backtracked exponentially
on wrapped heredoc/table lines — hovering one froze the tab for minutes.
Non-empty tokens + bounded reps make it O(n); regression test extracts the
shipped patterns and pins timing on the real killer shapes.
worker-src 'self' blob: is now unconditional so terminal-ui's _safeYield
tick worker (throttling escape) isn't CSP-blocked on non-gesture installs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>