Skip to content

Prompt uploads: where they should land, documents and video, and a visible Attach on phones #553

Closed

Description

@JDProfresh

On #529 you said you'd follow up separately on where uploads should land (a visible temp/ versus a hidden Codeman folder like .claude-images/). I've been working on the same area from the prompt side and ran into that question too, so here's a proposal for it plus two PRs that are built and tested but that I'm holding until you've weighed in on the location.

Where uploads land

Today paste-image writes into <workspace>/.claude-images/. That name works for Claude, but nine of the ten run modes aren't Claude, and once documents come in it isn't only images either.

The rule I'd suggest: a file goes where the one who has to open it can reach it.

  • Things the agent opens go in the workspace, because that's the only path that resolves the same for a local agent, a Docker case (only the workspace is bind-mounted, at the same absolute path) and a remote host.
  • Things only Codeman reads stay in ~/.codeman.
  • Another tool's config stays in that tool's directory.

For uploads that gives <workspace>/.codeman/attachments/, with a .codeman/.gitignore containing *. That keeps uploads out of the user's repo without touching their own ignore file. The ignore file is written once, with exclusive create, so a user's own file there is never overwritten. The old .claude-images/ is never written again but stays readable for one release, so the GC sweep and the delete cleanup still cover it. Both directory levels are checked to be real directories, so a planted .codeman -> /other-case/.codeman symlink can't carry the recursive delete or the sweep into another tree.

A visible temp/ is the other option. My worry with it is that it lands in git status and in the agent's own view of the repo. If you prefer it, the PRs only need the directory constant changed: the names live in one place (paste-image-gc.ts).

