@fitlab-ai/agent-infra 0.6.3 → 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 +30 -9
- package/dist/lib/sandbox/clipboard/darwin.js +7 -14
- package/dist/lib/sandbox/clipboard/index.js +12 -3
- package/dist/lib/sandbox/commands/create.js +11 -0
- package/dist/lib/sandbox/commands/enter.js +20 -3
- package/dist/lib/sandbox/commands/rebuild.js +11 -5
- package/dist/lib/sandbox/host-timezone.js +33 -0
- 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 +24 -19
- package/lib/cp.ts +177 -0
- package/lib/sandbox/clipboard/bridge.ts +30 -10
- package/lib/sandbox/clipboard/darwin.ts +15 -14
- package/lib/sandbox/clipboard/index.ts +12 -3
- package/lib/sandbox/commands/create.ts +12 -0
- package/lib/sandbox/commands/enter.ts +41 -3
- package/lib/sandbox/commands/rebuild.ts +15 -5
- package/lib/sandbox/host-timezone.ts +42 -0
- 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 +24 -19
- package/package.json +7 -7
- package/templates/.agents/rules/create-issue.github.en.md +19 -1
- package/templates/.agents/rules/create-issue.github.zh-CN.md +19 -1
- package/templates/.agents/rules/milestone-inference.github.en.md +12 -0
- package/templates/.agents/rules/milestone-inference.github.zh-CN.md +12 -0
- package/templates/.agents/rules/testing-discipline.en.md +44 -0
- package/templates/.agents/rules/testing-discipline.zh-CN.md +44 -0
- package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/create-task/config/verify.json +1 -0
- package/templates/.agents/skills/import-issue/SKILL.en.md +1 -1
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
|
@@ -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) {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { shareBranchDir, shareCommonDir } from "./constants.js";
|
|
4
|
+
const DOTFILES_README = `# User-level dotfiles channel
|
|
5
|
+
|
|
6
|
+
This directory is mounted **read-only** into every sandbox container at
|
|
7
|
+
\`/dotfiles\`. On entry, \`sandbox-dotfiles-link\` mirrors every file here as a
|
|
8
|
+
symlink under \`$HOME\` (e.g. \`.tmux.conf\` -> \`/home/devuser/.tmux.conf\`),
|
|
9
|
+
overriding image defaults so your editor, shell, and tool preferences follow
|
|
10
|
+
you across \`ai sandbox destroy + create\`.
|
|
11
|
+
|
|
12
|
+
See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#user-level-dotfiles-channel
|
|
13
|
+
|
|
14
|
+
Common usage - drop files or symlinks here:
|
|
15
|
+
|
|
16
|
+
\`\`\`sh
|
|
17
|
+
# Real files
|
|
18
|
+
echo "set -g mouse on" > ~/.agent-infra/dotfiles/.tmux.conf
|
|
19
|
+
|
|
20
|
+
# Symlinks to live host paths
|
|
21
|
+
ln -s ~/.tmux.conf ~/.agent-infra/dotfiles/.tmux.conf
|
|
22
|
+
ln -s ~/.config/lazygit ~/.agent-infra/dotfiles/.config/lazygit
|
|
23
|
+
\`\`\`
|
|
24
|
+
|
|
25
|
+
> Do **not** put secrets here. Use the dedicated SSH / credential mounts.
|
|
26
|
+
|
|
27
|
+
If you delete this file, the next \`ai sandbox create\` will re-create it
|
|
28
|
+
verbatim. To stop seeing it, edit or empty the file in place - the scaffold
|
|
29
|
+
only writes \`README.md\` when it is missing, never when it already exists.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
# 用户级 dotfiles 通道
|
|
34
|
+
|
|
35
|
+
该目录被以**只读**方式挂载到每个 sandbox 容器的 \`/dotfiles\`。容器启动时,
|
|
36
|
+
\`sandbox-dotfiles-link\` 会把这里的每个文件 \`ln -sfn\` 到 \`$HOME\` 对应路径
|
|
37
|
+
(例如 \`.tmux.conf -> /home/devuser/.tmux.conf\`),覆盖镜像默认值,让你的编辑器、
|
|
38
|
+
shell、工具偏好跨 \`ai sandbox destroy + create\` 持久存在。
|
|
39
|
+
|
|
40
|
+
参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#用户级-dotfiles-通道
|
|
41
|
+
|
|
42
|
+
常见用法:把文件或符号链接放进来:
|
|
43
|
+
|
|
44
|
+
\`\`\`sh
|
|
45
|
+
# 直接放文件
|
|
46
|
+
echo "set -g mouse on" > ~/.agent-infra/dotfiles/.tmux.conf
|
|
47
|
+
|
|
48
|
+
# 用符号链接指向 host 实际文件
|
|
49
|
+
ln -s ~/.tmux.conf ~/.agent-infra/dotfiles/.tmux.conf
|
|
50
|
+
ln -s ~/.config/lazygit ~/.agent-infra/dotfiles/.config/lazygit
|
|
51
|
+
\`\`\`
|
|
52
|
+
|
|
53
|
+
> **不要**在此放任何凭证。SSH / 凭证请使用专用挂载通道。
|
|
54
|
+
|
|
55
|
+
如果你删除该文件,下一次 \`ai sandbox create\` 会原样重新生成。如果你不想再
|
|
56
|
+
看到它,**就地编辑或清空内容**即可:scaffold 仅在 \`README.md\` **缺失**时
|
|
57
|
+
写入,文件存在(哪怕被清空)就不会被重写。
|
|
58
|
+
`;
|
|
59
|
+
const SHARE_COMMON_README = `# /share/common - host <-> sandbox shared scratch (cross-branch)
|
|
60
|
+
|
|
61
|
+
This directory is mounted **read-write** into every sandbox container of this
|
|
62
|
+
project at \`/share/common\`, regardless of branch. Drop files here to share
|
|
63
|
+
between host and any sandbox without polluting the git worktree.
|
|
64
|
+
|
|
65
|
+
See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#host-sandbox-file-exchange
|
|
66
|
+
|
|
67
|
+
This file is safe to delete; the next \`ai sandbox create\` will re-create it.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
# /share/common - 宿主 <-> sandbox 共享暂存(跨分支)
|
|
72
|
+
|
|
73
|
+
该目录被以**读写**方式挂载到本项目所有 sandbox 容器的 \`/share/common\`,
|
|
74
|
+
跨分支可见。可用来在宿主和任意 sandbox 之间传文件,无需弄脏 git worktree。
|
|
75
|
+
|
|
76
|
+
参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#宿主-沙箱文件交换
|
|
77
|
+
|
|
78
|
+
该文件可以安全删除;下一次 \`ai sandbox create\` 会重新生成。
|
|
79
|
+
`;
|
|
80
|
+
const SHARE_BRANCH_README = `# /share/branch - host <-> sandbox shared scratch (branch-exclusive)
|
|
81
|
+
|
|
82
|
+
This directory is mounted **read-write** into the sandbox container of this
|
|
83
|
+
project's current branch at \`/share/branch\`. Files here are exclusive to this
|
|
84
|
+
branch's sandbox and do not leak across branches.
|
|
85
|
+
|
|
86
|
+
See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#host-sandbox-file-exchange
|
|
87
|
+
|
|
88
|
+
This file is safe to delete; the next \`ai sandbox create\` will re-create it.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
# /share/branch - 宿主 <-> sandbox 共享暂存(分支独占)
|
|
93
|
+
|
|
94
|
+
该目录被以**读写**方式挂载到本项目当前分支 sandbox 容器的 \`/share/branch\`,
|
|
95
|
+
仅当前分支可见,不会跨分支泄漏。
|
|
96
|
+
|
|
97
|
+
参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#宿主-沙箱文件交换
|
|
98
|
+
|
|
99
|
+
该文件可以安全删除;下一次 \`ai sandbox create\` 会重新生成。
|
|
100
|
+
`;
|
|
101
|
+
function errorDetail(error) {
|
|
102
|
+
return error instanceof Error ? error.message : 'unknown error';
|
|
103
|
+
}
|
|
104
|
+
function errorCode(error) {
|
|
105
|
+
return typeof error === 'object' && error !== null && 'code' in error
|
|
106
|
+
? String(error.code)
|
|
107
|
+
: '';
|
|
108
|
+
}
|
|
109
|
+
function ensureFile(target, content, options) {
|
|
110
|
+
const writeStderr = options.writeStderr ?? ((chunk) => process.stderr.write(chunk));
|
|
111
|
+
const fsModule = options.fsModule ?? fs;
|
|
112
|
+
const result = { created: false, path: target };
|
|
113
|
+
try {
|
|
114
|
+
fsModule.mkdirSync(path.dirname(target), { recursive: true });
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
writeStderr(`sandbox-readme-scaffold: skipping ${target} (${errorDetail(error)})\n`);
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
fsModule.writeFileSync(target, content, { encoding: 'utf8', flag: 'wx' });
|
|
122
|
+
result.created = true;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (errorCode(error) === 'EEXIST') {
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
writeStderr(`sandbox-readme-scaffold: skipping ${target} (${errorDetail(error)})\n`);
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
export function ensureDotfilesReadme(dotfilesDir, options = {}) {
|
|
133
|
+
return ensureFile(path.join(dotfilesDir, 'README.md'), DOTFILES_README, options);
|
|
134
|
+
}
|
|
135
|
+
export function ensureShareCommonReadme(config, options = {}) {
|
|
136
|
+
return ensureFile(path.join(shareCommonDir(config), 'README.md'), SHARE_COMMON_README, options);
|
|
137
|
+
}
|
|
138
|
+
export function ensureShareBranchReadme(config, branch, options = {}) {
|
|
139
|
+
return ensureFile(path.join(shareBranchDir(config, branch), 'README.md'), SHARE_BRANCH_README, options);
|
|
140
|
+
}
|
|
141
|
+
export function ensureSandboxDiscoveryReadmes(config, branch, options = {}) {
|
|
142
|
+
return [
|
|
143
|
+
ensureDotfilesReadme(config.dotfilesDir, options),
|
|
144
|
+
ensureShareCommonReadme(config, options),
|
|
145
|
+
ensureShareBranchReadme(config, branch, options)
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=readme-scaffold.js.map
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
FROM ubuntu:
|
|
1
|
+
FROM ubuntu:24.04
|
|
2
2
|
|
|
3
3
|
LABEL description="AI coding sandbox"
|
|
4
4
|
|
|
5
5
|
ENV DEBIAN_FRONTEND=noninteractive
|
|
6
|
-
ENV TZ=Asia/Shanghai
|
|
7
6
|
|
|
8
7
|
ARG HOST_UID=1000
|
|
9
8
|
ARG HOST_GID=1000
|
|
@@ -22,14 +21,14 @@ RUN apt-get update && apt-get install -y \
|
|
|
22
21
|
build-essential ca-certificates gnupg lsb-release \
|
|
23
22
|
libevent-core-2.1-7 libncursesw6 libtinfo6 \
|
|
24
23
|
pkg-config bison libevent-dev libncurses-dev \
|
|
25
|
-
locales \
|
|
24
|
+
locales tzdata \
|
|
26
25
|
&& locale-gen en_US.UTF-8 \
|
|
27
26
|
&& (curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
28
27
|
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg) \
|
|
29
28
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
|
30
29
|
> /etc/apt/sources.list.d/github-cli.list \
|
|
31
30
|
&& apt-get update && apt-get install -y gh \
|
|
32
|
-
&& TMUX_VERSION=3.
|
|
31
|
+
&& TMUX_VERSION=3.6b \
|
|
33
32
|
&& wget -qO /tmp/tmux.tar.gz \
|
|
34
33
|
"https://github.com/tmux/tmux/releases/download/${TMUX_VERSION}/tmux-${TMUX_VERSION}.tar.gz" \
|
|
35
34
|
&& tar xzf /tmp/tmux.tar.gz -C /tmp \
|
|
@@ -45,13 +44,13 @@ RUN apt-get update && apt-get install -y \
|
|
|
45
44
|
&& rm -rf /var/lib/apt/lists/*
|
|
46
45
|
|
|
47
46
|
# Enable extended keys in CSI u format so Shift+Enter and other modified
|
|
48
|
-
# keys are forwarded through tmux. Preserve terminal
|
|
47
|
+
# keys are forwarded through tmux. Preserve terminal/timezone variables
|
|
49
48
|
# injected at `docker exec` time when new tmux sessions are created.
|
|
50
49
|
RUN printf '%s\n' \
|
|
51
50
|
'set -g extended-keys always' \
|
|
52
51
|
'set -g extended-keys-format csi-u' \
|
|
53
52
|
"set -as terminal-features 'xterm*:extkeys'" \
|
|
54
|
-
"set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION'" \
|
|
53
|
+
"set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION TZ'" \
|
|
55
54
|
'set -g mouse on' \
|
|
56
55
|
'set -g status-interval 1' \
|
|
57
56
|
'set -g status-right-length 80' \
|
|
@@ -127,7 +126,7 @@ find . -type f -print | while IFS= read -r rel; do
|
|
|
127
126
|
.config/opencode|.config/opencode/*|\
|
|
128
127
|
.local/share/opencode|.local/share/opencode/*|\
|
|
129
128
|
.host-shell-config|.host-shell-config/*|\
|
|
130
|
-
.gitconfig|.gitignore_global|.stCommitMsg|.bash_aliases)
|
|
129
|
+
.gitconfig|.gitignore_global|.stCommitMsg|.bash_aliases|README.md)
|
|
131
130
|
continue ;;
|
|
132
131
|
esac
|
|
133
132
|
|
|
@@ -146,7 +145,7 @@ RUN cat > /usr/local/bin/sandbox-tmux-entry <<'SCRIPT' && chmod +x /usr/local/bi
|
|
|
146
145
|
#!/bin/sh
|
|
147
146
|
set -eu
|
|
148
147
|
|
|
149
|
-
sandbox-dotfiles-link >/dev/null || true
|
|
148
|
+
sandbox-dotfiles-link >/dev/null 2>&1 || true
|
|
150
149
|
|
|
151
150
|
SESSION=work
|
|
152
151
|
|
|
@@ -154,20 +153,26 @@ if ! command -v tmux >/dev/null 2>&1; then
|
|
|
154
153
|
exec bash
|
|
155
154
|
fi
|
|
156
155
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null | \
|
|
162
|
-
while read -r name attached; do
|
|
163
|
-
[ "$name" = "$SESSION" ] && continue
|
|
156
|
+
# Drop stale grouped sessions left by older entry-script versions (the windows
|
|
157
|
+
# live on $SESSION, so killing the group members only removes view entries).
|
|
158
|
+
tmux list-sessions -F '#{session_name}' 2>/dev/null | while IFS= read -r name; do
|
|
164
159
|
case "$name" in
|
|
165
|
-
|
|
160
|
+
"$SESSION"-*) tmux kill-session -t "$name" 2>/dev/null || true ;;
|
|
166
161
|
esac
|
|
167
|
-
|
|
168
|
-
|
|
162
|
+
done
|
|
163
|
+
|
|
164
|
+
# Reuse the single $SESSION; -d detaches any pre-existing client so the new
|
|
165
|
+
# one becomes the sole owner of window-size, eliminating size races.
|
|
166
|
+
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
167
|
+
# Push the per-exec TZ into the running session's env so new
|
|
168
|
+
# windows/panes pick up the host timezone without a session kill.
|
|
169
|
+
if [ -n "${TZ:-}" ]; then
|
|
170
|
+
tmux set-environment -t "$SESSION" TZ "$TZ" 2>/dev/null || true
|
|
171
|
+
fi
|
|
172
|
+
exec tmux attach -d -t "$SESSION"
|
|
173
|
+
fi
|
|
169
174
|
|
|
170
|
-
exec tmux new-session -
|
|
175
|
+
exec tmux new-session -s "$SESSION"
|
|
171
176
|
SCRIPT
|
|
172
177
|
|
|
173
178
|
ENV LANG=en_US.UTF-8
|
package/lib/cp.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
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, type ClipboardAdapter } from './sandbox/clipboard/index.ts';
|
|
8
|
+
|
|
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 COMMAND_TIMEOUT_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
export type SpawnResult = {
|
|
13
|
+
status: number | null;
|
|
14
|
+
stdout: string;
|
|
15
|
+
stderr: string;
|
|
16
|
+
error?: Error;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type SpawnFn = (cmd: string, args: string[], input?: string) => SpawnResult;
|
|
20
|
+
type CreateAdapterFn = (options?: { platformName?: NodeJS.Platform }) => ClipboardAdapter | null;
|
|
21
|
+
type MkDTempFn = (prefix: string) => string;
|
|
22
|
+
type WriteFileFn = (file: string, data: Buffer) => void;
|
|
23
|
+
type RmFn = (target: string, options: { recursive: boolean; force: boolean }) => void;
|
|
24
|
+
|
|
25
|
+
export type CpDeps = {
|
|
26
|
+
platform?: NodeJS.Platform;
|
|
27
|
+
createAdapter?: CreateAdapterFn;
|
|
28
|
+
spawnFn?: SpawnFn;
|
|
29
|
+
randomId?: () => string;
|
|
30
|
+
mkdtempFn?: MkDTempFn;
|
|
31
|
+
writeFileFn?: WriteFileFn;
|
|
32
|
+
rmFn?: RmFn;
|
|
33
|
+
tmpdir?: () => string;
|
|
34
|
+
writeStdout?: (chunk: string) => unknown;
|
|
35
|
+
writeStderr?: (chunk: string) => unknown;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function runCommand(cmd: string, args: string[], input?: string): SpawnResult {
|
|
39
|
+
const result = spawnSync(cmd, args, {
|
|
40
|
+
input,
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
43
|
+
timeout: COMMAND_TIMEOUT_MS
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
status: result.status,
|
|
48
|
+
stdout: result.stdout ?? '',
|
|
49
|
+
stderr: result.stderr ?? '',
|
|
50
|
+
error: result.error
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function cmdCp(args: string[], deps: CpDeps = {}): Promise<number> {
|
|
55
|
+
const {
|
|
56
|
+
platform = currentPlatform(),
|
|
57
|
+
createAdapter = createClipboardAdapter,
|
|
58
|
+
spawnFn = runCommand,
|
|
59
|
+
randomId = randomUUID,
|
|
60
|
+
mkdtempFn = fs.mkdtempSync,
|
|
61
|
+
writeFileFn = fs.writeFileSync,
|
|
62
|
+
rmFn = fs.rmSync,
|
|
63
|
+
tmpdir = defaultTmpdir,
|
|
64
|
+
writeStdout = (chunk: string) => process.stdout.write(chunk),
|
|
65
|
+
writeStderr = (chunk: string) => process.stderr.write(chunk)
|
|
66
|
+
} = deps;
|
|
67
|
+
|
|
68
|
+
if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
|
|
69
|
+
writeStdout(USAGE);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let positionals: string[];
|
|
74
|
+
try {
|
|
75
|
+
({ positionals } = parseArgs({ args, allowPositionals: true, strict: true }));
|
|
76
|
+
} catch {
|
|
77
|
+
writeStderr(USAGE);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const alias = positionals[0];
|
|
82
|
+
if (!alias || positionals.length !== 1) {
|
|
83
|
+
writeStderr(USAGE);
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
if (alias.startsWith('-')) {
|
|
87
|
+
writeStderr(`invalid ssh alias '${alias}': must not start with '-'\n`);
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (platform !== 'darwin') {
|
|
92
|
+
writeStderr(`ai cp currently supports macOS senders only (got ${platform})\n`);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const adapter = createAdapter({ platformName: platform });
|
|
97
|
+
const png = adapter?.readImagePng() ?? null;
|
|
98
|
+
if (png === null) {
|
|
99
|
+
writeStderr('no image on clipboard\n');
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let uploaded = false;
|
|
104
|
+
let localTmpDir: string | null = null;
|
|
105
|
+
let remotePath: string | null = null;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
localTmpDir = mkdtempFn(path.join(tmpdir(), 'agent-infra-cp-'));
|
|
109
|
+
const localPng = path.join(localTmpDir, 'clipboard.png');
|
|
110
|
+
writeFileFn(localPng, png);
|
|
111
|
+
|
|
112
|
+
remotePath = `/tmp/agent-infra-cp-${randomId()}.png`;
|
|
113
|
+
const upload = spawnFn('scp', [
|
|
114
|
+
'-o',
|
|
115
|
+
'BatchMode=yes',
|
|
116
|
+
'-o',
|
|
117
|
+
'ConnectTimeout=10',
|
|
118
|
+
localPng,
|
|
119
|
+
`${alias}:${remotePath}`
|
|
120
|
+
]);
|
|
121
|
+
if (upload.status !== 0) {
|
|
122
|
+
writeStderr(`failed to upload image to ${alias}:\n${commandDetail(upload)}\n`);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
uploaded = true;
|
|
126
|
+
|
|
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));
|
|
140
|
+
if (setRemote.status !== 0) {
|
|
141
|
+
writeStderr(`failed to set remote clipboard on ${alias}:\n${commandDetail(setRemote)}\n`);
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
writeStdout(`copied clipboard image to ${alias}\n`);
|
|
146
|
+
return 0;
|
|
147
|
+
} finally {
|
|
148
|
+
if (uploaded && remotePath) {
|
|
149
|
+
spawnFn('ssh', [
|
|
150
|
+
'-o',
|
|
151
|
+
'BatchMode=yes',
|
|
152
|
+
'-o',
|
|
153
|
+
'ConnectTimeout=10',
|
|
154
|
+
alias,
|
|
155
|
+
'rm',
|
|
156
|
+
'-f',
|
|
157
|
+
remotePath
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
if (localTmpDir) {
|
|
161
|
+
rmFn(localTmpDir, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function commandDetail(result: SpawnResult): string {
|
|
167
|
+
const detail = result.stderr || result.error?.message || result.stdout || 'unknown error';
|
|
168
|
+
return detail.trimEnd();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function remoteSetScript(remotePath: string): string {
|
|
172
|
+
const escapedPath = remotePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
173
|
+
return [
|
|
174
|
+
`set theFile to POSIX file "${escapedPath}"`,
|
|
175
|
+
'set the clipboard to (read theFile as «class PNGf»)'
|
|
176
|
+
].join('\n');
|
|
177
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
2
3
|
import { createClipboardAdapter, type ClipboardAdapter } from './index.ts';
|
|
3
4
|
import { buildBracketedPaste, CtrlVDetector, type CtrlVMatch } from './keys.ts';
|
|
4
5
|
import {
|
|
@@ -32,6 +33,26 @@ type BridgeOptions = {
|
|
|
32
33
|
const FALLBACK_PREFIX = 'Warning: clipboard image paste bridge disabled';
|
|
33
34
|
const PARTIAL_ESCAPE_FLUSH_MS = 30;
|
|
34
35
|
|
|
36
|
+
// Node's stdin.setRawMode(true) uses libuv's RAW mode, which (unlike the
|
|
37
|
+
// cfmakeraw that `docker exec -it` applies on the non-bridge path) keeps ONLCR
|
|
38
|
+
// set on the shared host TTY. With ONLCR on, the kernel rewrites the bare \n
|
|
39
|
+
// that tmux emits after homing the cursor inside the right pane into \r\n,
|
|
40
|
+
// snapping the cursor to column 1 so the following erase/redraw wipes the left
|
|
41
|
+
// pane. Clearing OPOST brings the host TTY in line with the non-bridge path.
|
|
42
|
+
// Best-effort: setRawMode(false) on teardown restores the original termios, and
|
|
43
|
+
// a missing/failed stty only reinstates the redraw glitch.
|
|
44
|
+
function disableOutputPostProcessing(stdin: NodeJS.ReadStream): void {
|
|
45
|
+
const candidate = (stdin as { fd?: unknown }).fd;
|
|
46
|
+
if (typeof candidate !== 'number') {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
spawnSync('stty', ['-opost'], { stdio: [candidate, 'ignore', 'ignore'] });
|
|
51
|
+
} catch {
|
|
52
|
+
// stty unavailable or fd is not a tty; leave the terminal as-is.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
35
56
|
export async function runInteractiveWithClipboardBridge(options: BridgeOptions): Promise<number> {
|
|
36
57
|
const {
|
|
37
58
|
engine,
|
|
@@ -56,14 +77,11 @@ export async function runInteractiveWithClipboardBridge(options: BridgeOptions):
|
|
|
56
77
|
return runInteractive(engine, 'docker', dockerArgs);
|
|
57
78
|
}
|
|
58
79
|
|
|
59
|
-
if (platformName !== 'darwin') {
|
|
60
|
-
return runInteractive(engine, 'docker', dockerArgs);
|
|
61
|
-
}
|
|
62
80
|
if (!stdin.isTTY || !stdout.isTTY) {
|
|
63
81
|
return fallback('host stdin/stdout is not a TTY');
|
|
64
82
|
}
|
|
65
83
|
if (!adapter) {
|
|
66
|
-
return fallback('
|
|
84
|
+
return fallback('no clipboard adapter available on this platform');
|
|
67
85
|
}
|
|
68
86
|
const available = adapter.available();
|
|
69
87
|
if (!available.ok) {
|
|
@@ -153,14 +171,15 @@ async function runBridge({
|
|
|
153
171
|
|
|
154
172
|
function handleCtrlV(match: CtrlVMatch, target: PtyProcess): void {
|
|
155
173
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
174
|
+
// readImagePng returns null both for "no image on clipboard" and for
|
|
175
|
+
// unexpected read failures; both cases forward the original Ctrl+V so
|
|
176
|
+
// the container app handles it as a regular keystroke. The throw branch
|
|
177
|
+
// below only fires on truly unexpected exceptions (e.g. fs write
|
|
178
|
+
// errors writing to the host clipboard dir).
|
|
161
179
|
const png = adapter.readImagePng();
|
|
162
180
|
if (!png) {
|
|
163
|
-
|
|
181
|
+
target.write(match.raw);
|
|
182
|
+
return;
|
|
164
183
|
}
|
|
165
184
|
const filename = pngClipboardFilename(png);
|
|
166
185
|
writeClipboardPngAtomic(clipboardHostDir(home), filename, png);
|
|
@@ -184,6 +203,7 @@ async function runBridge({
|
|
|
184
203
|
|
|
185
204
|
try {
|
|
186
205
|
stdin.setRawMode?.(true);
|
|
206
|
+
disableOutputPostProcessing(stdin);
|
|
187
207
|
stdin.resume();
|
|
188
208
|
stdin.on('data', onData);
|
|
189
209
|
stdout.on('resize', onResize);
|
|
@@ -4,7 +4,12 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import type { ExecFileSyncOptions } from 'node:child_process';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Quick "is osascript callable at all" probe used by available(). Not for
|
|
8
|
+
// clipboard work — clipboard work shares READ_IMAGE_TIMEOUT_MS below.
|
|
9
|
+
// Generous 2s budget: this is a once-per-session bridge enablement check;
|
|
10
|
+
// failing it just disables the clipboard bridge for that session, so we'd
|
|
11
|
+
// rather tolerate a cold osascript spawn than misreport "unavailable".
|
|
12
|
+
const OSASCRIPT_PROBE_TIMEOUT_MS = 2_000;
|
|
8
13
|
const READ_IMAGE_TIMEOUT_MS = 5_000;
|
|
9
14
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
10
15
|
|
|
@@ -12,7 +17,14 @@ type ExecFn = (cmd: string, args: string[], options?: ExecFileSyncOptions) => Bu
|
|
|
12
17
|
|
|
13
18
|
export type DarwinClipboardAdapter = {
|
|
14
19
|
available(): { ok: true } | { ok: false; reason: string };
|
|
15
|
-
|
|
20
|
+
// Returns PNG bytes when the clipboard holds (or can synthesize) an image,
|
|
21
|
+
// null otherwise. No separate hasImage(): a probing `clipboard info` call
|
|
22
|
+
// forces NSPasteboard to materialize TIFF/BMP/8BPS representations to
|
|
23
|
+
// report their sizes, which can take seconds when the clipboard holds a
|
|
24
|
+
// Retina screenshot. Letting AppleScript's `as «class PNGf»` either succeed
|
|
25
|
+
// (image present, possibly auto-converted from TIFF/JPEG/GIF) or error
|
|
26
|
+
// (nothing PNG-coercible) keeps the path O(PNG size) regardless of how
|
|
27
|
+
// many other representations the source declared.
|
|
16
28
|
readImagePng(): Buffer | null;
|
|
17
29
|
};
|
|
18
30
|
|
|
@@ -30,23 +42,12 @@ export function createDarwinClipboardAdapter({
|
|
|
30
42
|
return {
|
|
31
43
|
available() {
|
|
32
44
|
try {
|
|
33
|
-
execFn('osascript', ['-e', 'return "ok"'], { encoding: 'utf8', timeout:
|
|
45
|
+
execFn('osascript', ['-e', 'return "ok"'], { encoding: 'utf8', timeout: OSASCRIPT_PROBE_TIMEOUT_MS });
|
|
34
46
|
return { ok: true };
|
|
35
47
|
} catch {
|
|
36
48
|
return { ok: false, reason: 'macOS osascript is unavailable' };
|
|
37
49
|
}
|
|
38
50
|
},
|
|
39
|
-
hasImage() {
|
|
40
|
-
try {
|
|
41
|
-
const output = String(execFn('osascript', ['-e', 'clipboard info'], {
|
|
42
|
-
encoding: 'utf8',
|
|
43
|
-
timeout: HAS_IMAGE_TIMEOUT_MS
|
|
44
|
-
}));
|
|
45
|
-
return /\b(PNGf|TIFF|JPEG|GIFf)\b/.test(output);
|
|
46
|
-
} catch {
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
51
|
readImagePng() {
|
|
51
52
|
const tmpDir = mkdtempFn(path.join(os.tmpdir(), 'agent-infra-clipboard-'));
|
|
52
53
|
const outputPath = path.join(tmpDir, 'clipboard.png');
|
|
@@ -6,8 +6,17 @@ export type ClipboardAdapter = DarwinClipboardAdapter;
|
|
|
6
6
|
export function createClipboardAdapter({
|
|
7
7
|
platformName = platform()
|
|
8
8
|
}: { platformName?: NodeJS.Platform } = {}): ClipboardAdapter | null {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
switch (platformName) {
|
|
10
|
+
case 'darwin':
|
|
11
|
+
return createDarwinClipboardAdapter();
|
|
12
|
+
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;
|
|
16
|
+
case 'win32':
|
|
17
|
+
// Future work: native Win32 clipboard reader. Returning null disables the bridge.
|
|
18
|
+
return null;
|
|
19
|
+
default:
|
|
20
|
+
return null;
|
|
11
21
|
}
|
|
12
|
-
return createDarwinClipboardAdapter();
|
|
13
22
|
}
|
|
@@ -43,11 +43,13 @@ import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.
|
|
|
43
43
|
import { validateSelinuxDisableEnv } from '../engines/selinux.ts';
|
|
44
44
|
import { resolveBuildUid } from '../engines/native.ts';
|
|
45
45
|
import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
|
|
46
|
+
import { ensureSandboxDiscoveryReadmes } from '../readme-scaffold.ts';
|
|
46
47
|
import {
|
|
47
48
|
prepareClaudeCredentials,
|
|
48
49
|
redactCommandError,
|
|
49
50
|
validateClaudeCredentialsEnvOverride
|
|
50
51
|
} from '../credentials.ts';
|
|
52
|
+
import { detectHostTimezone } from '../host-timezone.ts';
|
|
51
53
|
|
|
52
54
|
const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
|
|
53
55
|
const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
|
|
@@ -1286,6 +1288,13 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1286
1288
|
message(`Created default sandbox aliases at ${aliasesFile.path}`);
|
|
1287
1289
|
}
|
|
1288
1290
|
|
|
1291
|
+
const readmeResults = ensureSandboxDiscoveryReadmes(effectiveConfig, branch);
|
|
1292
|
+
for (const { created, path: readmePath } of readmeResults) {
|
|
1293
|
+
if (created) {
|
|
1294
|
+
message(`Created discovery README at ${readmePath}`);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1289
1298
|
const gitconfigPath = path.join(effectiveConfig.home, '.gitconfig');
|
|
1290
1299
|
const gitconfigContent = fs.existsSync(gitconfigPath)
|
|
1291
1300
|
? fs.readFileSync(gitconfigPath, 'utf8')
|
|
@@ -1364,6 +1373,8 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1364
1373
|
const dotfilesMount = dotfilesSnapshot
|
|
1365
1374
|
? buildDotfilesVolumeArgs(engine, dotfilesSnapshot.cacheDir)
|
|
1366
1375
|
: [];
|
|
1376
|
+
const hostTz = detectHostTimezone();
|
|
1377
|
+
const tzFlags = hostTz ? ['-e', `TZ=${hostTz}`] : [];
|
|
1367
1378
|
|
|
1368
1379
|
runEngineTaskCommand(engine, 'docker', [
|
|
1369
1380
|
'run',
|
|
@@ -1398,6 +1409,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1398
1409
|
...liveMountVolumes,
|
|
1399
1410
|
...shellConfigVolumes,
|
|
1400
1411
|
...envFile.dockerArgs,
|
|
1412
|
+
...tzFlags,
|
|
1401
1413
|
'-w',
|
|
1402
1414
|
'/workspace',
|
|
1403
1415
|
effectiveConfig.imageName
|