From 58b49348ff0c3afd7c11653f47554bd4cdd9560c Mon Sep 17 00:00:00 2001 From: Glenn Conner <781106+mrsharpoblunto@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:44:22 -0700 Subject: [PATCH 1/4] fix(web): recover pasted images when getAsFile() returns null Desktop Ctrl+V image paste was silently dropped whenever DataTransferItem.getAsFile() returned null or the image item arrived with an empty MIME type (seen in Chrome and Firefox, Codex and Claude sessions). - image-input.js: _collectPastedImages() now probes empty-MIME file items, reads clipboardData.files, guards getAsFile() nulls/throws, dedups blobs seen via both items and files, and reports sawImageData so the trap can try the async navigator.clipboard.read() fallback instead of dying silently (with a toast when recovery fails). - keyboard-accessory.js: composer/textarea paste handlers share the same file-local collection rules. - Duplicate-paste invariant preserved: one Ctrl+V may emit two paste events; the trap still consumes only the first. --- src/web/public/image-input.js | 142 ++++++++++++++++-- src/web/public/keyboard-accessory.js | 123 +++++++++++++--- test/image-paste-trap.test.ts | 209 ++++++++++++++++++++++++++- 3 files changed, 437 insertions(+), 37 deletions(-) diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js index 009e8c38f..5dadb9cde 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,113 @@ 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. + */ + _readPastedImageViaClipboardApi() { + try { + var secure = + typeof window !== 'undefined' && window.isSecureContext && + typeof navigator !== 'undefined' && navigator.clipboard && + typeof navigator.clipboard.read === 'function'; + if (!secure) return Promise.resolve([]); + return 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([]); + } + }, + // 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/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index b4206897f..a4bd5edde 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/test/image-paste-trap.test.ts b/test/image-paste-trap.test.ts index 230993f25..2545490af 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,147 @@ 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('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(); From 80ce4a02e603b7dd1319c2a5548a1d4513c6eb6e Mon Sep 17 00:00:00 2001 From: Glenn Conner <781106+mrsharpoblunto@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:57:03 -0700 Subject: [PATCH 2/4] fix(web): bound the clipboard.read() fallback so it always settles Some engines leave navigator.clipboard.read() pending forever when the permission prompt can never be answered (observed in headless Firefox, which does expose the API). Race the read against a 10s timeout so the paste trap always reaches the failure toast instead of silently doing nothing. --- src/web/public/image-input.js | 16 +++++++++++++++- test/image-paste-trap.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js index 5dadb9cde..023ae87e4 100644 --- a/src/web/public/image-input.js +++ b/src/web/public/image-input.js @@ -200,15 +200,19 @@ Object.assign(CodemanApp.prototype, { * 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([]); - return navigator.clipboard.read().then(function(clipboardItems) { + readAttempt = navigator.clipboard.read().then(function(clipboardItems) { var blobs = []; var pending = []; (clipboardItems || []).forEach(function(clipboardItem) { @@ -231,6 +235,16 @@ Object.assign(CodemanApp.prototype, { } 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 diff --git a/test/image-paste-trap.test.ts b/test/image-paste-trap.test.ts index 2545490af..8e7227b13 100644 --- a/test/image-paste-trap.test.ts +++ b/test/image-paste-trap.test.ts @@ -301,6 +301,28 @@ describe('paste trap clipboard fallback', () => { 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 () => { From a85d8de2db28ae99ebe1b13fb3255f5ff2448884 Mon Sep 17 00:00:00 2001 From: Zima Date: Wed, 30 Sep 2026 14:15:55 -0700 Subject: [PATCH 3/4] fix(web): save pasted images over ssh in remote sessions Local-fs save died as 500 ENOENT for remote-SSH cases, whose workingDir lives on another host. Pipe validated bytes over ssh (mkdir, probe-verify dir containment, stream via stdin); unreachable hosts map to 502. --- src/remote-files.ts | 106 ++++++++- src/web/routes/session-routes.ts | 47 +++- test/remote-files.test.ts | 20 ++ .../session-routes-remote-paste.test.ts | 213 ++++++++++++++++++ 4 files changed, 381 insertions(+), 5 deletions(-) create mode 100644 test/routes/session-routes-remote-paste.test.ts diff --git a/src/remote-files.ts b/src/remote-files.ts index 565b3098d..6245cdc26 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/routes/session-routes.ts b/src/web/routes/session-routes.ts index 6a7b78ad6..d99d87113 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/remote-files.test.ts b/test/remote-files.test.ts index d0198c6a1..1c56c0bf3 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 000000000..e977a5950 --- /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(); + }); +}); From 06a28cf9ac4b6961f8921d41df2cb01ce08d2c21 Mon Sep 17 00:00:00 2001 From: Zima Date: Wed, 30 Sep 2026 14:15:56 -0700 Subject: [PATCH 4/4] feat(web): add Night Owl skin option --- src/web/public/index.html | 3 ++- src/web/public/styles.css | 21 +++++++++++++++++++++ src/web/public/terminal-ui.js | 1 + 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/web/public/index.html b/src/web/public/index.html index a5009b53f..bae4f98d4 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/styles.css b/src/web/public/styles.css index 050671a48..5c2e0af42 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 657e16246..a6d085bb1 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' },