Other Codeman artifacts could move under .codeman/ later (Ralph's @fix_plan.md and @ralph_prompt.md at the repo root, .claude/ralph-loop.local.md), but that changes the prompt contract the agent follows, so I've kept it out of these PRs.

What the two PRs do

1. A visible Attach button on phones, plus video. Today the only way to hand an agent a photo from a phone is to raise the keyboard, open Compose and tap its Image key, so with the keyboard down there's nothing to tap.

  • The phone toolbar gets an Attach button that opens the native picker (photos, camera, files). The picked path goes onto the prompt as unsent text, so you can add words and send.
  • Videos are accepted through ?kind=video on paste-image. They are streamed to disk under their own cap (1 GB, CODEMAN_MAX_PASTE_VIDEO_BYTES) and the container is checked from the first bytes as they arrive (mov, mp4, m4v, webm, 3gp, mkv). The image path is byte-identical without the flag, so an image can never ride the larger cap through memory.
  • One client classifier (_promptAttachKind()) now feeds every pick and paste filter, instead of four scattered type.startsWith('image/') checks.
  • A remote (SSH) session is refused before anything touches disk, because its workingDir is a path on the remote host.

Tested in a real iOS Safari simulator and on my iPhone. One iOS 26.x quirk is WebKit's, not ours: the keyboard doesn't come back by itself after the picker closes, so it takes one tap on the prompt. iOS 27 restores it on its own.

2. Documents.

  • paste-image also admits pdf, docx, pptx, xlsx, zip and the text family (txt, md, csv, tsv, json, log, yaml, yml, xml), recognized by the file name's extension.
  • Each upload is then checked on its bytes by the same fail-closed sniffer: %PDF-, the zip signature for the OOXML trio and zip, and UTF-8 with no NUL byte for text. That is a signature and encoding check, not a content filter.
  • Script and markup types (sh, py, js, html) stay refused by extension.
  • Saved names keep a sanitized stem, for example paste-<ts>-<hex>-My_Report.pdf.
  • Dispatch is MIME-first, so anything the image path accepted before is still accepted.
  • This PR moves uploads to .codeman/attachments/.

How this relates to #529

#529 adds an upload from the Files panel into temp/. That's a different entry point (browsing a workspace) from Attach (adding to the prompt you're typing), so I don't think they compete. If you go with .codeman/attachments/, both could write to the same place. @shenlvkang-collab, I didn't want this to land on your PR as a surprise.

Questions

  1. .codeman/attachments/, a visible temp/, or something else?
  2. Is the document list right? Anything you'd add or drop?
  3. Would you rather review these as two PRs, as above, or differently?

I'll open the PRs once the location is settled, rebased onto master. Both have been running on my own install since 2026-10-07.

Activity

  1. Ark0N commented on Oct 9, 2026

    @Ark0N
    Owner

    Thanks for writing this up before opening the PRs, @JDProfresh, it made the decision easy. Short version: yes to a hidden folder that Codeman owns and that ignores itself, but under a different name than .codeman/.

    1. Location: <workspace>/.codeman-uploads/

    I agree with your rule (a file goes where whoever opens it can reach it) and with what follows from it: the workspace rather than ~/.codeman, hidden rather than temp/ (a visible temp/ shows up in git status, gets committed by accident and sits in the agent's view of the repo), the .gitignore containing * written once with exclusive create, the real-directory checks, and .claude-images/ staying readable for one release.

    The one thing I'd change is the name. When the workspace is the home directory, <workspace>/.codeman/ IS Codeman's data dir: that dir is join(homedir(), '.codeman') (src/config/instance.ts:66), and nothing refuses home as a workspace (safePathSchema only asks for an absolute path, so POST /api/cases/link and POST /api/sessions both accept /home/<you>). A session there would write attachments/ and a .gitignore into the directory that holds state.json, users.json and the app clone. Your version survives that, because the recursive delete only targets attachments/, but your own roadmap moves more Codeman files under .codeman/ later, and then the workspace folder and the data dir are one tree.

    A flat .codeman-uploads/ avoids that, follows the .codeman-* prefix a case root already carries (.codeman-agent-case.json), and leaves one directory level to check for symlinks instead of two. Please still have the sweep and the delete cleanup refuse a resolved directory at or under getDataDir(). CODEMAN_INSTANCE=uploads would make the names meet again, which is contrived, but it's one check next to a recursive delete.

    I picked "uploads" over "attachments" because Codeman already has an attachments subsystem that goes the other way (attachment-registry.ts, the /attachments routes, codeman://attach: files the agent hands to the user). Keeping the two words apart keeps the code and the docs readable. The button can still say Attach.

    Ralph's files staying where they are for now: agreed, and the shared prefix leaves room for them later.

    2. The document list

    Fine as proposed. A few notes:

    • Refusing sh/py/js is a scope choice, not a security property: the agent can already write any of those, and file-raw serves non-media text as inert text/plain with nosniff. Please word it that way in the code. html/htm/svg are the ones that really have to stay out.
    • For video, please start from VIDEO_ATTACHMENT_EXTENSIONS in attachment-registry.ts (mp4, webm, mov, m4v, ogv). It's single-sourced so that what a user hands in also plays in the file viewer. 3gp and mkv would upload but not preview; if you've seen phones produce them, adding them is fine, just say so in the PR.
    • 1 GB per video under the existing 30 uploads per minute (session-routes.ts:5134) means up to 30 GB of disk a minute, which is no longer the bound that limit was written for. A separate, much lower rate for ?kind=video, or a per-workspace budget, would close that. Please also add a test that the image path's cap is unchanged without the flag.
    • Non-blocking: xlsx, zip, tsv, log, m4v, 3gp and mkv aren't in FILE_PATH_LINK_PATTERN (constants.js), so those paths won't be clickable when the agent echoes them.

    3. PR split: your two, plus a small one in front

    Please move the relocation out into its own PR and land it first. It's small, but it touches the recursive delete, and otherwise the Attach/video PR ships writing into .claude-images/ and has to move again. It should cover every site that names the old dir:

    • the route (session-routes.ts:5246), paste-image-gc.ts and the cleanup in server.ts:1538, including pasteImageDirInUseByOtherSession()
    • the legacy .claude-images/ staying covered by both the sweep and the delete cleanup for one release
    • the remote refusal, since it fixes the existing route
    • docs/architecture-invariants.md (two passages name .claude-images) and the comments in reboot-restore-routes.ts and terminal-ui.js
    • test/paste-image-dir-shared.test.ts, the path regex in test/routes/session-routes.test.ts, and test/link-provider-regex.test.ts

    One more for that PR: the workspace file watcher (image-watcher.ts, off by default) excludes nothing upload-related today. With it on, a user's own upload (pdf and docx are detected types) comes back as a "detected" card, so its ignored filter should skip the upload dir.

    Then Attach + video and documents on top, in either order. paste-image is part of the public API, so please describe the new behaviour in docs/api-reference.md as well.

    #529

    @shenlvkang-collab, this settles what I left open on your PR. I'll leave a note there too.

  2. JDProfresh commented on Oct 9, 2026

    @JDProfresh
    ContributorAuthor

    Thanks, that settles it. .codeman-uploads/ it is, and the data-dir check sits next to the delete.

    The relocation is #574, on its own as you asked, covering the sites you listed plus the image watcher's ignore filter and the port-0 conversion of test/paste-image-dir-shared.test.ts. Attach + video and documents follow on top of it once it lands, reshaped to your notes: the video list starts from VIDEO_ATTACHMENT_EXTENSIONS (dropping 3gp and mkv, since I never saw a phone produce either; those test files were made with ffmpeg), ?kind=video gets its own much lower rate, a test pins the image cap without the flag, the sh/py/js refusal is worded as a scope choice, and both describe paste-image in docs/api-reference.md.

  3. Ark0N commented on Oct 14, 2026

    @Ark0N
    Owner

    Status, for anyone landing here. Two of the three parts have shipped:

    Documents are #621, and the client-side filter for video containers the route refuses is #622. Both are open. I'll leave this issue open until #621 lands. @JDProfresh, if you add Closes #553 to #621's description, GitHub closes it on merge.

  4. Ark0N commented on Oct 16, 2026

    @Ark0N
    Owner

    All three parts of this plan have shipped, so I'm closing it. Thanks again @JDProfresh for writing it up so clearly and then building all of it:

    The follow-ups from the #621 review (a clearer refusal for non-UTF-8 CSV/TXT exports, a parity test for the document list) and the partial-refusal toast you offered on #622 are welcome as their own PRs.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions