@fitlab-ai/agent-infra 0.6.2 → 0.6.3
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 +13 -3
- package/README.zh-CN.md +10 -3
- package/bin/cli.ts +6 -1
- package/dist/bin/cli.js +6 -1
- package/dist/lib/sandbox/clipboard/bridge.js +216 -0
- package/dist/lib/sandbox/clipboard/darwin.js +73 -0
- package/dist/lib/sandbox/clipboard/index.js +9 -0
- package/dist/lib/sandbox/clipboard/keys.js +58 -0
- package/dist/lib/sandbox/clipboard/node-pty.js +13 -0
- package/dist/lib/sandbox/clipboard/paths.js +59 -0
- package/dist/lib/sandbox/commands/create.js +11 -2
- package/dist/lib/sandbox/commands/enter.js +8 -2
- package/dist/lib/sandbox/commands/ls.js +19 -4
- package/dist/lib/sandbox/commands/prune.js +176 -0
- package/dist/lib/sandbox/commands/rm.js +27 -33
- package/dist/lib/sandbox/config.js +1 -0
- package/dist/lib/sandbox/constants.js +6 -0
- package/dist/lib/sandbox/index.js +7 -1
- package/dist/lib/sandbox/managed-fs.js +25 -0
- package/dist/lib/sandbox/tools.js +1 -1
- package/dist/lib/version.js +9 -2
- package/lib/sandbox/clipboard/bridge.ts +285 -0
- package/lib/sandbox/clipboard/darwin.ts +90 -0
- package/lib/sandbox/clipboard/index.ts +13 -0
- package/lib/sandbox/clipboard/keys.ts +78 -0
- package/lib/sandbox/clipboard/node-pty.d.ts +19 -0
- package/lib/sandbox/clipboard/node-pty.ts +34 -0
- package/lib/sandbox/clipboard/paths.ts +71 -0
- package/lib/sandbox/commands/create.ts +15 -2
- package/lib/sandbox/commands/enter.ts +8 -2
- package/lib/sandbox/commands/ls.ts +28 -4
- package/lib/sandbox/commands/prune.ts +211 -0
- package/lib/sandbox/commands/rm.ts +30 -32
- package/lib/sandbox/config.ts +2 -0
- package/lib/sandbox/constants.ts +9 -0
- package/lib/sandbox/index.ts +7 -1
- package/lib/sandbox/managed-fs.ts +27 -0
- package/lib/sandbox/tools.ts +1 -1
- package/lib/version.ts +11 -4
- package/package.json +5 -1
- package/templates/.agents/README.en.md +19 -0
- package/templates/.agents/README.zh-CN.md +19 -0
- package/templates/.agents/skills/analyze-task/SKILL.en.md +26 -0
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +26 -0
- package/templates/.agents/skills/analyze-task/config/verify.en.json +51 -0
- package/templates/.agents/skills/analyze-task/config/{verify.json → verify.zh-CN.json} +6 -2
- package/templates/.agents/skills/complete-task/SKILL.en.md +15 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +15 -0
- package/templates/.agents/skills/complete-task/config/{verify.json → verify.en.json} +10 -0
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +48 -0
- package/templates/.agents/skills/implement-task/SKILL.en.md +14 -0
- package/templates/.agents/skills/implement-task/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/implement-task/config/verify.en.json +51 -0
- package/templates/.agents/skills/implement-task/config/{verify.json → verify.zh-CN.json} +7 -2
- package/templates/.agents/skills/implement-task/reference/report-template.en.md +15 -0
- package/templates/.agents/skills/implement-task/reference/report-template.zh-CN.md +15 -0
- package/templates/.agents/skills/plan-task/SKILL.en.md +22 -0
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +22 -0
- package/templates/.agents/skills/plan-task/config/verify.en.json +52 -0
- package/templates/.agents/skills/plan-task/config/{verify.json → verify.zh-CN.json} +6 -2
- package/templates/.agents/skills/post-release/SKILL.en.md +1 -0
- package/templates/.agents/skills/post-release/SKILL.zh-CN.md +1 -0
- package/templates/.agents/skills/refine-task/SKILL.en.md +14 -0
- package/templates/.agents/skills/refine-task/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/refine-task/config/verify.en.json +47 -0
- package/templates/.agents/skills/refine-task/config/{verify.json → verify.zh-CN.json} +7 -2
- package/templates/.agents/skills/refine-task/reference/report-template.en.md +15 -0
- package/templates/.agents/skills/refine-task/reference/report-template.zh-CN.md +15 -0
- package/templates/.agents/skills/review-task/SKILL.en.md +14 -0
- package/templates/.agents/skills/review-task/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/review-task/config/verify.en.json +50 -0
- package/templates/.agents/skills/review-task/config/{verify.json → verify.zh-CN.json} +5 -2
- package/templates/.agents/skills/review-task/reference/report-template.en.md +15 -0
- package/templates/.agents/skills/review-task/reference/report-template.zh-CN.md +15 -0
- package/dist/package.json +0 -5
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
import { createClipboardAdapter, type ClipboardAdapter } from './index.ts';
|
|
3
|
+
import { buildBracketedPaste, CtrlVDetector, type CtrlVMatch } from './keys.ts';
|
|
4
|
+
import {
|
|
5
|
+
clipboardHostDir,
|
|
6
|
+
containerClipboardPath,
|
|
7
|
+
pngClipboardFilename,
|
|
8
|
+
pruneClipboardDir,
|
|
9
|
+
writeClipboardPngAtomic
|
|
10
|
+
} from './paths.ts';
|
|
11
|
+
import { commandForEngine, restoreTerminal, runInteractiveEngine, runOkEngine } from '../shell.ts';
|
|
12
|
+
import { loadNodePty, type NodePty, type PtyProcess } from './node-pty.ts';
|
|
13
|
+
|
|
14
|
+
type BridgeOptions = {
|
|
15
|
+
engine: string;
|
|
16
|
+
dockerArgs: string[];
|
|
17
|
+
container: string;
|
|
18
|
+
home: string;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
env?: NodeJS.ProcessEnv;
|
|
21
|
+
platformName?: NodeJS.Platform;
|
|
22
|
+
adapter?: ClipboardAdapter | null;
|
|
23
|
+
loadPty?: () => Promise<NodePty | null>;
|
|
24
|
+
runInteractive?: typeof runInteractiveEngine;
|
|
25
|
+
runOk?: typeof runOkEngine;
|
|
26
|
+
writeStderr?: (chunk: string) => unknown;
|
|
27
|
+
stdin?: NodeJS.ReadStream;
|
|
28
|
+
stdout?: NodeJS.WriteStream;
|
|
29
|
+
createDetector?: () => CtrlVDetector;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const FALLBACK_PREFIX = 'Warning: clipboard image paste bridge disabled';
|
|
33
|
+
const PARTIAL_ESCAPE_FLUSH_MS = 30;
|
|
34
|
+
|
|
35
|
+
export async function runInteractiveWithClipboardBridge(options: BridgeOptions): Promise<number> {
|
|
36
|
+
const {
|
|
37
|
+
engine,
|
|
38
|
+
dockerArgs,
|
|
39
|
+
container,
|
|
40
|
+
home,
|
|
41
|
+
cwd = process.cwd(),
|
|
42
|
+
env = process.env,
|
|
43
|
+
platformName = process.platform,
|
|
44
|
+
adapter = createClipboardAdapter({ platformName }),
|
|
45
|
+
loadPty = loadNodePty,
|
|
46
|
+
runInteractive = runInteractiveEngine,
|
|
47
|
+
runOk = runOkEngine,
|
|
48
|
+
writeStderr = (chunk) => process.stderr.write(chunk),
|
|
49
|
+
stdin = process.stdin,
|
|
50
|
+
stdout = process.stdout,
|
|
51
|
+
createDetector = () => new CtrlVDetector()
|
|
52
|
+
} = options;
|
|
53
|
+
|
|
54
|
+
function fallback(reason: string): number {
|
|
55
|
+
writeStderr(`${FALLBACK_PREFIX}: ${reason}\n`);
|
|
56
|
+
return runInteractive(engine, 'docker', dockerArgs);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (platformName !== 'darwin') {
|
|
60
|
+
return runInteractive(engine, 'docker', dockerArgs);
|
|
61
|
+
}
|
|
62
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
63
|
+
return fallback('host stdin/stdout is not a TTY');
|
|
64
|
+
}
|
|
65
|
+
if (!adapter) {
|
|
66
|
+
return fallback('macOS clipboard adapter is unavailable');
|
|
67
|
+
}
|
|
68
|
+
const available = adapter.available();
|
|
69
|
+
if (!available.ok) {
|
|
70
|
+
return fallback(available.reason);
|
|
71
|
+
}
|
|
72
|
+
if (!runOk(engine, 'docker', ['exec', container, 'sh', '-c', '[ -d /clipboard ] && [ -r /clipboard ]'])) {
|
|
73
|
+
return fallback('container /clipboard mount is missing; rebuild the sandbox to enable image paste');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const pty = await loadPty();
|
|
77
|
+
if (!pty) {
|
|
78
|
+
return fallback('node-pty optional dependency is unavailable');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const command = commandForEngine(engine, 'docker', dockerArgs);
|
|
82
|
+
let child: PtyProcess;
|
|
83
|
+
try {
|
|
84
|
+
child = pty.spawn(command.cmd, command.args, {
|
|
85
|
+
name: env.TERM || 'xterm-256color',
|
|
86
|
+
cols: stdout.columns || 120,
|
|
87
|
+
rows: stdout.rows || 40,
|
|
88
|
+
cwd,
|
|
89
|
+
env
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const message = error instanceof Error ? error.message : 'unknown error';
|
|
93
|
+
return fallback(`node-pty spawn failed: ${message}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return runBridge({
|
|
97
|
+
child,
|
|
98
|
+
home,
|
|
99
|
+
adapter,
|
|
100
|
+
writeStderr,
|
|
101
|
+
stdin,
|
|
102
|
+
stdout,
|
|
103
|
+
detector: createDetector()
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function runBridge({
|
|
108
|
+
child,
|
|
109
|
+
home,
|
|
110
|
+
adapter,
|
|
111
|
+
writeStderr,
|
|
112
|
+
stdin,
|
|
113
|
+
stdout,
|
|
114
|
+
detector
|
|
115
|
+
}: {
|
|
116
|
+
child: PtyProcess;
|
|
117
|
+
home: string;
|
|
118
|
+
adapter: ClipboardAdapter;
|
|
119
|
+
writeStderr: (chunk: string) => unknown;
|
|
120
|
+
stdin: NodeJS.ReadStream;
|
|
121
|
+
stdout: NodeJS.WriteStream;
|
|
122
|
+
detector: CtrlVDetector;
|
|
123
|
+
}): Promise<number> {
|
|
124
|
+
let warnedPasteFailure = false;
|
|
125
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
126
|
+
const inputDecoder = new StringDecoder('utf8');
|
|
127
|
+
|
|
128
|
+
const onData = (chunk: Buffer) => {
|
|
129
|
+
clearFlushTimer();
|
|
130
|
+
for (const token of detector.feed(inputDecoder.write(chunk))) {
|
|
131
|
+
if (token.kind === 'text') {
|
|
132
|
+
child.write(token.raw);
|
|
133
|
+
} else {
|
|
134
|
+
handleCtrlV(token, child);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (detector.hasPending()) {
|
|
138
|
+
flushTimer = setTimeout(() => {
|
|
139
|
+
flushTimer = null;
|
|
140
|
+
for (const token of detector.flush()) {
|
|
141
|
+
if (token.kind === 'text') {
|
|
142
|
+
child.write(token.raw);
|
|
143
|
+
} else {
|
|
144
|
+
handleCtrlV(token, child);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}, PARTIAL_ESCAPE_FLUSH_MS);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const onResize = () => child.resize(stdout.columns || 120, stdout.rows || 40);
|
|
151
|
+
const onSigint = () => child.kill('SIGINT');
|
|
152
|
+
const onSigterm = () => child.kill('SIGTERM');
|
|
153
|
+
|
|
154
|
+
function handleCtrlV(match: CtrlVMatch, target: PtyProcess): void {
|
|
155
|
+
try {
|
|
156
|
+
if (!adapter.hasImage()) {
|
|
157
|
+
target.write(match.raw);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const png = adapter.readImagePng();
|
|
162
|
+
if (!png) {
|
|
163
|
+
throw new Error('clipboard image could not be read');
|
|
164
|
+
}
|
|
165
|
+
const filename = pngClipboardFilename(png);
|
|
166
|
+
writeClipboardPngAtomic(clipboardHostDir(home), filename, png);
|
|
167
|
+
pruneClipboardDir(clipboardHostDir(home));
|
|
168
|
+
target.write(buildBracketedPaste(containerClipboardPath(filename)));
|
|
169
|
+
} catch (error) {
|
|
170
|
+
target.write(match.raw);
|
|
171
|
+
if (!warnedPasteFailure) {
|
|
172
|
+
warnedPasteFailure = true;
|
|
173
|
+
writeStderr(`Warning: clipboard image paste failed; forwarded original Ctrl+V (${error instanceof Error ? error.message : 'unknown error'})\n`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function clearFlushTimer(): void {
|
|
179
|
+
if (flushTimer) {
|
|
180
|
+
clearTimeout(flushTimer);
|
|
181
|
+
flushTimer = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
stdin.setRawMode?.(true);
|
|
187
|
+
stdin.resume();
|
|
188
|
+
stdin.on('data', onData);
|
|
189
|
+
stdout.on('resize', onResize);
|
|
190
|
+
process.on('SIGINT', onSigint);
|
|
191
|
+
process.on('SIGTERM', onSigterm);
|
|
192
|
+
child.onData((data) => stdout.write(data));
|
|
193
|
+
|
|
194
|
+
return exitCode(await onceExit(child, stdin));
|
|
195
|
+
} finally {
|
|
196
|
+
clearFlushTimer();
|
|
197
|
+
// The child pty is already exiting here; flushing buffered input is
|
|
198
|
+
// best-effort and must never block terminal/stdin cleanup below.
|
|
199
|
+
try {
|
|
200
|
+
for (const token of detector.feed(inputDecoder.end())) {
|
|
201
|
+
if (token.kind === 'text') {
|
|
202
|
+
child.write(token.raw);
|
|
203
|
+
} else {
|
|
204
|
+
handleCtrlV(token, child);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const token of detector.flush()) {
|
|
208
|
+
if (token.kind === 'text') {
|
|
209
|
+
child.write(token.raw);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Writing to an already-closed pty can throw; ignore on teardown.
|
|
214
|
+
}
|
|
215
|
+
stdin.off('data', onData);
|
|
216
|
+
stdout.off('resize', onResize);
|
|
217
|
+
process.off('SIGINT', onSigint);
|
|
218
|
+
process.off('SIGTERM', onSigterm);
|
|
219
|
+
stdin.setRawMode?.(false);
|
|
220
|
+
// Release stdin so the resumed TTY handle stops keeping the event loop
|
|
221
|
+
// alive; without this the CLI hangs after the sandbox exits until Ctrl+C.
|
|
222
|
+
stdin.pause?.();
|
|
223
|
+
restoreTerminal();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function onceExit(
|
|
228
|
+
child: PtyProcess,
|
|
229
|
+
stdin: NodeJS.ReadStream
|
|
230
|
+
): Promise<{ exitCode: number; signal?: number | string }> {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
let settled = false;
|
|
233
|
+
const finish = (event: { exitCode: number; signal?: number | string }) => {
|
|
234
|
+
if (settled) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
settled = true;
|
|
238
|
+
stdin.off('end', onStdinEnd);
|
|
239
|
+
stdin.off('close', onStdinEnd);
|
|
240
|
+
resolve(event);
|
|
241
|
+
};
|
|
242
|
+
const onStdinEnd = () => {
|
|
243
|
+
child.kill('SIGHUP');
|
|
244
|
+
finish({ exitCode: 0, signal: 'SIGHUP' });
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
child.onExit(finish);
|
|
248
|
+
stdin.once('end', onStdinEnd);
|
|
249
|
+
stdin.once('close', onStdinEnd);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function exitCode(event: { exitCode: number; signal?: number | string }): number {
|
|
254
|
+
if (event.signal !== undefined && event.signal !== null) {
|
|
255
|
+
return signalExitCode(event.signal);
|
|
256
|
+
}
|
|
257
|
+
if (event.exitCode !== undefined && event.exitCode !== null) {
|
|
258
|
+
return event.exitCode;
|
|
259
|
+
}
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function signalExitCode(signal: number | string): number {
|
|
264
|
+
if (typeof signal === 'number') {
|
|
265
|
+
return 128 + signal;
|
|
266
|
+
}
|
|
267
|
+
const signals: Record<string, number> = {
|
|
268
|
+
SIGHUP: 1,
|
|
269
|
+
SIGINT: 2,
|
|
270
|
+
SIGQUIT: 3,
|
|
271
|
+
SIGILL: 4,
|
|
272
|
+
SIGTRAP: 5,
|
|
273
|
+
SIGABRT: 6,
|
|
274
|
+
SIGBUS: 7,
|
|
275
|
+
SIGFPE: 8,
|
|
276
|
+
SIGKILL: 9,
|
|
277
|
+
SIGUSR1: 10,
|
|
278
|
+
SIGSEGV: 11,
|
|
279
|
+
SIGUSR2: 12,
|
|
280
|
+
SIGPIPE: 13,
|
|
281
|
+
SIGALRM: 14,
|
|
282
|
+
SIGTERM: 15
|
|
283
|
+
};
|
|
284
|
+
return 128 + (signals[signal] ?? 0);
|
|
285
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
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 HAS_IMAGE_TIMEOUT_MS = 500;
|
|
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
|
+
|
|
13
|
+
export type DarwinClipboardAdapter = {
|
|
14
|
+
available(): { ok: true } | { ok: false; reason: string };
|
|
15
|
+
hasImage(): boolean;
|
|
16
|
+
readImagePng(): Buffer | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function createDarwinClipboardAdapter({
|
|
20
|
+
execFn = execFileSync,
|
|
21
|
+
mkdtempFn = fs.mkdtempSync,
|
|
22
|
+
readFileFn = fs.readFileSync,
|
|
23
|
+
rmFn = fs.rmSync
|
|
24
|
+
}: {
|
|
25
|
+
execFn?: ExecFn;
|
|
26
|
+
mkdtempFn?: typeof fs.mkdtempSync;
|
|
27
|
+
readFileFn?: typeof fs.readFileSync;
|
|
28
|
+
rmFn?: typeof fs.rmSync;
|
|
29
|
+
} = {}): DarwinClipboardAdapter {
|
|
30
|
+
return {
|
|
31
|
+
available() {
|
|
32
|
+
try {
|
|
33
|
+
execFn('osascript', ['-e', 'return "ok"'], { encoding: 'utf8', timeout: HAS_IMAGE_TIMEOUT_MS });
|
|
34
|
+
return { ok: true };
|
|
35
|
+
} catch {
|
|
36
|
+
return { ok: false, reason: 'macOS osascript is unavailable' };
|
|
37
|
+
}
|
|
38
|
+
},
|
|
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
|
+
readImagePng() {
|
|
51
|
+
const tmpDir = mkdtempFn(path.join(os.tmpdir(), 'agent-infra-clipboard-'));
|
|
52
|
+
const outputPath = path.join(tmpDir, 'clipboard.png');
|
|
53
|
+
try {
|
|
54
|
+
try {
|
|
55
|
+
execFn('osascript', ['-e', pngWriteScript(outputPath)], {
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
timeout: READ_IMAGE_TIMEOUT_MS
|
|
58
|
+
});
|
|
59
|
+
} catch {
|
|
60
|
+
execFn('pngpaste', [outputPath], {
|
|
61
|
+
encoding: 'utf8',
|
|
62
|
+
timeout: READ_IMAGE_TIMEOUT_MS
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const png = Buffer.from(readFileFn(outputPath));
|
|
66
|
+
return isPng(png) ? png : null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
} finally {
|
|
70
|
+
rmFn(tmpDir, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isPng(buffer: Buffer): boolean {
|
|
77
|
+
return buffer.length >= PNG_MAGIC.length && PNG_MAGIC.every((byte, index) => buffer[index] === byte);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pngWriteScript(outputPath: string): string {
|
|
81
|
+
const escapedPath = outputPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
82
|
+
return [
|
|
83
|
+
'set pngData to the clipboard as «class PNGf»',
|
|
84
|
+
`set outFile to POSIX file "${escapedPath}"`,
|
|
85
|
+
'set fileRef to open for access outFile with write permission',
|
|
86
|
+
'set eof fileRef to 0',
|
|
87
|
+
'write pngData to fileRef',
|
|
88
|
+
'close access fileRef'
|
|
89
|
+
].join('\n');
|
|
90
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { platform } from 'node:os';
|
|
2
|
+
import { createDarwinClipboardAdapter, type DarwinClipboardAdapter } from './darwin.ts';
|
|
3
|
+
|
|
4
|
+
export type ClipboardAdapter = DarwinClipboardAdapter;
|
|
5
|
+
|
|
6
|
+
export function createClipboardAdapter({
|
|
7
|
+
platformName = platform()
|
|
8
|
+
}: { platformName?: NodeJS.Platform } = {}): ClipboardAdapter | null {
|
|
9
|
+
if (platformName !== 'darwin') {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return createDarwinClipboardAdapter();
|
|
13
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export type CtrlVMatch = {
|
|
2
|
+
kind: 'ctrl-v';
|
|
3
|
+
raw: string;
|
|
4
|
+
label: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type KeyToken =
|
|
8
|
+
| { kind: 'text'; raw: string }
|
|
9
|
+
| CtrlVMatch;
|
|
10
|
+
|
|
11
|
+
const CTRL_V_SEQUENCES = [
|
|
12
|
+
{ raw: '\x16', label: 'ctrl-v 0x16' },
|
|
13
|
+
{ raw: '\x1b[118;5u', label: 'ctrl-v csi-u ESC[118;5u' },
|
|
14
|
+
{ raw: '\x1b[27;5;118~', label: 'ctrl-v modifyOtherKeys ESC[27;5;118~' }
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export class CtrlVDetector {
|
|
18
|
+
#pending = '';
|
|
19
|
+
|
|
20
|
+
hasPending(): boolean {
|
|
21
|
+
return this.#pending.length > 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
feed(raw: string): KeyToken[] {
|
|
25
|
+
let input = this.#pending + raw;
|
|
26
|
+
this.#pending = '';
|
|
27
|
+
const tokens: KeyToken[] = [];
|
|
28
|
+
|
|
29
|
+
while (input.length > 0) {
|
|
30
|
+
const match = CTRL_V_SEQUENCES.find((sequence) => input.startsWith(sequence.raw));
|
|
31
|
+
if (match) {
|
|
32
|
+
tokens.push({ kind: 'ctrl-v', raw: match.raw, label: match.label });
|
|
33
|
+
input = input.slice(match.raw.length);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const partial = CTRL_V_SEQUENCES.some((sequence) =>
|
|
38
|
+
sequence.raw.startsWith(input) && input.length < sequence.raw.length
|
|
39
|
+
);
|
|
40
|
+
if (partial) {
|
|
41
|
+
this.#pending = input;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const first = input.slice(0, 1);
|
|
46
|
+
tokens.push({ kind: 'text', raw: first });
|
|
47
|
+
input = input.slice(first.length);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return coalesceText(tokens);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
flush(): KeyToken[] {
|
|
54
|
+
if (!this.#pending) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const raw = this.#pending;
|
|
58
|
+
this.#pending = '';
|
|
59
|
+
return [{ kind: 'text', raw }];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function coalesceText(tokens: KeyToken[]): KeyToken[] {
|
|
64
|
+
const result: KeyToken[] = [];
|
|
65
|
+
for (const token of tokens) {
|
|
66
|
+
const previous = result.at(-1);
|
|
67
|
+
if (token.kind === 'text' && previous?.kind === 'text') {
|
|
68
|
+
previous.raw += token.raw;
|
|
69
|
+
} else {
|
|
70
|
+
result.push(token);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function buildBracketedPaste(text: string): string {
|
|
77
|
+
return `\x1b[200~${text}\x1b[201~`;
|
|
78
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
declare module '@lydell/node-pty' {
|
|
2
|
+
export function spawn(
|
|
3
|
+
file: string,
|
|
4
|
+
args: string[],
|
|
5
|
+
options: {
|
|
6
|
+
name: string;
|
|
7
|
+
cols: number;
|
|
8
|
+
rows: number;
|
|
9
|
+
cwd: string;
|
|
10
|
+
env: NodeJS.ProcessEnv;
|
|
11
|
+
}
|
|
12
|
+
): {
|
|
13
|
+
onData(callback: (data: string) => void): void;
|
|
14
|
+
onExit(callback: (event: { exitCode: number; signal?: number | string }) => void): void;
|
|
15
|
+
write(data: string): void;
|
|
16
|
+
resize(cols: number, rows: number): void;
|
|
17
|
+
kill(signal?: string): void;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
export type PtyProcess = {
|
|
4
|
+
onData(callback: (data: string) => void): void;
|
|
5
|
+
onExit(callback: (event: { exitCode: number; signal?: number | string }) => void): void;
|
|
6
|
+
write(data: string): void;
|
|
7
|
+
resize(cols: number, rows: number): void;
|
|
8
|
+
kill(signal?: string): void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type NodePty = {
|
|
12
|
+
spawn(
|
|
13
|
+
file: string,
|
|
14
|
+
args: string[],
|
|
15
|
+
options: {
|
|
16
|
+
name: string;
|
|
17
|
+
cols: number;
|
|
18
|
+
rows: number;
|
|
19
|
+
cwd: string;
|
|
20
|
+
env: NodeJS.ProcessEnv;
|
|
21
|
+
}
|
|
22
|
+
): PtyProcess;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function loadNodePty(): Promise<NodePty | null> {
|
|
26
|
+
try {
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const mod = require('@lydell/node-pty') as NodePty | { default?: NodePty };
|
|
29
|
+
const maybeDefault = mod as { default?: NodePty };
|
|
30
|
+
return maybeDefault.default ?? (mod as NodePty);
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { hostJoin } from '../engines/wsl2-paths.ts';
|
|
5
|
+
|
|
6
|
+
export const CONTAINER_CLIPBOARD_MOUNT = '/clipboard';
|
|
7
|
+
const DEFAULT_KEEP = 20;
|
|
8
|
+
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
export function clipboardHostDir(home: string): string {
|
|
11
|
+
return hostJoin(home, '.agent-infra', 'clipboard');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function containerClipboardPath(filename: string): string {
|
|
15
|
+
return path.posix.join(CONTAINER_CLIPBOARD_MOUNT, filename);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function pngClipboardFilename(buffer: Buffer): string {
|
|
19
|
+
return `${crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 16)}.png`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writeClipboardPngAtomic(dir: string, filename: string, buffer: Buffer): string {
|
|
23
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
24
|
+
try {
|
|
25
|
+
fs.chmodSync(dir, 0o700);
|
|
26
|
+
} catch {
|
|
27
|
+
// Best effort: existing directories may live on filesystems that ignore chmod.
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const target = path.join(dir, filename);
|
|
31
|
+
const tmp = path.join(dir, `.${filename}.${process.pid}.tmp`);
|
|
32
|
+
fs.writeFileSync(tmp, buffer);
|
|
33
|
+
fs.renameSync(tmp, target);
|
|
34
|
+
return target;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function pruneClipboardDir(
|
|
38
|
+
dir: string,
|
|
39
|
+
{ keep = DEFAULT_KEEP, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Date.now() }:
|
|
40
|
+
{ keep?: number; maxAgeMs?: number; now?: number } = {}
|
|
41
|
+
): string[] {
|
|
42
|
+
if (!fs.existsSync(dir)) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const entries = fs.readdirSync(dir)
|
|
47
|
+
.filter((name) => name.endsWith('.png'))
|
|
48
|
+
.map((name) => {
|
|
49
|
+
const fullPath = path.join(dir, name);
|
|
50
|
+
const stat = fs.statSync(fullPath);
|
|
51
|
+
return { fullPath, mtimeMs: stat.mtimeMs };
|
|
52
|
+
})
|
|
53
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
54
|
+
|
|
55
|
+
const keepSet = new Set(entries.slice(0, keep).map((entry) => entry.fullPath));
|
|
56
|
+
const removed: string[] = [];
|
|
57
|
+
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (keepSet.has(entry.fullPath) && now - entry.mtimeMs <= maxAgeMs) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
fs.rmSync(entry.fullPath, { force: true });
|
|
64
|
+
removed.push(entry.fullPath);
|
|
65
|
+
} catch {
|
|
66
|
+
// Cleanup is opportunistic; a failed prune should not break paste.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return removed;
|
|
71
|
+
}
|
|
@@ -17,9 +17,9 @@ import {
|
|
|
17
17
|
sandboxBranchLabel,
|
|
18
18
|
sandboxImageConfigLabel,
|
|
19
19
|
sandboxLabel,
|
|
20
|
-
sanitizeBranchName,
|
|
21
20
|
shareBranchDir,
|
|
22
21
|
shareCommonDir,
|
|
22
|
+
shellConfigDir,
|
|
23
23
|
worktreeDirCandidates
|
|
24
24
|
} from '../constants.ts';
|
|
25
25
|
import { prepareDockerfile } from '../dockerfile.ts';
|
|
@@ -39,6 +39,7 @@ import { resolveTaskBranch } from '../task-resolver.ts';
|
|
|
39
39
|
import { resolveTools, toolConfigDirCandidates, toolNpmPackagesArg } from '../tools.ts';
|
|
40
40
|
import type { SandboxTool } from '../tools.ts';
|
|
41
41
|
import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.ts';
|
|
42
|
+
import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.ts';
|
|
42
43
|
import { validateSelinuxDisableEnv } from '../engines/selinux.ts';
|
|
43
44
|
import { resolveBuildUid } from '../engines/native.ts';
|
|
44
45
|
import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
|
|
@@ -127,7 +128,17 @@ function resolveToolDirs(config: Pick<SandboxCreateConfig, 'project'>, tools: Sa
|
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
export function hostShellConfigDir(home: string, project: string, branch: string): string {
|
|
130
|
-
return
|
|
131
|
+
return shellConfigDir(
|
|
132
|
+
{ shellConfigBase: hostJoin(home, '.agent-infra', 'config', project) },
|
|
133
|
+
branch
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function buildClipboardVolumeArgs(engine: string, home: string): string[] {
|
|
138
|
+
return [
|
|
139
|
+
'-v',
|
|
140
|
+
volumeArg(engine, clipboardHostDir(home), CONTAINER_CLIPBOARD_MOUNT, ':ro')
|
|
141
|
+
];
|
|
131
142
|
}
|
|
132
143
|
|
|
133
144
|
function runtimeChecks(runtimes: string[]): RuntimeCheck[] {
|
|
@@ -1344,6 +1355,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1344
1355
|
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
1345
1356
|
fs.mkdirSync(shareCommon, { recursive: true });
|
|
1346
1357
|
fs.mkdirSync(shareBranch, { recursive: true });
|
|
1358
|
+
fs.mkdirSync(clipboardHostDir(effectiveConfig.home), { recursive: true, mode: 0o700 });
|
|
1347
1359
|
|
|
1348
1360
|
const dotfilesSnapshot = materializeDotfiles(
|
|
1349
1361
|
effectiveConfig.dotfilesDir,
|
|
@@ -1372,6 +1384,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1372
1384
|
volumeArg(engine, shareCommon, '/share/common'),
|
|
1373
1385
|
'-v',
|
|
1374
1386
|
volumeArg(engine, shareBranch, '/share/branch'),
|
|
1387
|
+
...buildClipboardVolumeArgs(engine, effectiveConfig.home),
|
|
1375
1388
|
'-v',
|
|
1376
1389
|
volumeArg(
|
|
1377
1390
|
engine,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { runInteractiveEngine, runSafeEngine } from '../shell.ts';
|
|
12
12
|
import { resolveTaskBranch } from '../task-resolver.ts';
|
|
13
13
|
import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
|
|
14
|
+
import { runInteractiveWithClipboardBridge } from '../clipboard/bridge.ts';
|
|
14
15
|
|
|
15
16
|
const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
|
|
16
17
|
const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
|
|
@@ -65,7 +66,7 @@ export function formatCredentialSyncStatus(
|
|
|
65
66
|
return null;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
export function enter(args: string[]): number {
|
|
69
|
+
export async function enter(args: string[]): Promise<number> {
|
|
69
70
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
70
71
|
process.stdout.write(`${USAGE}\n`);
|
|
71
72
|
if (args.length === 0) {
|
|
@@ -108,7 +109,12 @@ export function enter(args: string[]): number {
|
|
|
108
109
|
process.stderr.write(`Warning: dotfiles snapshot rebuild failed: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
return
|
|
112
|
+
return runInteractiveWithClipboardBridge({
|
|
113
|
+
engine,
|
|
114
|
+
dockerArgs: ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH],
|
|
115
|
+
container,
|
|
116
|
+
home: config.home
|
|
117
|
+
});
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, ...cmd]);
|