@fitlab-ai/agent-infra 0.6.4 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/README.zh-CN.md +12 -2
- package/bin/cli.ts +18 -6
- package/dist/bin/cli.js +20 -6
- package/dist/lib/cp.js +127 -0
- package/dist/lib/sandbox/clipboard/bridge.js +23 -4
- package/dist/lib/sandbox/clipboard/index.js +12 -3
- package/dist/lib/sandbox/commands/create.js +7 -0
- package/dist/lib/sandbox/commands/enter.js +14 -2
- package/dist/lib/sandbox/commands/rebuild.js +11 -5
- package/dist/lib/sandbox/index.js +4 -3
- package/dist/lib/sandbox/readme-scaffold.js +148 -0
- package/dist/lib/sandbox/runtimes/ai-tools.dockerfile +2 -0
- package/dist/lib/sandbox/runtimes/base.dockerfile +3 -3
- package/lib/cp.ts +177 -0
- package/lib/sandbox/clipboard/bridge.ts +23 -4
- package/lib/sandbox/clipboard/index.ts +12 -3
- package/lib/sandbox/commands/create.ts +8 -0
- package/lib/sandbox/commands/enter.ts +34 -2
- package/lib/sandbox/commands/rebuild.ts +15 -5
- package/lib/sandbox/index.ts +4 -3
- package/lib/sandbox/readme-scaffold.ts +177 -0
- package/lib/sandbox/runtimes/ai-tools.dockerfile +2 -0
- package/lib/sandbox/runtimes/base.dockerfile +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -202,9 +202,13 @@ This detects the packaged template version and renders all managed files. The sa
|
|
|
202
202
|
|
|
203
203
|
The sandbox image also preinstalls `gh`. When `gh auth token` succeeds on the host, `ai sandbox create` injects the token into the container as `GH_TOKEN`, so `gh` commands work inside the sandbox without extra setup.
|
|
204
204
|
|
|
205
|
+
`ai sandbox rebuild` keeps Docker's build cache by default, so it quickly retags the sandbox image without refreshing every package. Use `ai sandbox rebuild --refresh` when you want to upgrade the image: it passes `--no-cache --pull` to Docker, pulls the current Ubuntu base image, and reruns the apt, tmux build, and global npm install layers. Claude Code updates are disabled inside the container, and OpenCode startup update checks are disabled; `--refresh` is the routine upgrade path for sandbox-managed tools. Manual `opencode upgrade` remains outside this guard. The default `python3` provided by the Ubuntu 24.04 sandbox base is Python 3.12, so scripts that hard-code Python 3.10 paths may need adjustment.
|
|
206
|
+
|
|
205
207
|
`ai sandbox exec` also forwards a small terminal-detection whitelist (`TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LC_TERMINAL`, `LC_TERMINAL_VERSION`) into the container. This keeps interactive TUIs aligned with the host terminal for behaviors such as Claude Code's Shift+Enter newline support, without passing through the full host environment.
|
|
206
208
|
|
|
207
|
-
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.
|
|
209
|
+
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.
|
|
210
|
+
|
|
211
|
+
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.
|
|
208
212
|
|
|
209
213
|
`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.
|
|
210
214
|
|
|
@@ -230,6 +234,11 @@ remove only dirs without an active sandbox container.
|
|
|
230
234
|
Existing sandboxes pick up these mounts after `ai sandbox rm <branch>` and
|
|
231
235
|
`ai sandbox create <branch>`.
|
|
232
236
|
|
|
237
|
+
On first `ai sandbox create`, agent-infra writes a bilingual `README.md` into
|
|
238
|
+
`~/.agent-infra/share/<project>/common/` and each `branches/<branch>/`
|
|
239
|
+
directory to help you discover these channels. The READMEs are idempotent and
|
|
240
|
+
can be safely deleted; the scaffold only writes them when missing.
|
|
241
|
+
|
|
233
242
|
### User-level dotfiles channel
|
|
234
243
|
|
|
235
244
|
`ai sandbox create` also mounts an optional read-only channel for host user preferences:
|
|
@@ -294,6 +303,7 @@ target.
|
|
|
294
303
|
| `.config/opencode/*`, `.local/share/opencode/*` | OpenCode credentials and data use dedicated bind mounts. |
|
|
295
304
|
| `.host-shell-config/*` | agent-infra managed shell and Git configuration. |
|
|
296
305
|
| `.gitconfig`, `.gitignore_global`, `.stCommitMsg`, `.bash_aliases` | agent-infra symlinks these to `.host-shell-config/`, including `safe.directory` and GPG sync state. |
|
|
306
|
+
| `README.md` | agent-infra scaffolds a discoverability README at the dotfiles root on first create; the link hook ignores it so `$HOME/README.md` is not shadowed. |
|
|
297
307
|
|
|
298
308
|
Other existing real directories, such as `~/.config/` or `~/.cache/`, are not
|
|
299
309
|
replaced by top-level dotfiles. If a file conflicts with one of those
|
|
@@ -787,7 +797,7 @@ The generated `.agents/.airc.json` file is the central contract between the boot
|
|
|
787
797
|
"project": "my-project",
|
|
788
798
|
"org": "my-org",
|
|
789
799
|
"language": "en",
|
|
790
|
-
"templateVersion": "v0.6.
|
|
800
|
+
"templateVersion": "v0.6.5",
|
|
791
801
|
"templates": {
|
|
792
802
|
"sources": [
|
|
793
803
|
{ "type": "local", "path": "~/private-templates" }
|
package/README.zh-CN.md
CHANGED
|
@@ -202,9 +202,13 @@ CLI 会收集项目元数据,向所有支持的 AI TUI 安装 `update-agent-in
|
|
|
202
202
|
|
|
203
203
|
沙箱镜像也会预装 `gh`。如果宿主机上的 `gh auth token` 能成功返回 token,`ai sandbox create` 会把它以 `GH_TOKEN` 环境变量注入容器,让你在沙箱里直接使用 `gh`,无需额外登录配置。
|
|
204
204
|
|
|
205
|
+
`ai sandbox rebuild` 默认保留 Docker build cache,因此会快速重打沙箱镜像,不会刷新每个软件包。需要升级镜像时使用 `ai sandbox rebuild --refresh`:它会向 Docker 传入 `--no-cache --pull`,重新拉取当前 Ubuntu 基础镜像,并重跑 apt、tmux 编译和全局 npm 安装层。容器内 Claude Code 更新已关闭,OpenCode 启动时更新检查也已关闭;`--refresh` 是沙箱托管工具的常规升级入口。手动 `opencode upgrade` 不受该保护覆盖。Ubuntu 24.04 沙箱基础镜像提供的默认 `python3` 是 Python 3.12,因此硬编码 Python 3.10 路径的脚本可能需要调整。
|
|
206
|
+
|
|
205
207
|
`ai sandbox exec` 也会向容器透传一小组终端检测白名单变量(`TERM_PROGRAM`、`TERM_PROGRAM_VERSION`、`LC_TERMINAL`、`LC_TERMINAL_VERSION`)。这样可以让交互式 TUI 保持与宿主终端一致的行为,例如 Claude Code 的 `Shift+Enter` 换行支持,同时避免把整个宿主环境灌入容器。
|
|
206
208
|
|
|
207
|
-
在 macOS 上,交互式 `ai sandbox exec <branch>` 会尽力桥接宿主图片粘贴。当你按下 `Ctrl+V` 且宿主剪贴板当前是图片时,agent-infra 会从宿主剪贴板读取图片,将 PNG 写到 `~/.agent-infra/clipboard/`,再以 bracketed paste 注入容器内路径,让 Claude Code、Codex、Gemini CLI 和 OpenCode 按图片附件处理。宿主剪贴板只读,不会被改写。该能力会自动降级:已有沙箱需要重建后才有 `/clipboard` 挂载;如果可选 pty
|
|
209
|
+
在 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` 跳过桥接,直接进入原本的交互路径。
|
|
210
|
+
|
|
211
|
+
当你通过 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),后续可扩展支持其他远端平台。
|
|
208
212
|
|
|
209
213
|
`ai sandbox exec` 和 `ai sandbox refresh` 会在宿主机凭证存储与 `~/.agent-infra/credentials/*` 下的所有沙箱项目副本之间做双向 reconcile。长时间运行的沙箱如果先刷新了 OAuth token,下一次进入或刷新命令会把最新有效副本回写到宿主 Keychain 或 `~/.claude/.credentials.json`;宿主机更新时也会继续覆盖项目副本。如果所有副本都已失效,`ai sandbox refresh` 会尝试 `claude /status` 探活,只有探活无法恢复时才提示重新登录。
|
|
210
214
|
|
|
@@ -220,6 +224,11 @@ CLI 会收集项目元数据,向所有支持的 AI TUI 安装 `update-agent-in
|
|
|
220
224
|
可先用 `ai sandbox prune --dry-run` 查看旧版本或异常中断遗留的孤儿 per-branch 状态目录,再用 `ai sandbox prune` 只删除没有活跃 sandbox 容器对应的目录。
|
|
221
225
|
已有沙箱需要执行 `ai sandbox rm <branch>` 后再执行 `ai sandbox create <branch>`,才能加载新的挂载点。
|
|
222
226
|
|
|
227
|
+
首次执行 `ai sandbox create` 时,agent-infra 会在
|
|
228
|
+
`~/.agent-infra/share/<project>/common/` 以及每个 `branches/<branch>/`
|
|
229
|
+
目录下写入一份中英双语 `README.md`,帮助你发现这些通道。README 是幂等的,
|
|
230
|
+
可以安全删除;scaffold 仅在文件缺失时写入。
|
|
231
|
+
|
|
223
232
|
#### 用户级 dotfiles 通道
|
|
224
233
|
|
|
225
234
|
`ai sandbox create` 还会自动挂载一条可选的只读通道,用于把宿主机用户级偏好带进沙箱:
|
|
@@ -270,6 +279,7 @@ dotfiles 树解引用到
|
|
|
270
279
|
| `.config/opencode/*`, `.local/share/opencode/*` | OpenCode 凭证和数据使用专用 bind mount。 |
|
|
271
280
|
| `.host-shell-config/*` | agent-infra 管理的 shell 和 Git 配置。 |
|
|
272
281
|
| `.gitconfig`, `.gitignore_global`, `.stCommitMsg`, `.bash_aliases` | agent-infra 将这些路径软链到 `.host-shell-config/`,包含 `safe.directory` 和 GPG 同步状态。 |
|
|
282
|
+
| `README.md` | agent-infra 会在 dotfiles 根目录 scaffold 一份发现性 README;link hook 会忽略它,避免遮蔽 `$HOME/README.md`。 |
|
|
273
283
|
|
|
274
284
|
其他已经存在的真实目录(如 `~/.config/`、`~/.cache/`)不会被顶层 dotfile 替换。如果某个文件与这类目录冲突,钩子会打印警告并跳过:
|
|
275
285
|
|
|
@@ -760,7 +770,7 @@ import-issue #42 从 GitHub Issue 导入任务
|
|
|
760
770
|
"project": "my-project",
|
|
761
771
|
"org": "my-org",
|
|
762
772
|
"language": "en",
|
|
763
|
-
"templateVersion": "v0.6.
|
|
773
|
+
"templateVersion": "v0.6.5",
|
|
764
774
|
"templates": {
|
|
765
775
|
"sources": [
|
|
766
776
|
{ "type": "local", "path": "~/private-templates" }
|
package/bin/cli.ts
CHANGED
|
@@ -13,12 +13,13 @@ if (major < 22) {
|
|
|
13
13
|
const USAGE = `agent-infra ${VERSION} - bootstrap AI collaboration infrastructure
|
|
14
14
|
|
|
15
15
|
Usage:
|
|
16
|
-
agent-infra
|
|
17
|
-
agent-infra
|
|
18
|
-
agent-infra
|
|
19
|
-
agent-infra
|
|
20
|
-
agent-infra
|
|
21
|
-
agent-infra
|
|
16
|
+
agent-infra cp <ssh-alias> Copy local clipboard image to a remote macOS NSPasteboard
|
|
17
|
+
agent-infra help Show this help message
|
|
18
|
+
agent-infra init Initialize a new project with update-agent-infra seed command
|
|
19
|
+
agent-infra merge Merge tasks from another workspace directory (active/blocked/completed/archive)
|
|
20
|
+
agent-infra sandbox Manage Docker-based AI sandboxes
|
|
21
|
+
agent-infra update Update seed files and sync file registry for an existing project
|
|
22
|
+
agent-infra version Show version
|
|
22
23
|
|
|
23
24
|
Shorthand: ai (e.g. ai init)
|
|
24
25
|
|
|
@@ -96,6 +97,17 @@ switch (command) {
|
|
|
96
97
|
});
|
|
97
98
|
break;
|
|
98
99
|
}
|
|
100
|
+
case 'cp': {
|
|
101
|
+
const imported = await importCommand('../lib/cp.ts');
|
|
102
|
+
if (!imported) break;
|
|
103
|
+
const { cmdCp } = imported;
|
|
104
|
+
const code = await cmdCp(process.argv.slice(3)).catch((e: unknown) => {
|
|
105
|
+
process.stderr.write(`Error: ${errorMessage(e)}\n`);
|
|
106
|
+
return 1;
|
|
107
|
+
});
|
|
108
|
+
if (code) process.exitCode = code;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
99
111
|
case 'version': {
|
|
100
112
|
if (process.argv[3] === '--raw') {
|
|
101
113
|
console.log(VERSION);
|
package/dist/bin/cli.js
CHANGED
|
@@ -17,12 +17,13 @@ if (major < 22) {
|
|
|
17
17
|
const USAGE = `agent-infra ${VERSION} - bootstrap AI collaboration infrastructure
|
|
18
18
|
|
|
19
19
|
Usage:
|
|
20
|
-
agent-infra
|
|
21
|
-
agent-infra
|
|
22
|
-
agent-infra
|
|
23
|
-
agent-infra
|
|
24
|
-
agent-infra
|
|
25
|
-
agent-infra
|
|
20
|
+
agent-infra cp <ssh-alias> Copy local clipboard image to a remote macOS NSPasteboard
|
|
21
|
+
agent-infra help Show this help message
|
|
22
|
+
agent-infra init Initialize a new project with update-agent-infra seed command
|
|
23
|
+
agent-infra merge Merge tasks from another workspace directory (active/blocked/completed/archive)
|
|
24
|
+
agent-infra sandbox Manage Docker-based AI sandboxes
|
|
25
|
+
agent-infra update Update seed files and sync file registry for an existing project
|
|
26
|
+
agent-infra version Show version
|
|
26
27
|
|
|
27
28
|
Shorthand: ai (e.g. ai init)
|
|
28
29
|
|
|
@@ -99,6 +100,19 @@ switch (command) {
|
|
|
99
100
|
});
|
|
100
101
|
break;
|
|
101
102
|
}
|
|
103
|
+
case 'cp': {
|
|
104
|
+
const imported = await importCommand('../lib/cp.ts');
|
|
105
|
+
if (!imported)
|
|
106
|
+
break;
|
|
107
|
+
const { cmdCp } = imported;
|
|
108
|
+
const code = await cmdCp(process.argv.slice(3)).catch((e) => {
|
|
109
|
+
process.stderr.write(`Error: ${errorMessage(e)}\n`);
|
|
110
|
+
return 1;
|
|
111
|
+
});
|
|
112
|
+
if (code)
|
|
113
|
+
process.exitCode = code;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
102
116
|
case 'version': {
|
|
103
117
|
if (process.argv[3] === '--raw') {
|
|
104
118
|
console.log(VERSION);
|
package/dist/lib/cp.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { platform as currentPlatform, tmpdir as defaultTmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import { createClipboardAdapter } from "./sandbox/clipboard/index.js";
|
|
8
|
+
const USAGE = 'Usage: ai cp <ssh-alias>\n\nCopy the local clipboard image (PNG) to a remote macOS NSPasteboard over ssh/scp.\n';
|
|
9
|
+
const COMMAND_TIMEOUT_MS = 30_000;
|
|
10
|
+
export function runCommand(cmd, args, input) {
|
|
11
|
+
const result = spawnSync(cmd, args, {
|
|
12
|
+
input,
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
15
|
+
timeout: COMMAND_TIMEOUT_MS
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
status: result.status,
|
|
19
|
+
stdout: result.stdout ?? '',
|
|
20
|
+
stderr: result.stderr ?? '',
|
|
21
|
+
error: result.error
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export async function cmdCp(args, deps = {}) {
|
|
25
|
+
const { platform = currentPlatform(), createAdapter = createClipboardAdapter, spawnFn = runCommand, randomId = randomUUID, mkdtempFn = fs.mkdtempSync, writeFileFn = fs.writeFileSync, rmFn = fs.rmSync, tmpdir = defaultTmpdir, writeStdout = (chunk) => process.stdout.write(chunk), writeStderr = (chunk) => process.stderr.write(chunk) } = deps;
|
|
26
|
+
if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
|
|
27
|
+
writeStdout(USAGE);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
let positionals;
|
|
31
|
+
try {
|
|
32
|
+
({ positionals } = parseArgs({ args, allowPositionals: true, strict: true }));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
writeStderr(USAGE);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
const alias = positionals[0];
|
|
39
|
+
if (!alias || positionals.length !== 1) {
|
|
40
|
+
writeStderr(USAGE);
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
if (alias.startsWith('-')) {
|
|
44
|
+
writeStderr(`invalid ssh alias '${alias}': must not start with '-'\n`);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
if (platform !== 'darwin') {
|
|
48
|
+
writeStderr(`ai cp currently supports macOS senders only (got ${platform})\n`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
const adapter = createAdapter({ platformName: platform });
|
|
52
|
+
const png = adapter?.readImagePng() ?? null;
|
|
53
|
+
if (png === null) {
|
|
54
|
+
writeStderr('no image on clipboard\n');
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
let uploaded = false;
|
|
58
|
+
let localTmpDir = null;
|
|
59
|
+
let remotePath = null;
|
|
60
|
+
try {
|
|
61
|
+
localTmpDir = mkdtempFn(path.join(tmpdir(), 'agent-infra-cp-'));
|
|
62
|
+
const localPng = path.join(localTmpDir, 'clipboard.png');
|
|
63
|
+
writeFileFn(localPng, png);
|
|
64
|
+
remotePath = `/tmp/agent-infra-cp-${randomId()}.png`;
|
|
65
|
+
const upload = spawnFn('scp', [
|
|
66
|
+
'-o',
|
|
67
|
+
'BatchMode=yes',
|
|
68
|
+
'-o',
|
|
69
|
+
'ConnectTimeout=10',
|
|
70
|
+
localPng,
|
|
71
|
+
`${alias}:${remotePath}`
|
|
72
|
+
]);
|
|
73
|
+
if (upload.status !== 0) {
|
|
74
|
+
writeStderr(`failed to upload image to ${alias}:\n${commandDetail(upload)}\n`);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
uploaded = true;
|
|
78
|
+
// Remote write currently targets macOS only: it pipes an AppleScript to the
|
|
79
|
+
// remote `osascript` to set its NSPasteboard. This is the extension point for
|
|
80
|
+
// other remote platforms later (e.g. dispatch on remote OS to wl-copy/xclip
|
|
81
|
+
// on Linux); a non-macOS remote fails here with a clear non-zero error today.
|
|
82
|
+
const setRemote = spawnFn('ssh', [
|
|
83
|
+
'-o',
|
|
84
|
+
'BatchMode=yes',
|
|
85
|
+
'-o',
|
|
86
|
+
'ConnectTimeout=10',
|
|
87
|
+
alias,
|
|
88
|
+
'osascript',
|
|
89
|
+
'-'
|
|
90
|
+
], remoteSetScript(remotePath));
|
|
91
|
+
if (setRemote.status !== 0) {
|
|
92
|
+
writeStderr(`failed to set remote clipboard on ${alias}:\n${commandDetail(setRemote)}\n`);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
writeStdout(`copied clipboard image to ${alias}\n`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
if (uploaded && remotePath) {
|
|
100
|
+
spawnFn('ssh', [
|
|
101
|
+
'-o',
|
|
102
|
+
'BatchMode=yes',
|
|
103
|
+
'-o',
|
|
104
|
+
'ConnectTimeout=10',
|
|
105
|
+
alias,
|
|
106
|
+
'rm',
|
|
107
|
+
'-f',
|
|
108
|
+
remotePath
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
if (localTmpDir) {
|
|
112
|
+
rmFn(localTmpDir, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function commandDetail(result) {
|
|
117
|
+
const detail = result.stderr || result.error?.message || result.stdout || 'unknown error';
|
|
118
|
+
return detail.trimEnd();
|
|
119
|
+
}
|
|
120
|
+
function remoteSetScript(remotePath) {
|
|
121
|
+
const escapedPath = remotePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
122
|
+
return [
|
|
123
|
+
`set theFile to POSIX file "${escapedPath}"`,
|
|
124
|
+
'set the clipboard to (read theFile as «class PNGf»)'
|
|
125
|
+
].join('\n');
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=cp.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
2
3
|
import { createClipboardAdapter } from "./index.js";
|
|
3
4
|
import { buildBracketedPaste, CtrlVDetector } from "./keys.js";
|
|
4
5
|
import { clipboardHostDir, containerClipboardPath, pngClipboardFilename, pruneClipboardDir, writeClipboardPngAtomic } from "./paths.js";
|
|
@@ -6,20 +7,37 @@ import { commandForEngine, restoreTerminal, runInteractiveEngine, runOkEngine }
|
|
|
6
7
|
import { loadNodePty } from "./node-pty.js";
|
|
7
8
|
const FALLBACK_PREFIX = 'Warning: clipboard image paste bridge disabled';
|
|
8
9
|
const PARTIAL_ESCAPE_FLUSH_MS = 30;
|
|
10
|
+
// Node's stdin.setRawMode(true) uses libuv's RAW mode, which (unlike the
|
|
11
|
+
// cfmakeraw that `docker exec -it` applies on the non-bridge path) keeps ONLCR
|
|
12
|
+
// set on the shared host TTY. With ONLCR on, the kernel rewrites the bare \n
|
|
13
|
+
// that tmux emits after homing the cursor inside the right pane into \r\n,
|
|
14
|
+
// snapping the cursor to column 1 so the following erase/redraw wipes the left
|
|
15
|
+
// pane. Clearing OPOST brings the host TTY in line with the non-bridge path.
|
|
16
|
+
// Best-effort: setRawMode(false) on teardown restores the original termios, and
|
|
17
|
+
// a missing/failed stty only reinstates the redraw glitch.
|
|
18
|
+
function disableOutputPostProcessing(stdin) {
|
|
19
|
+
const candidate = stdin.fd;
|
|
20
|
+
if (typeof candidate !== 'number') {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
spawnSync('stty', ['-opost'], { stdio: [candidate, 'ignore', 'ignore'] });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// stty unavailable or fd is not a tty; leave the terminal as-is.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
9
30
|
export async function runInteractiveWithClipboardBridge(options) {
|
|
10
31
|
const { engine, dockerArgs, container, home, cwd = process.cwd(), env = process.env, platformName = process.platform, adapter = createClipboardAdapter({ platformName }), loadPty = loadNodePty, runInteractive = runInteractiveEngine, runOk = runOkEngine, writeStderr = (chunk) => process.stderr.write(chunk), stdin = process.stdin, stdout = process.stdout, createDetector = () => new CtrlVDetector() } = options;
|
|
11
32
|
function fallback(reason) {
|
|
12
33
|
writeStderr(`${FALLBACK_PREFIX}: ${reason}\n`);
|
|
13
34
|
return runInteractive(engine, 'docker', dockerArgs);
|
|
14
35
|
}
|
|
15
|
-
if (platformName !== 'darwin') {
|
|
16
|
-
return runInteractive(engine, 'docker', dockerArgs);
|
|
17
|
-
}
|
|
18
36
|
if (!stdin.isTTY || !stdout.isTTY) {
|
|
19
37
|
return fallback('host stdin/stdout is not a TTY');
|
|
20
38
|
}
|
|
21
39
|
if (!adapter) {
|
|
22
|
-
return fallback('
|
|
40
|
+
return fallback('no clipboard adapter available on this platform');
|
|
23
41
|
}
|
|
24
42
|
const available = adapter.available();
|
|
25
43
|
if (!available.ok) {
|
|
@@ -121,6 +139,7 @@ async function runBridge({ child, home, adapter, writeStderr, stdin, stdout, det
|
|
|
121
139
|
}
|
|
122
140
|
try {
|
|
123
141
|
stdin.setRawMode?.(true);
|
|
142
|
+
disableOutputPostProcessing(stdin);
|
|
124
143
|
stdin.resume();
|
|
125
144
|
stdin.on('data', onData);
|
|
126
145
|
stdout.on('resize', onResize);
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { platform } from 'node:os';
|
|
2
2
|
import { createDarwinClipboardAdapter } from "./darwin.js";
|
|
3
3
|
export function createClipboardAdapter({ platformName = platform() } = {}) {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
switch (platformName) {
|
|
5
|
+
case 'darwin':
|
|
6
|
+
return createDarwinClipboardAdapter();
|
|
7
|
+
case 'linux':
|
|
8
|
+
// Future work: dispatch based on $WAYLAND_DISPLAY (wl-paste) or $DISPLAY (xclip);
|
|
9
|
+
// see Issue #386 follow-up. Returning null disables the bridge for now.
|
|
10
|
+
return null;
|
|
11
|
+
case 'win32':
|
|
12
|
+
// Future work: native Win32 clipboard reader. Returning null disables the bridge.
|
|
13
|
+
return null;
|
|
14
|
+
default:
|
|
15
|
+
return null;
|
|
6
16
|
}
|
|
7
|
-
return createDarwinClipboardAdapter();
|
|
8
17
|
}
|
|
9
18
|
//# sourceMappingURL=index.js.map
|
|
@@ -19,6 +19,7 @@ import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from "../clipboard/paths.
|
|
|
19
19
|
import { validateSelinuxDisableEnv } from "../engines/selinux.js";
|
|
20
20
|
import { resolveBuildUid } from "../engines/native.js";
|
|
21
21
|
import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
|
|
22
|
+
import { ensureSandboxDiscoveryReadmes } from "../readme-scaffold.js";
|
|
22
23
|
import { prepareClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
|
|
23
24
|
import { detectHostTimezone } from "../host-timezone.js";
|
|
24
25
|
const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
|
|
@@ -981,6 +982,12 @@ export async function create(args) {
|
|
|
981
982
|
if (aliasesFile.created) {
|
|
982
983
|
message(`Created default sandbox aliases at ${aliasesFile.path}`);
|
|
983
984
|
}
|
|
985
|
+
const readmeResults = ensureSandboxDiscoveryReadmes(effectiveConfig, branch);
|
|
986
|
+
for (const { created, path: readmePath } of readmeResults) {
|
|
987
|
+
if (created) {
|
|
988
|
+
message(`Created discovery README at ${readmePath}`);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
984
991
|
const gitconfigPath = path.join(effectiveConfig.home, '.gitconfig');
|
|
985
992
|
const gitconfigContent = fs.existsSync(gitconfigPath)
|
|
986
993
|
? fs.readFileSync(gitconfigPath, 'utf8')
|
|
@@ -34,6 +34,17 @@ export function hostTimezoneEnvFlags(detect = detectHostTimezone) {
|
|
|
34
34
|
const tz = detect();
|
|
35
35
|
return tz ? ['-e', `TZ=${tz}`] : [];
|
|
36
36
|
}
|
|
37
|
+
export function clipboardBridgeDisabled(env = process.env) {
|
|
38
|
+
const value = (env.AI_SANDBOX_NO_CLIPBOARD_BRIDGE ?? '').trim().toLowerCase();
|
|
39
|
+
return value === '1' || value === 'true' || value === 'yes';
|
|
40
|
+
}
|
|
41
|
+
export function runSandboxInteractive(params) {
|
|
42
|
+
const { engine, dockerArgs, container, home, env = process.env, runBridge = runInteractiveWithClipboardBridge, runInteractive = runInteractiveEngine } = params;
|
|
43
|
+
if (clipboardBridgeDisabled(env)) {
|
|
44
|
+
return runInteractive(engine, 'docker', dockerArgs);
|
|
45
|
+
}
|
|
46
|
+
return runBridge({ engine, dockerArgs, container, home });
|
|
47
|
+
}
|
|
37
48
|
export function formatCredentialSyncStatus(result, isTTY = process.stderr.isTTY) {
|
|
38
49
|
if (result.status === 'STALE_ACCESS') {
|
|
39
50
|
return 'Warning: Claude Code credentials on host appear stale. Run "ai sandbox refresh" or "claude /login" to renew.\n';
|
|
@@ -97,9 +108,10 @@ export async function enter(args) {
|
|
|
97
108
|
catch (error) {
|
|
98
109
|
process.stderr.write(`Warning: dotfiles snapshot rebuild failed: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
|
|
99
110
|
}
|
|
100
|
-
|
|
111
|
+
const dockerArgs = ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH];
|
|
112
|
+
return runSandboxInteractive({
|
|
101
113
|
engine,
|
|
102
|
-
dockerArgs
|
|
114
|
+
dockerArgs,
|
|
103
115
|
container,
|
|
104
116
|
home: config.home
|
|
105
117
|
});
|
|
@@ -10,7 +10,7 @@ import { runEngine, runOkEngine, runSafeEngine, runVerboseEngine } from "../shel
|
|
|
10
10
|
import { resolveTools, toolNpmPackagesArg } from "../tools.js";
|
|
11
11
|
import { toEnginePath } from "../engines/wsl2-paths.js";
|
|
12
12
|
import { resolveBuildUid } from "../engines/native.js";
|
|
13
|
-
const USAGE = `Usage: ai sandbox rebuild [--quiet]`;
|
|
13
|
+
const USAGE = `Usage: ai sandbox rebuild [--quiet] [--refresh]`;
|
|
14
14
|
function buildSignature(preparedDockerfile, tools) {
|
|
15
15
|
return createHash('sha256')
|
|
16
16
|
.update(JSON.stringify({
|
|
@@ -20,7 +20,7 @@ function buildSignature(preparedDockerfile, tools) {
|
|
|
20
20
|
.digest('hex')
|
|
21
21
|
.slice(0, 12);
|
|
22
22
|
}
|
|
23
|
-
export function buildArgs(config, tools, dockerfilePath, imageSignature, { engine, runFn = runEngine, runSafeFn = runSafeEngine, env = process.env } = {}) {
|
|
23
|
+
export function buildArgs(config, tools, dockerfilePath, imageSignature, { engine, runFn = runEngine, runSafeFn = runSafeEngine, env = process.env, refresh = false } = {}) {
|
|
24
24
|
const selectedEngine = engine ?? detectEngine(config);
|
|
25
25
|
const { uid: hostUid, gid: hostGid } = resolveBuildUid({
|
|
26
26
|
engine: selectedEngine,
|
|
@@ -28,7 +28,7 @@ export function buildArgs(config, tools, dockerfilePath, imageSignature, { engin
|
|
|
28
28
|
runSafeFn,
|
|
29
29
|
env
|
|
30
30
|
});
|
|
31
|
-
|
|
31
|
+
const args = [
|
|
32
32
|
'build',
|
|
33
33
|
'-t',
|
|
34
34
|
config.imageName,
|
|
@@ -46,6 +46,10 @@ export function buildArgs(config, tools, dockerfilePath, imageSignature, { engin
|
|
|
46
46
|
toEnginePath(selectedEngine, dockerfilePath),
|
|
47
47
|
toEnginePath(selectedEngine, config.repoRoot)
|
|
48
48
|
];
|
|
49
|
+
if (refresh) {
|
|
50
|
+
args.splice(1, 0, '--no-cache', '--pull');
|
|
51
|
+
}
|
|
52
|
+
return args;
|
|
49
53
|
}
|
|
50
54
|
function removeImageIfPresent(imageName, engine) {
|
|
51
55
|
if (runOkEngine(engine, 'docker', ['image', 'inspect', imageName])) {
|
|
@@ -58,6 +62,7 @@ export async function rebuild(args) {
|
|
|
58
62
|
allowPositionals: true,
|
|
59
63
|
strict: true,
|
|
60
64
|
options: {
|
|
65
|
+
refresh: { type: 'boolean' },
|
|
61
66
|
quiet: { type: 'boolean', short: 'q' },
|
|
62
67
|
help: { type: 'boolean', short: 'h' }
|
|
63
68
|
}
|
|
@@ -71,6 +76,7 @@ export async function rebuild(args) {
|
|
|
71
76
|
const preparedDockerfile = prepareDockerfile(config);
|
|
72
77
|
const imageSignature = buildSignature(preparedDockerfile, tools);
|
|
73
78
|
const quiet = values.quiet ?? false;
|
|
79
|
+
const refresh = values.refresh ?? false;
|
|
74
80
|
const engine = detectEngine(config);
|
|
75
81
|
await ensureDocker(config, undefined);
|
|
76
82
|
p.intro(pc.cyan('Rebuilding sandbox image'));
|
|
@@ -81,7 +87,7 @@ export async function rebuild(args) {
|
|
|
81
87
|
removeImageIfPresent(config.imageName, engine);
|
|
82
88
|
spinner.stop('Old image removed');
|
|
83
89
|
spinner.start('Building image...');
|
|
84
|
-
runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine }), {
|
|
90
|
+
runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }), {
|
|
85
91
|
cwd: config.repoRoot
|
|
86
92
|
});
|
|
87
93
|
spinner.stop(pc.green('Sandbox image rebuilt'));
|
|
@@ -90,7 +96,7 @@ export async function rebuild(args) {
|
|
|
90
96
|
p.log.step(`Removing old image ${config.imageName}`);
|
|
91
97
|
removeImageIfPresent(config.imageName, engine);
|
|
92
98
|
p.log.step('Building image');
|
|
93
|
-
runVerboseEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine }), { cwd: config.repoRoot });
|
|
99
|
+
runVerboseEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }), { cwd: config.repoRoot });
|
|
94
100
|
p.log.success(pc.green('Sandbox image rebuilt'));
|
|
95
101
|
}
|
|
96
102
|
}
|
|
@@ -3,12 +3,13 @@ const USAGE = `Usage: ai sandbox <command> [options]
|
|
|
3
3
|
Commands:
|
|
4
4
|
create <branch> [base] Create a sandbox (VM + image + worktree + container)
|
|
5
5
|
exec <branch> [cmd...] Enter sandbox or run a command
|
|
6
|
-
refresh Sync host Claude Code credentials to all sandbox copies
|
|
7
6
|
ls List sandboxes for the current project
|
|
8
|
-
rm <branch> [--all] Remove a sandbox or all sandboxes
|
|
9
7
|
prune [--dry-run] Remove orphaned per-branch state dirs
|
|
8
|
+
rebuild [--quiet] [--refresh]
|
|
9
|
+
Rebuild the sandbox image (--refresh pulls base + tools)
|
|
10
|
+
refresh Sync host Claude Code credentials to all sandbox copies
|
|
11
|
+
rm <branch> [--all] Remove a sandbox or all sandboxes
|
|
10
12
|
vm status|start|stop Manage the sandbox VM (macOS) or check the backend (Windows)
|
|
11
|
-
rebuild [--quiet] Rebuild the sandbox image
|
|
12
13
|
|
|
13
14
|
Run 'ai sandbox <command> --help' for details.`;
|
|
14
15
|
export async function runSandbox(args) {
|