Skip to content

Commit 0e8d151

Browse files
CodeCasterXcodexclaude
authored
feat(cli): 支持云端 Linux TUI 剪贴板图片桥接 (#602)
* feat(cli): bridge clipboard images to Linux - detect remote platforms and receive PNGs through a versioned Linux inbox - inject pending clipboard paths in headless sandbox sessions with safe validation Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(clipboard): guard Linux receiver cases --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 35b22db commit 0e8d151

8 files changed

Lines changed: 292 additions & 46 deletions

File tree

bin/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const USAGE = `agent-infra ${VERSION} - bootstrap AI collaboration infrastructur
1515
Usage: ai <command> [options]
1616
1717
Commands:
18-
cp <ssh-alias> Copy local clipboard image to a remote macOS NSPasteboard
18+
cp <ssh-alias> Copy local clipboard image to a remote macOS clipboard or Linux sandbox over SSH
1919
decide Record a human decision for a task HD item
2020
help Show this help message
2121
init Initialize a new project with update-agent-infra seed command

docs/en/sandbox.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Host `~/.ssh` is not bind-mounted into the sandbox. GitHub access is expected to
2222

2323
On macOS, interactive `ai sandbox exec <branch>` sessions can bridge image paste into the sandbox. When you press `Ctrl+V` and the host clipboard currently holds an image, agent-infra reads the image from the host clipboard, writes a PNG under `~/.agent-infra/clipboard/`, and injects the container path as bracketed paste so Claude Code, Codex, Gemini CLI, and OpenCode can attach it. The host clipboard is only read, never rewritten. The bridge is best-effort: existing sandboxes must be rebuilt to receive the `/clipboard` mount, and if the optional pty dependency or clipboard probe is unavailable the session falls back to the normal interactive path. Set `AI_SANDBOX_NO_CLIPBOARD_BRIDGE=1` to skip the bridge and enter the normal interactive path directly when diagnosing mouse, scrolling, or other input issues.
2424

25-
When you run the sandbox from a remote Mac over SSH, use `ai cp <ssh-alias>` on the Mac in front of you to push the local clipboard image to that remote Mac first. Copy an image with Cmd+C, run `ai cp mini`, then return to the existing SSH session and press `Ctrl+V`; the sandbox bridge reads the remote Mac's NSPasteboard and injects the image as usual. This command handles PNG images only and uses non-interactive ssh/scp with key-based authentication. For now both the sender and the remote must be macOS—the remote NSPasteboard is written via `osascript`—but the remote-write step is the natural extension point for other platforms later.
25+
When you run the sandbox over SSH, use `ai cp <ssh-alias>` on the Mac in front of you to push the local PNG clipboard image to a remote Mac or headless Linux host. Copy an image with Cmd+C, run `ai cp mini`, then return to the existing SSH session and press `Ctrl+V`. Darwin keeps the NSPasteboard path; Linux requires a compatible agent-infra receiver and stores the image under `~/.agent-infra/clipboard/` for the sandbox's read-only `/clipboard` mount. The command uses non-interactive ssh/scp with key-based authentication.
2626

2727
`ai sandbox exec` and `ai sandbox refresh` reconcile Claude Code credentials in both directions across the host credential store and every sandbox project copy under `~/.agent-infra/credentials/*`. When a long-running sandbox refreshes OAuth tokens first, the next entry or refresh command writes the freshest valid copy back to the host Keychain or `~/.claude/.credentials.json`; when the host is fresher, it updates the project copies. If every copy is stale, `ai sandbox refresh` probes `claude /status` and asks you to log in only when the probe cannot recover credentials.
2828

docs/zh-CN/sandbox.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
在 macOS 上,交互式 `ai sandbox exec <branch>` 会尽力桥接宿主图片粘贴。当你按下 `Ctrl+V` 且宿主剪贴板当前是图片时,agent-infra 会从宿主剪贴板读取图片,将 PNG 写到 `~/.agent-infra/clipboard/`,再以 bracketed paste 注入容器内路径,让 Claude Code、Codex、Gemini CLI 和 OpenCode 按图片附件处理。宿主剪贴板只读,不会被改写。该能力会自动降级:已有沙箱需要重建后才有 `/clipboard` 挂载;如果可选 pty 依赖或剪贴板探测不可用,会回退到原本的交互进入方式。排查鼠标、滚动或其他输入异常时,可以设置 `AI_SANDBOX_NO_CLIPBOARD_BRIDGE=1` 跳过桥接,直接进入原本的交互路径。
2424

25-
当你通过 SSH 在远端 Mac 上运行沙箱时,可先在手边这台 Mac 上执行 `ai cp <ssh-alias>`把本机剪贴板图片推送到远端 Mac。典型流程是:Cmd+C 复制图片,运行 `ai cp mini`,回到已有 SSH session 后按 `Ctrl+V`;沙箱桥会读取远端 Mac 的 NSPasteboard,并按原路径注入图片。该命令只处理 PNG 图片,并使用基于 ssh key 的非交互 ssh/scp。目前发送端与远端都需为 macOS(远端通过 `osascript` 写入 NSPasteboard),后续可扩展支持其他远端平台
25+
当你通过 SSH 运行远端沙箱时,可先在手边这台 Mac 上执行 `ai cp <ssh-alias>`把本机剪贴板 PNG 推送到远端 Mac 或无桌面 Linux 主机。典型流程是:Cmd+C 复制图片,运行 `ai cp mini`,回到已有 SSH session 后按 `Ctrl+V`。Darwin 继续写入 NSPasteboard;Linux 需要远端已安装兼容版本的 agent-infra,图片会写入 `~/.agent-infra/clipboard/`,再由沙箱的只读 `/clipboard` 挂载注入。命令使用基于 ssh key 的非交互 ssh/scp。
2626

2727
`ai sandbox exec``ai sandbox refresh` 会在宿主机凭证存储与 `~/.agent-infra/credentials/*` 下的所有沙箱项目副本之间做双向 reconcile。长时间运行的沙箱如果先刷新了 OAuth token,下一次进入或刷新命令会把最新有效副本回写到宿主 Keychain 或 `~/.claude/.credentials.json`;宿主机更新时也会继续覆盖项目副本。如果所有副本都已失效,`ai sandbox refresh` 会尝试 `claude /status` 探活,只有探活无法恢复时才提示重新登录。
2828

lib/cp.ts

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { platform as currentPlatform, tmpdir as defaultTmpdir } from 'node:os';
55
import path from 'node:path';
66
import { parseArgs } from 'node:util';
77
import { createClipboardAdapter, type ClipboardAdapter } from './sandbox/clipboard/index.ts';
8+
import { RECEIVER_CAPABILITY, receiveRemoteClipboardPng } from './sandbox/clipboard/inbox.ts';
89

9-
const USAGE = 'Usage: ai cp <ssh-alias>\n\nCopy the local clipboard image (PNG) to a remote macOS NSPasteboard over ssh/scp.\n';
10+
const USAGE = 'Usage: ai cp <ssh-alias>\n\nCopy the local clipboard image (PNG) to a remote macOS clipboard or Linux sandbox over SSH.\n';
1011
const COMMAND_TIMEOUT_MS = 30_000;
1112

1213
export type SpawnResult = {
@@ -65,6 +66,21 @@ export async function cmdCp(args: string[], deps: CpDeps = {}): Promise<number>
6566
writeStderr = (chunk: string) => process.stderr.write(chunk)
6667
} = deps;
6768

69+
if (args.length === 1 && args[0] === '--remote-receive-capability') {
70+
if (platform === 'linux') writeStdout(`${RECEIVER_CAPABILITY}\n`);
71+
return platform === 'linux' ? 0 : 1;
72+
}
73+
if (args.length === 2 && args[0] === '--remote-receive-v1') {
74+
try {
75+
const result = receiveRemoteClipboardPng(process.env.HOME || process.env.USERPROFILE || '', args[1]!, platform);
76+
writeStdout(`${result}\n`);
77+
return 0;
78+
} catch (error) {
79+
writeStderr(`${error instanceof Error ? error.message : 'clipboard receive failed'}\n`);
80+
return 1;
81+
}
82+
}
83+
6884
if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
6985
writeStdout(USAGE);
7086
return 0;
@@ -110,6 +126,35 @@ export async function cmdCp(args: string[], deps: CpDeps = {}): Promise<number>
110126
writeFileFn(localPng, png);
111127

112128
remotePath = `/tmp/agent-infra-cp-${randomId()}.png`;
129+
const remoteSystem = spawnFn('ssh', [
130+
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'uname', '-s'
131+
]);
132+
if (remoteSystem.status !== 0) {
133+
writeStderr(`failed to detect remote platform on ${alias}:\n${commandDetail(remoteSystem)}\n`);
134+
return 1;
135+
}
136+
const remotePlatform = remoteSystem.stdout.trim();
137+
if (remotePlatform !== 'Darwin' && remotePlatform !== 'Linux') {
138+
writeStderr(`unsupported remote platform on ${alias}: ${remotePlatform || 'unknown'}\n`);
139+
return 1;
140+
}
141+
if (remotePlatform === 'Linux') {
142+
const homeCheck = spawnFn('ssh', [
143+
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'test', '-d', '$HOME/.agent-infra'
144+
]);
145+
if (homeCheck.status !== 0) {
146+
writeStderr(`remote agent-infra installation is missing on ${alias}\n`);
147+
return 1;
148+
}
149+
const capability = spawnFn('ssh', [
150+
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'ai', 'cp', '--remote-receive-capability'
151+
]);
152+
if (capability.status !== 0 || capability.stderr !== '' || capability.stdout.replace(/\n$/u, '') !== RECEIVER_CAPABILITY) {
153+
writeStderr(`remote agent-infra clipboard receiver is unavailable or incompatible on ${alias}; update agent-infra\n`);
154+
return 1;
155+
}
156+
}
157+
113158
const upload = spawnFn('scp', [
114159
'-o',
115160
'BatchMode=yes',
@@ -124,25 +169,21 @@ export async function cmdCp(args: string[], deps: CpDeps = {}): Promise<number>
124169
}
125170
uploaded = true;
126171

127-
// Remote write currently targets macOS only: it pipes an AppleScript to the
128-
// remote `osascript` to set its NSPasteboard. This is the extension point for
129-
// other remote platforms later (e.g. dispatch on remote OS to wl-copy/xclip
130-
// on Linux); a non-macOS remote fails here with a clear non-zero error today.
131-
const setRemote = spawnFn('ssh', [
132-
'-o',
133-
'BatchMode=yes',
134-
'-o',
135-
'ConnectTimeout=10',
136-
alias,
137-
'osascript',
138-
'-'
139-
], remoteSetScript(remotePath));
172+
const setRemote = remotePlatform === 'Darwin'
173+
? spawnFn('ssh', [
174+
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'osascript', '-'
175+
], remoteSetScript(remotePath))
176+
: spawnFn('ssh', [
177+
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'ai', 'cp', '--remote-receive-v1', remotePath
178+
]);
140179
if (setRemote.status !== 0) {
141180
writeStderr(`failed to set remote clipboard on ${alias}:\n${commandDetail(setRemote)}\n`);
142181
return 1;
143182
}
144183

145-
writeStdout(`copied clipboard image to ${alias}\n`);
184+
writeStdout(remotePlatform === 'Linux'
185+
? `uploaded clipboard image to ${alias}; return to the SSH session and press Ctrl+V\n`
186+
: `copied clipboard image to ${alias}\n`);
146187
return 0;
147188
} finally {
148189
if (uploaded && remotePath) {

lib/sandbox/clipboard/bridge.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
pruneClipboardDir,
1010
writeClipboardPngAtomic
1111
} from './paths.ts';
12+
import { isClipboardInboxReadable, readPendingClipboardPath } from './inbox.ts';
1213
import { commandForEngine, restoreTerminal, runInteractiveEngine, runOkEngine } from '../shell.ts';
1314
import { loadNodePty, type NodePty, type PtyProcess } from './node-pty.ts';
1415

@@ -85,13 +86,9 @@ export async function runInteractiveWithClipboardBridge(options: BridgeOptions):
8586
if (!stdin.isTTY || !stdout.isTTY) {
8687
return fallback('host stdin/stdout is not a TTY');
8788
}
88-
if (!adapter) {
89-
return fallback('no clipboard adapter available on this platform');
90-
}
91-
const available = adapter.available();
92-
if (!available.ok) {
93-
return fallback(available.reason);
94-
}
89+
const available = adapter?.available() ?? { ok: false as const, reason: 'no clipboard adapter available on this platform' };
90+
const inboxReady = isClipboardInboxReadable(home);
91+
if (!available.ok && !inboxReady) return fallback(available.reason);
9592
if (!runOk(engine, 'docker', ['exec', container, 'sh', '-c', '[ -d /clipboard ] && [ -r /clipboard ]'])) {
9693
return fallback('container /clipboard mount is missing; rebuild the sandbox to enable image paste');
9794
}
@@ -119,7 +116,7 @@ export async function runInteractiveWithClipboardBridge(options: BridgeOptions):
119116
return runBridge({
120117
child,
121118
home,
122-
adapter,
119+
adapter: available.ok ? adapter : null,
123120
writeStderr,
124121
stdin,
125122
stdout,
@@ -138,7 +135,7 @@ async function runBridge({
138135
}: {
139136
child: PtyProcess;
140137
home: string;
141-
adapter: ClipboardAdapter;
138+
adapter: ClipboardAdapter | null;
142139
writeStderr: (chunk: string) => unknown;
143140
stdin: NodeJS.ReadStream;
144141
stdout: NodeJS.WriteStream;
@@ -182,9 +179,9 @@ async function runBridge({
182179
}
183180

184181
function handleText(raw: string, target: PtyProcess): void {
185-
const textAdapter = adapter as TextImageClipboardAdapter;
182+
const textAdapter = adapter as TextImageClipboardAdapter | null;
186183
const pastedText = extractSinglePathPaste(raw);
187-
if (!textAdapter.readImageFromText || pastedText === null) {
184+
if (!textAdapter || !textAdapter.readImageFromText || pastedText === null) {
188185
target.write(raw);
189186
return;
190187
}
@@ -207,12 +204,17 @@ async function runBridge({
207204

208205
function handleCtrlV(match: CtrlVMatch, target: PtyProcess): void {
209206
try {
207+
const pendingPath = readPendingClipboardPath(home);
208+
if (pendingPath) {
209+
target.write(buildBracketedPaste(pendingPath));
210+
return;
211+
}
210212
// readImagePng returns null both for "no image on clipboard" and for
211213
// unexpected read failures; both cases forward the original Ctrl+V so
212214
// the container app handles it as a regular keystroke. The throw branch
213215
// below only fires on truly unexpected exceptions (e.g. fs write
214216
// errors writing to the host clipboard dir).
215-
const png = adapter.readImagePng();
217+
const png = adapter?.readImagePng() ?? null;
216218
if (!png) {
217219
target.write(match.raw);
218220
return;

lib/sandbox/clipboard/inbox.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import {
4+
clipboardHostDir,
5+
containerClipboardPath,
6+
pngClipboardFilename,
7+
pruneClipboardDir,
8+
writeClipboardPngAtomic
9+
} from './paths.ts';
10+
11+
export const RECEIVER_CAPABILITY = 'AGENT_INFRA_CLIPBOARD_RECEIVER:v1';
12+
const MARKER = '.pending.json';
13+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
14+
const HASHED_PNG = /^[a-f0-9]{16}\.png$/u;
15+
const REMOTE_TEMP = /^agent-infra-cp-[0-9a-f-]{36}\.png$/u;
16+
17+
export function isClipboardInboxReadable(home: string): boolean {
18+
const dir = clipboardHostDir(home);
19+
try {
20+
fs.accessSync(dir, fs.constants.R_OK);
21+
return fs.statSync(dir).isDirectory();
22+
} catch {
23+
return false;
24+
}
25+
}
26+
27+
export function receiveRemoteClipboardPng(home: string, tempPath: string, platformName: NodeJS.Platform = process.platform): string {
28+
if (platformName !== 'linux') throw new Error('remote clipboard receiver requires Linux');
29+
if (path.dirname(tempPath) !== '/tmp' || !REMOTE_TEMP.test(path.basename(tempPath))) {
30+
throw new Error('invalid clipboard upload path');
31+
}
32+
const signature = Buffer.alloc(PNG_SIGNATURE.length);
33+
const fd = fs.openSync(tempPath, 'r');
34+
let signatureBytes: number;
35+
try {
36+
signatureBytes = fs.readSync(fd, signature, 0, signature.length, 0);
37+
} finally {
38+
fs.closeSync(fd);
39+
}
40+
if (signatureBytes !== PNG_SIGNATURE.length || !signature.equals(PNG_SIGNATURE)) {
41+
throw new Error('uploaded clipboard file is not a PNG');
42+
}
43+
const png = fs.readFileSync(tempPath);
44+
const dir = clipboardHostDir(home);
45+
if (!fs.existsSync(path.dirname(dir))) throw new Error('agent-infra is not installed on this host');
46+
const filename = pngClipboardFilename(png);
47+
writeClipboardPngAtomic(dir, filename, png);
48+
pruneClipboardDir(dir);
49+
const markerTmp = path.join(dir, `.${MARKER}.${process.pid}.tmp`);
50+
fs.writeFileSync(markerTmp, JSON.stringify({ version: 1, filename, createdAt: new Date().toISOString() }) + '\n', { mode: 0o600 });
51+
fs.renameSync(markerTmp, path.join(dir, MARKER));
52+
return containerClipboardPath(filename);
53+
}
54+
55+
export function readPendingClipboardPath(home: string): string | null {
56+
const dir = clipboardHostDir(home);
57+
const markerPath = path.join(dir, MARKER);
58+
let markerText: string;
59+
try {
60+
markerText = fs.readFileSync(markerPath, 'utf8');
61+
} catch (error) {
62+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
63+
throw new Error('invalid clipboard pending marker', { cause: error });
64+
}
65+
66+
let marker: { version?: unknown; filename?: unknown };
67+
try {
68+
marker = JSON.parse(markerText) as { version?: unknown; filename?: unknown };
69+
} catch (error) {
70+
throw new Error('invalid clipboard pending marker', { cause: error });
71+
}
72+
if (marker.version !== 1 || typeof marker.filename !== 'string' || !HASHED_PNG.test(marker.filename)) {
73+
throw new Error('invalid clipboard pending marker');
74+
}
75+
const filePath = path.join(dir, marker.filename);
76+
try {
77+
const stat = fs.lstatSync(filePath);
78+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('pending clipboard image is not a regular file');
79+
} catch (error) {
80+
throw new Error('invalid clipboard pending marker', { cause: error });
81+
}
82+
return containerClipboardPath(marker.filename);
83+
}

0 commit comments

Comments
 (0)