mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Merge pull request #606: feat(mobile): phone Attach button and camera-roll video uploads
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>
This commit is contained in:
commit
6c7d63d45e
24 files changed
+1418
-118
No files matched your search
@@ -301,7 +301,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||
|
||||
**Prompt uploads** (`POST /api/sessions/:id/paste-image`; client wiring in `image-input.js` and `keyboard-accessory.js`): a pasted file is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history)
|
||||
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode. A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history)
|
||||
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entryLine truncated
|
||||
|
||||
|
||||
@@ -585,6 +585,20 @@ owned case is fine, its workspace is bind-mounted at the same absolute path; an
|
||||
adopted container (`owned: false`) mounts nothing, so its agent can open the file
|
||||
only if the container itself exposes that host path.
|
||||
|
||||
The optional query flag `?kind=video` selects a second path for a camera-roll
|
||||
video: the file streams to disk instead of being buffered, under a far larger
|
||||
per-file cap (`MAX_PASTE_VIDEO_BYTES`, 1 GB by default, env
|
||||
`CODEMAN_MAX_PASTE_VIDEO_BYTES`), and only video container types are accepted
|
||||
(mp4, webm, mov, m4v, ogv, single-sourced from the file viewer's playable set).
|
||||
The container is checked from the first bytes as
|
||||
the body arrives; a file whose bytes do not match its declared type is unlinked
|
||||
and answered 415, one over the cap is unlinked and answered 413, and a transfer
|
||||
that dies midway leaves nothing behind. Because one video can be 1 GB,
|
||||
`?kind=video` draws on its own rate limit, 3 uploads per minute per (IP, session),
|
||||
beside the 30 per minute the image path keeps; without the flag the route is
|
||||
byte-identical to the image-only version, same 50 MB image cap and same 30/min
|
||||
bucket. The response shape is unchanged (`path`, `filename`).
|
||||
|
||||
## Session lineage (`parentSessionId`)
|
||||
|
||||
A create request may name the session that spawned it, which the web UI draws as a
|
||||
|
||||
@@ -1094,7 +1094,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
||||
|
||||
### Buffers, uploads, and terminal history
|
||||
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
|
||||
**Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. ⚠️ And because a live session's workspace can go onto a network mount that later stops answering, the route probes `session.workingDir` BOUNDED (`probePathKind(..., { pastCap: true })`) before the first disk touch, refusing an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED) exactly as `POST /api/sessions` does, so a mount already unreachable cannot freeze a libuv worker on every upload (`ensureUploadDir()`'s own lstat/mkdir then start on a reachable path; a mount that dies between the probe and that write is the same residual race `POST /api/sessions` accepts, since a pre-check cannot bound the write that follows it). Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`, `test/routes/session-create-unreachable-path.test.ts`.
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ autocorrect: Enter adds a new line, and only Send delivers the text, as one past
|
||||
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
|
||||
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
|
||||
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
|
||||
is parked. The editor's Image button attaches photos and puts their paths into the draft.
|
||||
is parked. The editor's Image button attaches photos or videos and puts their paths into the draft.
|
||||
Destructive commands need a double press, so you cannot fire `/clear` with a stray thumb. On
|
||||
Codex sessions the bar also shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to
|
||||
editing the last queued message and walking the prompt stack.
|
||||
@@ -100,6 +100,29 @@ looks exactly like a dead button.
|
||||
On phones this button replaces the desktop's **Run Shell** control; starting a shell moved
|
||||
into the Run dropdown.
|
||||
|
||||
## The Attach button
|
||||
|
||||
The toolbar's **Attach** button (the picture icon beside the mic) is the keyboard-down way to
|
||||
hand the agent a photo or a video. One tap opens the camera and photo library sheet, nothing
|
||||
else: no editor, no keyboard. The chosen files upload and their paths land on the prompt, so
|
||||
you then tap the terminal, write the rest of your message and press Enter. Videos (mp4, webm,
|
||||
mov, m4v, ogv) upload as recorded, up to 1 GB each by default
|
||||
(`CODEMAN_MAX_PASTE_VIDEO_BYTES` on the server changes it), so a long 4K clip takes a moment
|
||||
on the way up.
|
||||
|
||||
If the keyboard was up when you tapped Attach, iOS closes it to show the photo sheet and does
|
||||
not bring it back, so the tap on the terminal is how you get it again. That is the system's
|
||||
behaviour around its own picker rather than a Codeman setting.
|
||||
|
||||
The paths go onto the prompt as unsent text, exactly like something you typed, which means
|
||||
they travel with it. Tap **Compose** afterwards and the editor opens holding the paths *and*
|
||||
anything you had already typed, so you can write in front of a path, fix a word, and Send.
|
||||
**Use terminal keyboard** hands a single-line message back the other way, onto the prompt,
|
||||
so neither surface holds a half of your message. A multi-line message cannot make that trip
|
||||
(the prompt is a single line), so it stays parked as a draft and the dot on the Compose key
|
||||
shows it is waiting. The Compose editor's own Image button remains for the times you are
|
||||
already writing in the editor.
|
||||
|
||||
## Tapping, links and copying
|
||||
|
||||
- **Tap a link** in terminal output and it opens in a new tab. Same for a link in an agent's
|
||||
|
||||
@@ -217,6 +217,8 @@ Some things are configured before the server starts, not in the UI:
|
||||
| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. |
|
||||
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
|
||||
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
|
||||
| `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image. 50 MB by default. |
|
||||
| `CODEMAN_MAX_PASTE_VIDEO_BYTES` | Cap on one attached video (phone Attach, drop). 1 GB by default; videos stream to disk, so this bounds disk use, not memory. |
|
||||
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
|
||||
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
|
||||
| `CODEMAN_PATH_PROBE_MAX_STALLED` | Unanswered folder checks allowed to pile up before new ones are refused. 2 by default: one below the threadpool size minus one, so it follows `UV_THREADPOOL_SIZE` (4 unless set), and it is never allowed above that ceiling. A check you start by opening one case or session may use the one slot left above it. |
|
||||
|
||||
@@ -155,9 +155,13 @@ expose everyone.
|
||||
|
||||
Paste from the clipboard or drag and drop straight onto the terminal. The image is written
|
||||
where the agent can read it and the reference is inserted into your prompt. On a phone, the
|
||||
image key in the keyboard bar opens the camera or photo library.
|
||||
toolbar's Attach button opens the camera or photo library, videos included, and puts the path
|
||||
on your prompt, where it behaves like text you typed; the Image button inside the Compose
|
||||
editor does the same into the editor. Dropping a video onto the terminal works the same way.
|
||||
|
||||
HEIC images from an iPhone are converted to JPEG on the way in.
|
||||
Videos (mp4, webm, mov, m4v, ogv) are stored as recorded, up to 1 GB each by default
|
||||
(`CODEMAN_MAX_PASTE_VIDEO_BYTES`); they stream to disk rather than through server memory.
|
||||
|
||||
## Generated artifacts
|
||||
|
||||
|
||||
@@ -113,6 +113,16 @@ export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
|
||||
*/
|
||||
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
|
||||
|
||||
/**
|
||||
* Maximum size (bytes) of a single video uploaded via POST
|
||||
* /api/sessions/:id/paste-image?kind=video (the phone toolbar's Attach button
|
||||
* offers the camera roll's videos beside its photos). Videos stream to disk
|
||||
* rather than through memory, so this is a disk-fill sanity bound, not a memory
|
||||
* bound: a minute of 4K60 from an iPhone is about 400MB, so the image cap would
|
||||
* refuse most clips. Override: CODEMAN_MAX_PASTE_VIDEO_BYTES (bytes)
|
||||
*/
|
||||
export const MAX_PASTE_VIDEO_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_VIDEO_BYTES || '') || 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
// ============================================================================
|
||||
// File Download Limits
|
||||
// ============================================================================
|
||||
|
||||
@@ -2047,7 +2047,7 @@ function computeRewriteScrollLine(input) {
|
||||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||||
*/
|
||||
const FILE_PATH_LINK_PATTERN =
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
|
||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||
function absoluteFilePathPattern() {
|
||||
|
||||
@@ -166,8 +166,9 @@
|
||||
'Restore the grid': '恢复平铺网格',
|
||||
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
|
||||
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
|
||||
// A file dropped on a tile (tile-grid.js) or the single view (image-input.js).
|
||||
'Only image files are supported': '仅支持图像文件',
|
||||
// A file of an unsupported type dropped on a tile (tile-grid.js); the single
|
||||
// view's drop (image-input.js) uses 'Only image or video files are supported'.
|
||||
'Unsupported file type': '不支持的文件类型',
|
||||
// Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
|
||||
'No active session': '没有活动会话',
|
||||
'This session is sized by its own window': '此会话的尺寸由它自己的窗口决定',
|
||||
@@ -676,6 +677,9 @@
|
||||
// placeholder and label are looked up by the module at build time, since
|
||||
// the DOM translator skips <textarea> subtrees.
|
||||
'Compose prompt': '撰写提示词',
|
||||
Attach: '添加附件',
|
||||
'Open a session to attach a file': '请先打开一个会话再添加附件',
|
||||
'Only image or video files are supported': '仅支持图片或视频文件',
|
||||
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
|
||||
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
|
||||
'Enter adds a new line': '按 Enter 换行',
|
||||
|
||||
+185
-15
@@ -14,6 +14,69 @@
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
initImageInput() {
|
||||
// Phone toolbar Attach (index.html): the tap lands on the file INPUT, which
|
||||
// sits invisibly over the button, and the browser opens the picker itself.
|
||||
//
|
||||
// Nothing here may open the picker from script. While the keyboard is up,
|
||||
// app.js's keyboard tap shim cancels every touch on a toolbar BUTTON and
|
||||
// re-dispatches it as a scripted click, and iOS will not open a file picker
|
||||
// from an untrusted click: measured on iOS 17, `isTrusted: false` two
|
||||
// milliseconds after pointerdown, no picker, no error, and no real click after
|
||||
// it either because the gesture was cancelled. A native tap on the input is not
|
||||
// a button tap, so the shim leaves it alone and the click stays trusted. It also
|
||||
// anchors the picker popover to the input's own frame (a `hidden` input has
|
||||
// none, which is why the composer's picker floated loose).
|
||||
//
|
||||
// Cancelling mousedown's default keeps focus on the terminal's hidden textarea,
|
||||
// so the fixed toolbar never reflows out from under the finger mid-tap.
|
||||
//
|
||||
// It does NOT keep the keyboard up, and nothing here can: iOS resigns first
|
||||
// responder to present the picker, and on DEVICE it does not hand the keyboard
|
||||
// back when the picker closes, even though focus never left the textarea.
|
||||
// (An iOS 27 simulator does hand it back, which is a simulator artifact, so do
|
||||
// not take a keyboard measurement from one.) A blur-then-focus on the close
|
||||
// of the picker, cancel and change alike, restores DOM focus and still leaves
|
||||
// the keyboard down, because iOS only raises it inside a user gesture. So the
|
||||
// user taps the prompt when ready to type, which is the keyboard policy
|
||||
// everywhere else in the app.
|
||||
//
|
||||
// Do not try to hide the keyboard's exit by freezing the page's keyboard
|
||||
// state across the picker. That was tried (holdKeyboardState, reverted) to
|
||||
// avoid the reflow and the two PTY resizes the hide/show pair costs: with the
|
||||
// keyboard gone and the layout still keyboard-sized, the toolbar and accessory
|
||||
// bar strand in the MIDDLE of the screen over a dead black band, which is far
|
||||
// worse than the reflow. The page must follow the keyboard out.
|
||||
const attachBtn = document.getElementById('attachBtnMobile');
|
||||
const attachInput = document.getElementById('attachFileInput');
|
||||
if (attachBtn && attachInput) {
|
||||
attachInput.addEventListener('mousedown', (event) => event.preventDefault());
|
||||
// The button is the ACCESSIBLE control (the input is aria-hidden and not
|
||||
// tabbable), so a keyboard or VoiceOver activation arrives here instead of on
|
||||
// the input. That click is trusted, so opening the picker from it is allowed.
|
||||
attachBtn.addEventListener('click', () => attachInput.click());
|
||||
attachInput.addEventListener('click', (event) => {
|
||||
if (!this.activeSessionId) {
|
||||
event.preventDefault(); // refuse before the picker opens, not after a pick
|
||||
this.showToast('Open a session to attach a file', 'error');
|
||||
return;
|
||||
}
|
||||
// Captured before the picker opens: the session can be deleted while it is
|
||||
// up, and the pick must not land on whatever replaced it.
|
||||
this._attachSessionId = this.activeSessionId;
|
||||
});
|
||||
attachInput.addEventListener('change', () => {
|
||||
const files = Array.from(attachInput.files || []);
|
||||
// Reset so picking the same file again still fires change.
|
||||
attachInput.value = '';
|
||||
// Captured when the picker opened (see the click handler above).
|
||||
const sessionId = this._attachSessionId;
|
||||
this._attachSessionId = null;
|
||||
if (files.length > 0) {
|
||||
void this._attachFromToolbar(files, sessionId).catch((err) => console.warn('Attach failed:', err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-and-drop handlers on terminal container
|
||||
const container = document.getElementById('terminalContainer');
|
||||
if (!container) return;
|
||||
@@ -38,15 +101,100 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!this.activeSessionId) return;
|
||||
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||||
|
||||
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) {
|
||||
this.showToast('Only image files are supported', 'error');
|
||||
const mediaFiles = Array.from(e.dataTransfer.files).filter((f) => this._promptAttachKind(f));
|
||||
if (mediaFiles.length === 0) {
|
||||
this.showToast('Only image or video files are supported', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages(imageFiles);
|
||||
this._uploadAndInsertImages(mediaFiles);
|
||||
});
|
||||
},
|
||||
|
||||
/** Upload the toolbar Attach selection and put the paths on the prompt. The
|
||||
* Compose dialog is deliberately not involved (see initImageInput).
|
||||
*
|
||||
* With local echo on (every phone by default) the paths go into the OVERLAY as
|
||||
* pending text rather than to the PTY. That is what makes them part of "what the
|
||||
* user typed": they paint at the prompt, anything typed after them appends in
|
||||
* order, Enter flushes the whole line, and opening Compose adopts path and prose
|
||||
* together (_takePendingLocalEcho reads exactly this buffer). Writing to the PTY
|
||||
* instead would strand the path on the terminal while Compose took only the prose.
|
||||
* Without the overlay (local echo off) there is nothing to buffer into, so the
|
||||
* text goes straight out. The trailing space keeps the next word off the path. */
|
||||
async _attachFromToolbar(files, sessionId) {
|
||||
const media = files.filter((f) => this._promptAttachKind(f));
|
||||
if (media.length === 0) {
|
||||
this.showToast('Only image or video files are supported', 'error');
|
||||
return;
|
||||
}
|
||||
if (!sessionId) return; // no captured session: refuse rather than guess at one
|
||||
const paths = await this._uploadAndInsertImages(media, { insert: false, sessionId });
|
||||
if (paths.length === 0) return;
|
||||
// Deleted while the upload ran: a write would go to a dead session, and the
|
||||
// flushed maps below would gain an entry whose cleanup has already run.
|
||||
if (this.sessions && !this.sessions.has(sessionId)) return;
|
||||
const text = paths.join(' ');
|
||||
|
||||
// The prompt composer, if it opened for this session while the upload ran, is
|
||||
// where the prompt now lives: it took the terminal's pending text with it, so
|
||||
// appending behind it would strand the path outside the text being composed.
|
||||
// _insertComposerText adds its own separator and fires the draft save.
|
||||
const openComposer = KeyboardAccessoryBar._composerOverlay;
|
||||
const composerTextarea =
|
||||
openComposer?.isConnected && openComposer.dataset.sessionId === sessionId
|
||||
? openComposer.querySelector('.prompt-composer-textarea')
|
||||
: null;
|
||||
if (composerTextarea) {
|
||||
KeyboardAccessoryBar._insertComposerText(composerTextarea, text);
|
||||
return;
|
||||
}
|
||||
|
||||
const stillActive = this.activeSessionId === sessionId;
|
||||
// The overlay holds the prompt only while local echo actually owns it. A
|
||||
// session handed back to plain PTY echo by a composer nav key (echo
|
||||
// passthrough) skips the whole overlay branch in onData, Enter included, so
|
||||
// text buffered there would sit on screen and never be submitted. Nor while
|
||||
// the tile grid owns the terminal area: the main terminal is parked and
|
||||
// hidden then, and a tile's Enter never reaches its overlay, so the path
|
||||
// goes to the PTY like any other background write.
|
||||
const overlayOwnsPrompt =
|
||||
stillActive &&
|
||||
!this._tilesOwnTerminal?.() &&
|
||||
this._localEchoEnabled &&
|
||||
this._localEchoOverlay &&
|
||||
!this._echoPassthroughSessions?.has(sessionId);
|
||||
if (overlayOwnsPrompt) {
|
||||
// Prompt text the overlay has not adopted yet (a tab completion, a restored
|
||||
// tab) only lands in its flushed half once detection runs, and appendText
|
||||
// runs it AFTER we would have measured. Detect first, or the separator is
|
||||
// computed against an empty string and the path fuses onto a real word.
|
||||
if (!this._localEchoOverlay.pendingText && !this._localEchoOverlay.getFlushed().count) {
|
||||
this._localEchoOverlay.detectBufferText();
|
||||
}
|
||||
const flushed = this._localEchoOverlay.getFlushed().text || '';
|
||||
const prompt = flushed + (this._localEchoOverlay.pendingText || '');
|
||||
const gap = prompt && !/\s$/.test(prompt) ? ' ' : '';
|
||||
this._localEchoOverlay.appendText(gap + text + ' ');
|
||||
} else {
|
||||
// No buffer to measure, so lead with a separator unconditionally rather than
|
||||
// risk fusing onto whatever the CLI's own composer already holds.
|
||||
const out = ` ${text} `;
|
||||
this._sendInputAsync(sessionId, out);
|
||||
// A background session's prompt is tracked in the flushed maps, which is what
|
||||
// a later composer open adopts and erases. An untracked write leaves that
|
||||
// count short, so the erase would eat the tail of this path instead. Not for
|
||||
// a passthrough session: its cursor can sit mid-text, nothing tracks its
|
||||
// composer, and Enter there never clears the maps, so a record would offset
|
||||
// the overlay for the next prompt instead.
|
||||
if (!stillActive && !this._echoPassthroughSessions?.has(sessionId)) {
|
||||
if (!this._flushedOffsets) this._flushedOffsets = new Map();
|
||||
if (!this._flushedTexts) this._flushedTexts = new Map();
|
||||
this._flushedTexts.set(sessionId, (this._flushedTexts.get(sessionId) || '') + out);
|
||||
this._flushedOffsets.set(sessionId, (this._flushedOffsets.get(sessionId) || 0) + out.length);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
||||
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||
@@ -82,15 +230,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (pasteConsumed) return;
|
||||
pasteConsumed = true;
|
||||
|
||||
// Check for images in clipboard items
|
||||
// Check for images or videos in clipboard items
|
||||
var imageFiles = [];
|
||||
var items = e.clipboardData && e.clipboardData.items;
|
||||
if (items) {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob && self._promptAttachKind(blob)) imageFiles.push(blob);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +266,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.execCommand('paste');
|
||||
},
|
||||
|
||||
/** What a pick or a paste may upload: 'image', or 'video' for the camera
|
||||
* roll's clips (the server streams those to disk under their own cap), else
|
||||
* null. The one filter behind Attach, drop, the Compose and paste-dialog
|
||||
* pickers and the clipboard handlers. The MIME type decides; some Android
|
||||
* file managers hand over a File with none, so the name's extension is the
|
||||
* fallback, mirroring the server's own allowlists. */
|
||||
_promptAttachKind(file) {
|
||||
if (file.type.startsWith('image/')) return 'image';
|
||||
if (file.type.startsWith('video/')) return 'video';
|
||||
const ext = (file.name || '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image';
|
||||
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(ext)) return 'video';
|
||||
return null;
|
||||
},
|
||||
|
||||
// Max images accepted in one batch (paste / drop / mobile picker). Each is
|
||||
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
|
||||
// rate limit while covering "select a bunch of photos at once".
|
||||
@@ -136,6 +297,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
async _uploadAndInsertImages(fileList, options = {}) {
|
||||
const sessionId = options.sessionId || this.activeSessionId;
|
||||
if (!sessionId) return [];
|
||||
if (options.sessionId && this.sessions && !this.sessions.has(sessionId)) return [];
|
||||
|
||||
let files = Array.from(fileList || []);
|
||||
if (files.length === 0) return [];
|
||||
@@ -153,7 +315,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
let failReason = ''; // first server reason, shown in the toast so a failure is not just a count
|
||||
const results = new Array(total); // preserve selection order for insertion
|
||||
const progress = () =>
|
||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
|
||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} file${total > 1 ? 's' : ''}…`, 'info');
|
||||
progress();
|
||||
|
||||
// Bounded-concurrency worker pool over the file list.
|
||||
@@ -197,11 +359,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||
const parts = [];
|
||||
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
|
||||
if (paths.length > 0) parts.push(`${paths.length} file${paths.length > 1 ? 's' : ''} ready`);
|
||||
if (failed > 0) parts.push(failReason ? `${failed} failed: ${failReason}` : `${failed} failed`);
|
||||
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||
this.showToast(parts.join(' · ') || 'No files uploaded', tone);
|
||||
return paths;
|
||||
},
|
||||
|
||||
@@ -209,7 +371,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
|
||||
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image', {
|
||||
// A video takes the server's disk-streamed path and its larger cap; the flag
|
||||
// has to ride the URL because the size budget is fixed before the part is read.
|
||||
const kind = this._promptAttachKind(file) === 'video' ? '?kind=video' : '';
|
||||
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image' + kind, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
@@ -231,9 +396,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// decode/encode failure the original file is returned unchanged so the server
|
||||
// still gets a chance (and logs a precise diagnostic).
|
||||
async _normalizeImageForUpload(file) {
|
||||
if (file.type === 'image/gif') return file;
|
||||
// Only an image goes through the canvas; a video is uploaded as picked. With no
|
||||
// MIME (some Android file managers) the name's extension stands in, as it does in
|
||||
// _promptAttachKind, so a .gif keeps its frames and a .png its transparency.
|
||||
const name = (file.name || '').toLowerCase();
|
||||
const isGif = file.type === 'image/gif' || (!file.type && name.endsWith('.gif'));
|
||||
if (this._promptAttachKind(file) !== 'image' || isGif) return file;
|
||||
|
||||
const toPng = file.type === 'image/png';
|
||||
const toPng = file.type === 'image/png' || (!file.type && name.endsWith('.png'));
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = new Image();
|
||||
|
||||
@@ -776,6 +776,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile-only case button + gear -->
|
||||
<!-- Phone-only Attach (wired in image-input.js): the button opens its own file input,
|
||||
which sits invisibly over the button so iOS anchors the picker popover to it. The
|
||||
Compose dialog is deliberately not involved: it focuses its textarea, which raises
|
||||
the keyboard under the picker sheet. Sits BEFORE the mic so the two share order: 5
|
||||
and source order keeps Attach on the left. -->
|
||||
<span class="btn-attach-mobile-wrap">
|
||||
<button type="button" class="btn-attach-mobile" id="attachBtnMobile" title="Attach" aria-label="Attach">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<path d="m21 15-5-5L5 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*" multiple tabindex="-1" aria-hidden="true">
|
||||
</span>
|
||||
<button class="btn-voice-mobile" id="voiceInputBtnMobile" onclick="VoiceInput.toggle()"
|
||||
title="Voice input" aria-label="Start voice input" aria-pressed="false">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
||||
@@ -1247,7 +1247,11 @@ const KeyboardAccessoryBar = {
|
||||
const sessionId = app.activeSessionId;
|
||||
const pending = this._takePendingLocalEcho(sessionId);
|
||||
const saved = this._composerDrafts.get(sessionId) || '';
|
||||
const initial = saved + pending;
|
||||
// A parked draft and text typed (or attached) on the prompt since are two
|
||||
// separate pieces of prose; one space keeps the draft's last word off the
|
||||
// prompt's first, unless either side already supplies the gap.
|
||||
const needsGap = saved && pending && !/\s$/.test(saved) && !/^\s/.test(pending);
|
||||
const initial = needsGap ? `${saved} ${pending}` : saved + pending;
|
||||
|
||||
this._composerOverlay?._closeComposer?.({ restoreFocus: false });
|
||||
const overlay = document.createElement('div');
|
||||
@@ -1269,7 +1273,7 @@ const KeyboardAccessoryBar = {
|
||||
<button type="button" class="paste-cancel">Cancel</button>
|
||||
<button type="button" class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1285,6 +1289,10 @@ const KeyboardAccessoryBar = {
|
||||
const fileInput = overlay.querySelector('.paste-file-input');
|
||||
const imageButton = overlay.querySelector('.paste-image');
|
||||
const sendButton = overlay.querySelector('.paste-send');
|
||||
// Disabled alongside Send while an upload runs: this button deletes the draft
|
||||
// and closes the overlay, and the upload would then land its paths in a FRESH
|
||||
// draft, which the next open would prepend to the handed-back text.
|
||||
const terminalButton = overlay.querySelector('.prompt-composer-terminal');
|
||||
const focusTrap = new FocusTrap(overlay);
|
||||
textarea.value = initial;
|
||||
if (initial) this._composerDrafts.set(sessionId, initial);
|
||||
@@ -1292,6 +1300,7 @@ const KeyboardAccessoryBar = {
|
||||
const initialUploads = this._composerUploads.get(sessionId) || 0;
|
||||
imageButton.disabled = initialUploads > 0;
|
||||
sendButton.disabled = initialUploads > 0;
|
||||
terminalButton.disabled = initialUploads > 0;
|
||||
if (initialUploads > 0) imageButton.textContent = 'Uploading…';
|
||||
|
||||
const saveDraft = () => {
|
||||
@@ -1318,7 +1327,7 @@ const KeyboardAccessoryBar = {
|
||||
close({ preserveDraft: false });
|
||||
};
|
||||
const handleImages = async (files) => {
|
||||
const images = Array.from(files || []).filter((file) => file.type.startsWith('image/'));
|
||||
const images = Array.from(files || []).filter((file) => app._promptAttachKind(file));
|
||||
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
|
||||
saveDraft();
|
||||
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
|
||||
@@ -1335,6 +1344,8 @@ const KeyboardAccessoryBar = {
|
||||
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
|
||||
}
|
||||
if (currentSendButton) currentSendButton.disabled = count > 0;
|
||||
const currentTerminalButton = currentOverlay?.querySelector('.prompt-composer-terminal');
|
||||
if (currentTerminalButton) currentTerminalButton.disabled = count > 0;
|
||||
};
|
||||
syncUploadUi();
|
||||
try {
|
||||
@@ -1366,9 +1377,8 @@ const KeyboardAccessoryBar = {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const images = Array.from(items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean);
|
||||
.filter((file) => file && app._promptAttachKind(file));
|
||||
if (images.length > 0) {
|
||||
event.preventDefault();
|
||||
void handleImages(images);
|
||||
@@ -1382,7 +1392,55 @@ const KeyboardAccessoryBar = {
|
||||
});
|
||||
imageButton.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => close({ focusTerminal: true }));
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => {
|
||||
// Hand the text BACK to the terminal prompt so both surfaces hold the same
|
||||
// prompt, the mirror of the adoption composePrompt() does on open. Only a
|
||||
// single-line draft can make the trip: the prompt transport is single-line
|
||||
// and strips embedded newlines, so a multi-line draft would arrive mangled
|
||||
// and instead stays parked as a draft (the dot on the Compose key shows it).
|
||||
const text = textarea.value;
|
||||
if (!text || text.includes('\n')) {
|
||||
close({ focusTerminal: true });
|
||||
return;
|
||||
}
|
||||
// Refuse an oversized draft BEFORE it is deleted, or the text is gone from
|
||||
// both surfaces. Same cap as Send: the overlay branch lays the whole text out
|
||||
// as DOM lines on every repaint, so the transport's larger paste cap is not
|
||||
// the bound that matters here.
|
||||
if (text.length > this._composerMaxLength) {
|
||||
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
return;
|
||||
}
|
||||
// Cleared, never kept: the text now lives on the prompt, and a surviving
|
||||
// draft would be prepended to it the next time the composer opens.
|
||||
this._composerDrafts.delete(sessionId);
|
||||
close({ focusTerminal: true, preserveDraft: false });
|
||||
if (
|
||||
app._localEchoEnabled &&
|
||||
app._localEchoOverlay &&
|
||||
app.activeSessionId === sessionId &&
|
||||
!app._tilesOwnTerminal?.() &&
|
||||
!app._echoPassthroughSessions?.has(sessionId)
|
||||
) {
|
||||
// Buffer echo: the prompt is the overlay, so the text goes there unsent.
|
||||
// Passthrough is excluded deliberately: such a session skips the overlay
|
||||
// branch on Enter, so text parked there would never be submitted. The tile
|
||||
// grid is excluded for the same reason _attachFromToolbar is: the main
|
||||
// terminal is parked and hidden then, and a tile's Enter cannot flush its
|
||||
// overlay, so the text goes to the PTY below instead of stranding there.
|
||||
app._localEchoOverlay.appendText(text);
|
||||
} else {
|
||||
// Write-through echo (codex) or no local echo at all: there is no buffer
|
||||
// to park text in, because the prompt the user sees IS the CLI's own
|
||||
// composer on the PTY. Checking for the overlay alone left this branch
|
||||
// doing nothing on codex, so the button looked dead there. That composer
|
||||
// may already hold text the open adopted nothing of, so lead with a
|
||||
// separator rather than fuse onto its last word (harmless when empty).
|
||||
app._predictiveEcho?.clearPredictions();
|
||||
app._sendInputAsync(sessionId, ` ${text}`);
|
||||
}
|
||||
this._syncComposerDraftIndicator();
|
||||
});
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
|
||||
sendButton.addEventListener('click', send);
|
||||
overlay.addEventListener('click', (event) => {
|
||||
@@ -1399,7 +1457,7 @@ const KeyboardAccessoryBar = {
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
* - Image (picker): the "Image" button opens a native file picker
|
||||
* (accept=image/* → camera / photo library / files), the most reliable
|
||||
* (accept=image/*,video/* → camera / photo library / files), the most reliable
|
||||
* way to attach a photo on mobile.
|
||||
* - Image (paste): if the browser exposes image blobs on the textarea's
|
||||
* paste event, we intercept them and upload directly. Support is spotty
|
||||
@@ -1414,13 +1472,13 @@ const KeyboardAccessoryBar = {
|
||||
overlay.className = 'paste-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="paste-dialog">
|
||||
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach an image"></textarea>
|
||||
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach a photo or video"></textarea>
|
||||
<div class="paste-actions">
|
||||
<button class="paste-image">🖼 Image</button>
|
||||
<button class="paste-cancel">Cancel</button>
|
||||
<button class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1438,10 +1496,10 @@ const KeyboardAccessoryBar = {
|
||||
}
|
||||
};
|
||||
|
||||
// Filter to images, close the dialog, and hand off to the shared
|
||||
// Filter to images and videos, close the dialog, and hand off to the shared
|
||||
// upload+insert pipeline. Returns true if any image was handled.
|
||||
const handleImages = (files) => {
|
||||
const images = Array.from(files || []).filter((f) => f.type.startsWith('image/'));
|
||||
const images = Array.from(files || []).filter((f) => app._promptAttachKind(f));
|
||||
if (images.length === 0) return false;
|
||||
close();
|
||||
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images);
|
||||
@@ -1458,10 +1516,8 @@ const KeyboardAccessoryBar = {
|
||||
if (!items) return;
|
||||
const imageFiles = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob && app._promptAttachKind(blob)) imageFiles.push(blob);
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -614,7 +614,9 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
/* Mobile voice input button in toolbar — initially hidden via inline style,
|
||||
shown by VoiceInput._showButtons() clearing the inline display */
|
||||
.btn-voice-mobile {
|
||||
/* The Attach button shares the mic's box; its toolbar slot is on its wrapper below. */
|
||||
.btn-voice-mobile,
|
||||
.btn-attach-mobile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -626,15 +628,48 @@ html.mobile-init .file-browser-panel {
|
||||
color: #93c5fd;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
order: 5; /* Between Run Shell and settings gear */
|
||||
}
|
||||
|
||||
.btn-voice-mobile svg {
|
||||
/* Toolbar slot for both, between Run Shell and the settings gear. Attach's
|
||||
WRAPPER takes it (the button inside is not a toolbar flex item), and at an
|
||||
equal order source order keeps Attach on the left of the mic. */
|
||||
.btn-voice-mobile,
|
||||
.btn-attach-mobile-wrap {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
/* Relative box for the invisible file input that sits over the button and TAKES
|
||||
THE TAPS (see initImageInput): a native tap on the input is the only thing that
|
||||
opens the picker while the keyboard is up, and iOS anchors the picker popover to
|
||||
the input's own frame. overflow: hidden is load-bearing: WebKit does not clip a
|
||||
file input, so its "Choose Files" content (~115px in this 26px box) spilled out
|
||||
invisibly over the mic and took its taps. Chromium clips it, so only iOS showed it. */
|
||||
.btn-attach-mobile-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-attach-mobile-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.btn-voice-mobile svg,
|
||||
.btn-attach-mobile svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.btn-voice-mobile:active {
|
||||
.btn-voice-mobile:active,
|
||||
.btn-attach-mobile:active,
|
||||
/* The input above the button swallows the press, so the wrapper is what goes
|
||||
:active. Without this the only phone button with no press feedback is Attach. */
|
||||
.btn-attach-mobile-wrap:active .btn-attach-mobile {
|
||||
background: #2a3a5f;
|
||||
}
|
||||
|
||||
@@ -3245,13 +3280,22 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
both stylesheets and repainted the armed modifier back to a resting button on
|
||||
all four light skins. Excluding the state here fixes phone and tablet at once;
|
||||
adding a class to the armed rules would only have moved the tie. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-attach-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
|
||||
background: var(--control-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-attach-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Attach's press feedback comes from its wrapper (the file input over the button
|
||||
takes the press). Kept out of the :is() list above on purpose: a descendant
|
||||
selector there would raise that list's specificity for every button in it. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-attach-mobile-wrap:active .btn-attach-mobile {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
|
||||
@@ -6152,6 +6152,12 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile Attach button (wrapper holds the button and its file input) - hidden on
|
||||
desktop; the phone block in mobile.css shows it */
|
||||
.btn-attach-mobile-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile case settings gear - hidden on desktop */
|
||||
.btn-case-settings-mobile {
|
||||
display: none;
|
||||
|
||||
@@ -324,12 +324,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionId = tileAt(e.target);
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
if (!sessionId || files.length === 0) return;
|
||||
const images = files.filter((f) => String(f?.type || '').startsWith('image/'));
|
||||
if (images.length === 0) {
|
||||
this.showToast?.('Only image files are supported', 'error');
|
||||
// The one classifier every pick and drop filter reads (image-input.js):
|
||||
// MIME first, then the name's extension, so documents and videos count too.
|
||||
const media = files.filter((f) => this._promptAttachKind?.(f));
|
||||
if (media.length === 0) {
|
||||
this.showToast?.('Unsupported file type', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages?.(images, { sessionId });
|
||||
this._uploadAndInsertImages?.(media, { sessionId });
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -155,7 +155,8 @@ import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayout
|
||||
import { RunSummaryTracker } from '../../run-summary.js';
|
||||
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES, MAX_PASTE_VIDEO_BYTES } from '../../config/buffer-limits.js';
|
||||
import { VIDEO_ATTACHMENT_EXTENSIONS } from '../../attachment-registry.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
@@ -360,6 +361,19 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
|
||||
const brand = data.subarray(8, 12).toString('ascii');
|
||||
return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
|
||||
}
|
||||
case '.mp4':
|
||||
case '.m4v':
|
||||
case '.mov':
|
||||
// ISO Base Media File Format / QuickTime: size + a top-level atom type. Brands
|
||||
// vary too widely to enumerate (qt, isom, mp42, avc1, hev1, ...), and an older
|
||||
// QuickTime file can open on an atom other than ftyp.
|
||||
return ['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip'].includes(data.subarray(4, 8).toString('ascii'));
|
||||
case '.webm':
|
||||
// EBML header (Matroska's WebM subset)
|
||||
return u32be(0) === 0x1a45dfa3;
|
||||
case '.ogv':
|
||||
// Ogg container ("OggS")
|
||||
return u32be(0) === 0x4f676753;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -412,37 +426,70 @@ async function ensureUploadDir(workingDir: string): Promise<string | null> {
|
||||
return uploadDir;
|
||||
}
|
||||
|
||||
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
|
||||
// Bucket map entries are pruned when they drift > 1h stale to bound memory
|
||||
// against a flood of unique IP keys.
|
||||
/** Open a fresh, uniquely named paste file for writing. */
|
||||
async function openPasteFile(imageDir: string, ext: string) {
|
||||
// Date.now() collides on same-ms uploads from two tabs (last-write wins
|
||||
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
||||
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
|
||||
const filepath = join(imageDir, filename);
|
||||
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
|
||||
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
|
||||
const fh = await fs.open(
|
||||
filepath,
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
|
||||
);
|
||||
return { filename, filepath, fh };
|
||||
}
|
||||
|
||||
// Per-(IP, sessionId) token buckets for paste-image. Images: 30/min. Videos: 3/min,
|
||||
// a much lower rate because one video can be up to MAX_PASTE_VIDEO_BYTES (1GB), so the
|
||||
// 30/min cap would let a single authenticated client write 30GB of disk a minute; 3/min
|
||||
// bounds that to 3GB. Bucket entries are pruned when they drift > 1h stale to bound
|
||||
// memory against a flood of unique IP keys.
|
||||
const PASTE_RATE_TOKENS = 30;
|
||||
const PASTE_RATE_REFILL_PER_MS = PASTE_RATE_TOKENS / 60_000;
|
||||
const VIDEO_RATE_TOKENS = 3;
|
||||
const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
|
||||
const PASTE_BUCKET_GC_THRESHOLD = 1000;
|
||||
const pasteRateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
|
||||
const videoRateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
|
||||
|
||||
export function consumePasteToken(key: string, now: number = Date.now()): boolean {
|
||||
if (pasteRateBuckets.size > PASTE_BUCKET_GC_THRESHOLD) {
|
||||
for (const [k, b] of pasteRateBuckets) {
|
||||
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS) pasteRateBuckets.delete(k);
|
||||
function takeToken(
|
||||
buckets: Map<string, { tokens: number; lastRefill: number }>,
|
||||
key: string,
|
||||
capacity: number,
|
||||
now: number
|
||||
): boolean {
|
||||
if (buckets.size > PASTE_BUCKET_GC_THRESHOLD) {
|
||||
for (const [k, b] of buckets) {
|
||||
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS) buckets.delete(k);
|
||||
}
|
||||
}
|
||||
let b = pasteRateBuckets.get(key);
|
||||
let b = buckets.get(key);
|
||||
if (!b) {
|
||||
b = { tokens: PASTE_RATE_TOKENS, lastRefill: now };
|
||||
pasteRateBuckets.set(key, b);
|
||||
b = { tokens: capacity, lastRefill: now };
|
||||
buckets.set(key, b);
|
||||
}
|
||||
const delta = (now - b.lastRefill) * PASTE_RATE_REFILL_PER_MS;
|
||||
b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
|
||||
b.tokens = Math.min(capacity, b.tokens + (now - b.lastRefill) * (capacity / 60_000));
|
||||
b.lastRefill = now;
|
||||
if (b.tokens < 1) return false;
|
||||
b.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function consumePasteToken(key: string, now: number = Date.now()): boolean {
|
||||
return takeToken(pasteRateBuckets, key, PASTE_RATE_TOKENS, now);
|
||||
}
|
||||
|
||||
// Videos stream to disk under a far larger per-file cap, so they draw on their own,
|
||||
// much lower bucket (see the comment above the constants).
|
||||
export function consumeVideoToken(key: string, now: number = Date.now()): boolean {
|
||||
return takeToken(videoRateBuckets, key, VIDEO_RATE_TOKENS, now);
|
||||
}
|
||||
|
||||
// Test hook: reset between runs.
|
||||
export function _resetPasteRateBuckets(): void {
|
||||
pasteRateBuckets.clear();
|
||||
videoRateBuckets.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5296,6 +5343,17 @@ export function registerSessionRoutes(
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
|
||||
// Single-sourced from the attachment registry (what the file viewer can also play),
|
||||
// so an uploaded video is a type that previews. 3gp/mkv are deliberately out: they
|
||||
// would upload but not preview, and a phone's camera roll produces neither.
|
||||
const ALLOWED_VIDEO_EXTS = new Set([...VIDEO_ATTACHMENT_EXTENSIONS].map((e) => `.${e}`));
|
||||
const VIDEO_MIME_EXT: Record<string, string> = {
|
||||
mp4: '.mp4',
|
||||
quicktime: '.mov',
|
||||
'x-m4v': '.m4v',
|
||||
webm: '.webm',
|
||||
ogg: '.ogv',
|
||||
};
|
||||
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
|
||||
|
||||
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
||||
@@ -5331,13 +5389,23 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
// `?kind=video` selects the disk-streamed path (see below). It is read here so the
|
||||
// rate check draws on the right bucket before any work: videos get 3/min, images 30.
|
||||
const isVideo = (req.query as { kind?: string }).kind === 'video';
|
||||
|
||||
// Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
|
||||
// — even an authenticated attacker can otherwise loop large image POSTs.
|
||||
if (!consumePasteToken(`${req.ip}:${id}`)) {
|
||||
// Rate limit per (IP, sessionId). Defends against disk-fill DoS — even an
|
||||
// authenticated client can otherwise loop large uploads. Videos get their own,
|
||||
// far lower bucket because the per-file cap is 1GB (see the bucket constants).
|
||||
const rateKey = `${req.ip}:${id}`;
|
||||
if (!(isVideo ? consumeVideoToken(rateKey) : consumePasteToken(rateKey))) {
|
||||
reply.code(429);
|
||||
reply.header('Retry-After', '60');
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
isVideo
|
||||
? 'Rate limit exceeded (3 video uploads/min per session)'
|
||||
: 'Rate limit exceeded (30 uploads/min per session)'
|
||||
);
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
@@ -5381,9 +5449,17 @@ export function registerSessionRoutes(
|
||||
// boundary scanner with several bugs: literal boundary matches anywhere in
|
||||
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
|
||||
// offsets), no part-count cap.
|
||||
// `?kind=video` (read above for the rate check) is the ONLY way onto the video
|
||||
// path: it selects the larger, disk-streamed budget before the part is seen
|
||||
// (busboy takes its limits up front), and it admits video types alone, so an
|
||||
// image can never ride the video cap through memory. Without it the route is
|
||||
// byte-identical to the image-only version. Truncation is checked on the stream
|
||||
// below rather than thrown, since the file is being written as it arrives.
|
||||
let part: import('@fastify/multipart').MultipartFile | undefined;
|
||||
try {
|
||||
part = await req.file();
|
||||
part = await req.file(
|
||||
isVideo ? { limits: { fileSize: MAX_PASTE_VIDEO_BYTES }, throwFileSizeLimit: false } : undefined
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
reply.code(413);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'Invalid multipart payload');
|
||||
@@ -5396,6 +5472,73 @@ export function registerSessionRoutes(
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
let ext = '';
|
||||
if (part.filename) {
|
||||
const origExt = extname(part.filename).toLowerCase();
|
||||
if (ALLOWED_VIDEO_EXTS.has(origExt)) ext = origExt;
|
||||
}
|
||||
const videoMime = (part.mimetype || '').toLowerCase().match(/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/);
|
||||
if (videoMime) ext = VIDEO_MIME_EXT[videoMime[1]] ?? ext;
|
||||
if (!ext) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`
|
||||
);
|
||||
}
|
||||
const imageDir = await ensureUploadDir(session.workingDir);
|
||||
if (!imageDir) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
||||
}
|
||||
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
|
||||
// Written as it arrives: a phone video is far too large to buffer the way an
|
||||
// image is. The container is judged on the first 12 bytes as soon as they are
|
||||
// in, so a mislabeled upload stops touching disk at its first chunk; the rest
|
||||
// of the body is drained unwritten (as busboy does past its own size limit),
|
||||
// which is what lets the answer reach the client. A transfer that dies midway
|
||||
// (the phone left the network) leaves no partial file behind.
|
||||
const head = Buffer.alloc(12);
|
||||
let headLen = 0;
|
||||
let mislabeled = false;
|
||||
try {
|
||||
for await (const chunk of part.file) {
|
||||
if (mislabeled) continue;
|
||||
if (headLen < head.length) {
|
||||
headLen += chunk.copy(head, headLen, 0, head.length - headLen);
|
||||
mislabeled = headLen === head.length && !imageMagicMatchesExt(head, ext);
|
||||
if (mislabeled) continue;
|
||||
}
|
||||
// writeFile loops until the whole chunk is on disk; a bare write() may be short.
|
||||
await fh.writeFile(chunk);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
await fs.unlink(filepath).catch(() => {});
|
||||
throw err;
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
let failure: { status: number; message: string } | null = null;
|
||||
if (headLen === 0) failure = { status: 400, message: 'Empty file' };
|
||||
else if (!imageMagicMatchesExt(head.subarray(0, headLen), ext)) {
|
||||
console.warn(
|
||||
`[paste-image] video magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${head.subarray(0, headLen).toString('hex')}`
|
||||
);
|
||||
failure = { status: 415, message: `Video bytes do not match declared type ${ext}` };
|
||||
} else if (part.file.truncated) {
|
||||
const maxMb = Math.round(MAX_PASTE_VIDEO_BYTES / (1024 * 1024));
|
||||
failure = { status: 413, message: `File too large (max ${maxMb}MB)` };
|
||||
}
|
||||
if (failure) {
|
||||
await fs.unlink(filepath).catch(() => {});
|
||||
reply.code(failure.status);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, failure.message);
|
||||
}
|
||||
return { path: filepath, filename };
|
||||
}
|
||||
|
||||
let imageBytes: Buffer;
|
||||
try {
|
||||
imageBytes = await part.toBuffer();
|
||||
@@ -5472,16 +5615,7 @@ export function registerSessionRoutes(
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
||||
}
|
||||
// Date.now() collides on same-ms uploads from two tabs (last-write wins
|
||||
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
||||
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
|
||||
const filepath = join(imageDir, filename);
|
||||
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
|
||||
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
|
||||
const fh = await fs.open(
|
||||
filepath,
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
|
||||
);
|
||||
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
|
||||
try {
|
||||
await fh.writeFile(imageBytes);
|
||||
} finally {
|
||||
|
||||
@@ -148,6 +148,9 @@ describe('terminal link-provider regexes (shipped source)', () => {
|
||||
// paths at all outside /var and /tmp.
|
||||
'/Users/arbbot/codeman-cases/report.docx',
|
||||
'/mnt/d/captures/demo.mp4',
|
||||
// Camera-roll video uploads the agent echoes back (VIDEO_ATTACHMENT_EXTENSIONS).
|
||||
'/home/a/.codeman-uploads/paste-1-ab.m4v',
|
||||
'/home/a/.codeman-uploads/paste-2-cd.ogv',
|
||||
// Longer extension of a family must win over its prefix (tsx over ts).
|
||||
'/home/a/src/App.tsx',
|
||||
];
|
||||
|
||||
@@ -26,6 +26,9 @@ function loadComposer(sessionId = 'session-1') {
|
||||
localEcho.pendingText = '';
|
||||
}),
|
||||
suppressBufferDetection: vi.fn(),
|
||||
appendText: vi.fn((text: string) => {
|
||||
localEcho.pendingText += text;
|
||||
}),
|
||||
};
|
||||
const app = {
|
||||
activeSessionId: sessionId,
|
||||
@@ -42,6 +45,8 @@ function loadComposer(sessionId = 'session-1') {
|
||||
_predictiveEcho: { clearPredictions: vi.fn() },
|
||||
_sendInputAsync: vi.fn(),
|
||||
_uploadAndInsertImages: vi.fn(async () => ['/tmp/image-one.png']),
|
||||
_promptAttachKind: (file: { type: string }) =>
|
||||
file.type.startsWith('image/') ? 'image' : file.type.startsWith('video/') ? 'video' : null,
|
||||
showToast: vi.fn(),
|
||||
};
|
||||
const schedule = (callback: () => void, delay = 0) => {
|
||||
@@ -330,7 +335,7 @@ describe('mobile prompt composer', () => {
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the draft and focuses xterm when Use terminal keyboard is chosen', () => {
|
||||
it('returns the text to the prompt and focuses xterm when Use terminal keyboard is chosen', () => {
|
||||
const { app, bar, document, localEcho, runTimers } = loadComposer();
|
||||
const composeButton = mountComposeButton(bar, document);
|
||||
bar.composePrompt();
|
||||
@@ -339,11 +344,13 @@ describe('mobile prompt composer', () => {
|
||||
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
|
||||
|
||||
expect(app.terminal.focus).toHaveBeenCalledOnce();
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(true);
|
||||
expect(composeButton.title).toBe('Resume saved prompt draft');
|
||||
// The text is on the prompt now, not parked, so there is no draft to resume.
|
||||
expect(localEcho.pendingText).toBe('keep this');
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(false);
|
||||
runTimers();
|
||||
localEcho.pendingText = '; then continue';
|
||||
localEcho.pendingText += '; then continue';
|
||||
bar.composePrompt();
|
||||
// Adopted back exactly once: a surviving draft would double the first half.
|
||||
expect(textarea(document).value).toBe('keep this; then continue');
|
||||
});
|
||||
|
||||
@@ -374,6 +381,138 @@ describe('mobile prompt composer', () => {
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hands a single-line draft back to the terminal prompt and keeps no draft', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
input.value = 'describe this image';
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
|
||||
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._localEchoOverlay.appendText).toHaveBeenCalledWith('describe this image');
|
||||
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('describe this image');
|
||||
});
|
||||
|
||||
it('writes the text to the PTY when the session has no buffer overlay, as codex does', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
// Codex turns the buffer overlay off and echoes write-through instead.
|
||||
app._localEchoEnabled = false;
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
input.value = 'describe this image';
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
|
||||
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', ' describe this image');
|
||||
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes the handoff to the PTY, not the parked overlay, while the tile grid owns the terminal', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
// The grid parks and hides the main terminal, and a tile's Enter cannot flush
|
||||
// its overlay, so the text would strand there (same guard as _attachFromToolbar).
|
||||
app._tilesOwnTerminal = () => true;
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
input.value = 'describe this image';
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
|
||||
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', ' describe this image');
|
||||
});
|
||||
|
||||
it('refuses an oversized handoff with the draft intact rather than losing it from both surfaces', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
input.value = 'x'.repeat(bar._composerMaxLength + 1);
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
|
||||
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
|
||||
expect(app.showToast).toHaveBeenCalled();
|
||||
// Still open, still holding the text.
|
||||
expect(document.querySelector('.prompt-composer-overlay')).not.toBeNull();
|
||||
expect(textarea(document).value.length).toBe(bar._composerMaxLength + 1);
|
||||
});
|
||||
|
||||
it('disables the terminal handoff while an upload is in flight', async () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
let finishUpload!: (paths: string[]) => void;
|
||||
app._uploadAndInsertImages.mockImplementation(
|
||||
() => new Promise<string[]>((resolveUpload) => (finishUpload = resolveUpload))
|
||||
);
|
||||
bar.composePrompt();
|
||||
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
|
||||
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
|
||||
Object.defineProperty(fileInput, 'files', { configurable: true, value: [image] });
|
||||
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||
|
||||
// Tapping it mid-upload would close the overlay and let the upload rebuild a
|
||||
// draft holding only the paths, which the next open would prepend.
|
||||
expect((document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).disabled).toBe(true);
|
||||
finishUpload(['/tmp/image-one.png']);
|
||||
await vi.waitFor(() =>
|
||||
expect((document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).disabled).toBe(false)
|
||||
);
|
||||
});
|
||||
|
||||
it('writes to the PTY when the session is in echo passthrough, where Enter skips the overlay', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
app._echoPassthroughSessions = new Set(['session-1']);
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
input.value = 'describe this image';
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
|
||||
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', ' describe this image');
|
||||
});
|
||||
|
||||
// Forward lock, not a check on this diff: the pre-change handoff also preserved
|
||||
// the draft and never appended, so this passes on either side of the change. It
|
||||
// exists to pin that multi-line stays parked if the single-line route is widened.
|
||||
it('parks a multi-line draft instead of mangling it on the single-line prompt', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
input.value = 'first line\nsecond line';
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
|
||||
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('first line\nsecond line');
|
||||
});
|
||||
|
||||
it('separates a parked draft from text put on the prompt before the next open', () => {
|
||||
const { bar, document, localEcho, runTimers } = loadComposer();
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
input.value = 'describe this';
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
(document.querySelector('.paste-cancel') as HTMLButtonElement).click(); // parks the draft
|
||||
runTimers();
|
||||
// The toolbar Attach put a path on the now-empty prompt with no leading gap.
|
||||
localEcho.pendingText = '/case/.codeman-uploads/paste-1-a.png ';
|
||||
|
||||
bar.composePrompt();
|
||||
|
||||
expect(textarea(document).value).toBe('describe this /case/.codeman-uploads/paste-1-a.png ');
|
||||
});
|
||||
|
||||
it('finishes an upload into a reopened composer without restoring a deleted session draft', async () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
let finishUpload!: (paths: string[]) => void;
|
||||
@@ -436,4 +575,37 @@ describe('mobile prompt composer', () => {
|
||||
await vi.waitFor(() => expect(input.value).toBe('/tmp/first.png /tmp/second.png'));
|
||||
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('offers videos in the Image key picker and filters the pick through the shared classifier', async () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
|
||||
expect(fileInput.getAttribute('accept')).toBe('image/*,video/*');
|
||||
const video = new document.defaultView!.File(['clip'], 'clip.mov', { type: 'video/quicktime' });
|
||||
const pdf = new document.defaultView!.File(['%PDF'], 'notes.pdf', { type: 'application/pdf' });
|
||||
Object.defineProperty(fileInput, 'files', { configurable: true, value: [video, pdf] });
|
||||
|
||||
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([video], { insert: false });
|
||||
await vi.waitFor(() => expect(textarea(document).value).toContain('/tmp/image-one.png'));
|
||||
});
|
||||
|
||||
it('takes a video pasted into the editor through the shared classifier', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
const video = new document.defaultView!.File(['clip'], 'clip.mp4', { type: 'video/mp4' });
|
||||
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'clipboardData', {
|
||||
value: {
|
||||
items: [
|
||||
{ type: 'text/plain', getAsFile: () => null },
|
||||
{ type: 'video/mp4', getAsFile: () => video },
|
||||
],
|
||||
},
|
||||
});
|
||||
input.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([video], { insert: false });
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,10 @@ export const SELECTORS = {
|
||||
TOOLBAR_RIGHT: '.toolbar-right',
|
||||
SETTINGS_MOBILE: '.btn-settings-mobile',
|
||||
CASE_MOBILE: '.btn-case-mobile',
|
||||
ATTACH_MOBILE: '.btn-attach-mobile',
|
||||
// The desktop rule hides the WRAPPER, and assertHidden reads an element's own
|
||||
// computed style (an ancestor's display:none is invisible to it).
|
||||
ATTACH_MOBILE_WRAP: '.btn-attach-mobile-wrap',
|
||||
|
||||
// Tabs
|
||||
TABS_CONTAINER: '.session-tabs',
|
||||
|
||||
+14
-27
@@ -199,13 +199,24 @@ describe('Mobile Layout', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('uses phone upload and voice controls without toolbar overlap', async () => {
|
||||
it('keeps the desktop voice control visible and the phone attach button hidden', async () => {
|
||||
const { context, page } = await createDevicePage(iPadPro, baseUrl, 'chromium');
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await assertVisible(page, '#voiceInputBtn');
|
||||
await assertHidden(page, SELECTORS.ATTACH_MOBILE_WRAP);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses phone attach and voice controls without toolbar overlap', async () => {
|
||||
const device = REPRESENTATIVE_DEVICES['small-phone'];
|
||||
const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
expect(await page.locator('.toolbar-center .btn-upload').isVisible()).toBe(false);
|
||||
await assertVisible(page, '.btn-upload-mobile');
|
||||
await assertVisible(page, SELECTORS.ATTACH_MOBILE);
|
||||
await assertVisible(page, '#voiceInputBtnMobile');
|
||||
|
||||
const violations = await page.evaluate(() => {
|
||||
@@ -264,30 +275,6 @@ describe('Mobile Layout', () => {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps desktop upload and voice controls in one centered row', async () => {
|
||||
const { context, page } = await createDevicePage(iPadPro, baseUrl, 'chromium');
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await assertVisible(page, '.toolbar-center .btn-upload');
|
||||
await assertVisible(page, '#voiceInputBtn');
|
||||
|
||||
const layout = await page.evaluate(() => {
|
||||
const upload = document.querySelector('.toolbar-center .btn-upload')!.getBoundingClientRect();
|
||||
const voice = document.querySelector('#voiceInputBtn')!.getBoundingClientRect();
|
||||
return {
|
||||
centerYDifference: Math.abs(upload.top + upload.height / 2 - (voice.top + voice.height / 2)),
|
||||
centerXDifference: Math.abs(upload.left + upload.width / 2 - (voice.left + voice.width / 2)),
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout.centerYDifference).toBeLessThanOrEqual(2);
|
||||
expect(layout.centerXDifference).toBeGreaterThan(20);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Device Classes ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +38,11 @@ vi.mock('node:child_process', async (orig) => {
|
||||
return { ...actual, execFile };
|
||||
});
|
||||
vi.mock('../../src/web/heic-jpeg-converter.js', () => ({ convertHeicToJpeg: heicConvert }));
|
||||
// A tiny video cap so the streamed path's truncation branch is reachable in-process.
|
||||
vi.mock('../../src/config/buffer-limits.js', async (orig) => ({
|
||||
...(await orig<typeof import('../../src/config/buffer-limits.js')>()),
|
||||
MAX_PASTE_VIDEO_BYTES: 64,
|
||||
}));
|
||||
|
||||
// In-memory remote store so remote-case tests can inject hosts/cases without real JSON files.
|
||||
const remoteStore = vi.hoisted(() => ({
|
||||
@@ -79,7 +84,7 @@ vi.mock('../../src/utils/bounded-path-probe.js', async (orig) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { registerSessionRoutes, _resetPasteRateBuckets } from '../../src/web/routes/session-routes.js';
|
||||
import { RemoteWakeRegistry, REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS } from '../../src/remote-wake.js';
|
||||
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../src/utils/bounded-path-probe.js';
|
||||
@@ -274,6 +279,10 @@ describe('session-routes', () => {
|
||||
// ========== POST /api/sessions/:id/paste-image ==========
|
||||
|
||||
describe('POST /api/sessions/:id/paste-image', () => {
|
||||
// Rate buckets are process-global; clear them so one test's uploads cannot
|
||||
// exhaust another's budget (the video bucket is only 3/min).
|
||||
beforeEach(() => _resetPasteRateBuckets());
|
||||
|
||||
function imageUploadBody(boundary: string, filename: string, mimetype: string, imageBytes: Buffer): Buffer {
|
||||
return Buffer.concat([
|
||||
Buffer.from(
|
||||
@@ -500,6 +509,254 @@ describe('session-routes', () => {
|
||||
await rm(workDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
// `?kind=video`: the phone Attach button's camera-roll videos. Streamed to
|
||||
// disk under their own cap; without the flag the route stays image-only.
|
||||
const boundary = 'codeman-test-boundary';
|
||||
const movHeaders = {
|
||||
host: 'codeman.test',
|
||||
origin: 'http://codeman.test',
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
};
|
||||
// size + ftyp + "qt " brand, as an iPhone writes it, padded past the 12-byte sniff.
|
||||
const qtMov = Buffer.concat([Buffer.from('0000001466747970717420200000000071742020', 'hex'), Buffer.alloc(20)]);
|
||||
|
||||
it('streams a ?kind=video upload to .codeman-uploads under the video extension', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
|
||||
headers: movHeaders,
|
||||
payload: imageUploadBody(boundary, 'IMG_0001.MOV', 'video/quicktime', qtMov),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.path).toMatch(/\/\.codeman-uploads\/paste-\d+-[a-f0-9]{8}\.mov$/);
|
||||
expect(await readFile(body.data.path)).toEqual(qtMov);
|
||||
await rm(workDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('accepts the registry video containers across their magic families (m4v, webm, ogv)', async () => {
|
||||
// The admitted set is VIDEO_ATTACHMENT_EXTENSIONS (mp4/webm/mov/m4v/ogv); mov is
|
||||
// covered above. These three exercise the three sniff families: ISO BMFF (m4v),
|
||||
// EBML (webm) and Ogg (ogv). Three per session stays inside the 3/min video budget.
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
const m4v = Buffer.concat([Buffer.from('00000018667479706d703432', 'hex'), Buffer.alloc(16)]); // ftyp mp42
|
||||
const webm = Buffer.concat([Buffer.from('1a45dfa3a3428681', 'hex'), Buffer.alloc(16)]); // EBML
|
||||
const ogv = Buffer.concat([Buffer.from('4f67675300020000', 'hex'), Buffer.alloc(16)]); // "OggS"
|
||||
for (const [name, mime, bytes, ext] of [
|
||||
['VID_0001.m4v', 'video/x-m4v', m4v, '.m4v'],
|
||||
['VID_0002.webm', 'video/webm', webm, '.webm'],
|
||||
['VID_0003.ogv', '', ogv, '.ogv'], // no MIME type: the name's extension carries it
|
||||
] as const) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
|
||||
headers: movHeaders,
|
||||
payload: imageUploadBody(boundary, name, mime || 'application/octet-stream', bytes),
|
||||
});
|
||||
expect(res.statusCode, name).toBe(200);
|
||||
expect(JSON.parse(res.body).data.filename.endsWith(ext), name).toBe(true);
|
||||
}
|
||||
await rm(workDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('refuses 3gp and mkv, which a phone camera roll does not produce and the viewer cannot play', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
const threeGp = Buffer.concat([Buffer.from('0000001866747970336770340000000033677034', 'hex'), Buffer.alloc(20)]);
|
||||
const mkv = Buffer.concat([Buffer.from('1a45dfa3a3428681', 'hex'), Buffer.alloc(24)]);
|
||||
for (const [name, mime, bytes] of [
|
||||
['VID.3gp', 'video/3gpp', threeGp],
|
||||
['VID.mkv', 'video/x-matroska', mkv],
|
||||
] as const) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
|
||||
headers: movHeaders,
|
||||
payload: imageUploadBody(boundary, name, mime, bytes),
|
||||
});
|
||||
expect(res.statusCode, name).toBe(400);
|
||||
expect(JSON.parse(res.body).error, name).toMatch(/Unsupported video type/);
|
||||
}
|
||||
await rm(workDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('rejects a video without the flag, so the image path stays image-only', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
|
||||
headers: movHeaders,
|
||||
payload: imageUploadBody(boundary, 'IMG_0001.MOV', 'video/quicktime', qtMov),
|
||||
});
|
||||
// The image path falls back to .png for an unknown type and fails its magic check.
|
||||
expect(res.statusCode).toBe(415);
|
||||
expect(JSON.parse(res.body).error).toMatch(/do not match declared type/);
|
||||
});
|
||||
|
||||
it('refuses an image declared under ?kind=video, so nothing rides the video cap through memory', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
|
||||
headers: movHeaders,
|
||||
payload: imageUploadBody(boundary, 'a.png', 'image/png', qtMov),
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/Unsupported video type/);
|
||||
});
|
||||
|
||||
it('unlinks a ?kind=video upload whose bytes are not a video container', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
const png = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), Buffer.alloc(24)]);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
|
||||
headers: movHeaders,
|
||||
payload: imageUploadBody(boundary, 'fake.mov', 'video/quicktime', png),
|
||||
});
|
||||
expect(res.statusCode).toBe(415);
|
||||
expect(await readdir(join(workDir, '.codeman-uploads'))).toEqual(['.gitignore']); // the folder's own ignore file, no upload
|
||||
await rm(workDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('leaves no partial file behind when a ?kind=video transfer ends early', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
// The body stops mid-part: no closing boundary ever arrives.
|
||||
const cut = imageUploadBody(boundary, 'IMG_0003.MOV', 'video/quicktime', qtMov).subarray(0, -12);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
|
||||
headers: movHeaders,
|
||||
payload: cut,
|
||||
});
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400); // "Premature close" out of the write loop
|
||||
expect(await readdir(join(workDir, '.codeman-uploads'))).toEqual(['.gitignore']); // the folder's own ignore file, no upload
|
||||
await rm(workDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('unlinks a ?kind=video upload over the video cap and answers 413', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
const big = Buffer.concat([qtMov, Buffer.alloc(200)]); // cap is mocked to 64 bytes
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
|
||||
headers: movHeaders,
|
||||
payload: imageUploadBody(boundary, 'IMG_0002.MOV', 'video/quicktime', big),
|
||||
});
|
||||
expect(res.statusCode).toBe(413);
|
||||
expect(JSON.parse(res.body).error).toMatch(/too large/);
|
||||
expect(await readdir(join(workDir, '.codeman-uploads'))).toEqual(['.gitignore']); // the folder's own ignore file, no upload
|
||||
await rm(workDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('judges a ?kind=video upload on its header, before the body has landed on disk', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
// Mislabeled AND over the (64-byte) cap: the header verdict comes first, so the
|
||||
// answer is 415, never the 413 that would mean the whole body was taken in.
|
||||
const big = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), Buffer.alloc(200)]);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
|
||||
headers: movHeaders,
|
||||
payload: imageUploadBody(boundary, 'fake.mov', 'video/quicktime', big),
|
||||
});
|
||||
expect(res.statusCode).toBe(415);
|
||||
expect(await readdir(join(workDir, '.codeman-uploads'))).toEqual(['.gitignore']); // the folder's own ignore file, no upload
|
||||
await rm(workDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('refuses an upload for a remote session before touching disk, video or image', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
// A remote session's workingDir is the REMOTE path: a file written here is unreadable there.
|
||||
(harness.ctx._session as unknown as { remote: unknown }).remote = { hostId: 'gpu-box' };
|
||||
for (const [url, name, mime, bytes] of [
|
||||
[`/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`, 'IMG_0001.MOV', 'video/quicktime', qtMov],
|
||||
[
|
||||
`/api/sessions/${harness.ctx._sessionId}/paste-image`,
|
||||
'shot.png',
|
||||
'image/png',
|
||||
Buffer.from('89504e470d0a1a0a00000000', 'hex'),
|
||||
],
|
||||
] as const) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url,
|
||||
headers: movHeaders,
|
||||
payload: imageUploadBody(boundary, name, mime, bytes),
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/remote/);
|
||||
}
|
||||
(harness.ctx._session as unknown as { remote: unknown }).remote = undefined;
|
||||
expect(await readdir(workDir)).toEqual([]);
|
||||
await rm(workDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('keeps the image path on its own size cap: a valid image past the video cap is accepted without the flag', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-uploads-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
try {
|
||||
// A real PNG far larger than the mocked 64-byte video cap but well under the
|
||||
// harness's 10MB image cap. If the video path's per-call cap override leaked
|
||||
// onto the image path, @fastify/multipart would truncate this and the magic
|
||||
// check would fail; without ?kind=video it must be accepted whole.
|
||||
const png = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), Buffer.alloc(4096)]);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
|
||||
headers: movHeaders,
|
||||
payload: imageUploadBody(boundary, 'shot.png', 'image/png', png),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.path).toMatch(/\/\.codeman-uploads\/paste-\d+-[a-f0-9]{8}\.png$/);
|
||||
expect(await readFile(body.data.path)).toEqual(png);
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('caps ?kind=video at 3 per minute per session, far below the image rate, on its own bucket', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-uploads-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
try {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ok = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
|
||||
headers: movHeaders,
|
||||
payload: imageUploadBody(boundary, `IMG_000${i}.MOV`, 'video/quicktime', qtMov),
|
||||
});
|
||||
expect(ok.statusCode).toBe(200);
|
||||
}
|
||||
const limited = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
|
||||
headers: movHeaders,
|
||||
payload: imageUploadBody(boundary, 'IMG_0004.MOV', 'video/quicktime', qtMov),
|
||||
});
|
||||
expect(limited.statusCode).toBe(429);
|
||||
expect(JSON.parse(limited.body).error).toMatch(/3 video uploads\/min/);
|
||||
// The image bucket is independent, so an image still goes through.
|
||||
const png = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), Buffer.alloc(24)]);
|
||||
const img = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
|
||||
headers: movHeaders,
|
||||
payload: imageUploadBody(boundary, 'shot.png', 'image/png', png),
|
||||
});
|
||||
expect(img.statusCode).toBe(200);
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions ==========
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
* of Codeman. Now the grid section itself takes every file drag (bubble
|
||||
* phase): anywhere over it (a tile, an empty cell, a divider, its padding)
|
||||
* dragover and drop are cancelled, so the page never navigates, and a drop on
|
||||
* a tile uploads its images to THAT tile's session, the same upload and the
|
||||
* same "Only image files" toast as the single view. Tab and tile drags are
|
||||
* the targets' own (_acceptTabDrops, capture phase) and stay untouched.
|
||||
* a tile uploads its files to THAT tile's session, through the same classifier
|
||||
* (_promptAttachKind: images, videos and documents) and the same "Unsupported
|
||||
* file type" toast as the single view. Tab and tile drags are the targets' own
|
||||
* (_acceptTabDrops, capture phase) and stay untouched.
|
||||
*
|
||||
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||
*/
|
||||
@@ -31,6 +32,10 @@ function fileEvent(target: FakeEl, files: Array<{ type: string; name?: string }>
|
||||
function gridApp(ids = IDS): GridApp {
|
||||
const app = makeGridApp(ids);
|
||||
app._uploadAndInsertImages = vi.fn();
|
||||
// The single view's classifier, by MIME then by the name's extension (image-input.js).
|
||||
(app as unknown as { _promptAttachKind: (f: { type: string; name?: string }) => string | null })._promptAttachKind = (
|
||||
f
|
||||
) => (/^(image|video)\//.test(f.type) ? 'image' : /\.(pdf|docx|md)$/i.test(f.name || '') ? 'document' : null);
|
||||
app.openTileGrid(ids, { focusedId: 's-a' });
|
||||
return app;
|
||||
}
|
||||
@@ -61,19 +66,21 @@ describe('a file dropped on a tile', () => {
|
||||
expect(app.activeSessionId).toBe('s-a');
|
||||
});
|
||||
|
||||
it('only the images of a mixed drop are uploaded', () => {
|
||||
it('only the files the classifier admits are uploaded: a document and a video count, an unnamed text part does not', () => {
|
||||
const app = gridApp();
|
||||
section.dispatch('drop', fileEvent(tileEl('s-c'), [{ type: 'text/plain' }, PNG]));
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG], { sessionId: 's-c' });
|
||||
const pdf = { type: 'application/pdf', name: 'Q3 report.pdf' };
|
||||
const mov = { type: 'video/quicktime', name: 'IMG_0001.MOV' };
|
||||
section.dispatch('drop', fileEvent(tileEl('s-c'), [{ type: 'text/plain' }, PNG, pdf, mov]));
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG, pdf, mov], { sessionId: 's-c' });
|
||||
});
|
||||
|
||||
it('a drop with no image says so, as the single view does, and uploads nothing', () => {
|
||||
it('a drop with nothing the classifier admits says so, as the single view does, and uploads nothing', () => {
|
||||
const app = gridApp();
|
||||
const drop = fileEvent(tileEl('s-b'), [{ type: 'application/pdf' }]);
|
||||
const drop = fileEvent(tileEl('s-b'), [{ type: 'application/x-sh', name: 'run.sh' }]);
|
||||
section.dispatch('drop', drop);
|
||||
expect(drop.preventDefault).toHaveBeenCalled();
|
||||
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
||||
expect(app.showToast).toHaveBeenCalledWith('Only image files are supported', 'error');
|
||||
expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -334,7 +334,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
|
||||
'Could not attach the session',
|
||||
'This group has no session to show as tiles',
|
||||
'No sessions to show as tiles',
|
||||
'Only image files are supported',
|
||||
'Unsupported file type',
|
||||
'Split: unavailable while tiles are open',
|
||||
'Tiles: show several sessions side by side (right-click for how many)',
|
||||
'Tiles: back to a single session (right-click for how many tiles)',
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Phone toolbar Attach button (image-input.js): the tap lands on the file input
|
||||
* that sits over the button, so the BROWSER opens the picker, and a selection
|
||||
* uploads then puts the paths on the prompt. The Compose dialog is not involved.
|
||||
*
|
||||
* The native tap is load-bearing, not a style choice: app.js's keyboard tap shim
|
||||
* turns every toolbar BUTTON tap into a scripted click while the keyboard is up,
|
||||
* and iOS refuses to open a file picker from an untrusted click.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const imageInputSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
|
||||
|
||||
function loadToolbar(sessionId: string | null = 'session-1', localEcho = true, keyboardUp = false) {
|
||||
const dom = new JSDOM(
|
||||
'<!DOCTYPE html><html><body><span class="btn-attach-mobile-wrap">' +
|
||||
'<button type="button" id="attachBtnMobile"></button>' +
|
||||
'<input type="file" id="attachFileInput" accept="image/*,video/*" multiple>' +
|
||||
'</span></body></html>'
|
||||
);
|
||||
const { window } = dom;
|
||||
// Stands in for the real bar: the production code reaches for an OPEN composer
|
||||
// before touching the terminal prompt, and only this shape of it matters here.
|
||||
const composerBar = {
|
||||
_composerOverlay: null as null | HTMLElement,
|
||||
_insertComposerText: vi.fn(),
|
||||
};
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: { path: '/case/.codeman-uploads/p' } }),
|
||||
}));
|
||||
const context = vm.createContext({
|
||||
window,
|
||||
document: window.document,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
console,
|
||||
FormData: window.FormData,
|
||||
fetch: fetchMock,
|
||||
KeyboardAccessoryBar: composerBar,
|
||||
});
|
||||
vm.runInContext('class CodemanApp {}', context);
|
||||
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
|
||||
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => any;
|
||||
const app = new CodemanApp();
|
||||
app.activeSessionId = sessionId;
|
||||
app.showToast = vi.fn();
|
||||
app.sendInput = vi.fn(async () => {});
|
||||
app.terminal = { focus: vi.fn() };
|
||||
app._isMobileTerminalInputFocused = vi.fn(() => keyboardUp);
|
||||
app._blurMobileTerminalInput = vi.fn();
|
||||
app._sendInputAsync = vi.fn();
|
||||
app._echoPassthroughSessions = new Set<string>();
|
||||
app.sessions = new Map<string, unknown>([['session-1', { mode: 'claude' }]]);
|
||||
app._flushedTexts = new Map<string, string>();
|
||||
app._flushedOffsets = new Map<string, number>();
|
||||
app._localEchoEnabled = localEcho;
|
||||
app._localEchoOverlay = localEcho
|
||||
? {
|
||||
appendText: vi.fn(),
|
||||
pendingText: '',
|
||||
getFlushed: vi.fn(() => ({ count: 0, text: '' })),
|
||||
detectBufferText: vi.fn(),
|
||||
}
|
||||
: null;
|
||||
app._uploadAndInsertImages = vi.fn(async () => [
|
||||
'/case/.codeman-uploads/paste-1-a.png',
|
||||
'/case/.codeman-uploads/paste-2-b.jpg',
|
||||
]);
|
||||
app.initImageInput();
|
||||
const button = window.document.getElementById('attachBtnMobile') as HTMLButtonElement;
|
||||
const input = window.document.getElementById('attachFileInput') as HTMLInputElement;
|
||||
const inputClicks: number[] = [];
|
||||
input.addEventListener('click', () => inputClicks.push(1));
|
||||
// A real tap lands on the input, never on the button underneath it.
|
||||
const tap = () => {
|
||||
const event = new window.MouseEvent('click', { bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
const select = (files: File[]) => {
|
||||
tap(); // captures the session, exactly as a real tap does
|
||||
Object.defineProperty(input, 'files', { configurable: true, value: files });
|
||||
input.dispatchEvent(new window.Event('change', { bubbles: true }));
|
||||
};
|
||||
return { app, window, button, input, inputClicks, tap, select, composerBar, fetchMock };
|
||||
}
|
||||
|
||||
describe('phone toolbar Attach button', () => {
|
||||
it('lets a native tap on the input open the picker, with the session captured', () => {
|
||||
const { app, tap } = loadToolbar();
|
||||
const event = tap();
|
||||
expect(event.defaultPrevented).toBe(false); // the browser opens the picker
|
||||
expect(app._attachSessionId).toBe('session-1');
|
||||
});
|
||||
|
||||
// Pins the MECHANISM, not the outcome: whether the keyboard actually stays up is
|
||||
// only observable on a device, so this locks the cancelled mousedown that stops
|
||||
// the input taking focus. Same kind of lock as the multi-line composer test.
|
||||
it('cancels mousedown on the input so focus never leaves the terminal', () => {
|
||||
const { window, input } = loadToolbar();
|
||||
const event = new window.MouseEvent('mousedown', { bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
// The input is aria-hidden and not tabbable, so assistive tech and a hardware
|
||||
// keyboard reach the button instead. That click is trusted, so it may open the
|
||||
// picker from script.
|
||||
it('opens the input from the button for a keyboard or VoiceOver activation', () => {
|
||||
const { button, inputClicks } = loadToolbar();
|
||||
button.click();
|
||||
expect(inputClicks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses before the picker opens when no session is active', () => {
|
||||
const { app, tap } = loadToolbar(null);
|
||||
const event = tap();
|
||||
expect(event.defaultPrevented).toBe(true); // no picker, rather than a refused pick
|
||||
expect(app.showToast).toHaveBeenCalledWith('Open a session to attach a file', 'error');
|
||||
});
|
||||
|
||||
it('puts the paths in the local-echo overlay, not the PTY, so Compose can adopt them', async () => {
|
||||
const { app, window, select } = loadToolbar();
|
||||
const a = new window.File(['a'], 'a.png', { type: 'image/png' });
|
||||
const b = new window.File(['b'], 'b.jpg', { type: 'image/jpeg' });
|
||||
select([a, b]);
|
||||
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([a, b], { insert: false, sessionId: 'session-1' });
|
||||
expect(app._localEchoOverlay.appendText).toHaveBeenCalledWith(
|
||||
'/case/.codeman-uploads/paste-1-a.png /case/.codeman-uploads/paste-2-b.jpg '
|
||||
);
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the session-bound PTY write when there is no overlay to buffer into', async () => {
|
||||
const { app, window, select } = loadToolbar('session-1', false);
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(app._sendInputAsync).toHaveBeenCalledTimes(1));
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith(
|
||||
'session-1',
|
||||
' /case/.codeman-uploads/paste-1-a.png /case/.codeman-uploads/paste-2-b.jpg '
|
||||
);
|
||||
});
|
||||
|
||||
it('writes to the PTY, not the overlay, while the session is in echo passthrough', async () => {
|
||||
const { app, window, select } = loadToolbar();
|
||||
// A composer nav key hands the session back to plain PTY echo, and Enter then
|
||||
// skips the overlay flush entirely, so buffered text would never be submitted.
|
||||
app._echoPassthroughSessions.add('session-1');
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(app._sendInputAsync).toHaveBeenCalledTimes(1));
|
||||
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes to the PTY, not the overlay, while the tile grid owns the terminal', async () => {
|
||||
const { app, window, select } = loadToolbar();
|
||||
// Tiles park and hide the main terminal, and a tile's Enter never reaches its
|
||||
// overlay, so a path appended there would sit unseen and never be submitted.
|
||||
app._tilesOwnTerminal = () => true;
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(app._sendInputAsync).toHaveBeenCalledTimes(1));
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith(
|
||||
'session-1',
|
||||
' /case/.codeman-uploads/paste-1-a.png /case/.codeman-uploads/paste-2-b.jpg '
|
||||
);
|
||||
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('separates the path from a half-typed word already on the prompt', async () => {
|
||||
const { app, window, select } = loadToolbar();
|
||||
app._localEchoOverlay.pendingText = 'describe';
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
|
||||
expect(app._localEchoOverlay.appendText.mock.calls[0][0]).toBe(
|
||||
' /case/.codeman-uploads/paste-1-a.png /case/.codeman-uploads/paste-2-b.jpg '
|
||||
);
|
||||
});
|
||||
|
||||
it('separates the path from flushed prompt text when pending is empty', async () => {
|
||||
const { app, window, select } = loadToolbar();
|
||||
// After a tab completion or a tab-switch restore the words live in the flushed
|
||||
// half of the prompt and pending is empty.
|
||||
app._localEchoOverlay.getFlushed = vi.fn(() => ({ count: 8, text: 'describe' }));
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
|
||||
expect(app._localEchoOverlay.appendText.mock.calls[0][0].startsWith(' /case/')).toBe(true);
|
||||
});
|
||||
|
||||
it('adds no separator when the prompt already ends in whitespace', async () => {
|
||||
const { app, window, select } = loadToolbar();
|
||||
app._localEchoOverlay.pendingText = 'describe ';
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
|
||||
expect(app._localEchoOverlay.appendText.mock.calls[0][0].startsWith('/case/')).toBe(true);
|
||||
});
|
||||
|
||||
it('sends the path to the session the attach started on after a tab switch', async () => {
|
||||
const { app, window, select } = loadToolbar('session-1', true, true);
|
||||
app._uploadAndInsertImages.mockImplementation(async () => {
|
||||
// The user switches tabs while the upload is in flight.
|
||||
app.activeSessionId = 'session-2';
|
||||
return ['/case/.codeman-uploads/paste-1-a.png'];
|
||||
});
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(app._sendInputAsync).toHaveBeenCalledTimes(1));
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', ' /case/.codeman-uploads/paste-1-a.png ');
|
||||
// A background session's prompt is tracked, or a later composer open erases the wrong tail.
|
||||
expect(app._flushedTexts.get('session-1')).toBe(' /case/.codeman-uploads/paste-1-a.png ');
|
||||
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
|
||||
// The keyboard belongs to whatever the user is looking at now, not to us.
|
||||
expect(app.terminal.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Nothing in the attach path may move focus. Focus stays on the terminal through
|
||||
// the whole pick, so the keyboard is never dismissed and never has to be restored
|
||||
// (a programmatic focus does not raise it on iOS without user activation).
|
||||
it('never touches the keyboard, with it up or down', async () => {
|
||||
for (const keyboardUp of [true, false]) {
|
||||
const { app, window, select } = loadToolbar('session-1', true, keyboardUp);
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
|
||||
expect(app._blurMobileTerminalInput).not.toHaveBeenCalled();
|
||||
expect(app.terminal.focus).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to upload for a session deleted while the picker was open', async () => {
|
||||
const { app, window, tap, input, fetchMock } = loadToolbar();
|
||||
// The production upload path, spied: the refusal under test is its own.
|
||||
app._uploadAndInsertImages = vi.fn(Object.getPrototypeOf(app)._uploadAndInsertImages);
|
||||
tap();
|
||||
app.sessions.delete('session-1');
|
||||
app.activeSessionId = 'session-2';
|
||||
Object.defineProperty(input, 'files', {
|
||||
configurable: true,
|
||||
value: [new window.File(['a'], 'a.png', { type: 'image/png' })],
|
||||
});
|
||||
input.dispatchEvent(new window.Event('change', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1));
|
||||
expect(app._uploadAndInsertImages.mock.calls[0][1]).toEqual({ insert: false, sessionId: 'session-1' });
|
||||
await expect(app._uploadAndInsertImages.mock.results[0].value).resolves.toEqual([]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records nothing in the flushed maps for a background session in echo passthrough', async () => {
|
||||
const { app, window, select } = loadToolbar('session-1', true, true);
|
||||
app._echoPassthroughSessions.add('session-1');
|
||||
app._uploadAndInsertImages.mockImplementation(async () => {
|
||||
app.activeSessionId = 'session-2';
|
||||
return ['/case/.codeman-uploads/paste-1-a.png'];
|
||||
});
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(app._sendInputAsync).toHaveBeenCalledTimes(1));
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', ' /case/.codeman-uploads/paste-1-a.png ');
|
||||
// Nothing tracks a passthrough composer and Enter there never clears the maps,
|
||||
// so a record would offset the overlay for that session's next prompt.
|
||||
expect(app._flushedTexts.has('session-1')).toBe(false);
|
||||
expect(app._flushedOffsets.has('session-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('passes a .gif with no MIME through untouched, on the same extension fallback as the kind check', async () => {
|
||||
const { app, window } = loadToolbar();
|
||||
const gif = new window.File(['GIF89a'], 'anim.gif', { type: '' });
|
||||
await expect(app._normalizeImageForUpload(gif)).resolves.toBe(gif);
|
||||
});
|
||||
|
||||
it('drops the paths when the session was deleted while the upload ran', async () => {
|
||||
const { app, window, select } = loadToolbar('session-1', true, true);
|
||||
app._uploadAndInsertImages.mockImplementation(async () => {
|
||||
app.sessions.delete('session-1');
|
||||
app.activeSessionId = 'session-2';
|
||||
return ['/case/.codeman-uploads/paste-1-a.png'];
|
||||
});
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
|
||||
// No bookkeeping for a session whose cleanup already ran.
|
||||
expect(app._flushedTexts.has('session-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('detects undetected prompt text before measuring the separator', async () => {
|
||||
const { app, window, select } = loadToolbar();
|
||||
// Tab completion put text on the prompt the overlay has not adopted yet.
|
||||
app._localEchoOverlay.detectBufferText = vi.fn(() => {
|
||||
app._localEchoOverlay.getFlushed = vi.fn(() => ({ count: 8, text: 'describe' }));
|
||||
return 'describe';
|
||||
});
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
|
||||
expect(app._localEchoOverlay.appendText.mock.calls[0][0].startsWith(' /case/')).toBe(true);
|
||||
});
|
||||
|
||||
it('puts the paths into an open composer rather than behind it', async () => {
|
||||
const { app, window, select, composerBar } = loadToolbar();
|
||||
const overlay = window.document.createElement('div');
|
||||
overlay.dataset.sessionId = 'session-1';
|
||||
const area = window.document.createElement('textarea');
|
||||
area.className = 'prompt-composer-textarea';
|
||||
overlay.appendChild(area);
|
||||
window.document.body.appendChild(overlay);
|
||||
composerBar._composerOverlay = overlay;
|
||||
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(composerBar._insertComposerText).toHaveBeenCalledTimes(1));
|
||||
expect(composerBar._insertComposerText.mock.calls[0][0]).toBe(area);
|
||||
// Not behind it: the composer already took the terminal's pending text.
|
||||
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a composer open for a different session', async () => {
|
||||
const { app, window, select, composerBar } = loadToolbar();
|
||||
const overlay = window.document.createElement('div');
|
||||
overlay.dataset.sessionId = 'session-2';
|
||||
const area = window.document.createElement('textarea');
|
||||
area.className = 'prompt-composer-textarea';
|
||||
overlay.appendChild(area);
|
||||
window.document.body.appendChild(overlay);
|
||||
composerBar._composerOverlay = overlay;
|
||||
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
|
||||
expect(composerBar._insertComposerText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a selection with neither image nor video with a toast and no upload', async () => {
|
||||
const { app, window, select } = loadToolbar();
|
||||
select([new window.File(['%PDF-'], 'doc.pdf', { type: 'application/pdf' })]);
|
||||
await Promise.resolve();
|
||||
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(app.showToast).toHaveBeenCalledWith('Only image or video files are supported', 'error');
|
||||
});
|
||||
|
||||
it('classifies a pick by its name when the file manager supplied no MIME type', () => {
|
||||
const { app, window } = loadToolbar();
|
||||
const kind = (name: string, type: string) => app._promptAttachKind(new window.File(['x'], name, { type }));
|
||||
expect(kind('clip.ogv', '')).toBe('video');
|
||||
expect(kind('IMG_0001.MOV', '')).toBe('video');
|
||||
expect(kind('shot.heic', '')).toBe('image');
|
||||
expect(kind('notes.pdf', '')).toBeNull();
|
||||
expect(kind('clip.ogv', 'image/jpeg')).toBe('image'); // the MIME type wins when present
|
||||
});
|
||||
|
||||
it('flags the upload as a video by name as well as by MIME type', async () => {
|
||||
const { app, window, fetchMock } = loadToolbar();
|
||||
await app._uploadPasteImage('session-1', new window.File(['v'], 'clip.ogv', { type: '' }));
|
||||
await app._uploadPasteImage('session-1', new window.File(['v'], 'IMG_0001.MOV', { type: 'video/quicktime' }));
|
||||
await app._uploadPasteImage('session-1', new window.File(['a'], 'a.png', { type: 'image/png' }));
|
||||
const urls = fetchMock.mock.calls.map((c: unknown[]) => c[0]);
|
||||
expect(urls).toEqual([
|
||||
'/api/sessions/session-1/paste-image?kind=video',
|
||||
'/api/sessions/session-1/paste-image?kind=video',
|
||||
'/api/sessions/session-1/paste-image',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uploads a camera-roll video alongside photos', async () => {
|
||||
const { app, window, select } = loadToolbar();
|
||||
const mov = new window.File(['v'], 'IMG_0001.MOV', { type: 'video/quicktime' });
|
||||
const jpg = new window.File(['a'], 'a.jpg', { type: 'image/jpeg' });
|
||||
select([mov, jpg]);
|
||||
await vi.waitFor(() => expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1));
|
||||
expect(app._uploadAndInsertImages.mock.calls[0][0]).toEqual([mov, jpg]);
|
||||
});
|
||||
|
||||
it('types nothing when every upload failed', async () => {
|
||||
const { app, window, select } = loadToolbar();
|
||||
app._uploadAndInsertImages.mockResolvedValueOnce([]);
|
||||
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
|
||||
await vi.waitFor(() => expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1));
|
||||
await Promise.resolve();
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user