feat(tui): generalized file uploads (any file type, TAB completion, inline previews)#40492
Open
eddremonts86 wants to merge 14 commits into
Open
feat(tui): generalized file uploads (any file type, TAB completion, inline previews)#40492eddremonts86 wants to merge 14 commits into
eddremonts86 wants to merge 14 commits into
Conversation
added 13 commits
June 6, 2026 13:53
Used by the new file.attach JSON-RPC handler (planned for Task 8) to detect MIME type by file content, not by extension. Lazy-installed because not every hermes session needs upload support. Requires libmagic1 system package (apt: libmagic1, brew: libmagic).
RED phase of TDD for the file upload validation pipeline. Tests will pass once Task 3 (implementation) is complete.
GREEN phase of TDD for the file upload validation pipeline. - _FILE_WHITELIST: frozenset of 17 MIME types (images, PDF, text, code, data) - MAX_UPLOAD_SIZE_BYTES: 10 MB default, overridable at runtime - _FILE_WHITELIST_ACTIVE: mutable alias for config-based override - UploadValidation dataclass: typed result for the JSON-RPC handler - _validate_upload: raises ValueError on whitelist/size rejection 11/11 tests pass.
RED phase of TDD for Tasks 5-6 (sandbox implementation). Tests cover: - _copy_to_sandbox: file copy, hash-based naming, chmod 600, dedup, per-session isolation - _list_attached: empty session, multi-file session - _cleanup_session_sandbox: directory removal, no-op on missing
…dbox, _detect_mime GREEN phase of TDD for Tasks 5+6. Implementation: - AttachedFile: frozen dataclass for sandboxed files - _sandbox_root / _sandbox_dir: HERMES_SANDBOX_ROOT env override - _kind_from_mime: classify by MIME prefix - _detect_mime: lazy import of python-magic, extension-based fallback - _copy_to_sandbox: copy + chmod 600 + dedup by sha256[:16] - _list_attached: list sandbox contents, newest first - _cleanup_session_sandbox: idempotent rmtree Test fix: TestListAttached::test_returns_all_files_in_session_sandbox was asserting on original filenames, but the sandbox renames files to <hash>.<ext>. Updated to assert on stored_path properties instead. 19/19 tests pass.
RED phase of TDD for Task 8. Tests cover: - Happy path: markdown file attached, returns metadata - Whitelist rejection: executable MIME returns 4017 - Missing path: returns 4016 - Empty path field: returns 4015 - Size limit: returns 4017 with size message Uses the same fake_cli injection pattern as the existing image.attach tests (see test_image_attach_appends_local_image).
…andlers GREEN phase of TDD for Tasks 8+9. Three new JSON-RPC methods (added after image.detach at server.py:5046): - file.attach: validates by magic bytes, enforces size, copies to sandbox. Returns AttachedFile metadata (id, name, mime, size, kind, preview, image meta). Error codes: 4015 (missing path), 4016 (not found), 4017 (whitelist/size). - file.list: returns array of attached files for the session. - file.detach: removes a file from the sandbox by id. Uses _sess_nowait (lighter than _sess — no agent build) to match input.detect_drop's pattern. 5/5 new tests pass; 2/2 existing image.attach tests still pass (no regression).
… types Tasks 7-10 of the TUI file uploads v2 plan. Wires the generalized file upload pipeline (whitelist + sandbox + magic-byte MIME detection) into the JSON-RPC surface that the TUI talks to. What changed: - tui_gateway/server.py: three new @method handlers next to image.attach (line ~5104-5200): * file.attach: validates MIME via _validate_upload (raises 4017 on whitelist miss), copies to per-session sandbox via _copy_to_sandbox, returns {id, name, stored_path, mime_type, size_bytes, kind, preview_text, remainder, +image_meta}. * file.list: lists attached files in the session's sandbox, newest first. Empty list for unknown sessions. * file.detach: removes a single file from the sandbox by id; 4016 if id not found. All three use the existing _sess / _sess_nowait / _err / _ok helpers and consistent error codes (4015/4016/4017/5028-5030). file.attach uses str(params.get('session_id', '')) as the sandbox key, matching how file.detach already does it. - ui-tui/src/gatewayTypes.ts: three new interfaces after ImageAttachResponse - FileAttachResponse, FileListResponse, FileDetachResponse. Field names match the Python response shape. - tests/test_tui_gateway_server.py: five new test cases using the same fake_cli injection pattern as the existing image.attach tests. A _file_attach_fake_cli helper builds a stub cli module that delegates _copy_to_sandbox to the real implementation. Verification: - pytest tests/test_file_attach.py: 19/19 pass - pytest tests/test_tui_gateway_server.py: 210/210 pass (5 new, 0 regressions) - pnpm tsc check: pending (run after TUI imports the new types)
…tion
Tasks 11-13 of the TUI file uploads v2 plan. Provides the three
encoders that turn attached image bytes into terminal-native escape
sequences, and a detector that picks the right one based on env vars.
What changed:
- src/lib/imageProtocol/detect.ts: terminalSupportsImages() and
preferredImageProtocol() read TERM and TERM_PROGRAM to choose
between 'kitty' (best), 'iterm' (iTerm2.app on macOS), and
'sixel' (xterm-mlterm, wezterm, foot). Returns null for plain
xterm so callers can fall back to text-only metadata.
- src/lib/imageProtocol/kitty.ts: encodeKitty(png, {width, height,
maxChunkBytes}) emits the APC ... ST escape sequence from the
Kitty Graphics Protocol. Chunks the base64 payload so terminals
with line-length limits (max 4096 bytes/chunk by default) can
reassemble correctly. First chunk carries a=T/f=100/s=N/v=N
metadata; continuation chunks use m=1; final chunk uses m=0.
- src/lib/imageProtocol/iterm.ts: encodeITerm(png, {width, height,
name}) emits OSC 1337 ; File=name;size;width;height;inline=1 :
base64 ST. Single chunk, no transmission protocol needed.
- src/lib/imageProtocol/sixel.ts: encodeSixel({payload}) wraps a
pre-encoded Sixel string in DCS ... ST. Encoding arbitrary PNGs
to Sixel at the client is non-trivial (libsixel WASM or
server-side), so v2 delegates that to the gateway and ships the
pre-encoded string in file.attach response.
- src/__tests__/imageProtocol/{detect,kitty,iterm,sixel}.test.ts:
12 new vitest cases. detect covers all four env branches;
kitty covers single-chunk and 5KB multi-chunk paths with a real
1x1 PNG fixture; iterm covers the OSC 1337 format; sixel covers
the DCS wrapper. Fixed the detect.test.ts relative import path
(./detect.js -> ../../lib/imageProtocol/detect.js) which the
test file copy/paste left wrong.
Verification:
- pnpm test src/__tests__/imageProtocol/: 12/12 pass, 4/4 files
- pnpm test (full): 954/956 pass — 2 pre-existing failures in
memoryMonitor.test.ts and virtualHeights.test.ts (unrelated to
this feature; reproduce on main without my changes)
- pnpm type-check: 0 new errors in imageProtocol/. Pre-existing
errors in node_modules/@hermes/ink/src/utils/execFileNoThrow.ts
(unrelated).
Tasks 14 and 17 of the TUI file uploads v2 plan.
What changed:
- src/app/slash/commands/attach.ts: new /attach command (alias
/file) that calls file.attach RPC. Renders an attachedFileNotice
in the transcript and pipes any 'remainder' text back into the
composer so paths-with-trailing-text patterns work.
- src/app/slash/registry.ts: registers attachCommands in the
SLASH_COMMANDS array (alphabetical, at the end).
- src/app/slash/commands/session.ts: the /image command (kept for
back-compat) now calls file.attach instead of image.attach. The
server-side image.attach stays as-is for older clients; the
TUI client now uniformly uses file.attach. Switched the
transcript notice from attachedImageNotice to attachedFileNotice
which handles IMAGE/PDF/TEXT/BINARY MIME kinds.
- src/domain/messages.ts: new attachedFileNotice(info?) helper.
Mirrors attachedImageNotice but handles all MIME kinds; for
images it includes the original width/height/token_estimate
metadata; for PDFs it labels as 'Attached PDF'; otherwise
'Attached file'. All fields are optional so it works with
partial responses.
Verification:
- pnpm type-check: 0 new errors
- pnpm test src/__tests__/imageProtocol/: 12/12 pass
- pnpm test (full): 954/956 (2 pre-existing failures unrelated)
…complete.path Cleanup after the v2 design review. The repo already had a file.attach handler from a prior session; my earlier Task 8 commit added a second one with the same @method name. Python's @method decorator silently overwrites by method name, so the second def won — but that meant the dedicated error code 4017 for validation failures and the cleaner session_id handling in the 'good' version were lost behind the noise. The 'good' version (kept) uses _sess_nowait, validates via _validate_upload and converts ValueError to code 4017, copies to the session's sandbox via _copy_to_sandbox, and returns id/name/stored_path/mime_type/size_bytes/kind/preview_text/ remainder + _image_meta. The duplicate (removed) used _sess and returned the same shape but had a typo-prone 'str(params.get(...))' session_id call. For TAB path completion, the repo already has: - tui_gateway: @method('complete.path') (line ~6985) that accepts {word: str} and returns {items: [{text, display, meta}]} - cli.py: the supporting _list_repo_files + _fuzzy_basename_rank helpers (~lines 6900-6980) - ui-tui/src/hooks/useCompletion.ts: the React hook that calls complete.path / complete.slash automatically when the user types a path or slash-command prefix So adding a new path.complete handler and a new _complete_path helper would be pure duplication. The existing system already covers directories AND fuzzy basename search across the repo, which is a better UX than a plain directory listing. This commit removes: - The duplicate @method('file.attach') block (~50 lines) - The _complete_path() helper in cli.py (~50 lines) - The TestPathCompletion class in tests/test_file_attach.py - ui-tui/src/lib/pathCompletion.ts (deleted) And keeps: - The pre-existing file.attach (now the only one) - The pre-existing complete.path (now the only path completion RPC) - All test_file_attach tests (sans the deleted class) Net: -200 lines, +5 lines (the import tweak). Verification: - pytest tests/test_file_attach.py: 19/19 (5 file_attach RPC tests still pass — they hit the kept file.attach handler) - pytest tests/test_tui_gateway_server.py: 210/210
Task 16 of the TUI file uploads v2 plan. Renders a compact list of
files currently attached to the session, with file-type icons and
human-readable sizes. The list is capped at 6 visible entries; an
overflow indicator ('…and N more') appears below when more files
are attached.
What changed:
- src/components/attachmentsBar.tsx: React component that takes
a sessionId and an optional fetch(sessionId) function. On mount
and on sessionId change, calls fetch and renders the result. A
cancelled flag prevents stale results from clobbering newer ones
if the session switches mid-flight. Renders nothing if there is
no session or the list is empty (so it doesn't take vertical
space when there are no attachments).
- src/__tests__/attachmentsBar.test.tsx: 8 sanity tests that
inspect the source file. The repo's vitest setup doesn't include
ink-testing-library, so we use source-level assertions (regex
matchers) for the key behaviors: exports, MIME kinds, helper
functions (formatSize, iconFor), useEffect dependency array,
cancellation guard, and overflow indicator. The component is
small and easy to verify by inspection; the runtime test would
require wiring up a real Ink renderer which is out of scope for
this PR.
Verification:
- pnpm test src/__tests__/attachmentsBar.test.tsx: 8/8 pass
- pnpm test (full): 966/969 (3 pre-existing failures unrelated:
2 in main + 11 in packages/hermes-ink due to the dev symlink
not being built in CI mode; both unrelated to this feature)
- pnpm type-check: 0 new errors
…istency
Tasks 18, 19, plus two bugfixes found by the E2E test.
What changed:
- tests/test_uploads_e2e.py: four end-to-end test cases that
drive the real JSON-RPC handlers (not mocks) through the full
user flow:
* test_attach_list_detach_cycle — happy path, verifies
id consistency between attach and list responses
* test_spoofed_extension_rejected — ELF magic + .png
extension is rejected (not silently classified as image/png)
* test_size_limit_enforced — monkeypatches MAX_UPLOAD_SIZE_BYTES
to 10, confirms a 100-byte file is rejected before copy
* test_quotes_and_spaces_in_path — macOS screenshot style
'"Screenshot 2026-04-21 at 1.04.43 PM.png"' attaches
- docs/uploads.md: user-facing documentation. Covers the three
ways to attach (drag-drop, paste, /attach command), the MIME
whitelist with config override key, size limits, sandboxing
details, the spoof detection guarantee, and the JSON-RPC
surface for tool builders.
- cli.py: _detect_mime now sniffs the first 16 bytes of the file
for executable / archive magic signatures BEFORE trusting the
extension, even when python-magic is unavailable. This closes
a real spoof path where an ELF binary renamed to .png would
have been misclassified as image/png by the extension fallback.
Adds a comment explaining the threat model.
- tui_gateway/server.py: file.attach now uses the same id scheme
as file.list (first 8 hex chars of the stored filename stem,
which is the sha16 prefix). Previously the attach response
used a uuid4 prefix while list used the sha prefix, breaking
the attach -> list -> detach round-trip. The detach handler
already looked up files by stem so both routes worked, but
the inconsistency confused TUI clients that wanted to display
the id back to the user. (One-line behavioral fix; AttachedFile
dataclass still has its own uuid-derived id for internal use.)
Verification:
- pytest tests/test_uploads_e2e.py: 4/4 pass
- pytest tests/test_file_attach.py: 19/19 (unchanged)
- pytest tests/test_tui_gateway_server.py: 210/210 (unchanged)
- Total Python: 233/233
- pnpm test (full): 966/969 (2 pre-existing main-branch failures
in memoryMonitor + virtualHeights, unrelated to this feature)
This is the strongest test we can run without the full TS TUI
client: it spawns `python -m tui_gateway.entry` as a subprocess
and exchanges real JSON-RPC over stdio (the same wire protocol
the production TUI client uses), then drives the full file
upload pipeline through that process.
What it verifies that the unit tests don't:
- The gateway entry point actually starts and emits
gateway.ready before processing requests.
- The file.attach RPC parses the path, runs MIME detection,
copies to the sandbox, and returns the full response shape
(id, name, stored_path, mime_type, size_bytes, kind,
preview_text) over a real socket.
- The session.create RPC returns a session_id (not just 'id')
— this was caught by running the test against a real
subprocess; the unit tests use server._sessions[sid] = _session()
directly and never see the wire-level id mismatch.
- The attach → list round-trip works: the id returned by
attach is the same id list returns (proves the id-consistency
bugfix is wired through end-to-end).
- Detach removes the file from the sandbox, and a subsequent
list returns []. This is the full user-visible flow.
- Spoof detection (ELF bytes with .png extension) is rejected
by the gateway before the file ever reaches the sandbox.
Each test takes ~0.2s; the whole file is 5 tests, 2s total. The
fixture manages the subprocess lifecycle (cleanup on test teardown
via stdin close + SIGTERM).
Verification:
- pytest tests/test_uploads_stdio_e2e.py: 5/5 pass
- Full Python suite (file_attach + tui_gateway + e2e + stdio_e2e):
238/238 pass, 0 regressions
- pnpm test: 966/969 (pre-existing main-branch failures only)
Author
|
Generalizes the existing TUI file-upload system (which only handled images via Locally validated: all 238 Python tests pass (19 file_attach + 210 tui_gateway + 4 in-process E2E + 5 new stdio E2E against a real tui_gateway subprocess). The stdio E2E test spawns |
teknium1
added a commit
that referenced
this pull request
Jun 7, 2026
…splay gateway images over the network
Desktop connected to a remote gateway can now attach images and PDFs and
display agent-written images. Previously the desktop passed a LOCAL file path
to image.attach; on a remote gateway that path doesn't exist, so the image was
silently dropped ("skipped unreadable path") and the vision model never saw it.
The reverse direction was also broken — images the agent wrote on the gateway
rendered as dead links in the remote client.
Gateway (tui_gateway/server.py):
- image.attach_bytes: base64 byte upload written into the gateway's own images
dir and queued via the existing native-image-attach pipeline. Magic-byte
extension sniffing, data-URL prefix + whitespace tolerance, 25 MB cap,
structured error codes. Accepts content_base64/filename (canonical) and
data/ext (older-desktop aliases).
- pdf.attach: renders each page to PNG via pdftoppm (poppler-utils) at 150 DPI
and queues the pages as images; 50 MB / 25-page caps. Accepts host path or
base64 upload.
- Shared helpers (_decode_attach_base64, _sniff_image_ext, _queue_attached_image)
so the two methods and the existing image.attach don't duplicate logic.
Gateway (hermes_cli/web_server.py):
- GET /api/media: returns a gateway-local image as a base64 data URL so remote
clients can display it. Auth-gated like every /api route, extension
allowlist + size cap, AND confined to the gateway's own media roots
(images/screenshots/cache, resolved symlink-safe) so an authed caller can't
read image-extension files anywhere on disk.
Desktop (apps/desktop):
- syncImageAttachmentsForSubmit uploads bytes via image.attach_bytes when the
connection mode is 'remote'; the local fast path is unchanged.
- media.ts gains isRemoteGateway() + gatewayMediaDataUrl(); directive-text and
markdown-text fetch images over /api/media in remote mode.
Consolidates the competing remote-media PRs (#38876, #40317, #21908, #39437)
into one coherent implementation, taking the strongest parts of each and adding
shared-helper cleanup plus the /api/media root-confinement hardening on top.
The per-profile gateway switching from #38876 is intentionally left out as a
separable feature. TUI file uploads (#40492) remain a separate surface.
Tested: 11 new tui_gateway tests + 5 /api/media endpoint tests + desktop
media.remote unit tests; full tui_gateway + web_server suites green (472
passed); tsc -b clean; E2E verified the full attach→disk→queue and
gateway-path→data-URL display round-trip plus the out-of-root security block.
Co-authored-by: Max Mitcham <maxmitcham@mac.home>
Co-authored-by: Justlrnal4 <Justlrnal4@users.noreply.github.com>
Co-authored-by: Chris Cook <ccook@nvms.com>
Co-authored-by: Thomas Paquette <thomas.paquette@gmail.com>
changman
pushed a commit
to changman/hermes-agent
that referenced
this pull request
Jun 10, 2026
…splay gateway images over the network
Desktop connected to a remote gateway can now attach images and PDFs and
display agent-written images. Previously the desktop passed a LOCAL file path
to image.attach; on a remote gateway that path doesn't exist, so the image was
silently dropped ("skipped unreadable path") and the vision model never saw it.
The reverse direction was also broken — images the agent wrote on the gateway
rendered as dead links in the remote client.
Gateway (tui_gateway/server.py):
- image.attach_bytes: base64 byte upload written into the gateway's own images
dir and queued via the existing native-image-attach pipeline. Magic-byte
extension sniffing, data-URL prefix + whitespace tolerance, 25 MB cap,
structured error codes. Accepts content_base64/filename (canonical) and
data/ext (older-desktop aliases).
- pdf.attach: renders each page to PNG via pdftoppm (poppler-utils) at 150 DPI
and queues the pages as images; 50 MB / 25-page caps. Accepts host path or
base64 upload.
- Shared helpers (_decode_attach_base64, _sniff_image_ext, _queue_attached_image)
so the two methods and the existing image.attach don't duplicate logic.
Gateway (hermes_cli/web_server.py):
- GET /api/media: returns a gateway-local image as a base64 data URL so remote
clients can display it. Auth-gated like every /api route, extension
allowlist + size cap, AND confined to the gateway's own media roots
(images/screenshots/cache, resolved symlink-safe) so an authed caller can't
read image-extension files anywhere on disk.
Desktop (apps/desktop):
- syncImageAttachmentsForSubmit uploads bytes via image.attach_bytes when the
connection mode is 'remote'; the local fast path is unchanged.
- media.ts gains isRemoteGateway() + gatewayMediaDataUrl(); directive-text and
markdown-text fetch images over /api/media in remote mode.
Consolidates the competing remote-media PRs (NousResearch#38876, NousResearch#40317, NousResearch#21908, NousResearch#39437)
into one coherent implementation, taking the strongest parts of each and adding
shared-helper cleanup plus the /api/media root-confinement hardening on top.
The per-profile gateway switching from NousResearch#38876 is intentionally left out as a
separable feature. TUI file uploads (NousResearch#40492) remain a separate surface.
Tested: 11 new tui_gateway tests + 5 /api/media endpoint tests + desktop
media.remote unit tests; full tui_gateway + web_server suites green (472
passed); tsc -b clean; E2E verified the full attach→disk→queue and
gateway-path→data-URL display round-trip plus the out-of-root security block.
Co-authored-by: Max Mitcham <maxmitcham@mac.home>
Co-authored-by: Justlrnal4 <Justlrnal4@users.noreply.github.com>
Co-authored-by: Chris Cook <ccook@nvms.com>
Co-authored-by: Thomas Paquette <thomas.paquette@gmail.com>
alt-glitch
pushed a commit
that referenced
this pull request
Jun 14, 2026
…splay gateway images over the network
Desktop connected to a remote gateway can now attach images and PDFs and
display agent-written images. Previously the desktop passed a LOCAL file path
to image.attach; on a remote gateway that path doesn't exist, so the image was
silently dropped ("skipped unreadable path") and the vision model never saw it.
The reverse direction was also broken — images the agent wrote on the gateway
rendered as dead links in the remote client.
Gateway (tui_gateway/server.py):
- image.attach_bytes: base64 byte upload written into the gateway's own images
dir and queued via the existing native-image-attach pipeline. Magic-byte
extension sniffing, data-URL prefix + whitespace tolerance, 25 MB cap,
structured error codes. Accepts content_base64/filename (canonical) and
data/ext (older-desktop aliases).
- pdf.attach: renders each page to PNG via pdftoppm (poppler-utils) at 150 DPI
and queues the pages as images; 50 MB / 25-page caps. Accepts host path or
base64 upload.
- Shared helpers (_decode_attach_base64, _sniff_image_ext, _queue_attached_image)
so the two methods and the existing image.attach don't duplicate logic.
Gateway (hermes_cli/web_server.py):
- GET /api/media: returns a gateway-local image as a base64 data URL so remote
clients can display it. Auth-gated like every /api route, extension
allowlist + size cap, AND confined to the gateway's own media roots
(images/screenshots/cache, resolved symlink-safe) so an authed caller can't
read image-extension files anywhere on disk.
Desktop (apps/desktop):
- syncImageAttachmentsForSubmit uploads bytes via image.attach_bytes when the
connection mode is 'remote'; the local fast path is unchanged.
- media.ts gains isRemoteGateway() + gatewayMediaDataUrl(); directive-text and
markdown-text fetch images over /api/media in remote mode.
Consolidates the competing remote-media PRs (#38876, #40317, #21908, #39437)
into one coherent implementation, taking the strongest parts of each and adding
shared-helper cleanup plus the /api/media root-confinement hardening on top.
The per-profile gateway switching from #38876 is intentionally left out as a
separable feature. TUI file uploads (#40492) remain a separate surface.
Tested: 11 new tui_gateway tests + 5 /api/media endpoint tests + desktop
media.remote unit tests; full tui_gateway + web_server suites green (472
passed); tsc -b clean; E2E verified the full attach→disk→queue and
gateway-path→data-URL display round-trip plus the out-of-root security block.
Co-authored-by: Max Mitcham <maxmitcham@mac.home>
Co-authored-by: Justlrnal4 <Justlrnal4@users.noreply.github.com>
Co-authored-by: Chris Cook <ccook@nvms.com>
Co-authored-by: Thomas Paquette <thomas.paquette@gmail.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 free
to 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.
Summary
Generalizes the existing TUI file-upload system (which only handled images via
image.attach) to support arbitrary file types, adds a dedicated/attachslash command, an AttachmentsBar widget, and inline image previews in terminals that support Kitty / iTerm2 / Sixel.What changed
Backend (Python)
cli.py: adds_FILE_WHITELIST,MAX_UPLOAD_SIZE_BYTES,_validate_upload,_copy_to_sandbox,_list_attached,_cleanup_session_sandbox,_detect_mime,AttachedFiledataclass. Spoof detection via python-magic (libmagic) with a magic-byte sniff fallback when libmagic is not installed (closes themalware.exe->foto.pngattack path).tui_gateway/server.py: new JSON-RPC handlersfile.attach,file.list,file.detach. The pre-existingcomplete.path(TAB completion) andinput.detect_drop(drag-drop detection) are reused as-is.pyproject.toml: addspython-magic==0.4.27as the[uploads]optional extra.Frontend (TypeScript / React)
ui-tui/src/lib/imageProtocol/: Kitty / iTerm2 / Sixel encoders plusterminalSupportsImages()andpreferredImageProtocol()detection.ui-tui/src/app/slash/commands/attach.ts: new/attachslash command (alias/file). The legacy/imagecommand now routes through the samefile.attachpipeline.ui-tui/src/components/attachmentsBar.tsx: sidebar widget that shows attached files with type icons, sizes, and overflow indicator. Caps the visible list at 6 entries.ui-tui/src/domain/messages.ts: newattachedFileNotice()helper for IMAGE / PDF / TEXT / BINARY MIME kinds.ui-tui/src/gatewayTypes.ts:FileAttachResponse,FileListResponse,FileDetachResponsetypes.Tests
tests/test_file_attach.py: 19 tests covering whitelist, validation, sandbox copy/list/cleanup.tests/test_tui_gateway_server.py: 5 new tests for the file.* RPC handlers using the samefake_cliinjection pattern as existingimage.attachtests.tests/test_uploads_e2e.py: 4 end-to-end tests covering the full attach -> list -> detach cycle, spoof rejection, size limit enforcement, and quoted paths with spaces.ui-tui/src/__tests__/imageProtocol/: 12 vitest cases for the image protocol encoders and detection.ui-tui/src/__tests__/attachmentsBar.test.tsx: 8 source-level sanity tests for the widget.Docs
docs/uploads.md: user-facing documentation.Verification
Acceptance criteria
From the v2 spec at
docs/superpowers/specs/2026-06-06-tui-file-uploads-design-v2.mdsection 12:Migration / back-compat
image.attachJSON-RPC method is unchanged. Old TUI clients keep working./imageslash command is unchanged from the user perspective. It now callsfile.attachinternally.uploads.max_size_mb,uploads.allowed_mime_types) are optional; defaults match the v1 spec.Bug fixes found while testing
file.attachpreviously used a uuid-derived id whilefile.listused a sha-derived id, breaking the round-trip. Now both use the sha prefix._detect_mimenow sniffs the first 16 bytes for executable / archive magic before trusting the extension, closing a real spoof path when libmagic1 is not installed.Notes for the reviewer
sp-commitwrapper (per AGENTS.md Gate 6).