0059 — Sandboxed chunked file upload
0059 — Sandboxed chunked file upload
TL;DR. Add one capability-negotiated PUT_FILE command under the existing
command envelope. A consumer sends bounded, offset-addressed chunks under a
random upload id; the terminal-owning server writes only inside its own upload
directory, acknowledges every retained offset, verifies whole-file SHA-256, and
returns the completed absolute path. Clients never choose server paths.
Status: Accepted Date: 2026-07-25
Context
A projection consumer sometimes needs to hand a local artifact to a program in a remote terminal. phux-mobile’s first case is an image selected on iPhone that Claude Code can consume by filesystem path. The wire carried terminal input but no file transfer.
ADR-0020 in phux-mobile falsified the tempting workaround: base64 through a
hidden shell and INPUT_PASTE corrupted even small payloads nondeterministically
and canonical-mode PTY ingest was minutes-per-photo slow. Shell quoting,
heredocs, and a digest gate cannot turn the PTY input path into a file transport.
The server must own the write.
Decision
- Add
PUT_FILEas L1Commandtag0x15under the existingCOMMAND/COMMAND_RESULTenvelope. Bump the protocol version from0.5.0to0.6.0and advertiseServerFeature::FileUploadat bit0x00000020. A client MUST negotiate the feature before sending the command. - Each chunk carries a non-zero 128-bit consumer-generated upload id, a
TerminalId, a short ASCII filename extension, a byte offset, raw bytes, a final-chunk flag, and a whole-file SHA-256 digest only on the final chunk. Chunks are at most 8 MiB; a completed upload is at most 64 MiB. TerminalIdidentifies the destination host and requires a live Terminal on that host. It does not choose a directory or grant a client path. A federation hub rewrites a satellite Terminal id and relays the command; the satellite owns storage, digest verification, and the returned path.- The terminal-owning server maps the upload id to one partial file under
$PHUX_UPLOAD_DIR,$XDG_DATA_HOME/phux/uploads, or$HOME/.local/share/phux/uploadsin that order. The directory is mode 0700; files are mode 0600. The final name is server-generated asphux-upload-<id>.<extension>. Separators, dots, Unicode, and extensions over 16 bytes are rejected, so traversal is structurally impossible. - Chunks are offset-checked. A retry whose retained overlap is byte-identical
advances to the same next offset without duplicating bytes; a gap or
same-id/different-byte reuse is
INVALID_COMMAND. Partial state is durable across reconnects and server restarts because it is the file itself, not an in-memory ledger. - Every accepted chunk replies
OK_WITH(FILE_UPLOAD { next_offset, path? }).pathis absent until a final chunk’s SHA-256 matches the complete partial file. The server syncs and atomically renames the partial file before returning the path. Digest mismatch leaves the partial file unexposed for a corrected retry. - Completed files remain in the upload directory until the user or a future explicit retention policy removes them. Version one does not delete user artifacts behind a running agent. Stale-part cleanup is deferred; partials are hidden and bounded per upload, and support tooling can identify them.
- Payloads and completed paths MUST NOT enter routine telemetry. The command
lifecycle label is only
put_file; errors describe the class without logging file bytes.
Why
- The command envelope already provides request correlation, typed failure, and relay reply mapping. A parallel upload frame family would duplicate it.
- A server-chosen path removes traversal and avoids treating client path syntax as authority.
- Offset acknowledgements make reconnect continuation explicit. Whole-file digest verification catches storage, relay, and client chunking faults before a path is handed to an agent.
- A Terminal target gives federation a routing key without introducing a second host-identity namespace or exposing arbitrary remote filesystem writes.
- Raw bytes avoid the 4/3 base64 expansion and PTY/canonical-mode behavior.
Tradeoffs
- The v0 command uploads one file serially and retains a process-wide critical section during local disk writes. With an 8 MiB chunk ceiling this is bounded, simple, and avoids concurrent offset races; a high-throughput bulk-transfer lane can replace it if measurements justify the complexity.
- A live Terminal is required even though the write does not touch its PTY. This deliberately narrows authority and supplies federation routing, but prevents uploading to a host with no panes.
- Completed files consume disk until explicitly removed. Silent time-based deletion would race agents reading a path, so retention needs its own visible product contract.
- The returned absolute path reveals the server account’s upload-root prefix to the authenticated consumer. That consumer already controls a terminal as the same per-user server; the path is not logged or shared with other peers.
Alternatives
- Base64 through terminal input. Rejected by live evidence: corrupt, slow, shell-dependent, and capable of killing the pane input writer.
- Let the client provide a destination path. Rejected: canonicalization and symlink policy become a remote arbitrary-write surface.
- One 16 MiB frame per file. Rejected: framing overhead leaves less than the nominal cap, photos can exceed it, and reconnect cannot resume.
- HTTP upload endpoint. Rejected: it creates a second authenticated transport, capability handshake, federation route, and error model for one operation the command plane already supports.
- Store bytes in L3 metadata. Rejected: metadata is durable coordination state, not a bulk-object store; it would amplify snapshots and subscriptions.
Related
- ADR-0007 — federation hub and satellite routing.
- ADR-0021 — control-plane command envelope.
- ADR-0031 — remote consumer authentication and encryption.
- ADR-0053 — acknowledged idempotent input batches.
- phux-mobile ADR-0020 — image paste needs a wire verb.
- Mobile bead: phux-q7e.18.
- PTY corruption follow-up: phux-6b6t.