diff --git a/src/remote-files.ts b/src/remote-files.ts index 565b3098..6245cdc2 100644 --- a/src/remote-files.ts +++ b/src/remote-files.ts @@ -21,8 +21,12 @@ * each see one opaque argument. Never build a command here by concatenating a raw * path into the string. * - * Read-only by design: previews, text reads and streaming. Writing to a remote file - * is deliberately NOT implemented (docs/file-viewer-edit-plan.md §6), nor are the + * Read-only by design, with ONE narrow exception: previews, text reads and + * streaming are the only file-route consumers — except paste-image uploads + * (`remoteEnsureDir`/`remoteWriteFile` below), which write a server-generated + * filename of already-validated image bytes into the session's own + * `.claude-images` directory. Writing to a remote file is otherwise deliberately + * NOT implemented (docs/file-viewer-edit-plan.md §6), nor are the * office-conversion/thumbnail paths that would need the bytes on the server's disk. */ @@ -372,6 +376,104 @@ export function remoteCreateReadStream( }; } +/** + * Bound on a remote write: one mkdir plus a piped upload of up to the + * paste-image size cap. Generous on purpose — a 50MB photo over a slow link + * must not die mid-transfer and leave a truncated file the route reports as + * success (the exit-status check below would catch it, but only after waiting + * out the full window). + */ +const REMOTE_WRITE_TIMEOUT_MS = 120_000; + +/** + * Inner shell command that reads the file's bytes from stdin. + * + * Pure (no connection) so tests can pin the quoting without ssh: the path is + * one shellescaped token, exactly like the read side. + */ +export function buildRemoteWriteCommand(remotePath: string): string { + return `cat > ${shellescape(remotePath)}`; +} + +/** + * Create a remote directory, parents included (`mkdir -p`). + * + * Only ever called with a server-built path (the session's `.claude-images` + * dir), never a browser-supplied one — but the escaping discipline holds + * regardless: one shellescaped token inside the shared ssh line. + */ +export async function remoteEnsureDir(remote: SessionRemote, remoteDir: string): Promise { + assertNotUnderTest(); + const command = buildRemoteFileCommand(remote, `mkdir -p ${shellescape(remoteDir)}`); + try { + await runWithRemoteSshLimit(() => execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS })); + } catch (err) { + throw new RemoteFileAccessError( + `remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}` + ); + } +} + +/** + * Write bytes to a remote path, piped over the ssh connection's stdin. + * + * Bytes never touch the command line (no argv cap, nothing secret-adjacent in + * `ps` output): the remote end is just `cat > `. A + * non-zero ssh exit — including a transfer cut short by the timeout kill — + * rejects, so the caller never reports a truncated file as saved. + * + * Callers must verify the destination FIRST (probe the parent dir: real + * directory, resolved inside the workspace). The filename itself is always + * server-generated (paste--.), so no browser input reaches the + * remote shell. + */ +export async function remoteWriteFile(remote: SessionRemote, remotePath: string, data: Buffer): Promise { + assertNotUnderTest(); + const command = buildRemoteFileCommand(remote, buildRemoteWriteCommand(remotePath)); + await runWithRemoteSshLimit( + () => + new Promise((resolve, reject) => { + let settled = false; + const settle = (fn: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + const child = spawn(command, { shell: true, stdio: ['pipe', 'ignore', 'pipe'] }); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + settle(() => reject(new RemoteFileAccessError('remote write timed out'))); + }, REMOTE_WRITE_TIMEOUT_MS); + let stderr = ''; + child.stderr?.on('data', (chunk: Buffer) => { + if (stderr.length < 2000) stderr += chunk.toString(); + }); + child.on('error', (err: Error) => { + settle(() => reject(new RemoteFileAccessError(`remote write failed: ${err.message.slice(0, 200)}`))); + }); + child.on('close', (code: number | null) => { + settle(() => { + if (code === 0) { + resolve(); + } else { + const detail = stderr.trim().split('\n')[0]; + reject( + new RemoteFileAccessError( + `remote write failed (ssh exit ${code})${detail ? `: ${detail.slice(0, 200)}` : ''}` + ) + ); + } + }); + }); + // An early remote close surfaces as EPIPE here; the 'close' handler + // above owns the rejection, so this must never throw unhandled. + child.stdin?.on('error', () => {}); + child.stdin?.end(data); + }) + ); +} + /** * First useful line of an exec/stderr error, for a user-facing message. * diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js index 009e8c38..023ae87e 100644 --- a/src/web/public/image-input.js +++ b/src/web/public/image-input.js @@ -77,17 +77,14 @@ Object.assign(CodemanApp.prototype, { if (pasteConsumed) return; pasteConsumed = true; - // Check for images 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); - } - } - } + // Collect image blobs out of the paste event's DataTransfer. Some + // clipboard providers expose image bytes with an empty MIME type, and + // DataTransferItem.getAsFile() returns null intermittently depending on + // browser state, clipboard provider, or timing — the collector reports + // those cases via sawImageData so the async Clipboard API fallback below + // gets a chance instead of the paste dying silently. + var collected = self._collectPastedImages(e.clipboardData); + var imageFiles = collected.files; // Clean up the trap setTimeout(function() { @@ -106,7 +103,21 @@ Object.assign(CodemanApp.prototype, { // indistinguishable from typed input, weakening the CLI's // prompt-injection defenses. var text = e.clipboardData ? e.clipboardData.getData('text/plain') : ''; - if (text && self.terminal) self.terminal.paste(text); + if (text) { + if (self.terminal) self.terminal.paste(text); + } else if (collected.sawImageData) { + // Image data was on the clipboard but the synchronous APIs could + // not surface it. Try the async Clipboard API; where it is + // unavailable this resolves empty and the user gets a toast instead + // of the previous silent no-op. + self._readPastedImageViaClipboardApi().then(function(blobs) { + if (blobs.length > 0) { + self._uploadAndInsertImages(blobs); + } else if (self.showToast) { + self.showToast('Could not read the pasted image from the clipboard', 'warning'); + } + }); + } } }); @@ -115,6 +126,127 @@ Object.assign(CodemanApp.prototype, { document.execCommand('paste'); }, + /** Synchronously collect image blobs from a paste event's DataTransfer. + * + * Reads `items` first (guarding getAsFile(), which can throw or return null + * depending on browser state, clipboard provider, or timing), then + * `DataTransfer.files`, which some browsers populate independently of + * `items`. Items with an empty MIME type are probed too: some clipboard + * providers expose image bytes without a type, and the upload pipeline's + * canvas re-encode plus the server's magic-byte check decide whether the + * bytes really are an image. + * + * @param {DataTransfer|null|undefined} clipboardData + * @returns {{ files: Blob[], sawImageData: boolean }} The blobs found, plus + * whether the clipboard offered image-flavored data at all (used to decide + * whether the async Clipboard API fallback is worth attempting). + */ + _collectPastedImages(clipboardData) { + var files = []; + var sawImageData = false; + // A browser may expose the same file through both `items` and `files`; + // without this, one paste would upload it twice. + var seen = new Set(); + if (!clipboardData) return { files: files, sawImageData: sawImageData }; + + var pushUnique = function (blob) { + if (blob && !seen.has(blob)) { + seen.add(blob); + files.push(blob); + } + }; + + var items = clipboardData.items; + if (items) { + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var itemType = item.type || ''; + if (itemType.startsWith('image/') || (itemType === '' && item.kind === 'file')) { + sawImageData = true; + var blob = null; + try { + blob = item.getAsFile(); + } catch (err) { + // Leave blob null; the async fallback gets a chance below. + } + pushUnique(blob); + } + } + } + + var dtFiles = clipboardData.files; + if (dtFiles) { + for (var j = 0; j < dtFiles.length; j++) { + var f = dtFiles[j]; + if (f && (f.type || '').startsWith('image/')) { + sawImageData = true; + pushUnique(f); + } + } + } + + return { files: files, sawImageData: sawImageData }; + }, + + /** Best-effort async fallback for pasted images the synchronous clipboard + * APIs could not surface. + * + * Reads through `navigator.clipboard.read()`, which takes a different path + * than the paste event's DataTransfer. It needs a secure context and the + * `clipboard-read` permission, so on plain-HTTP installs (where the paste + * trap exists precisely because this API is unavailable) it resolves to an + * empty array instead of throwing. Never triggers a permission prompt for + * plain-text pastes: callers only invoke it when image-flavored clipboard + * data was present but yielded no blobs. + * + * @returns {Promise} The image blobs read, or [] when none could be. + * The promise always settles: if the platform leaves read() pending + * forever (e.g. an unanswerable permission prompt), it resolves [] after + * a bounded wait so the caller can show the failure toast. + */ + _readPastedImageViaClipboardApi() { + var readAttempt; + try { + var secure = + typeof window !== 'undefined' && window.isSecureContext && + typeof navigator !== 'undefined' && navigator.clipboard && + typeof navigator.clipboard.read === 'function'; + if (!secure) return Promise.resolve([]); + readAttempt = navigator.clipboard.read().then(function(clipboardItems) { + var blobs = []; + var pending = []; + (clipboardItems || []).forEach(function(clipboardItem) { + (clipboardItem.types || []).forEach(function(mimeType) { + if (String(mimeType).startsWith('image/')) { + pending.push( + clipboardItem.getType(mimeType).then(function(blob) { + if (blob) blobs.push(blob); + }).catch(function() { + // One unreadable type must not sink the batch. + }) + ); + } + }); + }); + return Promise.all(pending).then(function() { return blobs; }); + }).catch(function() { + return []; + }); + } catch (err) { + return Promise.resolve([]); + } + // Some engines leave the read() promise pending forever when the + // permission prompt can never be answered (observed in headless + // Firefox). Bound the wait so the caller always gets an answer and can + // show the "could not read" toast instead of silently doing nothing. + return Promise.race([ + readAttempt, + new Promise(function(resolve) { + setTimeout(function() { resolve([]); }, 10000); + }), + ]); + }, + // 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". diff --git a/src/web/public/index.html b/src/web/public/index.html index a5009b53..bae4f98d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -49,7 +49,7 @@ - + @@ -1964,6 +1964,7 @@

Appearance

+ diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index b4206897..a4bd5edd 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -646,6 +646,79 @@ const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024; const COMPOSER_PASTE_START = '\x1b[200~'; const COMPOSER_PASTE_END = '\x1b[201~'; +/** + * Synchronously collect image blobs from a paste event's DataTransfer. + * + * Mirrors CodemanApp._collectPastedImages (image-input.js), which the terminal + * paste trap uses. Kept as a file-local function instead of calling + * app._collectPastedImages so the accessory bar works standalone with a + * minimal `app` — test/mobile-prompt-composer.test.ts evaluates this file in + * isolation. If the collection rules change, update both. + * + * Reads `items` first (guarding getAsFile(), which can throw or return null + * depending on browser state, clipboard provider, or timing), then + * `DataTransfer.files`, which some browsers populate independently of + * `items`. Items with an empty MIME type are probed too: some clipboard + * providers expose image bytes without a type. A blob already collected via + * `items` is not added twice from `files`. + */ +function collectPastedImages(clipboardData) { + var files = []; + var sawImageData = false; + var seen = new Set(); + if (!clipboardData) return { files: files, sawImageData: sawImageData }; + + var pushUnique = function (blob) { + if (blob && !seen.has(blob)) { + seen.add(blob); + files.push(blob); + } + }; + + var items = clipboardData.items; + if (items) { + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var itemType = item.type || ''; + if (itemType.startsWith('image/') || (itemType === '' && item.kind === 'file')) { + sawImageData = true; + var blob = null; + try { + blob = item.getAsFile(); + } catch (err) { + // Leave blob null; the async fallback gets a chance below. + } + pushUnique(blob); + } + } + } + + var dtFiles = clipboardData.files; + if (dtFiles) { + for (var j = 0; j < dtFiles.length; j++) { + var f = dtFiles[j]; + if (f && (f.type || '').startsWith('image/')) { + sawImageData = true; + pushUnique(f); + } + } + } + + return { files: files, sawImageData: sawImageData }; +} + +/** + * Best-effort plain-text read from a paste event's DataTransfer. The getData + * call is guarded because synthetic or minimal clipboardData objects (and + * some mobile browsers) may not implement it. + */ +function getClipboardPlainText(clipboardData) { + if (clipboardData && typeof clipboardData.getData === 'function') { + return clipboardData.getData('text/plain') || ''; + } + return ''; +} + /** * KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing. */ @@ -1363,15 +1436,23 @@ const KeyboardAccessoryBar = { textarea.addEventListener('input', saveDraft); textarea.addEventListener('paste', (event) => { - 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); - if (images.length > 0) { + const collected = collectPastedImages(event.clipboardData); + const text = getClipboardPlainText(event.clipboardData); + if (collected.files.length > 0) { + event.preventDefault(); + void handleImages(collected.files); + } else if (!text && collected.sawImageData && typeof app._readPastedImageViaClipboardApi === 'function') { + // The clipboard offered image data the synchronous APIs could not + // surface (getAsFile() intermittently returns null). Try the async + // Clipboard API before giving up, mirroring the terminal paste trap. event.preventDefault(); - void handleImages(images); + void app._readPastedImageViaClipboardApi().then((blobs) => { + if (blobs.length > 0) { + void handleImages(blobs); + } else if (typeof app.showToast === 'function') { + app.showToast('Could not read the pasted image from the clipboard', 'warning'); + } + }); } }); overlay.addEventListener('keydown', (event) => { @@ -1454,18 +1535,22 @@ const KeyboardAccessoryBar = { // Best-effort: capture images pasted straight into the textarea. textarea.addEventListener('paste', (e) => { - const items = e.clipboardData && e.clipboardData.items; - 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); - } - } - if (imageFiles.length > 0) { + const collected = collectPastedImages(e.clipboardData); + const text = getClipboardPlainText(e.clipboardData); + if (collected.files.length > 0) { e.preventDefault(); - handleImages(imageFiles); + handleImages(collected.files); + } else if (!text && collected.sawImageData && typeof app._readPastedImageViaClipboardApi === 'function') { + // getAsFile() came back empty; try the async Clipboard API before + // giving up, mirroring the terminal paste trap (image-input.js). + e.preventDefault(); + void app._readPastedImageViaClipboardApi().then((blobs) => { + if (blobs.length > 0) { + handleImages(blobs); + } else if (typeof app.showToast === 'function') { + app.showToast('Could not read the pasted image from the clipboard', 'warning'); + } + }); } }); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 050671a4..5c2e0af4 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -202,6 +202,27 @@ html[data-skin="og"] { --ui-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } +/* ===== Skin: Night Owl (Sarah Drasner's Night Owl palette) ===== */ +html[data-skin="night-owl"] { + --bg-dark: #011627; --bg-card: #0b2942; --bg-input: #0e2d49; --bg-hover: #13344f; + --border: #1d3b53; --border-light: #2a4a66; + --text: #d6deeb; --text-dim: #8ba5bf; --text-muted: #5f7e97; + --accent: #82aaff; --accent-hover: #a0bfff; + --term-bg: #011627; + --green: #22da6e; --yellow: #ecc48d; --red: #ef5350; + --glass-bg: rgba(11, 41, 66, 0.85); --glass-border: rgba(130, 170, 255, 0.1); + --floating-bg: rgba(11, 41, 66, 0.96); + --accent-d: #5f8ae8; --emerald: #7fdbca; --teal: #21c7a8; + --accent-soft: #addb67; --accent-ink: #011627; + --accent-rgb: 130, 170, 255; + --accent-grad-a: #82aaff; --accent-grad-b: #5f8ae8; + --run-hover-a: #a0bfff; --run-hover-b: #7a9ff2; --gear-hover: #5f8ae8; + --ring-glow: 0 0 12px -2px rgba(130, 170, 255, 0.55); + --session-red: #ef5350; --session-orange: #f78c6c; --session-yellow: #ecc48d; + --session-green: #addb67; --session-blue: #82aaff; --session-purple: #c792ea; --session-pink: #ff869a; + --ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + /* ===== Light skins ===== Each palette covers both the application chrome and the xterm palette in terminal-ui.js. The names reference their source palettes; Paper Gray is a diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 657e1624..a6d085bb 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -191,6 +191,7 @@ const CODEMAN_XTERM_THEMES = { og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selectionBackground: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' }, 'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selectionBackground: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, + 'night-owl': { background: '#011627', foreground: '#d6deeb', cursor: '#80a4c2', cursorAccent: '#011627', selectionBackground: 'rgba(130,170,255,0.25)', black: '#011627', red: '#ef5350', green: '#22da6e', yellow: '#addb67', blue: '#82aaff', magenta: '#c792ea', cyan: '#21c7a8', white: '#d6deeb', brightBlack: '#637777', brightRed: '#ef5350', brightGreen: '#22da6e', brightYellow: '#ffeb95', brightBlue: '#82aaff', brightMagenta: '#c792ea', brightCyan: '#7fdbca', brightWhite: '#ffffff' }, 'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selectionBackground: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, 'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selectionBackground: 'rgba(9,105,218,0.2)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' }, 'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selectionBackground: 'rgba(38,139,210,0.2)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' }, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 6a7b78ad..d99d8711 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -104,6 +104,7 @@ import { validatePathWithinBase, } from '../route-helpers.js'; import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js'; +import { RemoteFileAccessError, remoteEnsureDir, remoteProbePaths, remoteWriteFile } from '../../remote-files.js'; import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js'; import { clampEnvOverridesForOwner } from '../../session-env-clamp.js'; import { enabledClis, getCli } from '../../config/cli-registry/registry.js'; @@ -5178,6 +5179,49 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`); } + // 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}`; + + // Remote-SSH case: workingDir is an absolute path on ANOTHER host, so the + // local-fs save below cannot work there (it died as a 500 ENOENT). Pipe + // the already-validated bytes over ssh instead: mkdir the remote dir, then + // verify it probed back as a real directory strictly inside the workspace + // (a planted `.claude-images -> /…` symlink resolves elsewhere and is + // refused, mirroring the local lstat guard), then stream the bytes in. + if (session.remote) { + const remote = session.remote; + const remoteDir = join(session.workingDir, '.claude-images'); + const remoteFilepath = join(remoteDir, filename); + try { + await remoteEnsureDir(remote, remoteDir); + const [workProbe, dirProbe] = await remoteProbePaths(remote, [session.workingDir, remoteDir]); + const workReal = workProbe && workProbe.kind === 'directory' ? workProbe.realPath : null; + if ( + !workReal || + !dirProbe || + dirProbe.kind !== 'directory' || + dirProbe.realPath !== `${workReal}/.claude-images` + ) { + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory'); + } + await remoteWriteFile(remote, remoteFilepath, imageBytes); + } catch (err: unknown) { + // An unreachable host is an infrastructure answer, not a 500 with a + // local path in it: the case points at a host this server could not reach. + if (err instanceof RemoteFileAccessError) { + reply.code(502); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `Could not save image on remote host: ${err.message}` + ); + } + throw err; + } + return { path: remoteFilepath, filename }; + } + // Save to {workingDir}/.claude-images/ // Refuse symlinks at imageDir — an agent or postinstall script could plant // `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir. @@ -5210,9 +5254,6 @@ export function registerSessionRoutes( } } } - // 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. diff --git a/test/image-paste-trap.test.ts b/test/image-paste-trap.test.ts index 230993f2..8e7227b1 100644 --- a/test/image-paste-trap.test.ts +++ b/test/image-paste-trap.test.ts @@ -33,23 +33,53 @@ interface FakeTrap { addEventListener: (ev: string, fn: TrapListener) => void; } +interface FirePayload { + text?: string; + /** image/* items whose getAsFile() returns a blob (the healthy path) */ + images?: string[]; + /** image/* items whose getAsFile() returns null (the intermittent failure) */ + nullImages?: string[]; + /** kind='file' items with an empty MIME type whose getAsFile() returns a blob of the given type */ + emptyMimeFiles?: string[]; + /** clipboardData.files entries, by MIME type */ + dtFiles?: string[]; +} + +interface ClipboardItemLike { + types: string[]; + getType: (t: string) => Promise; +} + +interface HarnessOptions { + secureContext?: boolean; + /** navigator.clipboard.read implementation; absent means the API does not exist */ + clipboardRead?: () => Promise; +} + interface Harness { /** Fire a paste event on the trap the last _handleImagePaste() call created. */ - firePaste: (payload: { text?: string; images?: string[] }) => void; + firePaste: (payload: FirePayload) => void; + /** Let the async clipboard.read() fallback settle (uses real node timers). */ + awaitFallback: () => Promise; /** Text handed to xterm's terminal.paste(), one entry per call. */ pastedText: string[]; /** Image batches handed to _uploadAndInsertImages(), one entry per call. */ uploadedBatches: Array>; + /** Toasts shown, in order. */ + toasts: Array<{ message: string; kind: string }>; + /** How many times navigator.clipboard.read() was invoked. */ + clipboardReadCalls: () => number; /** How many trap divs are still attached to the fake body. */ attachedTraps: () => number; runTimers: () => void; } -function loadPasteHarness(): Harness { +function loadPasteHarness(options: HarnessOptions = {}): Harness { const traps: FakeTrap[] = []; const listeners: TrapListener[] = []; const attached = new Set(); const timers: Array<() => void> = []; + let clipboardReadCalls = 0; const documentObj = { createElement: (): FakeTrap => { @@ -81,8 +111,20 @@ function loadPasteHarness(): Harness { getElementById: () => null, }; + const navigatorObj: Record = {}; + if (options.clipboardRead) { + const readImpl = options.clipboardRead; + navigatorObj.clipboard = { + read: () => { + clipboardReadCalls++; + return readImpl(); + }, + }; + } + const context = vm.createContext({ - window: {}, + window: { isSecureContext: options.secureContext ?? true }, + navigator: navigatorObj, document: documentObj, setTimeout: (fn: () => void) => { timers.push(fn); @@ -98,6 +140,7 @@ function loadPasteHarness(): Harness { const pastedText: string[] = []; const uploadedBatches: Array> = []; + const toasts: Array<{ message: string; kind: string }> = []; const app = new CodemanApp(); app.activeSessionId = 'session-1'; app.terminal = { @@ -107,16 +150,24 @@ function loadPasteHarness(): Harness { app._uploadAndInsertImages = (files: Array<{ type: string }>) => { uploadedBatches.push(Array.from(files)); }; - app.showToast = () => {}; + app.showToast = (message: string, kind: string) => { + toasts.push({ message, kind }); + }; (app._handleImagePaste as () => void).call(app); return { - firePaste({ text = '', images = [] }) { - const items = images.map((type) => ({ type, getAsFile: () => ({ type }) })); + firePaste({ text = '', images = [], nullImages = [], emptyMimeFiles = [], dtFiles = [] }) { + const items = [ + ...images.map((type) => ({ type, kind: 'file', getAsFile: () => ({ type }) })), + ...nullImages.map((type) => ({ type, kind: 'file', getAsFile: () => null })), + ...emptyMimeFiles.map((blobType) => ({ type: '', kind: 'file', getAsFile: () => ({ type: blobType }) })), + ]; + const files = dtFiles.map((type) => ({ type })); const event = { clipboardData: { items, + files, getData: () => text, }, preventDefault: () => {}, @@ -124,8 +175,13 @@ function loadPasteHarness(): Harness { }; for (const fn of listeners) fn(event); }, + awaitFallback: async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }, pastedText, uploadedBatches, + toasts, + clipboardReadCalls: () => clipboardReadCalls, attachedTraps: () => attached.size, runTimers: () => { const pending = timers.splice(0, timers.length); @@ -190,6 +246,169 @@ describe('Ctrl+V paste trap', () => { }); }); +describe('paste trap clipboard fallback', () => { + it('recovers via navigator.clipboard.read() when getAsFile() returns null', async () => { + const h = loadPasteHarness({ + clipboardRead: async () => [{ types: ['image/png'], getType: async () => ({ type: 'image/png' }) }], + }); + + h.firePaste({ nullImages: ['image/png'] }); + await h.awaitFallback(); + + expect(h.uploadedBatches).toHaveLength(1); + expect(h.uploadedBatches[0]).toEqual([{ type: 'image/png' }]); + expect(h.pastedText).toEqual([]); + expect(h.toasts).toEqual([]); + }); + + it('toasts instead of failing silently when the Clipboard API is absent', async () => { + const h = loadPasteHarness(); + + h.firePaste({ nullImages: ['image/png'] }); + await h.awaitFallback(); + + expect(h.uploadedBatches).toEqual([]); + expect(h.pastedText).toEqual([]); + expect(h.toasts).toEqual([{ message: 'Could not read the pasted image from the clipboard', kind: 'warning' }]); + }); + + it('toasts when the Clipboard API is unavailable on a non-secure context', async () => { + const h = loadPasteHarness({ + secureContext: false, + clipboardRead: async () => { + throw new Error('must not be called without a secure context'); + }, + }); + + h.firePaste({ nullImages: ['image/png'] }); + await h.awaitFallback(); + + expect(h.uploadedBatches).toEqual([]); + expect(h.toasts).toHaveLength(1); + }); + + it('toasts when clipboard.read() is denied', async () => { + const h = loadPasteHarness({ + clipboardRead: async () => { + throw new Error('denied'); + }, + }); + + h.firePaste({ nullImages: ['image/png'] }); + await h.awaitFallback(); + + expect(h.uploadedBatches).toEqual([]); + expect(h.toasts).toHaveLength(1); + }); + + it('resolves the fallback when clipboard.read() never settles (unanswerable permission prompt)', async () => { + const h = loadPasteHarness({ + // Some engines leave read() pending forever instead of rejecting. + clipboardRead: () => new Promise(() => {}), + }); + + h.firePaste({ nullImages: ['image/png'] }); + await h.awaitFallback(); + + // Still waiting: no toast yet, and the read was attempted once. + expect(h.clipboardReadCalls()).toBe(1); + expect(h.uploadedBatches).toEqual([]); + expect(h.toasts).toEqual([]); + + // The bounded wait elapses; the toast appears instead of silence. + h.runTimers(); + await h.awaitFallback(); + + expect(h.uploadedBatches).toEqual([]); + expect(h.toasts).toEqual([{ message: 'Could not read the pasted image from the clipboard', kind: 'warning' }]); + }); + + it('never touches the async Clipboard API for plain-text pastes', async () => { + const h = loadPasteHarness({ + clipboardRead: async () => { + throw new Error('must not prompt for a text paste'); + }, + }); + + h.firePaste({ text: 'hello world' }); + await h.awaitFallback(); + + expect(h.pastedText).toEqual(['hello world']); + expect(h.clipboardReadCalls()).toBe(0); + expect(h.toasts).toEqual([]); + }); + + it('uploads empty-MIME file items through the synchronous path', async () => { + const h = loadPasteHarness({ + clipboardRead: async () => { + throw new Error('must not be called when the sync path succeeds'); + }, + }); + + h.firePaste({ emptyMimeFiles: ['image/png'] }); + await h.awaitFallback(); + + expect(h.uploadedBatches).toHaveLength(1); + expect(h.uploadedBatches[0]).toEqual([{ type: 'image/png' }]); + expect(h.clipboardReadCalls()).toBe(0); + expect(h.toasts).toEqual([]); + }); + + it('uploads clipboardData.files entries through the synchronous path', async () => { + const h = loadPasteHarness({ + clipboardRead: async () => { + throw new Error('must not be called when the sync path succeeds'); + }, + }); + + h.firePaste({ dtFiles: ['image/jpeg'] }); + await h.awaitFallback(); + + expect(h.uploadedBatches).toHaveLength(1); + expect(h.clipboardReadCalls()).toBe(0); + }); + + it('attempts the fallback only once when two paste events arrive', async () => { + const h = loadPasteHarness({ clipboardRead: async () => [] }); + + h.firePaste({ nullImages: ['image/png'] }); + h.firePaste({ nullImages: ['image/png'] }); + await h.awaitFallback(); + + expect(h.clipboardReadCalls()).toBe(1); + expect(h.toasts).toHaveLength(1); + }); +}); + +describe('pasted image collection', () => { + it('collects the same blob once when a browser exposes it via both items and files', () => { + const app = loadImageInputApp(); + const blob = { type: 'image/png' }; + + const collected = app._collectPastedImages({ + items: [{ type: 'image/png', kind: 'file', getAsFile: () => blob }], + files: [blob], + }); + + expect(collected.files).toEqual([blob]); + expect(collected.sawImageData).toBe(true); + }); + + it('keeps distinct blobs collected from items and files', () => { + const app = loadImageInputApp(); + const fromItems = { type: 'image/png' }; + const fromFiles = { type: 'image/png' }; + + const collected = app._collectPastedImages({ + items: [{ type: 'image/png', kind: 'file', getAsFile: () => fromItems }], + files: [fromFiles], + }); + + expect(collected.files).toEqual([fromItems, fromFiles]); + expect(collected.sawImageData).toBe(true); + }); +}); + describe('image upload insertion policy', () => { it('returns ordered paths without terminal insertion when requested by the composer', async () => { const app = loadImageInputApp(); diff --git a/test/remote-files.test.ts b/test/remote-files.test.ts index d0198c6a..1c56c0bf 100644 --- a/test/remote-files.test.ts +++ b/test/remote-files.test.ts @@ -24,11 +24,14 @@ import { buildRemoteFileCommand, buildRemoteProbeCommand, buildRemoteReadCommand, + buildRemoteWriteCommand, parseRemoteProbeRecord, parseRemoteProbeOutput, + remoteEnsureDir, remoteProbePaths, remoteReadFile, remoteCreateReadStream, + remoteWriteFile, } from '../src/remote-files.js'; import type { SessionRemote } from '../src/types/session.js'; @@ -377,6 +380,13 @@ describe('under vitest', () => { await expect(remoteReadFile(remote, '/srv/case/a.txt', 1024)).rejects.toThrow(/disabled under test/); }); + it('never opens a connection: writes and mkdir reject with a clear error', async () => { + await expect(remoteEnsureDir(remote, '/srv/case/.claude-images')).rejects.toThrow(/disabled under test/); + await expect(remoteWriteFile(remote, '/srv/case/.claude-images/paste-1.png', Buffer.from('x'))).rejects.toThrow( + /disabled under test/ + ); + }); + it('never opens a connection: a stream fails through its own error path', async () => { const { stream, close } = remoteCreateReadStream(remote, '/srv/case/a.mp4'); const failure = await new Promise((resolveError) => stream.on('error', resolveError)); @@ -400,3 +410,13 @@ describe('buildRemoteReadCommand', () => { expect(buildRemoteReadCommand('/f', { start: 0, end: 0 })).toBe("tail -c +1 '/f' | head -c 1"); }); }); + +describe('buildRemoteWriteCommand', () => { + it('reads stdin into one shellescaped token', () => { + expect(buildRemoteWriteCommand("/srv/case/it's.png")).toBe("cat > '/srv/case/it'\\''s.png'"); + }); + + it('quotes spaces, globs and command substitution as literal filename text', () => { + expect(buildRemoteWriteCommand('/srv/case/$(touch pwned) *.png')).toBe("cat > '/srv/case/$(touch pwned) *.png'"); + }); +}); diff --git a/test/routes/session-routes-remote-paste.test.ts b/test/routes/session-routes-remote-paste.test.ts new file mode 100644 index 00000000..e977a595 --- /dev/null +++ b/test/routes/session-routes-remote-paste.test.ts @@ -0,0 +1,213 @@ +/** + * @fileoverview Route tests for paste-image in a remote (SSH) case. + * + * A remote case's `workingDir` is an absolute path on ANOTHER host, so the + * local-`fs` save path in POST /api/sessions/:id/paste-image dies as a 500 + * ENOENT there. These tests pin the remote branch: bytes go over ssh, the + * remote `.claude-images` dir is verified (no symlink escape), and ssh + * failures surface as 502s — never a 500 with a local path in it. + * + * The ssh layer (`src/remote-files.ts`) is mocked — a test never opens a + * connection — but the REAL module is kept alongside the mocks so + * `RemoteFileAccessError` stays authentic. + * + * Port: N/A (app.inject doesn't open ports) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import fastifyMultipart from '@fastify/multipart'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; +import { RemoteWakeRegistry } from '../../src/remote-wake.js'; + +// Keep the pure builders + the error class real; replace only the IO. +vi.mock('../../src/remote-files.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + remoteProbePaths: vi.fn(), + remoteEnsureDir: vi.fn(), + remoteWriteFile: vi.fn(), + }; +}); + +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { RemoteFileAccessError, remoteProbePaths, remoteEnsureDir, remoteWriteFile } from '../../src/remote-files.js'; +import type { RemoteProbe } from '../../src/remote-files.js'; +import type { SessionRemote } from '../../src/types/session.js'; + +const mockedProbePaths = vi.mocked(remoteProbePaths); +const mockedEnsureDir = vi.mocked(remoteEnsureDir); +const mockedWriteFile = vi.mocked(remoteWriteFile); + +const REMOTE_DIR = '/srv/remote/case'; +const REMOTE_IMG_DIR = '/srv/remote/case/.claude-images'; +const remote: SessionRemote = { + hostId: 'host-1', + label: 'testhost', + host: '192.0.2.10', + username: 'j', + remotePath: REMOTE_DIR, +}; + +function dirProbe(realPath: string): RemoteProbe { + return { realPath, kind: 'directory', size: 0, mtimeMs: 0 }; +} + +// A real 1x1 PNG so the server's magic-byte check passes. +const ONE_PX_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +); + +function imageUploadBody(boundary: string, filename: string, mimetype: string, imageBytes: Buffer): Buffer { + return Buffer.concat([ + Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="image"; filename="${filename}"\r\n` + + `Content-Type: ${mimetype}\r\n\r\n` + ), + imageBytes, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); +} + +interface LocalHarness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +async function createEnvelopeHarness( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + registerFn: (app: FastifyInstance, ctx: any) => void +): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + await app.register(fastifyMultipart, { + limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 }, + }); + + const ctx = createMockRouteContext(); + registerFn(app, ctx); + + app.addHook('preSerialization', (req, reply, payload: unknown, done) => { + if (!req.url.startsWith('/api')) return done(null, payload); + if (payload === null || typeof payload !== 'object') return done(null, payload); + if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') { + return done(null, payload); + } + const p = payload as { success?: unknown; errorCode?: unknown }; + if (p.success === false) { + if (reply.statusCode === 200 && typeof p.errorCode === 'string') { + reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode)); + } + return done(null, payload); + } + if (p.success === true) return done(null, payload); + return done(null, { success: true, data: payload }); + }); + + installRouteErrorHandler(app); + await app.ready(); + + return { app, ctx }; +} + +function postPng(harness: LocalHarness, filename = 'shot.png') { + const boundary = 'codeman-test-boundary'; + return harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/paste-image`, + headers: { + host: 'codeman.test', + origin: 'http://codeman.test', + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + payload: imageUploadBody(boundary, filename, 'image/png', ONE_PX_PNG), + }); +} + +describe('paste-image in a remote (SSH) case', () => { + let harness: LocalHarness; + + beforeEach(async () => { + const wakeRegistry = new RemoteWakeRegistry({ + probe: async () => true, + wake: async () => true, + waitUntilReady: async () => true, + delay: async () => {}, + log: () => {}, + }); + harness = await createEnvelopeHarness((app, ctx) => registerSessionRoutes(app, ctx, { remoteWake: wakeRegistry })); + harness.ctx._session.workingDir = REMOTE_DIR; + harness.ctx._session.remote = { ...remote }; + + mockedProbePaths.mockReset(); + mockedEnsureDir.mockReset(); + mockedWriteFile.mockReset(); + // Healthy remote: working dir + image dir both resolve as real directories. + mockedProbePaths.mockImplementation(async (_remote, paths) => + paths.map((p) => (p === REMOTE_DIR || p === REMOTE_IMG_DIR ? dirProbe(p) : null)) + ); + mockedEnsureDir.mockResolvedValue(undefined); + mockedWriteFile.mockResolvedValue(undefined); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + it('writes the bytes over ssh and returns the remote path', async () => { + const res = await postPng(harness); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.path).toMatch(/^\/srv\/remote\/case\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.png$/); + expect(mockedEnsureDir).toHaveBeenCalledWith(remote, REMOTE_IMG_DIR); + expect(mockedWriteFile).toHaveBeenCalledTimes(1); + const [writeRemote, writePath, writeBytes] = mockedWriteFile.mock.calls[0]; + expect(writeRemote).toEqual(remote); + expect(writePath.startsWith(REMOTE_IMG_DIR + '/paste-')).toBe(true); + expect(Buffer.compare(writeBytes as Buffer, ONE_PX_PNG)).toBe(0); + }); + + it('never touches the local filesystem for a remote session', async () => { + const { existsSync } = await import('node:fs'); + const res = await postPng(harness); + + expect(res.statusCode).toBe(200); + // The remote path must not exist locally (it lives on another host). + expect(existsSync(JSON.parse(res.body).data.path)).toBe(false); + }); + + it('maps an unreachable host to a 502, not a 500 with a local path', async () => { + mockedWriteFile.mockRejectedValueOnce(new RemoteFileAccessError('remote host testhost unreachable: timeout')); + + const res = await postPng(harness); + + expect(res.statusCode).toBe(502); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).not.toMatch(/ENOENT|no such file/); + }); + + it('refuses a symlinked remote .claude-images dir with a 403', async () => { + mockedProbePaths.mockImplementation(async (_remote, paths) => + paths.map((p) => { + if (p === REMOTE_DIR) return dirProbe(REMOTE_DIR); + // .claude-images resolves elsewhere: a planted symlink. + if (p === REMOTE_IMG_DIR) return dirProbe('/tmp/evil'); + return null; + }) + ); + + const res = await postPng(harness); + + expect(res.statusCode).toBe(403); + expect(mockedWriteFile).not.toHaveBeenCalled(); + }); +});