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:
Codeman maintainer committed 2026-10-13 18:21:52 +02:00
commit 6c7d63d45e
24 files changed
+1418 -118

No files matched your search

+10
View File
@@ -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
// ============================================================================
+1 -1
View File
@@ -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() {
+6 -2
View File
@@ -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
View File
@@ -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();
+15
View File
@@ -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">
+71 -15
View File
@@ -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();
+50 -6
View File
@@ -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);
+6
View File
@@ -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;
+6 -4
View File
@@ -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 });
});
},
+163 -29
View File
@@ -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 {