pi-web-ui 0.94.1 → 0.96.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/CHANGELOG.md +91 -2
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +910 -237
- package/dist/server/agent-service.js +2655 -231
- package/dist/server/approval-rules.js +596 -0
- package/dist/server/attachment-store.js +129 -0
- package/dist/server/attachments.js +92 -174
- package/dist/server/bg-servers.js +45 -7
- package/dist/server/claim-files-tool.js +4 -8
- package/dist/server/claim-store.js +4 -2
- package/dist/server/client-state.js +67 -14
- package/dist/server/compact-context-tool.js +126 -0
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +229 -0
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +69 -66
- package/dist/server/edit-soft-tool.js +84 -16
- package/dist/server/eval-tool.js +588 -0
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +65 -18
- package/dist/server/goal-service.js +371 -77
- package/dist/server/hashline-engine.js +703 -0
- package/dist/server/host-guard.js +94 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +456 -80
- package/dist/server/lsp-tool.js +1371 -0
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +112 -32
- package/dist/server/office-parse.js +375 -0
- package/dist/server/patch-tool.js +85 -0
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +114 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- package/dist/server/plugin-catalog-sync.js +36 -19
- package/dist/server/plugin-catalog.js +11 -4
- package/dist/server/plugin-facilities.js +92 -21
- package/dist/server/plugin-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +117 -13
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +950 -205
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +16 -6
- package/dist/server/prompt-composer.js +9 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +69 -30
- package/dist/server/resolve-global-sdk.js +30 -16
- package/dist/server/schedule-agent-tool.js +12 -15
- package/dist/server/scheduler-tasks.js +6 -0
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +111 -14
- package/dist/server/settings-service.js +112 -3
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +83 -66
- package/dist/server/terminals.js +248 -81
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +237 -12
- package/dist/server/tool-overrides.js +59 -0
- package/dist/server/update-check.js +28 -1
- package/dist/server/uploads.js +17 -2
- package/dist/server/wait-subscription-scan.js +18 -21
- package/dist/server/workspace-snapshot.js +113 -0
- package/dist/server/ws-client-id.js +29 -0
- package/dist/server/ws-pending-queue.js +60 -0
- package/extensions/webui.ts +60 -2
- package/package.json +5 -3
- package/plugin-sdk/README.md +25 -0
- package/plugin-sdk/index.d.ts +31 -38
- package/plugin-sdk/index.mjs +35 -19
- package/plugins/catalog.json +40 -0
- package/themes/aetheris.css +457 -0
- package/themes/ayu-light.css +6 -6
- package/themes/catppuccin-latte.css +6 -6
- package/themes/claude-code-dark.css +144 -0
- package/themes/codex.css +6 -6
- package/themes/everforest-light.css +6 -6
- package/themes/geist.css +6 -6
- package/themes/gruvbox-light.css +6 -6
- package/themes/kanagawa-lotus.css +6 -6
- package/themes/rose-pine-dawn.css +6 -6
- package/themes/solarized-light.css +6 -6
- package/themes/vs-code-dark.css +146 -0
- package/themes/zhupi-dark.css +658 -0
- package/themes/zhupi.css +711 -0
- package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
- package/web/dist/assets/index-Do9RgJC3.js +366 -0
- package/web/dist/assets/index-DryOsILO.css +1 -0
- package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/index-B3S9MxnN.css +0 -1
- package/web/dist/assets/index-DVLrHI2E.js +0 -364
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* eval-tool.ts — 受控的持久代码求值沙箱工具(eval)。
|
|
3
|
+
*
|
|
4
|
+
* 背景与设计:
|
|
5
|
+
* AI 在面对需要快速计算、复杂数据处理(如解析庞大的 CSV/JSON 数据、统计指标计算)、
|
|
6
|
+
* 算法推演或生成图表时,以往只能通过 `bash` 工具反复创建与删除临时脚本,且中间变量
|
|
7
|
+
* 无法跨调用持久化。
|
|
8
|
+
*
|
|
9
|
+
* 借鉴 oh-my-pi (omp) 的 eval 工具经验,同时彻底规避其两大缺陷:
|
|
10
|
+
* 1. 【工具挤占防范】:本工具默认关闭(defaultOn: false,opt-in),不挤占常规代码编写/检索;
|
|
11
|
+
* 2. 【IPC 死锁防范】:采用轻量独立子进程驱动,输出独立缓冲,配以跨平台进程树硬超时查杀
|
|
12
|
+
* (Windows 下 taskkill /F /T,Unix 下 SIGKILL),杜绝子进程挂起与假死。
|
|
13
|
+
*
|
|
14
|
+
* 支持语言:
|
|
15
|
+
* - "py": Python 3(使用内置 ast 解析,支持多语句执行、最后表达式求值与变量保持);
|
|
16
|
+
* - "js" / "ts": Node.js(基于 node:repl,支持 top-level await、变量保持与控制台捕获)。
|
|
17
|
+
*/
|
|
18
|
+
import { spawn, execSync } from "node:child_process";
|
|
19
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import readline from "node:readline";
|
|
23
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { Type } from "typebox";
|
|
25
|
+
import { pick } from "./i18n.js";
|
|
26
|
+
import { EVAL_TOOL_NAME } from "./tool-manager.js";
|
|
27
|
+
export { EVAL_TOOL_NAME };
|
|
28
|
+
const DEFAULT_TIMEOUT_SECONDS = 15;
|
|
29
|
+
const MAX_TIMEOUT_SECONDS = 120;
|
|
30
|
+
const MAX_OUTPUT_CHARS = 12000;
|
|
31
|
+
/** 跨平台递归查杀进程树。 */
|
|
32
|
+
export function killProcessTree(proc) {
|
|
33
|
+
if (!proc.pid)
|
|
34
|
+
return;
|
|
35
|
+
if (process.platform === "win32") {
|
|
36
|
+
try {
|
|
37
|
+
execSync(`taskkill /F /T /PID ${proc.pid}`, { stdio: "ignore" });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
try {
|
|
41
|
+
proc.kill("SIGKILL");
|
|
42
|
+
}
|
|
43
|
+
catch { }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
try {
|
|
52
|
+
proc.kill("SIGKILL");
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** 探测系统可用的 Python 命令。 */
|
|
59
|
+
function resolvePythonCmd() {
|
|
60
|
+
for (const cmd of ["python", "python3", "py"]) {
|
|
61
|
+
try {
|
|
62
|
+
const out = execSync(`${cmd} --version`, { stdio: "pipe", encoding: "utf8" });
|
|
63
|
+
if (out.toLowerCase().includes("python"))
|
|
64
|
+
return cmd;
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const PYTHON_DRIVER_SCRIPT = `
|
|
71
|
+
import sys, io, ast, json, traceback, os
|
|
72
|
+
|
|
73
|
+
WORKDIR = os.environ.get('PI_EVAL_WORKDIR', '')
|
|
74
|
+
PROJECT_DIR = os.environ.get('PI_EVAL_PROJECT_DIR', '')
|
|
75
|
+
|
|
76
|
+
_scope = {'__name__': '__main__', 'WORKDIR': WORKDIR, 'PROJECT_DIR': PROJECT_DIR}
|
|
77
|
+
|
|
78
|
+
def run_cell(code):
|
|
79
|
+
out = io.StringIO()
|
|
80
|
+
err = io.StringIO()
|
|
81
|
+
old_out, old_err = sys.stdout, sys.stderr
|
|
82
|
+
sys.stdout, sys.stderr = out, err
|
|
83
|
+
res = None
|
|
84
|
+
exc = None
|
|
85
|
+
try:
|
|
86
|
+
parsed = ast.parse(code)
|
|
87
|
+
if parsed.body and isinstance(parsed.body[-1], ast.Expr):
|
|
88
|
+
last = parsed.body.pop()
|
|
89
|
+
if parsed.body:
|
|
90
|
+
exec(compile(parsed, '<eval>', 'exec'), _scope)
|
|
91
|
+
res = eval(compile(ast.Expression(last.value), '<eval>', 'eval'), _scope)
|
|
92
|
+
else:
|
|
93
|
+
exec(compile(code, '<eval>', 'exec'), _scope)
|
|
94
|
+
except Exception:
|
|
95
|
+
exc = traceback.format_exc()
|
|
96
|
+
finally:
|
|
97
|
+
sys.stdout, sys.stderr = old_out, old_err
|
|
98
|
+
return {
|
|
99
|
+
'stdout': out.getvalue(),
|
|
100
|
+
'stderr': err.getvalue(),
|
|
101
|
+
'result': repr(res) if res is not None else None,
|
|
102
|
+
'error': exc
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for line in sys.stdin:
|
|
106
|
+
if not line.strip():
|
|
107
|
+
continue
|
|
108
|
+
try:
|
|
109
|
+
req = json.loads(line)
|
|
110
|
+
if req.get('action') == 'reset':
|
|
111
|
+
_scope.clear()
|
|
112
|
+
_scope['__name__'] = '__main__'
|
|
113
|
+
_scope['WORKDIR'] = WORKDIR
|
|
114
|
+
_scope['PROJECT_DIR'] = PROJECT_DIR
|
|
115
|
+
resp = {'ok': True, 'stdout': '', 'stderr': '', 'result': None, 'error': None}
|
|
116
|
+
else:
|
|
117
|
+
resp = run_cell(req.get('code', ''))
|
|
118
|
+
resp['ok'] = resp['error'] is None
|
|
119
|
+
except Exception as e:
|
|
120
|
+
resp = {'ok': False, 'stdout': '', 'stderr': '', 'result': None, 'error': traceback.format_exc()}
|
|
121
|
+
|
|
122
|
+
sys.stdout.write('__PI_EVAL_RES__' + json.dumps(resp, ensure_ascii=False) + '\\n')
|
|
123
|
+
sys.stdout.flush()
|
|
124
|
+
`;
|
|
125
|
+
const NODE_DRIVER_SCRIPT = `
|
|
126
|
+
import repl from "node:repl";
|
|
127
|
+
import { PassThrough } from "node:stream";
|
|
128
|
+
import readline from "node:readline";
|
|
129
|
+
|
|
130
|
+
let server;
|
|
131
|
+
function createRepl() {
|
|
132
|
+
const inStream = new PassThrough();
|
|
133
|
+
const outStream = new PassThrough();
|
|
134
|
+
server = repl.start({
|
|
135
|
+
input: inStream,
|
|
136
|
+
output: outStream,
|
|
137
|
+
terminal: false,
|
|
138
|
+
useColors: false,
|
|
139
|
+
preview: false
|
|
140
|
+
});
|
|
141
|
+
server.context.console = console;
|
|
142
|
+
server.context.setTimeout = setTimeout;
|
|
143
|
+
server.context.clearTimeout = clearTimeout;
|
|
144
|
+
server.context.setInterval = setInterval;
|
|
145
|
+
server.context.clearInterval = clearInterval;
|
|
146
|
+
server.context.Buffer = Buffer;
|
|
147
|
+
server.context.URL = URL;
|
|
148
|
+
server.context.WORKDIR = process.env.PI_EVAL_WORKDIR || "";
|
|
149
|
+
server.context.PROJECT_DIR = process.env.PI_EVAL_PROJECT_DIR || "";
|
|
150
|
+
}
|
|
151
|
+
createRepl();
|
|
152
|
+
|
|
153
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
154
|
+
rl.on("line", (line) => {
|
|
155
|
+
if (!line.trim()) return;
|
|
156
|
+
try {
|
|
157
|
+
const req = JSON.parse(line);
|
|
158
|
+
if (req.action === "reset") {
|
|
159
|
+
createRepl();
|
|
160
|
+
process.stdout.write("__PI_EVAL_RES__" + JSON.stringify({ ok: true, stdout: "", stderr: "", result: null, error: null }) + "\\n");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
let capturedOut = "";
|
|
164
|
+
let capturedErr = "";
|
|
165
|
+
const origLog = server.context.console.log;
|
|
166
|
+
const origErr = server.context.console.error;
|
|
167
|
+
const origWarn = server.context.console.warn;
|
|
168
|
+
server.context.console.log = (...args) => {
|
|
169
|
+
capturedOut += args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\\n";
|
|
170
|
+
};
|
|
171
|
+
server.context.console.error = (...args) => {
|
|
172
|
+
capturedErr += args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\\n";
|
|
173
|
+
};
|
|
174
|
+
server.context.console.warn = (...args) => {
|
|
175
|
+
capturedErr += args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\\n";
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
server.eval(req.code, server.context, "eval", (err, result) => {
|
|
179
|
+
server.context.console.log = origLog;
|
|
180
|
+
server.context.console.error = origErr;
|
|
181
|
+
server.context.console.warn = origWarn;
|
|
182
|
+
const resp = {
|
|
183
|
+
ok: !err,
|
|
184
|
+
stdout: capturedOut,
|
|
185
|
+
stderr: capturedErr,
|
|
186
|
+
result: result !== undefined ? (typeof result === "string" ? result : JSON.stringify(result, null, 2)) : null,
|
|
187
|
+
error: err ? (err.stack || String(err)) : null
|
|
188
|
+
};
|
|
189
|
+
process.stdout.write("__PI_EVAL_RES__" + JSON.stringify(resp) + "\\n");
|
|
190
|
+
});
|
|
191
|
+
} catch (e) {
|
|
192
|
+
const resp = { ok: false, stdout: "", stderr: "", result: null, error: e.stack || String(e) };
|
|
193
|
+
process.stdout.write("__PI_EVAL_RES__" + JSON.stringify(resp) + "\\n");
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
`;
|
|
197
|
+
/**
|
|
198
|
+
* 净化 Eval 子进程环境变量:仅保留操作系统基础变量,剥离所有包含 key/token/secret/auth/cred 等敏感环境变量,
|
|
199
|
+
* 杜绝沙箱脚本窃取宿主云凭据与 API Key。
|
|
200
|
+
*/
|
|
201
|
+
export function sanitizeEvalEnv(sourceEnv) {
|
|
202
|
+
const allowedKeys = new Set([
|
|
203
|
+
"path",
|
|
204
|
+
"pathext",
|
|
205
|
+
"systemroot",
|
|
206
|
+
"windir",
|
|
207
|
+
"temp",
|
|
208
|
+
"tmp",
|
|
209
|
+
"tmpdir",
|
|
210
|
+
"home",
|
|
211
|
+
"userprofile",
|
|
212
|
+
"lang",
|
|
213
|
+
"lc_all",
|
|
214
|
+
"term",
|
|
215
|
+
"shell",
|
|
216
|
+
"comspec",
|
|
217
|
+
"os",
|
|
218
|
+
"number_of_processors",
|
|
219
|
+
"processor_architecture",
|
|
220
|
+
"appdata",
|
|
221
|
+
"localappdata",
|
|
222
|
+
"homedrive",
|
|
223
|
+
"homepath",
|
|
224
|
+
"systemdrive",
|
|
225
|
+
"programdata",
|
|
226
|
+
"programfiles",
|
|
227
|
+
"programfiles(x86)",
|
|
228
|
+
"commonprogramfiles",
|
|
229
|
+
"node_env",
|
|
230
|
+
]);
|
|
231
|
+
const cleanEnv = {};
|
|
232
|
+
for (const [key, value] of Object.entries(sourceEnv)) {
|
|
233
|
+
if (value === undefined)
|
|
234
|
+
continue;
|
|
235
|
+
const lowerKey = key.toLowerCase();
|
|
236
|
+
if (lowerKey.includes("key") ||
|
|
237
|
+
lowerKey.includes("token") ||
|
|
238
|
+
lowerKey.includes("secret") ||
|
|
239
|
+
lowerKey.includes("auth") ||
|
|
240
|
+
lowerKey.includes("pass") ||
|
|
241
|
+
lowerKey.includes("cred")) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (allowedKeys.has(lowerKey) || lowerKey.startsWith("npm_") || lowerKey.startsWith("python")) {
|
|
245
|
+
cleanEnv[key] = value;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return cleanEnv;
|
|
249
|
+
}
|
|
250
|
+
/** 单个持久内核工作进程包装。 */
|
|
251
|
+
class EvalKernel {
|
|
252
|
+
language;
|
|
253
|
+
cwd;
|
|
254
|
+
proc = null;
|
|
255
|
+
rl = null;
|
|
256
|
+
current = null;
|
|
257
|
+
queue = [];
|
|
258
|
+
stderrTail = "";
|
|
259
|
+
closed = false;
|
|
260
|
+
workdir;
|
|
261
|
+
constructor(language, cwd) {
|
|
262
|
+
this.language = language;
|
|
263
|
+
this.cwd = cwd;
|
|
264
|
+
this.workdir = mkdtempSync(join(tmpdir(), `pi-eval-${language}-`));
|
|
265
|
+
}
|
|
266
|
+
spawn() {
|
|
267
|
+
if (this.proc && !this.proc.killed && this.proc.exitCode === null) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// 沙箱边界:内核 cwd 是本会话的独立临时目录(不落在项目里),要读项目
|
|
271
|
+
// 文件时用驱动内注入的 PROJECT_DIR 显式拼绝对路径(两个 env 见下)。
|
|
272
|
+
const spawnOpts = {
|
|
273
|
+
cwd: this.workdir,
|
|
274
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
275
|
+
detached: process.platform !== "win32",
|
|
276
|
+
env: {
|
|
277
|
+
...sanitizeEvalEnv(process.env),
|
|
278
|
+
PI_EVAL_WORKDIR: this.workdir,
|
|
279
|
+
PI_EVAL_PROJECT_DIR: this.cwd,
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
if (this.language === "py") {
|
|
283
|
+
const pyCmd = resolvePythonCmd();
|
|
284
|
+
if (!pyCmd) {
|
|
285
|
+
throw new Error("Python executable not found in PATH. Please install Python or use language: 'js'.");
|
|
286
|
+
}
|
|
287
|
+
this.proc = spawn(pyCmd, ["-u", "-c", PYTHON_DRIVER_SCRIPT], spawnOpts);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
// js / ts:独立 node -e 驱动,避免 eval 里的死循环卡住服务端事件循环。
|
|
291
|
+
this.proc = spawn(process.execPath, ["-e", NODE_DRIVER_SCRIPT], spawnOpts);
|
|
292
|
+
}
|
|
293
|
+
// stderr 必须持续排空:驱动只把用户的 print/console 劫进 StringIO,但
|
|
294
|
+
// os.write(2, …) / 原生扩展 / eval 里再 spawn 的子进程仍写真实 fd 2。
|
|
295
|
+
// piped stderr 无人读,写满 ~64KB 管道缓冲会把驱动挂住(表现为假超时)。
|
|
296
|
+
this.proc.stderr.on("data", (chunk) => {
|
|
297
|
+
this.stderrTail = (this.stderrTail + chunk.toString()).slice(-4000);
|
|
298
|
+
});
|
|
299
|
+
this.rl = readline.createInterface({ input: this.proc.stdout });
|
|
300
|
+
this.rl.on("line", (line) => {
|
|
301
|
+
if (!line.startsWith("__PI_EVAL_RES__"))
|
|
302
|
+
return;
|
|
303
|
+
const item = this.current;
|
|
304
|
+
if (!item)
|
|
305
|
+
return;
|
|
306
|
+
this.current = null;
|
|
307
|
+
try {
|
|
308
|
+
const data = JSON.parse(line.slice("__PI_EVAL_RES__".length));
|
|
309
|
+
this.settle(item, data);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
this.settle(item, {
|
|
313
|
+
ok: false,
|
|
314
|
+
stdout: "",
|
|
315
|
+
stderr: this.stderrTail,
|
|
316
|
+
result: null,
|
|
317
|
+
error: "Kernel returned an unparsable response frame",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
this.pump();
|
|
321
|
+
});
|
|
322
|
+
// 代际守卫:killProc 之后旧进程的 exit/error 是「迟到的」,那时 this.proc
|
|
323
|
+
// 已指向新进程(或 null),绝不能让它污染新内核的状态。
|
|
324
|
+
const proc = this.proc;
|
|
325
|
+
proc.on("error", (err) => {
|
|
326
|
+
if (this.proc !== proc)
|
|
327
|
+
return;
|
|
328
|
+
this.procDied(`Kernel process error: ${err.message}`);
|
|
329
|
+
});
|
|
330
|
+
proc.on("exit", (code) => {
|
|
331
|
+
if (this.proc !== proc)
|
|
332
|
+
return;
|
|
333
|
+
this.procDied(`Kernel process exited unexpectedly with code ${code}`);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/** 进程异常退出:结算在飞与排队中的请求(都当失败,不假装成功)。 */
|
|
337
|
+
procDied(message) {
|
|
338
|
+
if (this.current) {
|
|
339
|
+
const item = this.current;
|
|
340
|
+
this.current = null;
|
|
341
|
+
this.settle(item, {
|
|
342
|
+
ok: false,
|
|
343
|
+
stdout: "",
|
|
344
|
+
stderr: this.stderrTail,
|
|
345
|
+
result: null,
|
|
346
|
+
error: message,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
const queued = this.queue.splice(0);
|
|
350
|
+
for (const item of queued) {
|
|
351
|
+
this.settle(item, { ok: false, stdout: "", stderr: "", result: null, error: message });
|
|
352
|
+
}
|
|
353
|
+
this.pump();
|
|
354
|
+
}
|
|
355
|
+
async execute(code, timeoutMs, reset = false) {
|
|
356
|
+
if (this.closed)
|
|
357
|
+
throw new Error("Eval kernel is closed");
|
|
358
|
+
if (reset) {
|
|
359
|
+
await this.sendAction({ action: "reset" }, 3000).catch(() => { });
|
|
360
|
+
}
|
|
361
|
+
return await this.sendAction({ action: "eval", code }, timeoutMs);
|
|
362
|
+
}
|
|
363
|
+
sendAction(req, timeoutMs) {
|
|
364
|
+
return new Promise((resolve, reject) => {
|
|
365
|
+
const item = {
|
|
366
|
+
req,
|
|
367
|
+
timeoutMs,
|
|
368
|
+
timer: setTimeout(() => { }, 0),
|
|
369
|
+
done: false,
|
|
370
|
+
resolve,
|
|
371
|
+
reject,
|
|
372
|
+
};
|
|
373
|
+
item.timer = setTimeout(() => {
|
|
374
|
+
if (item.done)
|
|
375
|
+
return;
|
|
376
|
+
if (this.current === item) {
|
|
377
|
+
this.current = null;
|
|
378
|
+
// 硬超时:杀进程树(不留孤儿),后续 pump 按需重启内核。
|
|
379
|
+
this.killProc();
|
|
380
|
+
this.pump();
|
|
381
|
+
}
|
|
382
|
+
item.reject(new Error(`Execution timed out after ${Math.round(item.timeoutMs / 1000)}s`));
|
|
383
|
+
}, timeoutMs);
|
|
384
|
+
this.queue.push(item);
|
|
385
|
+
this.pump();
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/** 串行消费队列:驱动协议一次只发一条请求。 */
|
|
389
|
+
pump() {
|
|
390
|
+
if (this.current || this.queue.length === 0)
|
|
391
|
+
return;
|
|
392
|
+
try {
|
|
393
|
+
this.spawn();
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
const item = this.queue.shift();
|
|
397
|
+
if (item) {
|
|
398
|
+
this.settle(item, { ok: false, stdout: "", stderr: "", result: null, error: err.message });
|
|
399
|
+
}
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const item = this.queue.shift();
|
|
403
|
+
this.current = item;
|
|
404
|
+
try {
|
|
405
|
+
this.proc.stdin.write(JSON.stringify(item.req) + "\n");
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
this.current = null;
|
|
409
|
+
this.settle(item, {
|
|
410
|
+
ok: false,
|
|
411
|
+
stdout: "",
|
|
412
|
+
stderr: "",
|
|
413
|
+
result: null,
|
|
414
|
+
error: `Failed to write to kernel: ${err.message}`,
|
|
415
|
+
});
|
|
416
|
+
this.pump();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/** 结算一条请求(幂等;清定时器)。 */
|
|
420
|
+
settle(item, res) {
|
|
421
|
+
if (item.done)
|
|
422
|
+
return;
|
|
423
|
+
item.done = true;
|
|
424
|
+
clearTimeout(item.timer);
|
|
425
|
+
item.resolve(res);
|
|
426
|
+
}
|
|
427
|
+
/** 只杀进程(保留 workdir):超时路径用,之后可原样重启内核。 */
|
|
428
|
+
killProc() {
|
|
429
|
+
if (this.rl) {
|
|
430
|
+
this.rl.close();
|
|
431
|
+
this.rl = null;
|
|
432
|
+
}
|
|
433
|
+
if (this.proc) {
|
|
434
|
+
killProcessTree(this.proc);
|
|
435
|
+
this.proc = null;
|
|
436
|
+
}
|
|
437
|
+
this.stderrTail = "";
|
|
438
|
+
}
|
|
439
|
+
dispose() {
|
|
440
|
+
this.closed = true;
|
|
441
|
+
if (this.current) {
|
|
442
|
+
const item = this.current;
|
|
443
|
+
this.current = null;
|
|
444
|
+
this.settle(item, { ok: false, stdout: "", stderr: "", result: null, error: "Eval kernel disposed" });
|
|
445
|
+
}
|
|
446
|
+
const queued = this.queue.splice(0);
|
|
447
|
+
for (const item of queued) {
|
|
448
|
+
this.settle(item, { ok: false, stdout: "", stderr: "", result: null, error: "Eval kernel disposed" });
|
|
449
|
+
}
|
|
450
|
+
this.killProc();
|
|
451
|
+
try {
|
|
452
|
+
rmSync(this.workdir, { recursive: true, force: true });
|
|
453
|
+
}
|
|
454
|
+
catch { }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/** 会话级内核池(convId -> { py, js })。 */
|
|
458
|
+
const sessionKernels = new Map();
|
|
459
|
+
function getKernel(sessionId, language, cwd) {
|
|
460
|
+
let langMap = sessionKernels.get(sessionId);
|
|
461
|
+
if (!langMap) {
|
|
462
|
+
langMap = new Map();
|
|
463
|
+
sessionKernels.set(sessionId, langMap);
|
|
464
|
+
}
|
|
465
|
+
const normLang = language === "ts" ? "js" : language;
|
|
466
|
+
let kernel = langMap.get(normLang);
|
|
467
|
+
if (!kernel) {
|
|
468
|
+
kernel = new EvalKernel(normLang, cwd);
|
|
469
|
+
langMap.set(normLang, kernel);
|
|
470
|
+
}
|
|
471
|
+
return kernel;
|
|
472
|
+
}
|
|
473
|
+
/** 销毁指定会话的求值沙箱进程。 */
|
|
474
|
+
export function disposeEvalSession(sessionId) {
|
|
475
|
+
const langMap = sessionKernels.get(sessionId);
|
|
476
|
+
if (langMap) {
|
|
477
|
+
for (const kernel of langMap.values()) {
|
|
478
|
+
kernel.dispose();
|
|
479
|
+
}
|
|
480
|
+
sessionKernels.delete(sessionId);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** 清理全部求值沙箱。 */
|
|
484
|
+
export function disposeAllEvalKernels() {
|
|
485
|
+
for (const langMap of sessionKernels.values()) {
|
|
486
|
+
for (const kernel of langMap.values()) {
|
|
487
|
+
kernel.dispose();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
sessionKernels.clear();
|
|
491
|
+
}
|
|
492
|
+
export function makeEvalTool(opts) {
|
|
493
|
+
const getLang = opts.lang ?? (() => "en");
|
|
494
|
+
const sessionId = (opts.ownerId ?? "default").trim() || "default";
|
|
495
|
+
return defineTool({
|
|
496
|
+
name: EVAL_TOOL_NAME,
|
|
497
|
+
label: "Execute code in persistent sandbox",
|
|
498
|
+
description: "Execute Python or JavaScript/TypeScript code in an isolated evaluation sandbox. " +
|
|
499
|
+
"Variables and imported modules persist across calls within the conversation. " +
|
|
500
|
+
"Ideal for quick calculations, data transformations, algorithm verification, and inspecting outputs without creating temporary script files.",
|
|
501
|
+
promptSnippet: "evaluate Python or JS/TS code with persistent state (default-off sandbox)",
|
|
502
|
+
parameters: Type.Object({
|
|
503
|
+
code: Type.String({
|
|
504
|
+
description: "The code snippet to evaluate. Top-level variables and functions are preserved across calls.",
|
|
505
|
+
}),
|
|
506
|
+
language: Type.Optional(Type.Union([Type.Literal("py"), Type.Literal("js"), Type.Literal("ts")], {
|
|
507
|
+
description: 'Target language: "py" for Python (default), "js" or "ts" for Node.js sandbox.',
|
|
508
|
+
})),
|
|
509
|
+
title: Type.Optional(Type.String({
|
|
510
|
+
description: 'Optional short label for this step (e.g. "Calculate metrics", "Parse payload").',
|
|
511
|
+
})),
|
|
512
|
+
timeout: Type.Optional(Type.Integer({
|
|
513
|
+
minimum: 1,
|
|
514
|
+
maximum: MAX_TIMEOUT_SECONDS,
|
|
515
|
+
description: `Execution timeout in seconds (default: ${DEFAULT_TIMEOUT_SECONDS}, maximum: ${MAX_TIMEOUT_SECONDS}).`,
|
|
516
|
+
})),
|
|
517
|
+
reset: Type.Optional(Type.Boolean({
|
|
518
|
+
description: "Whether to reset the sandbox environment before executing (clears all previous variables).",
|
|
519
|
+
})),
|
|
520
|
+
}),
|
|
521
|
+
execute: async (_id, params) => {
|
|
522
|
+
const lang = getLang();
|
|
523
|
+
const language = params.language ?? "py";
|
|
524
|
+
const timeoutSec = Math.min(Math.max(1, params.timeout ?? DEFAULT_TIMEOUT_SECONDS), MAX_TIMEOUT_SECONDS);
|
|
525
|
+
const timeoutMs = timeoutSec * 1000;
|
|
526
|
+
const startTime = Date.now();
|
|
527
|
+
try {
|
|
528
|
+
const kernel = getKernel(sessionId, language, opts.cwd);
|
|
529
|
+
const res = await kernel.execute(params.code, timeoutMs, params.reset ?? false);
|
|
530
|
+
const durationMs = Date.now() - startTime;
|
|
531
|
+
let text = "";
|
|
532
|
+
const titlePart = params.title ? ` - ${params.title}` : "";
|
|
533
|
+
if (!res.ok) {
|
|
534
|
+
text = pick(lang, `[eval:${language} 出错 (${durationMs}ms)${titlePart}]\n${res.error || res.stderr || "执行异常"}`, `[eval:${language} error (${durationMs}ms)${titlePart}]\n${res.error || res.stderr || "Execution failed"}`);
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
const parts = [];
|
|
538
|
+
if (res.stdout.trim()) {
|
|
539
|
+
parts.push(res.stdout.trim());
|
|
540
|
+
}
|
|
541
|
+
if (res.result !== null && res.result !== undefined) {
|
|
542
|
+
parts.push(`=> ${res.result}`);
|
|
543
|
+
}
|
|
544
|
+
if (parts.length === 0) {
|
|
545
|
+
parts.push(pick(lang, "(执行成功,无输出)", "(Completed with no output)"));
|
|
546
|
+
}
|
|
547
|
+
text = `[eval:${language} (${durationMs}ms)${titlePart}]\n` + parts.join("\n");
|
|
548
|
+
}
|
|
549
|
+
if (text.length > MAX_OUTPUT_CHARS) {
|
|
550
|
+
text = text.slice(0, MAX_OUTPUT_CHARS) + `\n… [${text.length - MAX_OUTPUT_CHARS} chars truncated]`;
|
|
551
|
+
}
|
|
552
|
+
const maxDetailChars = MAX_OUTPUT_CHARS;
|
|
553
|
+
const truncateDetail = (s) => s && s.length > maxDetailChars ? s.slice(0, maxDetailChars) + `\n… [truncated]` : s;
|
|
554
|
+
return {
|
|
555
|
+
content: [{ type: "text", text }],
|
|
556
|
+
details: {
|
|
557
|
+
language,
|
|
558
|
+
durationMs,
|
|
559
|
+
ok: res.ok,
|
|
560
|
+
stdout: truncateDetail(res.stdout),
|
|
561
|
+
stderr: truncateDetail(res.stderr),
|
|
562
|
+
result: res.result,
|
|
563
|
+
error: res.error,
|
|
564
|
+
reset: params.reset ?? false,
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
const durationMs = Date.now() - startTime;
|
|
570
|
+
const errMsg = err.message;
|
|
571
|
+
const text = `[eval:${language} error (${durationMs}ms)]\n${errMsg}`;
|
|
572
|
+
return {
|
|
573
|
+
content: [{ type: "text", text }],
|
|
574
|
+
details: {
|
|
575
|
+
language,
|
|
576
|
+
durationMs,
|
|
577
|
+
ok: false,
|
|
578
|
+
stdout: "",
|
|
579
|
+
stderr: "",
|
|
580
|
+
result: null,
|
|
581
|
+
error: errMsg,
|
|
582
|
+
reset: params.reset ?? false,
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
}
|
|
@@ -64,12 +64,20 @@ export async function safeDestination(root, name) {
|
|
|
64
64
|
}
|
|
65
65
|
return path;
|
|
66
66
|
}
|
|
67
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Transform that enforces the 1 GiB expanded-size cap on the bytes that
|
|
69
|
+
* actually flow through it. `total` makes several transforms share one global
|
|
70
|
+
* counter — for ZIP extraction the header-declared uncompressedSize is
|
|
71
|
+
* attacker-controlled, so the cap must be enforced on REAL decompressed output.
|
|
72
|
+
*/
|
|
73
|
+
function byteLimit(total) {
|
|
68
74
|
let bytes = 0;
|
|
69
75
|
return new Transform({
|
|
70
76
|
transform(chunk, _encoding, done) {
|
|
71
77
|
bytes += chunk.length;
|
|
72
|
-
|
|
78
|
+
if (total)
|
|
79
|
+
total.bytes += chunk.length;
|
|
80
|
+
done((total ? total.bytes : bytes) > MAX_BYTES ? new Error("Archive exceeds 1 GiB expanded size") : null, chunk);
|
|
73
81
|
},
|
|
74
82
|
});
|
|
75
83
|
}
|
|
@@ -119,7 +127,10 @@ export async function createArchive(source, outputDir) {
|
|
|
119
127
|
async function unzip(source, stage) {
|
|
120
128
|
const zip = await new Promise((ok, fail) => yauzl.open(source, { lazyEntries: true, strictFileNames: true }, (err, z) => (err ? fail(err) : ok(z))));
|
|
121
129
|
let count = 0;
|
|
122
|
-
|
|
130
|
+
// 全局实量字节计数:zip 头声明的 uncompressedSize 可被伪造(头写小、解压输出
|
|
131
|
+
// 无限膨胀的 zip 炸弹),上限必须按每个条目实际解压输出累加,超限抛错 ——
|
|
132
|
+
// extractArchive 的 finally 会清理临时目录。tar 分支不受影响。
|
|
133
|
+
const total = { bytes: 0 };
|
|
123
134
|
const paths = new Set();
|
|
124
135
|
await new Promise((ok, fail) => {
|
|
125
136
|
const abort = (err) => {
|
|
@@ -135,7 +146,7 @@ async function unzip(source, stage) {
|
|
|
135
146
|
const type = (entry.externalFileAttributes >>> 16) & 0o170000;
|
|
136
147
|
if (type && type !== 0o100000 && type !== 0o040000)
|
|
137
148
|
throw new Error("Archive contains a link or special file");
|
|
138
|
-
if (++count > MAX_ENTRIES
|
|
149
|
+
if (++count > MAX_ENTRIES)
|
|
139
150
|
throw new Error("Archive limit exceeded (20,000 entries / 1 GiB)");
|
|
140
151
|
const dest = join(stage, name);
|
|
141
152
|
if (entry.fileName.endsWith("/"))
|
|
@@ -143,7 +154,7 @@ async function unzip(source, stage) {
|
|
|
143
154
|
else {
|
|
144
155
|
await mkdir(dirname(dest), { recursive: true });
|
|
145
156
|
const stream = await new Promise((resolveStream, reject) => zip.openReadStream(entry, (err, s) => (err ? reject(err) : resolveStream(s))));
|
|
146
|
-
await pipeline(stream, byteLimit(), createWriteStream(dest, { flags: "wx", mode: 0o600 }));
|
|
157
|
+
await pipeline(stream, byteLimit(total), createWriteStream(dest, { flags: "wx", mode: 0o600 }));
|
|
147
158
|
}
|
|
148
159
|
zip.readEntry();
|
|
149
160
|
})().catch(abort);
|