Skip to content

Commit c242d50

Browse files
CodeCasterXcodexclaude
authored
feat(cli): 沙箱剪贴板图片粘贴桥接补齐 Linux/Windows 支持 (#595)
* feat(sandbox): add Linux and Windows clipboard - add X11, Wayland, and PowerShell image clipboard adapters - preserve binary-safe PNG capture and fallback behavior with tests Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix(sandbox): handle Windows image paths - Convert pasted image paths and file drops into attachments - Escape PowerShell paths and cover fallback behavior Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 189e406 commit c242d50

7 files changed

Lines changed: 703 additions & 19 deletions

File tree

lib/sandbox/clipboard/bridge.ts

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,13 @@ type BridgeOptions = {
3030
createDetector?: () => CtrlVDetector;
3131
};
3232

33+
type TextImageClipboardAdapter = ClipboardAdapter & {
34+
readImageFromText?: (text: string) => Buffer | null;
35+
};
36+
3337
const FALLBACK_PREFIX = 'Warning: clipboard image paste bridge disabled';
3438
const PARTIAL_ESCAPE_FLUSH_MS = 30;
39+
const BRACKETED_PASTE_RE = /^\x1b\[200~([\s\S]*)\x1b\[201~$/u;
3540

3641
// Node's stdin.setRawMode(true) uses libuv's RAW mode, which (unlike the
3742
// cfmakeraw that `docker exec -it` applies on the non-bridge path) keeps ONLCR
@@ -147,7 +152,7 @@ async function runBridge({
147152
clearFlushTimer();
148153
for (const token of detector.feed(inputDecoder.write(chunk))) {
149154
if (token.kind === 'text') {
150-
child.write(token.raw);
155+
handleText(token.raw, child);
151156
} else {
152157
handleCtrlV(token, child);
153158
}
@@ -157,7 +162,7 @@ async function runBridge({
157162
flushTimer = null;
158163
for (const token of detector.flush()) {
159164
if (token.kind === 'text') {
160-
child.write(token.raw);
165+
handleText(token.raw, child);
161166
} else {
162167
handleCtrlV(token, child);
163168
}
@@ -169,6 +174,37 @@ async function runBridge({
169174
const onSigint = () => child.kill('SIGINT');
170175
const onSigterm = () => child.kill('SIGTERM');
171176

177+
function writeImagePaste(png: Buffer, target: PtyProcess): void {
178+
const filename = pngClipboardFilename(png);
179+
writeClipboardPngAtomic(clipboardHostDir(home), filename, png);
180+
pruneClipboardDir(clipboardHostDir(home));
181+
target.write(buildBracketedPaste(containerClipboardPath(filename)));
182+
}
183+
184+
function handleText(raw: string, target: PtyProcess): void {
185+
const textAdapter = adapter as TextImageClipboardAdapter;
186+
const pastedText = extractSinglePathPaste(raw);
187+
if (!textAdapter.readImageFromText || pastedText === null) {
188+
target.write(raw);
189+
return;
190+
}
191+
192+
try {
193+
const png = textAdapter.readImageFromText(pastedText);
194+
if (!png) {
195+
target.write(raw);
196+
return;
197+
}
198+
writeImagePaste(png, target);
199+
} catch (error) {
200+
target.write(raw);
201+
if (!warnedPasteFailure) {
202+
warnedPasteFailure = true;
203+
writeStderr(`Warning: clipboard image paste failed; forwarded pasted text (${error instanceof Error ? error.message : 'unknown error'})\n`);
204+
}
205+
}
206+
}
207+
172208
function handleCtrlV(match: CtrlVMatch, target: PtyProcess): void {
173209
try {
174210
// readImagePng returns null both for "no image on clipboard" and for
@@ -181,10 +217,7 @@ async function runBridge({
181217
target.write(match.raw);
182218
return;
183219
}
184-
const filename = pngClipboardFilename(png);
185-
writeClipboardPngAtomic(clipboardHostDir(home), filename, png);
186-
pruneClipboardDir(clipboardHostDir(home));
187-
target.write(buildBracketedPaste(containerClipboardPath(filename)));
220+
writeImagePaste(png, target);
188221
} catch (error) {
189222
target.write(match.raw);
190223
if (!warnedPasteFailure) {
@@ -219,14 +252,14 @@ async function runBridge({
219252
try {
220253
for (const token of detector.feed(inputDecoder.end())) {
221254
if (token.kind === 'text') {
222-
child.write(token.raw);
255+
handleText(token.raw, child);
223256
} else {
224257
handleCtrlV(token, child);
225258
}
226259
}
227260
for (const token of detector.flush()) {
228261
if (token.kind === 'text') {
229-
child.write(token.raw);
262+
handleText(token.raw, child);
230263
}
231264
}
232265
} catch {
@@ -244,6 +277,15 @@ async function runBridge({
244277
}
245278
}
246279

280+
function extractSinglePathPaste(raw: string): string | null {
281+
const match = raw.match(BRACKETED_PASTE_RE);
282+
const text = (match ? match[1] : raw)?.trim();
283+
if (!text || /[\r\n]/u.test(text)) {
284+
return null;
285+
}
286+
return text;
287+
}
288+
247289
function onceExit(
248290
child: PtyProcess,
249291
stdin: NodeJS.ReadStream

lib/sandbox/clipboard/index.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { platform } from 'node:os';
22
import { createDarwinClipboardAdapter, type DarwinClipboardAdapter } from './darwin.ts';
3+
import { createLinuxClipboardAdapter, type LinuxClipboardAdapter } from './linux.ts';
4+
import { createWin32ClipboardAdapter, type Win32ClipboardAdapter } from './win32.ts';
35

4-
export type ClipboardAdapter = DarwinClipboardAdapter;
6+
export type ClipboardAdapter = DarwinClipboardAdapter | LinuxClipboardAdapter | Win32ClipboardAdapter;
57

68
export function createClipboardAdapter({
79
platformName = platform()
@@ -10,12 +12,9 @@ export function createClipboardAdapter({
1012
case 'darwin':
1113
return createDarwinClipboardAdapter();
1214
case 'linux':
13-
// Future work: dispatch based on $WAYLAND_DISPLAY (wl-paste) or $DISPLAY (xclip);
14-
// see Issue #386 follow-up. Returning null disables the bridge for now.
15-
return null;
15+
return createLinuxClipboardAdapter();
1616
case 'win32':
17-
// Future work: native Win32 clipboard reader. Returning null disables the bridge.
18-
return null;
17+
return createWin32ClipboardAdapter();
1918
default:
2019
return null;
2120
}

lib/sandbox/clipboard/linux.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { execFileSync } from 'node:child_process';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import type { ExecFileSyncOptions } from 'node:child_process';
6+
7+
const PROBE_TIMEOUT_MS = 2_000;
8+
const READ_IMAGE_TIMEOUT_MS = 5_000;
9+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
10+
11+
type ExecFn = (cmd: string, args: string[], options?: ExecFileSyncOptions) => Buffer | string;
12+
type ExecToFileFn = (cmd: string, args: string[], outputPath: string, timeout: number) => void;
13+
14+
type LinuxBackend =
15+
| { kind: 'wayland'; command: 'wl-paste' }
16+
| { kind: 'x11'; command: 'xclip' };
17+
18+
export type LinuxClipboardAdapter = {
19+
available(): { ok: true } | { ok: false; reason: string };
20+
readImagePng(): Buffer | null;
21+
};
22+
23+
export function createLinuxClipboardAdapter({
24+
env = process.env,
25+
execFn = execFileSync,
26+
execToFileFn = execToFile,
27+
mkdtempFn = fs.mkdtempSync,
28+
readFileFn = fs.readFileSync,
29+
rmFn = fs.rmSync
30+
}: {
31+
env?: NodeJS.ProcessEnv;
32+
execFn?: ExecFn;
33+
execToFileFn?: ExecToFileFn;
34+
mkdtempFn?: typeof fs.mkdtempSync;
35+
readFileFn?: typeof fs.readFileSync;
36+
rmFn?: typeof fs.rmSync;
37+
} = {}): LinuxClipboardAdapter {
38+
return {
39+
available() {
40+
const backend = selectBackend(env);
41+
try {
42+
execFn(backend.command, versionArgs(backend), {
43+
encoding: 'utf8',
44+
timeout: PROBE_TIMEOUT_MS
45+
});
46+
return { ok: true };
47+
} catch {
48+
return { ok: false, reason: unavailableReason(backend) };
49+
}
50+
},
51+
readImagePng() {
52+
const backend = selectBackend(env);
53+
try {
54+
const mimeTypes = String(execFn(backend.command, mimeArgs(backend), {
55+
encoding: 'utf8',
56+
timeout: PROBE_TIMEOUT_MS
57+
}));
58+
if (!hasPngMime(mimeTypes)) {
59+
return null;
60+
}
61+
62+
const tmpDir = mkdtempFn(path.join(os.tmpdir(), 'agent-infra-clipboard-'));
63+
const outputPath = path.join(tmpDir, 'clipboard.png');
64+
try {
65+
execToFileFn(backend.command, imageArgs(backend), outputPath, READ_IMAGE_TIMEOUT_MS);
66+
const png = Buffer.from(readFileFn(outputPath));
67+
return isPng(png) ? png : null;
68+
} finally {
69+
rmFn(tmpDir, { recursive: true, force: true });
70+
}
71+
} catch {
72+
return null;
73+
}
74+
}
75+
};
76+
}
77+
78+
function selectBackend(env: NodeJS.ProcessEnv): LinuxBackend {
79+
return env.WAYLAND_DISPLAY?.trim()
80+
? { kind: 'wayland', command: 'wl-paste' }
81+
: { kind: 'x11', command: 'xclip' };
82+
}
83+
84+
function versionArgs(backend: LinuxBackend): string[] {
85+
return backend.kind === 'wayland' ? ['--version'] : ['-version'];
86+
}
87+
88+
function mimeArgs(backend: LinuxBackend): string[] {
89+
return backend.kind === 'wayland'
90+
? ['--list-types']
91+
: ['-selection', 'clipboard', '-t', 'TARGETS', '-o'];
92+
}
93+
94+
function imageArgs(backend: LinuxBackend): string[] {
95+
return backend.kind === 'wayland'
96+
? ['-t', 'image/png']
97+
: ['-selection', 'clipboard', '-t', 'image/png', '-o'];
98+
}
99+
100+
function unavailableReason(backend: LinuxBackend): string {
101+
return backend.kind === 'wayland'
102+
? 'Wayland clipboard tool wl-paste is unavailable; install wl-clipboard to enable image paste'
103+
: 'X11 clipboard tool xclip is unavailable; install xclip to enable image paste';
104+
}
105+
106+
function hasPngMime(output: string): boolean {
107+
return output.split(/\s+/u).some((type) => type === 'image/png');
108+
}
109+
110+
function execToFile(cmd: string, args: string[], outputPath: string, timeout: number): void {
111+
const fd = fs.openSync(outputPath, 'w', 0o600);
112+
try {
113+
execFileSync(cmd, args, {
114+
timeout,
115+
stdio: ['ignore', fd, 'pipe']
116+
});
117+
} finally {
118+
fs.closeSync(fd);
119+
}
120+
}
121+
122+
function isPng(buffer: Buffer): boolean {
123+
return buffer.length >= PNG_MAGIC.length && PNG_MAGIC.every((byte, index) => buffer[index] === byte);
124+
}

0 commit comments

Comments
 (0)