@epoch-agent/plugin-terminal 0.1.0
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/LICENSE +219 -0
- package/README.md +291 -0
- package/dist/index.d.ts +347 -0
- package/dist/index.js +1002 -0
- package/package.json +41 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { isAbsolute, resolve } from "path";
|
|
3
|
+
import {
|
|
4
|
+
checkDangerousCommand,
|
|
5
|
+
isInWorkspace,
|
|
6
|
+
sandboxModeForLevel
|
|
7
|
+
} from "@epoch-agent/infra";
|
|
8
|
+
|
|
9
|
+
// src/background.ts
|
|
10
|
+
import {
|
|
11
|
+
artifactsDir,
|
|
12
|
+
killTrackedProcess,
|
|
13
|
+
openArtifact,
|
|
14
|
+
shellSpawnArgs,
|
|
15
|
+
startLongLivedProcess
|
|
16
|
+
} from "@epoch-agent/infra";
|
|
17
|
+
|
|
18
|
+
// src/sandbox.ts
|
|
19
|
+
import {
|
|
20
|
+
classifyFailure,
|
|
21
|
+
confine
|
|
22
|
+
} from "@epoch-agent/infra";
|
|
23
|
+
function unconfined(skipped) {
|
|
24
|
+
return { mode: null, backend: "none", enforcement: null, skipped, failure: null };
|
|
25
|
+
}
|
|
26
|
+
function confineShell(sh, policy, cwd) {
|
|
27
|
+
const bare = (outcome2) => ({
|
|
28
|
+
...sh,
|
|
29
|
+
outcome: outcome2,
|
|
30
|
+
classify: () => outcome2
|
|
31
|
+
});
|
|
32
|
+
if (!policy) {
|
|
33
|
+
return bare(unconfined("not-requested"));
|
|
34
|
+
}
|
|
35
|
+
const result = confine(sh.file, sh.args, { ...policy, ...cwd ? { cwd } : {} });
|
|
36
|
+
if (!result.confined) {
|
|
37
|
+
return bare({
|
|
38
|
+
mode: result.mode,
|
|
39
|
+
backend: result.backend,
|
|
40
|
+
enforcement: null,
|
|
41
|
+
skipped: result.reason,
|
|
42
|
+
failure: null
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const outcome = {
|
|
46
|
+
mode: result.mode,
|
|
47
|
+
backend: result.backend,
|
|
48
|
+
enforcement: result.enforcement,
|
|
49
|
+
skipped: null,
|
|
50
|
+
failure: null
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
file: result.command,
|
|
54
|
+
args: result.args,
|
|
55
|
+
outcome,
|
|
56
|
+
classify: (exitCode, stderr) => ({
|
|
57
|
+
...outcome,
|
|
58
|
+
failure: classifyFailure({
|
|
59
|
+
exitCode,
|
|
60
|
+
stderr,
|
|
61
|
+
denialSignatures: result.denialSignatures,
|
|
62
|
+
runnerFailureRules: result.runnerFailureRules
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/background.ts
|
|
69
|
+
var MAX_TASKS = 8;
|
|
70
|
+
var MAX_TASK_OUTPUT = 256 * 1024;
|
|
71
|
+
var MAX_OUTPUT_CHUNK = 8 * 1024;
|
|
72
|
+
var bySession = /* @__PURE__ */ new Map();
|
|
73
|
+
var counter = 0;
|
|
74
|
+
function nextId() {
|
|
75
|
+
counter += 1;
|
|
76
|
+
return `t${counter}`;
|
|
77
|
+
}
|
|
78
|
+
function bucketOf(sessionId) {
|
|
79
|
+
const existing = bySession.get(sessionId);
|
|
80
|
+
if (existing) return existing;
|
|
81
|
+
const fresh = /* @__PURE__ */ new Map();
|
|
82
|
+
bySession.set(sessionId, fresh);
|
|
83
|
+
return fresh;
|
|
84
|
+
}
|
|
85
|
+
function taskIn(sessionId, id) {
|
|
86
|
+
return bySession.get(sessionId)?.get(id);
|
|
87
|
+
}
|
|
88
|
+
function startTask(sessionId, command, cwd, homeDir, sandbox) {
|
|
89
|
+
const bucket = bucketOf(sessionId);
|
|
90
|
+
const running = [...bucket.values()].filter((t) => t.status === "running");
|
|
91
|
+
if (running.length >= MAX_TASKS) {
|
|
92
|
+
const list = running.map((t) => `${t.id} (${t.command})`).join("\u3001");
|
|
93
|
+
return {
|
|
94
|
+
error: `\u540E\u53F0\u4EFB\u52A1\u5DF2\u8FBE\u4E0A\u9650 ${MAX_TASKS} \u4E2A\uFF0C\u5148\u7528 task_stop \u505C\u6389\u4E00\u4E2A\u3002\u5728\u8DD1\u7684\uFF1A${list}`
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const id = nextId();
|
|
98
|
+
let finish;
|
|
99
|
+
const done2 = new Promise((resolve2) => {
|
|
100
|
+
finish = resolve2;
|
|
101
|
+
});
|
|
102
|
+
const raw = shellSpawnArgs(command);
|
|
103
|
+
const sh = confineShell({ file: raw.file, args: raw.args }, sandbox, cwd);
|
|
104
|
+
const task = {
|
|
105
|
+
id,
|
|
106
|
+
command,
|
|
107
|
+
...cwd ? { cwd } : {},
|
|
108
|
+
pid: 0,
|
|
109
|
+
status: "running",
|
|
110
|
+
startedAt: Date.now(),
|
|
111
|
+
buffer: "",
|
|
112
|
+
totalBytes: 0,
|
|
113
|
+
droppedBytes: 0,
|
|
114
|
+
artifactsDir: artifactsDir(sessionId, homeDir),
|
|
115
|
+
sandbox: sh.outcome,
|
|
116
|
+
done: done2,
|
|
117
|
+
finish
|
|
118
|
+
};
|
|
119
|
+
bucket.set(id, task);
|
|
120
|
+
const started = startLongLivedProcess({
|
|
121
|
+
file: sh.file,
|
|
122
|
+
args: sh.args,
|
|
123
|
+
...cwd ? { cwd } : {},
|
|
124
|
+
...raw.options ? { spawnOptions: raw.options } : {},
|
|
125
|
+
onOutput: (chunk) => append(task, chunk),
|
|
126
|
+
onExit: (code) => {
|
|
127
|
+
if (task.status === "running") {
|
|
128
|
+
task.status = code === null ? "failed" : "exited";
|
|
129
|
+
if (code !== null) task.exitCode = code;
|
|
130
|
+
}
|
|
131
|
+
task.endedAt = Date.now();
|
|
132
|
+
if (task.status === "exited" && code !== null && code !== 0) {
|
|
133
|
+
task.sandbox = sh.classify(code, task.buffer);
|
|
134
|
+
}
|
|
135
|
+
task.spill?.close();
|
|
136
|
+
task.finish();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
task.pid = started.pid;
|
|
140
|
+
return { task: toInfo(task), sandbox: sh.outcome };
|
|
141
|
+
}
|
|
142
|
+
function taskSandbox(sessionId, id) {
|
|
143
|
+
return taskIn(sessionId, id)?.sandbox;
|
|
144
|
+
}
|
|
145
|
+
function append(task, chunk) {
|
|
146
|
+
task.totalBytes += chunk.length;
|
|
147
|
+
task.buffer += chunk;
|
|
148
|
+
if (task.buffer.length <= MAX_TASK_OUTPUT) return;
|
|
149
|
+
if (task.spill === void 0) {
|
|
150
|
+
task.spill = openArtifact(task.artifactsDir, `task-${task.id}`, ".txt") ?? null;
|
|
151
|
+
task.spill?.append(task.buffer);
|
|
152
|
+
} else {
|
|
153
|
+
task.spill?.append(chunk);
|
|
154
|
+
}
|
|
155
|
+
const drop = task.buffer.length - MAX_TASK_OUTPUT;
|
|
156
|
+
task.buffer = task.buffer.slice(drop);
|
|
157
|
+
task.droppedBytes += drop;
|
|
158
|
+
}
|
|
159
|
+
function listTasks(sessionId) {
|
|
160
|
+
const bucket = bySession.get(sessionId);
|
|
161
|
+
return bucket ? [...bucket.values()].map(toInfo) : [];
|
|
162
|
+
}
|
|
163
|
+
function taskOutput(sessionId, id, since = 0) {
|
|
164
|
+
const task = taskIn(sessionId, id);
|
|
165
|
+
if (!task) return void 0;
|
|
166
|
+
const from = Math.max(since, task.droppedBytes);
|
|
167
|
+
const missed = Math.max(0, task.droppedBytes - since);
|
|
168
|
+
const slice = task.buffer.slice(from - task.droppedBytes);
|
|
169
|
+
const output = slice.slice(0, MAX_OUTPUT_CHUNK);
|
|
170
|
+
return {
|
|
171
|
+
info: toInfo(task),
|
|
172
|
+
output,
|
|
173
|
+
nextCursor: from + output.length,
|
|
174
|
+
missed,
|
|
175
|
+
hasMore: output.length < slice.length
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async function stopTask(sessionId, id) {
|
|
179
|
+
const task = taskIn(sessionId, id);
|
|
180
|
+
if (!task || task.status !== "running") return false;
|
|
181
|
+
task.status = "killed";
|
|
182
|
+
task.endedAt = Date.now();
|
|
183
|
+
await killTrackedProcess(task.pid);
|
|
184
|
+
task.spill?.close();
|
|
185
|
+
task.finish();
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
async function waitTask(sessionId, id, timeoutMs) {
|
|
189
|
+
const task = taskIn(sessionId, id);
|
|
190
|
+
if (!task) return void 0;
|
|
191
|
+
if (task.status !== "running") return { info: toInfo(task), timedOut: false };
|
|
192
|
+
let timer;
|
|
193
|
+
const timeout = new Promise((resolve2) => {
|
|
194
|
+
timer = setTimeout(() => resolve2("timeout"), timeoutMs);
|
|
195
|
+
timer.unref?.();
|
|
196
|
+
});
|
|
197
|
+
try {
|
|
198
|
+
const winner = await Promise.race([task.done.then(() => "done"), timeout]);
|
|
199
|
+
return { info: toInfo(task), timedOut: winner === "timeout" };
|
|
200
|
+
} finally {
|
|
201
|
+
if (timer) clearTimeout(timer);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function rekeyTasks(from, to) {
|
|
205
|
+
if (from === to) return;
|
|
206
|
+
const moving = bySession.get(from);
|
|
207
|
+
if (!moving) return;
|
|
208
|
+
bySession.delete(from);
|
|
209
|
+
const target = bySession.get(to);
|
|
210
|
+
if (!target) {
|
|
211
|
+
bySession.set(to, moving);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (const [id, task] of moving) target.set(id, task);
|
|
215
|
+
}
|
|
216
|
+
function clearAllTasks() {
|
|
217
|
+
for (const bucket of bySession.values()) {
|
|
218
|
+
for (const task of bucket.values()) task.spill?.close();
|
|
219
|
+
}
|
|
220
|
+
bySession.clear();
|
|
221
|
+
counter = 0;
|
|
222
|
+
}
|
|
223
|
+
function toInfo(task) {
|
|
224
|
+
return {
|
|
225
|
+
id: task.id,
|
|
226
|
+
command: task.command,
|
|
227
|
+
...task.cwd ? { cwd: task.cwd } : {},
|
|
228
|
+
pid: task.pid,
|
|
229
|
+
status: task.status,
|
|
230
|
+
...task.exitCode === void 0 ? {} : { exitCode: task.exitCode },
|
|
231
|
+
startedAt: task.startedAt,
|
|
232
|
+
...task.endedAt === void 0 ? {} : { endedAt: task.endedAt },
|
|
233
|
+
outputBytes: task.totalBytes,
|
|
234
|
+
truncated: task.droppedBytes > 0,
|
|
235
|
+
...task.spill ? { artifact: task.spill.path } : {}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function describeTask(info) {
|
|
239
|
+
const dur = Math.round(((info.endedAt ?? Date.now()) - info.startedAt) / 1e3);
|
|
240
|
+
const state = info.status === "running" ? `\u8FD0\u884C\u4E2D ${dur}s` : info.status === "exited" ? `\u5DF2\u7ED3\u675F\uFF0C\u9000\u51FA\u7801 ${info.exitCode}` : info.status === "killed" ? "\u5DF2\u505C\u6B62" : "\u542F\u52A8\u5931\u8D25";
|
|
241
|
+
const trunc = info.truncated ? "\uFF0C\u5F00\u5934\u5DF2\u6EDA\u51FA\u7F13\u51B2" : "";
|
|
242
|
+
return `${info.id} ${state}\uFF08${info.outputBytes} \u5B57\u8282\u8F93\u51FA${trunc}\uFF09 ${info.command}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/exec.ts
|
|
246
|
+
import { spawn } from "child_process";
|
|
247
|
+
import {
|
|
248
|
+
createStreamDecoder,
|
|
249
|
+
killAllTrackedProcesses,
|
|
250
|
+
killProcessTree as killProcessTree2,
|
|
251
|
+
shellSpawnArgs as shellSpawnArgs2
|
|
252
|
+
} from "@epoch-agent/infra";
|
|
253
|
+
|
|
254
|
+
// src/limits.ts
|
|
255
|
+
var MAX_CAPTURE_BYTES = 10 * 1024 * 1024;
|
|
256
|
+
var OVER_CAPTURE_NOTICE = `
|
|
257
|
+
[\u8F93\u51FA\u8D85\u8FC7 ${MAX_CAPTURE_BYTES / 1024 / 1024} MB\uFF0C\u5DF2\u622A\u65AD\u5E76\u7EC8\u6B62\u8FDB\u7A0B\uFF1A\u8FD9\u4E0D\u662F\u4E00\u6B21\u6B63\u5E38\u7684\u547D\u4EE4\u8F93\u51FA\uFF08\u591A\u534A\u662F cat \u4E86\u4E8C\u8FDB\u5236\u6587\u4EF6\u6216\u8FDB\u4E86\u6B7B\u5FAA\u73AF\uFF09\u3002\u8BF7\u7F29\u5C0F\u8F93\u51FA\u8303\u56F4\u91CD\u8BD5\uFF0C\u6BD4\u5982\u52A0 head / tail / grep\u3002]`;
|
|
258
|
+
var NO_OUTPUT = "(\u65E0\u8F93\u51FA)";
|
|
259
|
+
|
|
260
|
+
// src/pty.ts
|
|
261
|
+
import { spawn as spawnPty } from "node-pty";
|
|
262
|
+
import { IS_WINDOWS, killProcessTree, shellPtyArgs } from "@epoch-agent/infra";
|
|
263
|
+
|
|
264
|
+
// src/pty-helper.ts
|
|
265
|
+
import { accessSync, chmodSync, constants, existsSync } from "fs";
|
|
266
|
+
import { createRequire } from "module";
|
|
267
|
+
import { arch, platform } from "os";
|
|
268
|
+
import { dirname, join } from "path";
|
|
269
|
+
function helperCandidates() {
|
|
270
|
+
let packageDir;
|
|
271
|
+
try {
|
|
272
|
+
packageDir = dirname(dirname(createRequire(import.meta.url).resolve("node-pty")));
|
|
273
|
+
} catch {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
return [
|
|
277
|
+
join(packageDir, "build", "Release", "spawn-helper"),
|
|
278
|
+
join(packageDir, "prebuilds", `${platform()}-${arch()}`, "spawn-helper")
|
|
279
|
+
];
|
|
280
|
+
}
|
|
281
|
+
var failure;
|
|
282
|
+
var done = false;
|
|
283
|
+
function ensurePtyHelperExecutable() {
|
|
284
|
+
if (done || platform() === "win32") return;
|
|
285
|
+
done = true;
|
|
286
|
+
for (const candidate of helperCandidates()) {
|
|
287
|
+
if (!existsSync(candidate)) continue;
|
|
288
|
+
try {
|
|
289
|
+
accessSync(candidate, constants.X_OK);
|
|
290
|
+
} catch {
|
|
291
|
+
try {
|
|
292
|
+
chmodSync(candidate, 493);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
failure = `${candidate}: ${err instanceof Error ? err.message : String(err)}`;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function ptyHelperHint() {
|
|
300
|
+
if (failure === void 0) return "";
|
|
301
|
+
return `
|
|
302
|
+
node-pty \u7684 spawn-helper \u6CA1\u6709\u6267\u884C\u6743\u9650\uFF0C\u4E14\u81EA\u52A8\u4FEE\u590D\u5931\u8D25\uFF08${failure}\uFF09\u3002
|
|
303
|
+
\u624B\u52A8\u4FEE\uFF1Achmod +x <\u4E0A\u9762\u8FD9\u4E2A\u8DEF\u5F84>`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/pty.ts
|
|
307
|
+
var PTY_IS_GROUP_LEADER = !IS_WINDOWS;
|
|
308
|
+
var ptySessions = /* @__PURE__ */ new Map();
|
|
309
|
+
function killPtyTree(pty) {
|
|
310
|
+
void killProcessTree({ pid: pty.pid, detached: PTY_IS_GROUP_LEADER, escalate: true, pty });
|
|
311
|
+
}
|
|
312
|
+
function confinePty(raw, policy, cwd) {
|
|
313
|
+
if (!Array.isArray(raw.args)) {
|
|
314
|
+
const outcome = unconfined(policy ? "no-backend" : "not-requested");
|
|
315
|
+
return { ...raw, outcome, classify: () => outcome };
|
|
316
|
+
}
|
|
317
|
+
const sh = confineShell({ file: raw.file, args: raw.args }, policy, cwd);
|
|
318
|
+
return { file: sh.file, args: sh.args, outcome: sh.outcome, classify: sh.classify };
|
|
319
|
+
}
|
|
320
|
+
function runCommandPty(command, options) {
|
|
321
|
+
return new Promise((resolve2) => {
|
|
322
|
+
const previous = ptySessions.get(options.tag);
|
|
323
|
+
if (previous) {
|
|
324
|
+
killPtyTree(previous);
|
|
325
|
+
ptySessions.delete(options.tag);
|
|
326
|
+
}
|
|
327
|
+
const raw = shellPtyArgs(command);
|
|
328
|
+
const sh = confinePty(raw, options.sandbox, options.cwd);
|
|
329
|
+
let pty;
|
|
330
|
+
try {
|
|
331
|
+
ensurePtyHelperExecutable();
|
|
332
|
+
pty = spawnPty(sh.file, sh.args, {
|
|
333
|
+
name: "xterm-256color",
|
|
334
|
+
cwd: options.cwd ?? process.cwd(),
|
|
335
|
+
env: { ...process.env, TERM: "xterm-256color", FORCE_COLOR: "1" },
|
|
336
|
+
cols: 120,
|
|
337
|
+
rows: 40
|
|
338
|
+
});
|
|
339
|
+
} catch (err) {
|
|
340
|
+
resolve2({
|
|
341
|
+
stdout: `PTY \u542F\u52A8\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}${ptyHelperHint()}`,
|
|
342
|
+
exitCode: 1,
|
|
343
|
+
sandbox: sh.outcome
|
|
344
|
+
});
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
ptySessions.set(options.tag, pty);
|
|
348
|
+
let output = "";
|
|
349
|
+
let truncated = false;
|
|
350
|
+
let settled = false;
|
|
351
|
+
const releaseSlot = () => {
|
|
352
|
+
if (ptySessions.get(options.tag) === pty) ptySessions.delete(options.tag);
|
|
353
|
+
};
|
|
354
|
+
const finish = (result) => {
|
|
355
|
+
if (settled) return;
|
|
356
|
+
settled = true;
|
|
357
|
+
clearTimeout(timer);
|
|
358
|
+
clearInterval(abortCheck);
|
|
359
|
+
releaseSlot();
|
|
360
|
+
resolve2(result);
|
|
361
|
+
};
|
|
362
|
+
const timer = setTimeout(() => {
|
|
363
|
+
killPtyTree(pty);
|
|
364
|
+
finish({ stdout: output.trimEnd() || "(\u8D85\u65F6\uFF0C\u65E0\u8F93\u51FA)", exitCode: -1, sandbox: sh.outcome });
|
|
365
|
+
}, options.timeout);
|
|
366
|
+
const abortCheck = setInterval(() => {
|
|
367
|
+
if (!options.signal?.aborted) return;
|
|
368
|
+
killPtyTree(pty);
|
|
369
|
+
finish({ stdout: output.trimEnd(), exitCode: -1, aborted: true, sandbox: sh.outcome });
|
|
370
|
+
}, 100);
|
|
371
|
+
if (options.stdin) {
|
|
372
|
+
setTimeout(() => {
|
|
373
|
+
try {
|
|
374
|
+
pty.write((options.stdin ?? "") + "\n");
|
|
375
|
+
} catch {
|
|
376
|
+
}
|
|
377
|
+
}, 200);
|
|
378
|
+
}
|
|
379
|
+
pty.onData((data) => {
|
|
380
|
+
if (truncated) return;
|
|
381
|
+
output += data;
|
|
382
|
+
options.onOutput?.(data);
|
|
383
|
+
if (output.length > MAX_CAPTURE_BYTES) {
|
|
384
|
+
truncated = true;
|
|
385
|
+
output = output.slice(0, MAX_CAPTURE_BYTES) + OVER_CAPTURE_NOTICE;
|
|
386
|
+
killPtyTree(pty);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
pty.onExit(({ exitCode }) => {
|
|
390
|
+
finish({
|
|
391
|
+
stdout: output.trimEnd() || NO_OUTPUT,
|
|
392
|
+
exitCode,
|
|
393
|
+
// PTY 只有一股流,**没有独立的 stderr** —— 而拒绝方言和 runner
|
|
394
|
+
// 致命行都是 runner 往 stderr 打的。这里把整股流当 stderr 喂进去是
|
|
395
|
+
// 对的:那两组正则都锚在行首的后端自称上(`sandbox-exec:`),
|
|
396
|
+
// 混进来的 stdout 不会把它们撞出假阳性
|
|
397
|
+
sandbox: sh.classify(exitCode, output)
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
function killAllPtySessions() {
|
|
403
|
+
const pending = [];
|
|
404
|
+
for (const [tag, pty] of ptySessions) {
|
|
405
|
+
pending.push(
|
|
406
|
+
killProcessTree({ pid: pty.pid, detached: PTY_IS_GROUP_LEADER, escalate: true, pty })
|
|
407
|
+
);
|
|
408
|
+
ptySessions.delete(tag);
|
|
409
|
+
}
|
|
410
|
+
return pending;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/exec.ts
|
|
414
|
+
var FORCE_SETTLE_MS = 3e3;
|
|
415
|
+
function killChildTree(child) {
|
|
416
|
+
void killProcessTree2({
|
|
417
|
+
pid: child.pid ?? 0,
|
|
418
|
+
detached: false,
|
|
419
|
+
escalate: true,
|
|
420
|
+
isExited: () => child.exitCode !== null || child.signalCode !== null
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
function collectStreams(child, onOverflow, onOutput) {
|
|
424
|
+
let stdout = "";
|
|
425
|
+
let stderr = "";
|
|
426
|
+
let truncated = false;
|
|
427
|
+
const outDecoder = createStreamDecoder();
|
|
428
|
+
const errDecoder = createStreamDecoder();
|
|
429
|
+
child.stdout?.on("data", (chunk) => {
|
|
430
|
+
if (truncated) return;
|
|
431
|
+
const piece = outDecoder.write(chunk);
|
|
432
|
+
if (piece) {
|
|
433
|
+
stdout += piece;
|
|
434
|
+
onOutput?.(piece);
|
|
435
|
+
}
|
|
436
|
+
if (stdout.length > MAX_CAPTURE_BYTES) {
|
|
437
|
+
truncated = true;
|
|
438
|
+
stdout = stdout.slice(0, MAX_CAPTURE_BYTES);
|
|
439
|
+
onOverflow();
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
child.stderr?.on("data", (chunk) => {
|
|
443
|
+
if (stderr.length >= MAX_CAPTURE_BYTES) return;
|
|
444
|
+
const piece = errDecoder.write(chunk);
|
|
445
|
+
if (!piece) return;
|
|
446
|
+
stderr += piece;
|
|
447
|
+
onOutput?.(piece);
|
|
448
|
+
});
|
|
449
|
+
return () => {
|
|
450
|
+
if (!truncated) stdout += outDecoder.end();
|
|
451
|
+
if (stderr.length < MAX_CAPTURE_BYTES) stderr += errDecoder.end();
|
|
452
|
+
const tail = truncated ? OVER_CAPTURE_NOTICE : "";
|
|
453
|
+
return { stdout: (stdout + tail).trimEnd(), stderr: stderr.trimEnd() };
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function runCommand(command, options) {
|
|
457
|
+
return new Promise((resolve2, reject) => {
|
|
458
|
+
const raw = shellSpawnArgs2(command);
|
|
459
|
+
const sh = confineShell({ file: raw.file, args: raw.args }, options.sandbox, options.cwd);
|
|
460
|
+
const child = spawn(sh.file, sh.args, {
|
|
461
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
462
|
+
cwd: options.cwd,
|
|
463
|
+
...raw.options
|
|
464
|
+
});
|
|
465
|
+
let killedBy = null;
|
|
466
|
+
let settled = false;
|
|
467
|
+
let forceTimer;
|
|
468
|
+
const drain = collectStreams(child, () => kill("overflow"), options.onOutput);
|
|
469
|
+
const timer = setTimeout(() => kill("timeout"), options.timeout);
|
|
470
|
+
const abortCheck = setInterval(() => {
|
|
471
|
+
if (options.signal?.aborted) kill("abort");
|
|
472
|
+
}, 100);
|
|
473
|
+
function clearTimers() {
|
|
474
|
+
clearTimeout(timer);
|
|
475
|
+
clearTimeout(forceTimer);
|
|
476
|
+
clearInterval(abortCheck);
|
|
477
|
+
}
|
|
478
|
+
function finish() {
|
|
479
|
+
if (settled) return;
|
|
480
|
+
settled = true;
|
|
481
|
+
clearTimers();
|
|
482
|
+
const { stdout, stderr } = drain();
|
|
483
|
+
const exitCode = child.exitCode ?? -1;
|
|
484
|
+
resolve2({
|
|
485
|
+
stdout,
|
|
486
|
+
stderr,
|
|
487
|
+
exitCode,
|
|
488
|
+
...killedBy === "timeout" ? { timedOut: true } : {},
|
|
489
|
+
...killedBy === "abort" ? { aborted: true } : {},
|
|
490
|
+
// 分类只在**我们没有主动杀它**的时候做:超时 / 中断的非零退出是我们
|
|
491
|
+
// 自己造成的,往分类器里送只会得到一句「命令自己失败了」的废话,
|
|
492
|
+
// 而调用方那边已经有 `timedOut` / `aborted` 两个更准确的字段
|
|
493
|
+
sandbox: killedBy ? sh.outcome : sh.classify(exitCode, stderr)
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
function kill(why) {
|
|
497
|
+
if (killedBy) return;
|
|
498
|
+
killedBy = why;
|
|
499
|
+
killChildTree(child);
|
|
500
|
+
forceTimer = setTimeout(finish, FORCE_SETTLE_MS);
|
|
501
|
+
}
|
|
502
|
+
if (options.signal?.aborted) kill("abort");
|
|
503
|
+
child.on("close", finish);
|
|
504
|
+
child.on("error", (err) => {
|
|
505
|
+
if (settled) return;
|
|
506
|
+
settled = true;
|
|
507
|
+
clearTimers();
|
|
508
|
+
reject(err);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
async function cleanupBackgroundProcesses() {
|
|
513
|
+
await Promise.all([killAllTrackedProcesses(), ...killAllPtySessions()]);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// src/sandbox-notice.ts
|
|
517
|
+
var MODE_BOUNDARY = {
|
|
518
|
+
"read-only": "\u53EA\u8BFB\uFF1A\u5199\u4EFB\u4F55\u6587\u4EF6\u90FD\u4F1A\u88AB OS \u6321\u4E0B",
|
|
519
|
+
"workspace-write": "\u5199\u5165\u9650\u4E8E\u5DE5\u4F5C\u533A\u3001\u4E34\u65F6\u76EE\u5F55\u548C\u5DE5\u5177\u94FE\u7F13\u5B58\uFF1B\u8BFB\u53D6\u548C\u7F51\u7EDC\u4E0D\u53D7\u9650",
|
|
520
|
+
"danger-full-access": "\u4E0D\u8BBE\u9650"
|
|
521
|
+
};
|
|
522
|
+
function sandboxNotice(sb) {
|
|
523
|
+
if (sb.skipped === "not-requested") return "";
|
|
524
|
+
if (sb.skipped === "mode-disabled") {
|
|
525
|
+
return "[\u6C99\u7BB1: \u5173] \u5F53\u524D\u6743\u9650\u6863\u4F4D\u662F\u300C\u5141\u8BB8\u4E00\u5207\u300D\uFF0C\u672C\u6B21\u6CA1\u6709 OS \u7EA7\u9694\u79BB\u3002\u5371\u9669\u547D\u4EE4\u8868\u4ECD\u7136\u5728\u5C97\u3002";
|
|
526
|
+
}
|
|
527
|
+
if (sb.skipped === "no-backend") {
|
|
528
|
+
return `[\u6C99\u7BB1: \u65E0] \u672C\u6B21\u6CA1\u6709 OS \u7EA7\u9694\u79BB\u2014\u2014\u8FD9\u4E2A\u5E73\u53F0\uFF08${sb.backend === "none" ? process.platform : sb.backend}\uFF09\u4E0A\u63A2\u6D4B\u4E0D\u5230\u53EF\u7528\u540E\u7AEF\uFF0C\u547D\u4EE4\u80FD\u8BFB\u5199\u6574\u4E2A\u6587\u4EF6\u7CFB\u7EDF\u3002`;
|
|
529
|
+
}
|
|
530
|
+
const boundary = MODE_BOUNDARY[sb.mode ?? ""] ?? "";
|
|
531
|
+
if (sb.enforcement === "partial") {
|
|
532
|
+
return `[\u6C99\u7BB1: ${sb.backend} \xB7 ${sb.mode} \xB7 \u5F3A\u5236\u4E0D\u5B8C\u6574] ${boundary}\u3002\u4E0D\u5B8C\u6574\u4E4B\u5904\uFF1APOSIX shell \u5FC5\u987B\u7559\u7740 /dev/null \u8FD9\u4E2A\u5199\u5165\u53E3\uFF0C\u5426\u5219\u5B83\u81EA\u5DF1\u90FD\u8D77\u4E0D\u6765\u3002`;
|
|
533
|
+
}
|
|
534
|
+
return `[\u6C99\u7BB1: ${sb.backend} \xB7 ${sb.mode}] ${boundary}\u3002`;
|
|
535
|
+
}
|
|
536
|
+
function sandboxError(sb) {
|
|
537
|
+
const failure2 = sb.failure;
|
|
538
|
+
if (!failure2) return null;
|
|
539
|
+
if (failure2.kind === "runner-failed") {
|
|
540
|
+
return {
|
|
541
|
+
message: `\u6C99\u7BB1\u542F\u52A8\u5931\u8D25\uFF0C\u547D\u4EE4\u6CA1\u6709\u6267\u884C\uFF1A${failure2.evidence ?? "(\u6C99\u7BB1\u672A\u7ED9\u51FA\u539F\u56E0)"}`,
|
|
542
|
+
suggestion: "\u8FD9\u4E0D\u662F\u547D\u4EE4\u672C\u8EAB\u7684\u95EE\u9898\uFF0C\u522B\u6539\u547D\u4EE4\u3002\u628A\u8FD9\u6761\u539F\u6587\u62A5\u7ED9\u7528\u6237\uFF0C\u6216\u6362\u5230\u300C\u5141\u8BB8\u4E00\u5207\u300D\u6863\u4F4D\u7ED5\u5F00\u6C99\u7BB1\u3002"
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
if (failure2.kind === "sandbox-denied") {
|
|
546
|
+
return {
|
|
547
|
+
message: `\u547D\u4EE4\u88AB\u6C99\u7BB1\u6321\u4E0B\uFF1A${failure2.evidence ?? "(\u6C99\u7BB1\u672A\u7ED9\u51FA\u539F\u56E0)"}`,
|
|
548
|
+
suggestion: sb.mode === "read-only" ? "\u5F53\u524D\u662F\u53EA\u8BFB\u6863\u4F4D\uFF0C\u4EFB\u4F55\u5199\u5165\u90FD\u4F1A\u88AB\u6321\u3002\u8981\u771F\u7684\u6539\u4E1C\u897F\uFF0C\u5148\u8BF7\u7528\u6237\u5207\u6362\u6743\u9650\u6863\u4F4D\u3002" : "\u6C99\u7BB1\u53EA\u653E\u5F00\u5DE5\u4F5C\u533A\u3001\u4E34\u65F6\u76EE\u5F55\u548C\u5DE5\u5177\u94FE\u7F13\u5B58\u3002\u8981\u5199\u5230\u522B\u5904\uFF0C\u5148\u8BF7\u7528\u6237\u786E\u8BA4\u3002"
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// src/spill.ts
|
|
555
|
+
import { artifactsDir as artifactsDir2, headCodePoints, tailCodePoints, writeArtifact } from "@epoch-agent/infra";
|
|
556
|
+
var PREVIEW_HEAD = 2e3;
|
|
557
|
+
var PREVIEW_TAIL = 2e3;
|
|
558
|
+
var SPILL_THRESHOLD = 5e4;
|
|
559
|
+
function formatBytes(text) {
|
|
560
|
+
return `${(Buffer.byteLength(text, "utf-8") / 1024).toFixed(1)} KB`;
|
|
561
|
+
}
|
|
562
|
+
function retrievalHint(locator) {
|
|
563
|
+
return `
|
|
564
|
+
|
|
565
|
+
\u5B8C\u6574\u8F93\u51FA\u5DF2\u4FDD\u5B58\uFF1A${locator}
|
|
566
|
+
\u53D6\u56DE\u65B9\u5F0F\uFF1A\u7528 file_search \u5728\u8FD9\u4E2A\u8DEF\u5F84\u91CC\u641C\u4F60\u8981\u7684\u5185\u5BB9\uFF08\u6BD4\u5982\u62A5\u9519\u5173\u952E\u5B57\uFF09\uFF0C\u6216\u7528 file_read \u5206\u9875\u8BFB\u3002`;
|
|
567
|
+
}
|
|
568
|
+
var NOT_STORED = "\n\n\uFF08\u8FD9\u6B21\u6CA1\u80FD\u4FDD\u5B58\u5B8C\u6574\u8F93\u51FA\uFF0C\u88AB\u7701\u7565\u7684\u90E8\u5206\u5DF2\u7ECF\u6CA1\u6709\u4E86\uFF09";
|
|
569
|
+
function spillOutput(input) {
|
|
570
|
+
const { full, sessionId, homeDir, label, mode } = input;
|
|
571
|
+
if (full.length <= SPILL_THRESHOLD) return { text: full };
|
|
572
|
+
const locator = sessionId ? writeArtifact(artifactsDir2(sessionId, homeDir), label, ".txt", full, "unique") : void 0;
|
|
573
|
+
const head = mode === "tail" ? "" : headCodePoints(full, PREVIEW_HEAD);
|
|
574
|
+
const tail = tailCodePoints(full, PREVIEW_TAIL);
|
|
575
|
+
const skipped = full.slice(head.length, full.length - tail.length);
|
|
576
|
+
if (skipped.length === 0) return { text: full, ...locator ? { locator } : {} };
|
|
577
|
+
const body = `[\u8F93\u51FA ${formatBytes(full)}\uFF0C\u4E0B\u9762\u53EA\u662F\u9884\u89C8]
|
|
578
|
+
${head ? `${head}
|
|
579
|
+
` : ""}\u2026[\u7701\u7565 ${formatBytes(skipped)}]\u2026
|
|
580
|
+
${tail}`;
|
|
581
|
+
return {
|
|
582
|
+
text: body + (locator ? retrievalHint(locator) : NOT_STORED),
|
|
583
|
+
...locator ? { locator } : {}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
function describeMissed(missed, locator) {
|
|
587
|
+
const where = locator ? retrievalHint(locator) : NOT_STORED;
|
|
588
|
+
return `\uFF08\u5F00\u5934 ${missed} \u5B57\u8282\u5DF2\u7ECF\u6EDA\u51FA\u73AF\u5F62\u7F13\u51B2\uFF09${where}`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/tasks.ts
|
|
592
|
+
var DEFAULT_WAIT_MS = 3e4;
|
|
593
|
+
var MAX_WAIT_MS = 3e5;
|
|
594
|
+
function ok(output) {
|
|
595
|
+
return { success: true, output, duration: 0 };
|
|
596
|
+
}
|
|
597
|
+
function notFound(id) {
|
|
598
|
+
return {
|
|
599
|
+
success: false,
|
|
600
|
+
output: "",
|
|
601
|
+
error: {
|
|
602
|
+
code: 2101,
|
|
603
|
+
message: `\u6CA1\u6709\u7F16\u53F7\u4E3A ${id} \u7684\u540E\u53F0\u4EFB\u52A1`,
|
|
604
|
+
suggestion: "\u7528 task_list \u770B\u5F53\u524D\u6709\u54EA\u4E9B"
|
|
605
|
+
},
|
|
606
|
+
duration: 0
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
var taskList = {
|
|
610
|
+
name: "task_list",
|
|
611
|
+
description: "\u5217\u51FA\u672C\u6B21\u4F1A\u8BDD\u8D77\u8FC7\u7684\u540E\u53F0\u4EFB\u52A1\uFF08terminal \u7684 background: true \u8D77\u7684\uFF09\uFF0C\u542B\u72B6\u6001\u3001\u9000\u51FA\u7801\u3001\u8F93\u51FA\u5B57\u8282\u6570\u3002",
|
|
612
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
613
|
+
annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: false },
|
|
614
|
+
operation: "file_read",
|
|
615
|
+
execute: (_args, ctx) => {
|
|
616
|
+
const tasks = listTasks(ctx.sessionId);
|
|
617
|
+
if (tasks.length === 0) {
|
|
618
|
+
return Promise.resolve(ok("\u6CA1\u6709\u540E\u53F0\u4EFB\u52A1\u3002\u7528 terminal \u7684 background: true \u8D77\u4E00\u4E2A\u3002"));
|
|
619
|
+
}
|
|
620
|
+
return Promise.resolve(
|
|
621
|
+
ok([`${tasks.length} \u4E2A\u540E\u53F0\u4EFB\u52A1`, ...tasks.map(describeTask)].join("\n"))
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
var taskOutputTool = {
|
|
626
|
+
name: "task_output",
|
|
627
|
+
description: `\u53D6\u4E00\u4E2A\u540E\u53F0\u4EFB\u52A1\u7684\u8F93\u51FA\u3002\u53C2\u6570: id(\u5FC5\u586B), since(\u4E0A\u6B21\u8FD4\u56DE\u7684 nextCursor\uFF0C\u7528\u6765\u53EA\u53D6\u65B0\u589E\u90E8\u5206)\u3002\u5355\u6B21\u6700\u591A ${MAX_OUTPUT_CHUNK} \u5B57\u8282\uFF1B\u8F93\u51FA\u5F88\u957F\u65F6\u4F1A\u5206\u6B21\u53D6\uFF0C\u8FD4\u56DE\u91CC\u4F1A\u8BF4\u660E\u8FD8\u6709\u6CA1\u6709\u66F4\u591A\u3002`,
|
|
628
|
+
parameters: {
|
|
629
|
+
type: "object",
|
|
630
|
+
properties: {
|
|
631
|
+
id: { type: "string", description: "\u4EFB\u52A1 id\uFF0C\u4F8B\u5982 t1" },
|
|
632
|
+
since: { type: "number", description: "\u4E0A\u6B21\u8FD4\u56DE\u7684 nextCursor\uFF1B\u4E0D\u7ED9\u5C31\u4ECE\u5934\u53D6" }
|
|
633
|
+
},
|
|
634
|
+
required: ["id"]
|
|
635
|
+
},
|
|
636
|
+
annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: false },
|
|
637
|
+
operation: "file_read",
|
|
638
|
+
describeTarget: (args) => String(args["id"] ?? ""),
|
|
639
|
+
execute: (args, ctx) => {
|
|
640
|
+
const id = String(args["id"] ?? "");
|
|
641
|
+
const since = Number(args["since"]);
|
|
642
|
+
const result = taskOutput(ctx.sessionId, id, Number.isFinite(since) && since > 0 ? since : 0);
|
|
643
|
+
if (!result) return Promise.resolve(notFound(id));
|
|
644
|
+
const head = describeTask(result.info);
|
|
645
|
+
const notes = [];
|
|
646
|
+
if (result.missed > 0) notes.push(describeMissed(result.missed, result.info.artifact));
|
|
647
|
+
if (result.hasMore) notes.push(`\uFF08\u8FD8\u6709\u66F4\u591A\uFF0C\u7528 since: ${result.nextCursor} \u7EE7\u7EED\u53D6\uFF09`);
|
|
648
|
+
const sb = taskSandbox(ctx.sessionId, id);
|
|
649
|
+
const sbErr = sb ? sandboxError(sb) : null;
|
|
650
|
+
if (sbErr) notes.push(`${sbErr.message}
|
|
651
|
+
${sbErr.suggestion}`);
|
|
652
|
+
const body = result.output || "\uFF08\u8FD9\u4E00\u6BB5\u6CA1\u6709\u65B0\u8F93\u51FA\uFF09";
|
|
653
|
+
return Promise.resolve(
|
|
654
|
+
ok([head, ...notes, "", body, "", `nextCursor: ${result.nextCursor}`].join("\n"))
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
var taskWait = {
|
|
659
|
+
name: "task_wait",
|
|
660
|
+
description: "\u963B\u585E\u7B49\u4E00\u4E2A\u540E\u53F0\u4EFB\u52A1\u7ED3\u675F\u3002\u53C2\u6570: id(\u5FC5\u586B), timeoutMs(\u9ED8\u8BA4 30000\uFF0C\u6700\u5927 300000)\u3002**\u8D85\u65F6\u4E0D\u4F1A\u6740\u5B83** \u2014\u2014 \u53EA\u662F\u544A\u8BC9\u4F60\u8FD8\u5728\u8DD1\uFF0C\u8981\u505C\u7528 task_stop\u3002",
|
|
661
|
+
parameters: {
|
|
662
|
+
type: "object",
|
|
663
|
+
properties: {
|
|
664
|
+
id: { type: "string", description: "\u4EFB\u52A1 id" },
|
|
665
|
+
timeoutMs: { type: "number", description: "\u6700\u591A\u7B49\u591A\u4E45\uFF08\u6BEB\u79D2\uFF09" }
|
|
666
|
+
},
|
|
667
|
+
required: ["id"]
|
|
668
|
+
},
|
|
669
|
+
annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: false },
|
|
670
|
+
operation: "file_read",
|
|
671
|
+
describeTarget: (args) => String(args["id"] ?? ""),
|
|
672
|
+
execute: async (args, ctx) => {
|
|
673
|
+
const id = String(args["id"] ?? "");
|
|
674
|
+
const requested = Number(args["timeoutMs"]);
|
|
675
|
+
const timeout = Math.min(
|
|
676
|
+
Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_WAIT_MS,
|
|
677
|
+
MAX_WAIT_MS
|
|
678
|
+
);
|
|
679
|
+
const result = await waitTask(ctx.sessionId, id, timeout);
|
|
680
|
+
if (!result) return notFound(id);
|
|
681
|
+
const tail = result.timedOut ? `
|
|
682
|
+
\u7B49\u4E86 ${Math.round(timeout / 1e3)}s \u8FD8\u6CA1\u7ED3\u675F\uFF0C**\u6CA1\u6709\u505C\u5B83**\u3002\u53EF\u4EE5\u63A5\u7740\u5E72\u522B\u7684\uFF0C\u6216\u518D\u7B49\u4E00\u6B21\u3002` : "\n\u7528 task_output \u53D6\u8F93\u51FA\u3002";
|
|
683
|
+
return ok(describeTask(result.info) + tail);
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
var taskStop = {
|
|
687
|
+
name: "task_stop",
|
|
688
|
+
description: "\u505C\u6389\u4E00\u4E2A\u540E\u53F0\u4EFB\u52A1\uFF08\u6740\u6574\u68F5\u8FDB\u7A0B\u6811\uFF0C\u542B\u5B83\u8D77\u7684\u5B50\u8FDB\u7A0B\uFF09\u3002\u53C2\u6570: id(\u5FC5\u586B)\u3002",
|
|
689
|
+
parameters: {
|
|
690
|
+
type: "object",
|
|
691
|
+
properties: { id: { type: "string", description: "\u4EFB\u52A1 id" } },
|
|
692
|
+
required: ["id"]
|
|
693
|
+
},
|
|
694
|
+
// 它真的会杀进程,所以**不是** readOnly
|
|
695
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
696
|
+
operation: "file_read",
|
|
697
|
+
describeTarget: (args) => String(args["id"] ?? ""),
|
|
698
|
+
execute: async (args, ctx) => {
|
|
699
|
+
const id = String(args["id"] ?? "");
|
|
700
|
+
const stopped = await stopTask(ctx.sessionId, id);
|
|
701
|
+
if (!stopped) {
|
|
702
|
+
const tasks = listTasks(ctx.sessionId);
|
|
703
|
+
const exists = tasks.some((t) => t.id === id);
|
|
704
|
+
return exists ? ok(`${id} \u5DF2\u7ECF\u4E0D\u5728\u8FD0\u884C\u4E2D\u4E86\u3002`) : notFound(id);
|
|
705
|
+
}
|
|
706
|
+
return ok(`${id} \u5DF2\u505C\u6B62\uFF08\u542B\u5B83\u8D77\u7684\u5B50\u8FDB\u7A0B\uFF09\u3002`);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
var TASK_TOOLS = [taskList, taskOutputTool, taskWait, taskStop];
|
|
710
|
+
|
|
711
|
+
// src/index.ts
|
|
712
|
+
var DEFAULT_TIMEOUT = 6e4;
|
|
713
|
+
var PTY_TIMEOUT = 3e4;
|
|
714
|
+
var CANCELLED = "\u547D\u4EE4\u5DF2\u88AB\u53D6\u6D88";
|
|
715
|
+
function detectCd(command) {
|
|
716
|
+
const trimmed = command.trim();
|
|
717
|
+
const cdMatch = trimmed.match(/^cd\s+([^&|;]+)$/);
|
|
718
|
+
if (!cdMatch) return null;
|
|
719
|
+
const rawTarget = cdMatch[1];
|
|
720
|
+
if (!rawTarget) return null;
|
|
721
|
+
return rawTarget.trim().replace(/^["']|["']$/g, "");
|
|
722
|
+
}
|
|
723
|
+
function sandboxPolicyFor(ctx, workspaceRoot) {
|
|
724
|
+
return {
|
|
725
|
+
mode: sandboxModeForLevel(ctx.permissionLevel),
|
|
726
|
+
workspaceRoot,
|
|
727
|
+
...ctx.extraRoots ? { extraRoots: ctx.extraRoots } : {},
|
|
728
|
+
allowNetwork: true
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
function toSandboxFailure(sb, output, duration) {
|
|
732
|
+
const err = sandboxError(sb);
|
|
733
|
+
if (!err) return null;
|
|
734
|
+
return {
|
|
735
|
+
success: false,
|
|
736
|
+
output,
|
|
737
|
+
error: {
|
|
738
|
+
// 2008 = 被沙箱挡下(命令跑了,动作被拒)
|
|
739
|
+
// 2009 = 沙箱自己没起来(命令**没有**跑)
|
|
740
|
+
code: sb.failure?.kind === "runner-failed" ? 2009 : 2008,
|
|
741
|
+
message: err.message,
|
|
742
|
+
suggestion: err.suggestion
|
|
743
|
+
},
|
|
744
|
+
duration
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
var CD_NOTICE = (target) => `[\u63D0\u793A] cd \u53EA\u5728\u672C\u6B21\u5B50 shell \u5185\u751F\u6548\uFF0C\u4E0B\u6B21\u8C03\u7528\u4ECD\u5728\u539F\u5DE5\u4F5C\u76EE\u5F55\u3002\u82E5\u540E\u7EED\u547D\u4EE4\u9700\u8981\u5728 ${target} \u4E0B\u6267\u884C\uFF0C\u8BF7\u4F20 workdir \u53C2\u6570\uFF0C\u6216\u5199\u6210 "cd ${target} && <\u547D\u4EE4>"\u3002`;
|
|
748
|
+
var terminalTool = {
|
|
749
|
+
name: "terminal",
|
|
750
|
+
description: "\u5728\u7EC8\u7AEF\u4E2D\u6267\u884C shell \u547D\u4EE4\u3002\u652F\u6301\u540E\u53F0(background)\u3001PTY \u4EA4\u4E92(pty)\u3001cwd \u5207\u6362\u3002\u9002\u7528\u4E8E\u811A\u672C\u3001\u6784\u5EFA\u3001\u5B89\u88C5\u3001\u4EA4\u4E92\u5F0F\u547D\u4EE4\u7B49\u3002",
|
|
751
|
+
parameters: {
|
|
752
|
+
type: "object",
|
|
753
|
+
properties: {
|
|
754
|
+
command: {
|
|
755
|
+
type: "string",
|
|
756
|
+
description: "\u8981\u6267\u884C\u7684 shell \u547D\u4EE4"
|
|
757
|
+
},
|
|
758
|
+
background: {
|
|
759
|
+
type: "boolean",
|
|
760
|
+
description: "\u540E\u53F0\u6267\u884C\uFF1A\u7ACB\u5373\u8FD4\u56DE\u4EFB\u52A1 id \u4E0D\u7B49\u7ED3\u679C\uFF0C\u914D\u5408 task_output / task_wait / task_stop \u7528\u3002\u9002\u5408\u6784\u5EFA\u3001\u8DD1 watch\u3001\u8D77\u670D\u52A1\uFF08\u8D77\u670D\u52A1\u518D curl \u5B83\u8FD9\u6761\u8DEF\u53EA\u80FD\u8FD9\u4E48\u8D70\uFF09\u3002**\u6743\u9650\u5224\u5B9A\u548C\u524D\u53F0\u5B8C\u5168\u4E00\u6837**\uFF0C\u540E\u53F0\u4E0D\u662F\u7ED5\u8FC7\u786E\u8BA4\u7684\u65C1\u8DEF\u3002"
|
|
761
|
+
},
|
|
762
|
+
pty: {
|
|
763
|
+
type: "boolean",
|
|
764
|
+
description: "PTY \u6A21\u5F0F\uFF1A\u7528\u4E8E\u4EA4\u4E92\u5F0F\u547D\u4EE4\uFF08npm init\u3001git add -p \u7B49\uFF09\u3002[Hermes/Codex]"
|
|
765
|
+
},
|
|
766
|
+
stdin: {
|
|
767
|
+
type: "string",
|
|
768
|
+
description: "PTY \u6A21\u5F0F\u4E0B\u53D1\u9001\u7ED9\u547D\u4EE4\u7684\u6807\u51C6\u8F93\u5165"
|
|
769
|
+
},
|
|
770
|
+
workdir: {
|
|
771
|
+
type: "string",
|
|
772
|
+
description: "\u5DE5\u4F5C\u76EE\u5F55\uFF08\u5FC5\u987B\u5728\u5DE5\u4F5C\u533A\u5185\uFF0C\u76F8\u5BF9\u8DEF\u5F84\u6309\u5DE5\u4F5C\u533A\u6839\u89E3\u6790\uFF09"
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
required: ["command"]
|
|
776
|
+
},
|
|
777
|
+
supportsParallel: false,
|
|
778
|
+
// 任意 shell 命令:能删数据、能联网,两个方向都到顶。
|
|
779
|
+
// `openWorldHint: true` 同时让 core 把输出包进 `<tool_output untrusted="true">`
|
|
780
|
+
// —— `curl` 抓回来的东西和 web_fetch 抓的没有区别。
|
|
781
|
+
annotations: {
|
|
782
|
+
readOnlyHint: false,
|
|
783
|
+
destructiveHint: true,
|
|
784
|
+
idempotentHint: false,
|
|
785
|
+
openWorldHint: true
|
|
786
|
+
},
|
|
787
|
+
operation: "command",
|
|
788
|
+
// 审批目标就是完整命令串(五候选里的 command 也能猜到,这里写明确的)
|
|
789
|
+
describeTarget: (args) => String(args["command"] ?? ""),
|
|
790
|
+
async execute(args, ctx) {
|
|
791
|
+
const command = String(args["command"] ?? "");
|
|
792
|
+
const isBackground = Boolean(args["background"]);
|
|
793
|
+
const usePty = Boolean(args["pty"]);
|
|
794
|
+
const stdin = args["stdin"] ? String(args["stdin"]) : void 0;
|
|
795
|
+
const workdir = args["workdir"] ? String(args["workdir"]) : void 0;
|
|
796
|
+
if (!command.trim()) {
|
|
797
|
+
return {
|
|
798
|
+
success: false,
|
|
799
|
+
output: "",
|
|
800
|
+
error: { code: 2004, message: "\u547D\u4EE4\u4E0D\u80FD\u4E3A\u7A7A", suggestion: "\u8BF7\u63D0\u4F9B\u8981\u6267\u884C\u7684\u547D\u4EE4" },
|
|
801
|
+
duration: 0
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
const danger = checkDangerousCommand(command);
|
|
805
|
+
if (danger.dangerous) {
|
|
806
|
+
return {
|
|
807
|
+
success: false,
|
|
808
|
+
output: "",
|
|
809
|
+
error: {
|
|
810
|
+
code: 2003,
|
|
811
|
+
message: `\u5371\u9669\u64CD\u4F5C\u5DF2\u88AB\u62E6\u622A: ${danger.desc}`,
|
|
812
|
+
suggestion: `\u547D\u4EE4 "${command}" \u6D89\u53CA ${danger.desc}`
|
|
813
|
+
},
|
|
814
|
+
duration: 0
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
const baseDir = ctx.workDir || process.cwd();
|
|
818
|
+
let cwd = baseDir;
|
|
819
|
+
if (workdir) {
|
|
820
|
+
if (!isInWorkspace(workdir, baseDir, ctx.extraRoots)) {
|
|
821
|
+
return {
|
|
822
|
+
success: false,
|
|
823
|
+
output: "",
|
|
824
|
+
error: {
|
|
825
|
+
code: 2006,
|
|
826
|
+
message: `workdir \u8D8A\u754C: ${workdir} \u4E0D\u5728\u5DE5\u4F5C\u533A ${baseDir} \u5185`,
|
|
827
|
+
suggestion: "\u53EA\u80FD\u5728\u5DE5\u4F5C\u533A\u5185\u6267\u884C\u547D\u4EE4\uFF1B\u5982\u9700\u8DE8\u76EE\u5F55\u64CD\u4F5C\u8BF7\u8BF4\u660E\u7406\u7531\u7531\u7528\u6237\u786E\u8BA4"
|
|
828
|
+
},
|
|
829
|
+
duration: 0
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
cwd = isAbsolute(workdir) ? resolve(workdir) : resolve(baseDir, workdir);
|
|
833
|
+
}
|
|
834
|
+
if (isBackground) {
|
|
835
|
+
const started = startTask(
|
|
836
|
+
ctx.sessionId,
|
|
837
|
+
command,
|
|
838
|
+
cwd,
|
|
839
|
+
ctx.homeDir,
|
|
840
|
+
sandboxPolicyFor(ctx, baseDir)
|
|
841
|
+
);
|
|
842
|
+
if ("error" in started) {
|
|
843
|
+
return {
|
|
844
|
+
success: false,
|
|
845
|
+
output: "",
|
|
846
|
+
error: { code: 2006, message: started.error, suggestion: "\u7528 task_stop \u505C\u6389\u4E00\u4E2A\u518D\u8BD5" },
|
|
847
|
+
duration: 0
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
const bgNotice = sandboxNotice(started.sandbox);
|
|
851
|
+
return {
|
|
852
|
+
success: true,
|
|
853
|
+
output: [
|
|
854
|
+
...bgNotice ? [bgNotice] : [],
|
|
855
|
+
`\u540E\u53F0\u4EFB\u52A1 ${started.task.id} \u5DF2\u542F\u52A8\uFF1A${command}`,
|
|
856
|
+
"task_output \u53D6\u8F93\u51FA \xB7 task_wait \u7B49\u5B83\u7ED3\u675F \xB7 task_stop \u505C\u6389 \xB7 task_list \u770B\u5168\u90E8\u3002",
|
|
857
|
+
"\u6CE8\u610F\uFF1A\u540E\u53F0\u4EFB\u52A1\u6D3B\u4E0D\u8FC7 epoch \u8FDB\u7A0B\uFF0C\u9000\u51FA\u65F6\u4F1A\u88AB\u4E00\u8D77\u6536\u6389\u3002"
|
|
858
|
+
].join("\n"),
|
|
859
|
+
duration: 0
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
const cdTarget = detectCd(command);
|
|
863
|
+
const startTime = Date.now();
|
|
864
|
+
if (usePty) {
|
|
865
|
+
try {
|
|
866
|
+
const result = await runCommandPty(command, {
|
|
867
|
+
timeout: PTY_TIMEOUT,
|
|
868
|
+
cwd,
|
|
869
|
+
tag: ctx.sessionId + "::pty",
|
|
870
|
+
...stdin ? { stdin } : {},
|
|
871
|
+
...ctx.signal ? { signal: ctx.signal } : {},
|
|
872
|
+
...ctx.onOutput ? { onOutput: ctx.onOutput } : {},
|
|
873
|
+
// 方案 46 PR-5:PTY 这条路 2026-08-17 起也有 OS 强制的写入边界。
|
|
874
|
+
// tty 分配 / 窗口尺寸 / 信号转发三样在真 Seatbelt 上逐条验过照旧,
|
|
875
|
+
// 实测表在 pty.ts 的文件头 —— 那三样正是方案 §三 点名要单独验的
|
|
876
|
+
sandbox: sandboxPolicyFor(ctx, baseDir)
|
|
877
|
+
});
|
|
878
|
+
const duration = Date.now() - startTime;
|
|
879
|
+
if (result.aborted) {
|
|
880
|
+
return {
|
|
881
|
+
success: false,
|
|
882
|
+
output: result.stdout,
|
|
883
|
+
error: { code: 2007, message: CANCELLED },
|
|
884
|
+
duration
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
let output = spillOutput({
|
|
888
|
+
full: result.stdout,
|
|
889
|
+
sessionId: ctx.sessionId,
|
|
890
|
+
...ctx.homeDir ? { homeDir: ctx.homeDir } : {},
|
|
891
|
+
label: "pty",
|
|
892
|
+
mode: "head-tail"
|
|
893
|
+
}).text;
|
|
894
|
+
const ptyNotice = sandboxNotice(result.sandbox);
|
|
895
|
+
if (ptyNotice) output = `${ptyNotice}
|
|
896
|
+
${output}`;
|
|
897
|
+
if (cdTarget) output += `
|
|
898
|
+
${CD_NOTICE(cdTarget)}`;
|
|
899
|
+
const ptySandboxFailure = toSandboxFailure(result.sandbox, output || NO_OUTPUT, duration);
|
|
900
|
+
if (ptySandboxFailure) return ptySandboxFailure;
|
|
901
|
+
return { success: result.exitCode === 0, output, duration };
|
|
902
|
+
} catch (err) {
|
|
903
|
+
const duration = Date.now() - startTime;
|
|
904
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
905
|
+
return {
|
|
906
|
+
success: false,
|
|
907
|
+
output: "",
|
|
908
|
+
error: {
|
|
909
|
+
code: 2005,
|
|
910
|
+
message: `PTY \u6267\u884C\u5931\u8D25: ${message}`,
|
|
911
|
+
suggestion: "\u68C0\u67E5 node-pty \u662F\u5426\u6B63\u786E\u5B89\u88C5"
|
|
912
|
+
},
|
|
913
|
+
duration
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
try {
|
|
918
|
+
const result = await runCommand(command, {
|
|
919
|
+
timeout: DEFAULT_TIMEOUT,
|
|
920
|
+
signal: ctx.signal,
|
|
921
|
+
cwd,
|
|
922
|
+
...ctx.onOutput ? { onOutput: ctx.onOutput } : {},
|
|
923
|
+
// 方案 46 PR-1:`terminal` 的默认路径从这里起有 OS 强制的写入边界。
|
|
924
|
+
// **边界按工作区主根算,不按 `cwd` 算** —— `cwd` 可能是工作区的子目录
|
|
925
|
+
// (模型传了 workdir),按它算的话「写工作区里另一个目录」会被挡,
|
|
926
|
+
// 而那是一次完全正常的操作
|
|
927
|
+
sandbox: sandboxPolicyFor(ctx, baseDir)
|
|
928
|
+
});
|
|
929
|
+
const duration = Date.now() - startTime;
|
|
930
|
+
const notice = sandboxNotice(result.sandbox);
|
|
931
|
+
let output = result.stdout;
|
|
932
|
+
if (result.stderr) output += (output ? "\n" : "") + result.stderr;
|
|
933
|
+
output = spillOutput({
|
|
934
|
+
full: output,
|
|
935
|
+
sessionId: ctx.sessionId,
|
|
936
|
+
...ctx.homeDir ? { homeDir: ctx.homeDir } : {},
|
|
937
|
+
label: "terminal",
|
|
938
|
+
mode: "head-tail"
|
|
939
|
+
}).text;
|
|
940
|
+
if (notice) output = `${notice}
|
|
941
|
+
${output}`;
|
|
942
|
+
if (result.timedOut) {
|
|
943
|
+
return {
|
|
944
|
+
success: false,
|
|
945
|
+
output,
|
|
946
|
+
error: {
|
|
947
|
+
code: 2002,
|
|
948
|
+
message: `\u547D\u4EE4\u6267\u884C\u8D85\u65F6\uFF08${DEFAULT_TIMEOUT / 1e3} \u79D2\uFF09\uFF0C\u8FDB\u7A0B\u6811\u5DF2\u7EC8\u6B62`,
|
|
949
|
+
suggestion: "\u5C1D\u8BD5\u7B80\u5316\u547D\u4EE4\uFF0C\u6216\u7528 background: true \u653E\u540E\u53F0\u8DD1"
|
|
950
|
+
},
|
|
951
|
+
duration
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
if (result.aborted) {
|
|
955
|
+
return {
|
|
956
|
+
success: false,
|
|
957
|
+
output,
|
|
958
|
+
error: { code: 2007, message: CANCELLED },
|
|
959
|
+
duration
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
if (cdTarget) output += `
|
|
963
|
+
${CD_NOTICE(cdTarget)}`;
|
|
964
|
+
const sandboxFailure = toSandboxFailure(result.sandbox, output || NO_OUTPUT, duration);
|
|
965
|
+
if (sandboxFailure) return sandboxFailure;
|
|
966
|
+
return { success: result.exitCode === 0, output: output || NO_OUTPUT, duration };
|
|
967
|
+
} catch (err) {
|
|
968
|
+
const duration = Date.now() - startTime;
|
|
969
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
970
|
+
return {
|
|
971
|
+
success: false,
|
|
972
|
+
output: "",
|
|
973
|
+
error: { code: 2005, message: `\u547D\u4EE4\u6267\u884C\u5931\u8D25: ${message}`, suggestion: "\u68C0\u67E5\u547D\u4EE4\u662F\u5426\u6B63\u786E" },
|
|
974
|
+
duration
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
var terminalPlugin = {
|
|
980
|
+
name: "@epoch-agent/plugin-terminal",
|
|
981
|
+
version: "0.0.0",
|
|
982
|
+
description: "\u7EC8\u7AEF\u547D\u4EE4\u6267\u884C + PTY \u4EA4\u4E92 + \u540E\u53F0\u4EFB\u52A1 [Hermes/Codex]",
|
|
983
|
+
// 四个后台任务工具**无条件注册**:它们只读任务表,没有任务时 task_list
|
|
984
|
+
// 就说一句「没有」。按「有没有任务」动态注册的话,模型第一次起后台任务之后
|
|
985
|
+
// 工具表会变,而它这一轮看到的还是旧的
|
|
986
|
+
tools: [terminalTool, ...TASK_TOOLS]
|
|
987
|
+
};
|
|
988
|
+
export {
|
|
989
|
+
MAX_TASKS,
|
|
990
|
+
TASK_TOOLS,
|
|
991
|
+
cleanupBackgroundProcesses,
|
|
992
|
+
clearAllTasks,
|
|
993
|
+
describeTask,
|
|
994
|
+
listTasks,
|
|
995
|
+
rekeyTasks,
|
|
996
|
+
startTask,
|
|
997
|
+
stopTask,
|
|
998
|
+
taskOutput,
|
|
999
|
+
taskSandbox,
|
|
1000
|
+
terminalPlugin,
|
|
1001
|
+
waitTask
|
|
1002
|
+
};
|