@fitlab-ai/agent-infra 0.6.2 → 0.6.4
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 +218 -0
- package/dist/lib/sandbox/clipboard/darwin.js +66 -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 +15 -2
- package/dist/lib/sandbox/commands/enter.js +14 -3
- 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/host-timezone.js +33 -0
- package/dist/lib/sandbox/index.js +7 -1
- package/dist/lib/sandbox/managed-fs.js +25 -0
- package/dist/lib/sandbox/runtimes/base.dockerfile +21 -16
- package/dist/lib/sandbox/tools.js +1 -1
- package/dist/lib/version.js +9 -2
- package/lib/sandbox/clipboard/bridge.ts +286 -0
- package/lib/sandbox/clipboard/darwin.ts +91 -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 +19 -2
- package/lib/sandbox/commands/enter.ts +15 -3
- 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/host-timezone.ts +42 -0
- package/lib/sandbox/index.ts +7 -1
- package/lib/sandbox/managed-fs.ts +27 -0
- package/lib/sandbox/runtimes/base.dockerfile +21 -16
- package/lib/sandbox/tools.ts +1 -1
- package/lib/version.ts +11 -4
- package/package.json +10 -6
- package/templates/.agents/README.en.md +19 -0
- package/templates/.agents/README.zh-CN.md +19 -0
- 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/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/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/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/import-issue/SKILL.en.md +1 -1
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
- 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,286 @@
|
|
|
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
|
+
// readImagePng returns null both for "no image on clipboard" and for
|
|
157
|
+
// unexpected read failures; both cases forward the original Ctrl+V so
|
|
158
|
+
// the container app handles it as a regular keystroke. The throw branch
|
|
159
|
+
// below only fires on truly unexpected exceptions (e.g. fs write
|
|
160
|
+
// errors writing to the host clipboard dir).
|
|
161
|
+
const png = adapter.readImagePng();
|
|
162
|
+
if (!png) {
|
|
163
|
+
target.write(match.raw);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const filename = pngClipboardFilename(png);
|
|
167
|
+
writeClipboardPngAtomic(clipboardHostDir(home), filename, png);
|
|
168
|
+
pruneClipboardDir(clipboardHostDir(home));
|
|
169
|
+
target.write(buildBracketedPaste(containerClipboardPath(filename)));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
target.write(match.raw);
|
|
172
|
+
if (!warnedPasteFailure) {
|
|
173
|
+
warnedPasteFailure = true;
|
|
174
|
+
writeStderr(`Warning: clipboard image paste failed; forwarded original Ctrl+V (${error instanceof Error ? error.message : 'unknown error'})\n`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function clearFlushTimer(): void {
|
|
180
|
+
if (flushTimer) {
|
|
181
|
+
clearTimeout(flushTimer);
|
|
182
|
+
flushTimer = null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
stdin.setRawMode?.(true);
|
|
188
|
+
stdin.resume();
|
|
189
|
+
stdin.on('data', onData);
|
|
190
|
+
stdout.on('resize', onResize);
|
|
191
|
+
process.on('SIGINT', onSigint);
|
|
192
|
+
process.on('SIGTERM', onSigterm);
|
|
193
|
+
child.onData((data) => stdout.write(data));
|
|
194
|
+
|
|
195
|
+
return exitCode(await onceExit(child, stdin));
|
|
196
|
+
} finally {
|
|
197
|
+
clearFlushTimer();
|
|
198
|
+
// The child pty is already exiting here; flushing buffered input is
|
|
199
|
+
// best-effort and must never block terminal/stdin cleanup below.
|
|
200
|
+
try {
|
|
201
|
+
for (const token of detector.feed(inputDecoder.end())) {
|
|
202
|
+
if (token.kind === 'text') {
|
|
203
|
+
child.write(token.raw);
|
|
204
|
+
} else {
|
|
205
|
+
handleCtrlV(token, child);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const token of detector.flush()) {
|
|
209
|
+
if (token.kind === 'text') {
|
|
210
|
+
child.write(token.raw);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// Writing to an already-closed pty can throw; ignore on teardown.
|
|
215
|
+
}
|
|
216
|
+
stdin.off('data', onData);
|
|
217
|
+
stdout.off('resize', onResize);
|
|
218
|
+
process.off('SIGINT', onSigint);
|
|
219
|
+
process.off('SIGTERM', onSigterm);
|
|
220
|
+
stdin.setRawMode?.(false);
|
|
221
|
+
// Release stdin so the resumed TTY handle stops keeping the event loop
|
|
222
|
+
// alive; without this the CLI hangs after the sandbox exits until Ctrl+C.
|
|
223
|
+
stdin.pause?.();
|
|
224
|
+
restoreTerminal();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function onceExit(
|
|
229
|
+
child: PtyProcess,
|
|
230
|
+
stdin: NodeJS.ReadStream
|
|
231
|
+
): Promise<{ exitCode: number; signal?: number | string }> {
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
let settled = false;
|
|
234
|
+
const finish = (event: { exitCode: number; signal?: number | string }) => {
|
|
235
|
+
if (settled) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
settled = true;
|
|
239
|
+
stdin.off('end', onStdinEnd);
|
|
240
|
+
stdin.off('close', onStdinEnd);
|
|
241
|
+
resolve(event);
|
|
242
|
+
};
|
|
243
|
+
const onStdinEnd = () => {
|
|
244
|
+
child.kill('SIGHUP');
|
|
245
|
+
finish({ exitCode: 0, signal: 'SIGHUP' });
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
child.onExit(finish);
|
|
249
|
+
stdin.once('end', onStdinEnd);
|
|
250
|
+
stdin.once('close', onStdinEnd);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function exitCode(event: { exitCode: number; signal?: number | string }): number {
|
|
255
|
+
if (event.signal !== undefined && event.signal !== null) {
|
|
256
|
+
return signalExitCode(event.signal);
|
|
257
|
+
}
|
|
258
|
+
if (event.exitCode !== undefined && event.exitCode !== null) {
|
|
259
|
+
return event.exitCode;
|
|
260
|
+
}
|
|
261
|
+
return 1;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function signalExitCode(signal: number | string): number {
|
|
265
|
+
if (typeof signal === 'number') {
|
|
266
|
+
return 128 + signal;
|
|
267
|
+
}
|
|
268
|
+
const signals: Record<string, number> = {
|
|
269
|
+
SIGHUP: 1,
|
|
270
|
+
SIGINT: 2,
|
|
271
|
+
SIGQUIT: 3,
|
|
272
|
+
SIGILL: 4,
|
|
273
|
+
SIGTRAP: 5,
|
|
274
|
+
SIGABRT: 6,
|
|
275
|
+
SIGBUS: 7,
|
|
276
|
+
SIGFPE: 8,
|
|
277
|
+
SIGKILL: 9,
|
|
278
|
+
SIGUSR1: 10,
|
|
279
|
+
SIGSEGV: 11,
|
|
280
|
+
SIGUSR2: 12,
|
|
281
|
+
SIGPIPE: 13,
|
|
282
|
+
SIGALRM: 14,
|
|
283
|
+
SIGTERM: 15
|
|
284
|
+
};
|
|
285
|
+
return 128 + (signals[signal] ?? 0);
|
|
286
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
// 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;
|
|
13
|
+
const READ_IMAGE_TIMEOUT_MS = 5_000;
|
|
14
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
15
|
+
|
|
16
|
+
type ExecFn = (cmd: string, args: string[], options?: ExecFileSyncOptions) => Buffer | string;
|
|
17
|
+
|
|
18
|
+
export type DarwinClipboardAdapter = {
|
|
19
|
+
available(): { ok: true } | { ok: false; reason: string };
|
|
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.
|
|
28
|
+
readImagePng(): Buffer | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function createDarwinClipboardAdapter({
|
|
32
|
+
execFn = execFileSync,
|
|
33
|
+
mkdtempFn = fs.mkdtempSync,
|
|
34
|
+
readFileFn = fs.readFileSync,
|
|
35
|
+
rmFn = fs.rmSync
|
|
36
|
+
}: {
|
|
37
|
+
execFn?: ExecFn;
|
|
38
|
+
mkdtempFn?: typeof fs.mkdtempSync;
|
|
39
|
+
readFileFn?: typeof fs.readFileSync;
|
|
40
|
+
rmFn?: typeof fs.rmSync;
|
|
41
|
+
} = {}): DarwinClipboardAdapter {
|
|
42
|
+
return {
|
|
43
|
+
available() {
|
|
44
|
+
try {
|
|
45
|
+
execFn('osascript', ['-e', 'return "ok"'], { encoding: 'utf8', timeout: OSASCRIPT_PROBE_TIMEOUT_MS });
|
|
46
|
+
return { ok: true };
|
|
47
|
+
} catch {
|
|
48
|
+
return { ok: false, reason: 'macOS osascript is unavailable' };
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
readImagePng() {
|
|
52
|
+
const tmpDir = mkdtempFn(path.join(os.tmpdir(), 'agent-infra-clipboard-'));
|
|
53
|
+
const outputPath = path.join(tmpDir, 'clipboard.png');
|
|
54
|
+
try {
|
|
55
|
+
try {
|
|
56
|
+
execFn('osascript', ['-e', pngWriteScript(outputPath)], {
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
timeout: READ_IMAGE_TIMEOUT_MS
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
execFn('pngpaste', [outputPath], {
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
timeout: READ_IMAGE_TIMEOUT_MS
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const png = Buffer.from(readFileFn(outputPath));
|
|
67
|
+
return isPng(png) ? png : null;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
} finally {
|
|
71
|
+
rmFn(tmpDir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isPng(buffer: Buffer): boolean {
|
|
78
|
+
return buffer.length >= PNG_MAGIC.length && PNG_MAGIC.every((byte, index) => buffer[index] === byte);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function pngWriteScript(outputPath: string): string {
|
|
82
|
+
const escapedPath = outputPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
83
|
+
return [
|
|
84
|
+
'set pngData to the clipboard as «class PNGf»',
|
|
85
|
+
`set outFile to POSIX file "${escapedPath}"`,
|
|
86
|
+
'set fileRef to open for access outFile with write permission',
|
|
87
|
+
'set eof fileRef to 0',
|
|
88
|
+
'write pngData to fileRef',
|
|
89
|
+
'close access fileRef'
|
|
90
|
+
].join('\n');
|
|
91
|
+
}
|
|
@@ -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';
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
redactCommandError,
|
|
48
49
|
validateClaudeCredentialsEnvOverride
|
|
49
50
|
} from '../credentials.ts';
|
|
51
|
+
import { detectHostTimezone } from '../host-timezone.ts';
|
|
50
52
|
|
|
51
53
|
const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
|
|
52
54
|
const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
|
|
@@ -127,7 +129,17 @@ function resolveToolDirs(config: Pick<SandboxCreateConfig, 'project'>, tools: Sa
|
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
export function hostShellConfigDir(home: string, project: string, branch: string): string {
|
|
130
|
-
return
|
|
132
|
+
return shellConfigDir(
|
|
133
|
+
{ shellConfigBase: hostJoin(home, '.agent-infra', 'config', project) },
|
|
134
|
+
branch
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function buildClipboardVolumeArgs(engine: string, home: string): string[] {
|
|
139
|
+
return [
|
|
140
|
+
'-v',
|
|
141
|
+
volumeArg(engine, clipboardHostDir(home), CONTAINER_CLIPBOARD_MOUNT, ':ro')
|
|
142
|
+
];
|
|
131
143
|
}
|
|
132
144
|
|
|
133
145
|
function runtimeChecks(runtimes: string[]): RuntimeCheck[] {
|
|
@@ -1344,6 +1356,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1344
1356
|
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
1345
1357
|
fs.mkdirSync(shareCommon, { recursive: true });
|
|
1346
1358
|
fs.mkdirSync(shareBranch, { recursive: true });
|
|
1359
|
+
fs.mkdirSync(clipboardHostDir(effectiveConfig.home), { recursive: true, mode: 0o700 });
|
|
1347
1360
|
|
|
1348
1361
|
const dotfilesSnapshot = materializeDotfiles(
|
|
1349
1362
|
effectiveConfig.dotfilesDir,
|
|
@@ -1352,6 +1365,8 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1352
1365
|
const dotfilesMount = dotfilesSnapshot
|
|
1353
1366
|
? buildDotfilesVolumeArgs(engine, dotfilesSnapshot.cacheDir)
|
|
1354
1367
|
: [];
|
|
1368
|
+
const hostTz = detectHostTimezone();
|
|
1369
|
+
const tzFlags = hostTz ? ['-e', `TZ=${hostTz}`] : [];
|
|
1355
1370
|
|
|
1356
1371
|
runEngineTaskCommand(engine, 'docker', [
|
|
1357
1372
|
'run',
|
|
@@ -1372,6 +1387,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1372
1387
|
volumeArg(engine, shareCommon, '/share/common'),
|
|
1373
1388
|
'-v',
|
|
1374
1389
|
volumeArg(engine, shareBranch, '/share/branch'),
|
|
1390
|
+
...buildClipboardVolumeArgs(engine, effectiveConfig.home),
|
|
1375
1391
|
'-v',
|
|
1376
1392
|
volumeArg(
|
|
1377
1393
|
engine,
|
|
@@ -1385,6 +1401,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1385
1401
|
...liveMountVolumes,
|
|
1386
1402
|
...shellConfigVolumes,
|
|
1387
1403
|
...envFile.dockerArgs,
|
|
1404
|
+
...tzFlags,
|
|
1388
1405
|
'-w',
|
|
1389
1406
|
'/workspace',
|
|
1390
1407
|
effectiveConfig.imageName
|