Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 104 additions & 2 deletions src/remote-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

Expand Down Expand Up @@ -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<void> {
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 > <one shellescaped token>`. 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-<ts>-<rand>.<ext>), so no browser input reaches the
* remote shell.
*/
export async function remoteWriteFile(remote: SessionRemote, remotePath: string, data: Buffer): Promise<void> {
assertNotUnderTest();
const command = buildRemoteFileCommand(remote, buildRemoteWriteCommand(remotePath));
await runWithRemoteSshLimit(
() =>
new Promise<void>((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.
*
Expand Down
156 changes: 144 additions & 12 deletions src/web/public/image-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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');
}
});
}
}
});

Expand All @@ -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<Blob[]>} 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".
Expand Down
3 changes: 2 additions & 1 deletion src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
<script>try{var s=localStorage.getItem('codeman:skin'),a=['og','daylight-green','daylight-blue','paper-gray','solarized-light','catppuccin-latte','rose-pine-dawn'];if(a.indexOf(s)<0)s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
<script>try{var s=localStorage.getItem('codeman:skin'),a=['og','daylight-green','daylight-blue','night-owl','paper-gray','solarized-light','catppuccin-latte','rose-pine-dawn'];if(a.indexOf(s)<0)s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
<!-- Apply the saved per-device language before first paint. The full translation
layer loads below; setting lang/dir here prevents an English accessibility
tree from flashing while the deferred scripts start. -->
Expand Down Expand Up @@ -1964,6 +1964,7 @@ <h2>Appearance</h2>
<option value="daylight-blue">Daylight Blue</option>
<option value="daylight-green">Daylight Green</option>
<option value="og">OG Codeman</option>
<option value="night-owl">Night Owl</option>
</optgroup>
</select>
</div>
Expand Down
Loading