Skip to content

[FEATURE]: upload attachments to the server so agent tools can read them (managed per-session store) #46173

Open

Description

@safzanpirani

Feature hasn't been suggested before.

  • I have verified this feature I'm about to request hasn't been suggested before.

Describe the enhancement you want to request

My partner uses the opencode web UI and attaches PDFs. The model can see them, but the agent's tools can't touch them — remote attachments arrive as data URIs, so there's no file on the server to read or convert. Replaying big base64 attachments also feeds the memory/db growth in #42263.

Proposal: add a streaming multipart upload route to the v2 session API. Store each file under <Global.Path.data>/attachments/<sessionID>/<attachmentID>/ and return an opaque managed URI. Clients put that URI in the existing PromptInput.FileAttachment; core validates session ownership and resolves it to an absolute path before provider-history lowering.

Limits and safety: stream to an exclusive temp file then rename atomically. 25 MiB/file, 100 MiB/session, 1 GiB global, typed 413 on overflow with partial cleanup. Filenames sanitized cross-platform, traversal and symlinks rejected, media types sniffed, unknown content stays path-only. No download route. Existing server auth policy applies. Unbound uploads deleted after 24h; bound files live with the session, cleanup to align with the shared disk-GC direction (#36093).

Model behavior: supported image/PDF gets native media on the first eligible turn with the path in filename (extends #41789). Everything else, and all replays, get one text part with the path — never both, and never persisted base64.

Plan: 3 PRs — (1) path-only backend flow + tests, (2) first-turn native media with path-only replay, (3) app/session-ui wiring. I have PR 1 implemented and tested, happy to open it once this design is ok'd.

Open questions: should this adopt the pending opaque managed-output URI format, or is opencode://attachment/<id> fine for now? Should the quotas join the planned shared disk-GC config?

Activity

  1. chrishalltu commented on Sep 1, 2026

    @chrishalltu

    Strong +1 on the path-only direction — "unknown content stays path-only, never persisted base64" is the right call, and it happens to solve most of a TUI bug I've just finished tracing. One case it doesn't reach, which I think is cheap to fold in while the design is still open.

    The TUI can hand you a path that has already expired

    The managed store assumes the client still holds the bytes (web/desktop attach flows) or a path that resolves. In the TUI neither is guaranteed, because a terminal drop is not a drop at all.

    There is no drag-and-drop code in the TUI. When a file is dropped on a terminal, the emulator types the file's path into the tty, and it arrives as an ordinary bracketed paste — so drop and paste share one code path (onPaste → pasteInputText → readLocalAttachment, packages/tui/src/component/prompt/index.tsx:1396). Raw bytes never arrive. The only input is a path string, and the only question is whether it still resolves later.

    For a drop from a non-Finder source — an email attachment, Slack, AirDrop, a Safari download drag — that path points at a promise/pasteboard file:

    /Users/<me>/Library/Group Containers/group.com.apple.coreservices.useractivityd/shared-pasteboard/items/<uuid>/data.csv
    $TMPDIR/TemporaryItems/NSIRD_.../report.xlsx
    

    Those are reaped, or EPERM under sandbox, shortly after the drop. Anthropic hit the identical path in anthropics/claude-code#65181; their conclusion was the same — copy the attachment somewhere readable before substituting the path into the prompt.

    Why images already survive this and nothing else does

    Purely a question of when the bytes are read:

    Drop Result Why
    Image, any app works bytes read at drop time into a data: URL (index.tsx:1195-1201); the source path expiring is irrelevant
    Non-image, Finder works rejected as an attachment, path falls through as literal text (index.tsx:1215); Finder paths are durable, so Read resolves it later
    Non-image, non-Finder silently fails same fall-through, but the path is gone by the time a tool touches it

    The rejection is one line, packages/tui/src/component/prompt/local-attachment.ts:44:

    if (!mime.startsWith("image/") && mime !== "application/pdf") return

    with MIME from an 8-entry extension map and no sniffing (local-attachment.ts:25-34).

    So the failure is lazy resolution of a path with a shorter lifetime than the turn. Your design fixes the file-type half for web/desktop; the TUI half stays broken because the file is never captured while it still exists.

    The ask

    PR 3 (#46185) wires the app/session-ui. If the TUI is a later follow-up, could the store's contract be worded so a client may upload at input time, not only at submit? Concretely, for the TUI:

    1. On drop, classify the path — durable (Finder, project file, anything outside the pasteboard/TemporaryItems/portal dirs, passing an access() probe) vs ephemeral.
    2. Durable → keep today's behavior, insert the real path. Better than a copy: the agent can edit the original in place, and there's nothing to GC.
    3. Ephemeral → capture immediately into the managed store and reference the returned URI.

    Only step 3 needs anything from this design, and it needs the upload to be legal before a prompt exists — which matters because a drop can precede submit by minutes, and the source file will not survive the wait. If uploads are bound to a session at submit, the 24h unbound-GC window sounds like it already covers this; worth confirming that's intended rather than incidental.

    Two things worth pinning either way, both TUI-specific:

    • Lifetime. The two existing TUI temp writes (clipboard.ts:50, editor.ts:49) delete in a finally. A dropped attachment has to outlive submit, since the server reads it lazily — so it can't follow that pattern.
    • History replay. PromptInfo.parts is persisted to prompt-history.jsonl, and restoreExtmarksFromParts (index.tsx:658-700) will cheerfully restore a placeholder pointing at something GC'd days ago. Your "all replays get one text part with the path" rule makes this coherent, but recall should degrade visibly rather than silently attach nothing.

    Happy to file this separately as a TUI-scoped issue if you'd rather keep this one focused — just didn't want to duplicate it while the design is live. Note that #31939 (assigned) requests path-only for external drops and reads as adjacent, but it assumes the dropped path is durable, which is exactly the assumption that breaks here.

  2. chrishalltu commented on Sep 1, 2026

    @chrishalltu

    I jumped the gun and created #46664

  3. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed proposal and for already having PR 1 ready.

    To restate the request: add a v2 upload route that stores attachments in a managed per-session folder on the server and returns a managed URI. Core would turn that URI into a real file path, so agent tools can read and convert files uploaded from the web UI, instead of only getting inline data URIs.

    That matches the current code. Prompt attachments are taken as data: URIs (inline only), file: URIs (the server's own filesystem) or media: blob refs. A file path is only exposed to the model for file: sources, and there's no upload route yet.

    Related issues:

    Maintainers will look at the design and your open questions (the managed URI format, and whether the quotas belong in the shared disk-GC config) and decide whether and how to take it forward. It's probably best to wait for their feedback on the design before opening the PRs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions