@evandu/super-pi 0.1.85 → 0.1.87

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.
@@ -1,23 +1,163 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
4
  import { randomUUID } from "node:crypto";
5
+ import { join } from "node:path";
3
6
  const MAX_LOG_BYTES = 512_000;
4
7
  const MAX_TIMEOUT_MS = 10 * 60_000;
8
+ /** The environment build gets its OWN budget: a cold `pip install` must not eat the tool's timeout. */
9
+ const MAX_ENV_TIMEOUT_MS = 10 * 60_000;
10
+ const isWindows = process.platform === "win32";
5
11
  /** Runs a registered Python entrypoint without a shell. It intentionally accepts no command string. */
6
12
  export class ToolRunner {
13
+ dataDir;
7
14
  runs = new Map();
15
+ /** Optional so the existing unit tests can build a runner with no data dir; a tool that needs no venv never touches it. */
16
+ constructor(dataDir) {
17
+ this.dataDir = dataDir;
18
+ }
19
+ /**
20
+ * THE RESOLUTION ORDER, IN ONE PLACE, AND IT IS AN ORDER ON PURPOSE.
21
+ *
22
+ * 1. the tool's own venv - a tool's dependencies belong to that tool and to nothing else;
23
+ * 2. `SUPER_PI_PYTHON_COMMAND` - the user's explicit choice, already honoured before this existed;
24
+ * 3. the system interpreter - today's behaviour;
25
+ * 4. nothing usable, which is REPORTED as a sentence rather than discovered as an ENOENT.
26
+ *
27
+ * Bundling an interpreter later is a fourth rung on this chain, which is why nothing in a tool's
28
+ * files has to know about it.
29
+ */
30
+ resolvePython(toolId, needsVenv) {
31
+ if (needsVenv && this.dataDir) {
32
+ const envPython = this.envPython(toolId);
33
+ if (existsSync(envPython))
34
+ return { path: envPython, kind: "tool-env" };
35
+ }
36
+ const override = process.env.SUPER_PI_PYTHON_COMMAND?.trim();
37
+ if (override)
38
+ return { path: override, kind: "override" };
39
+ return { path: isWindows ? "python.exe" : "python3", kind: "system" };
40
+ }
41
+ /** The venv lives BESIDE the tools, not inside one: export walks the tool directory, and the mount route serves everything under it. */
42
+ envDir(toolId) { return join(this.dataDir ?? ".", "tool-envs", toolId); }
43
+ envPython(toolId) { return join(this.envDir(toolId), ".venv", isWindows ? "Scripts" : "bin", isWindows ? "python.exe" : "python"); }
44
+ /** MEASURE this machine's Python. One spawn; the answer is what every fingerprint is compared to. */
45
+ async describeLocalPython() {
46
+ const choice = this.resolvePython("", false);
47
+ const probe = await runCapture(choice.path, ["-I", "-c", "import json,platform,sys;print(json.dumps({'python':sys.version.split()[0],'platform':platform.system().lower()+'-'+platform.machine()}))"], 10_000);
48
+ if (!probe.ok)
49
+ return undefined;
50
+ try {
51
+ const parsed = JSON.parse(probe.stdout);
52
+ if (typeof parsed?.python !== "string")
53
+ return undefined;
54
+ return { ...choice, python: parsed.python, platform: parsed.platform };
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ }
60
+ async environmentState(tool) {
61
+ const environment = tool.environment;
62
+ const requires = environment?.requires ?? [];
63
+ const wants = requires.length > 0 || Boolean(environment?.requiresPython);
64
+ const local = await this.describeLocalPython();
65
+ const base = { python: local?.python, platform: local?.platform, requires };
66
+ if (!wants)
67
+ return { ...base, state: "zero-deps" };
68
+ if (!local)
69
+ return { ...base, state: "needs-python", detail: "这台机器上找不到可用的 Python 解释器。" };
70
+ if (environment?.requiresPython && !satisfiesRange(local.python ?? "", environment.requiresPython))
71
+ return { ...base, state: "needs-python", detail: `这个工具需要 Python ${environment.requiresPython},本机是 ${local.python}。` };
72
+ if (requires.length > 0 && this.dataDir && !existsSync(this.envPython(tool.id)))
73
+ return { ...base, state: "needs-env", detail: "首次运行这个工具时会自动安装这些依赖。" };
74
+ const known = environment?.fingerprint;
75
+ if (known?.python && known.platform && (majorMinor(known.python) !== majorMinor(local.python ?? "") || known.platform !== local.platform))
76
+ return { ...base, state: "differs", detail: `导出方是 Python ${known.python} / ${known.platform},本机是 ${local.python} / ${local.platform}。` };
77
+ return { ...base, state: "ready" };
78
+ }
79
+ /**
80
+ * Starts a run. It RETURNS SYNCHRONOUSLY - so bad parameters still throw at the call site, exactly
81
+ * as before - while the interpreter and, if the tool needs one, its virtual environment are prepared
82
+ * in the background. The run is already `running` at that point and the build's output lands in the
83
+ * run's own `logs`, which is why the tool's existing dialog shows the progress without knowing
84
+ * anything about environments.
85
+ */
8
86
  start(input) {
9
87
  validateParams(input.definition.parameters ?? {}, input.params);
10
- const python = process.env.SUPER_PI_PYTHON_COMMAND?.trim() || (process.platform === "win32" ? "python.exe" : "python3");
11
88
  const id = `toolrun_${randomUUID()}`;
12
89
  const run = { id, toolId: input.toolId, status: "running", startedAt: new Date().toISOString(), logs: "" };
13
- // -I ignores user Python configuration and user site packages. No shell means params never become code.
14
- const child = spawn(python, ["-I", "-u", input.script], { cwd: input.cwd, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"], env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMP: process.env.TMP, PYTHONIOENCODING: "utf-8", PYTHONNOUSERSITE: "1", SUPER_PI_TOOL_ID: input.toolId } });
15
- const active = { run, child, stdout: "", cancelled: false, timer: setTimeout(() => this.finish(id, "timed_out", `脚本超过 ${boundedTimeout(input.definition.timeoutMs)}ms 时限`), boundedTimeout(input.definition.timeoutMs)) };
16
- active.timer.unref();
90
+ const timer = setTimeout(() => this.finish(id, "timed_out", `准备运行环境超过 ${MAX_ENV_TIMEOUT_MS}ms 时限`), MAX_ENV_TIMEOUT_MS);
91
+ timer.unref();
92
+ const active = { run, stdout: "", cancelled: false, timer };
17
93
  this.runs.set(id, active);
18
- const log = (data) => { active.run.logs = clipped(`${active.run.logs}${data.toString("utf8")}`); };
94
+ void this.prepare(id, active, input);
95
+ return copy(run);
96
+ }
97
+ async prepare(id, active, input) {
98
+ try {
99
+ const requires = input.environment?.requires ?? [];
100
+ let python = this.resolvePython(input.toolId, false);
101
+ if (requires.length > 0) {
102
+ const built = await this.ensureEnvironment(input.toolId, python, requires, active);
103
+ if (!built.ok) {
104
+ this.finish(id, "failed", built.error);
105
+ return;
106
+ }
107
+ python = { path: this.envPython(input.toolId), kind: "tool-env" };
108
+ }
109
+ if (active.cancelled)
110
+ return;
111
+ if (active.timer)
112
+ clearTimeout(active.timer);
113
+ const scriptTimer = setTimeout(() => this.finish(id, "timed_out", `脚本超过 ${boundedTimeout(input.definition.timeoutMs)}ms 时限`), boundedTimeout(input.definition.timeoutMs));
114
+ scriptTimer.unref();
115
+ active.timer = scriptTimer;
116
+ this.launch(id, active, python, input);
117
+ }
118
+ catch (error) {
119
+ this.finish(id, "failed", error instanceof Error ? error.message : "tool environment could not be prepared");
120
+ }
121
+ }
122
+ /**
123
+ * THE VENV IS PYTHON'S OWN, ON PURPOSE. `python -m venv` finds its site-packages through the
124
+ * pyvenv.cfg next to the interpreter, which is why it survives the `-I` a tool runs under - a
125
+ * PYTHONPATH-based vendor directory does not (verified: `-I` implies `-E`, so PYTHONPATH is
126
+ * ignored, and dropping `-I` would give up the isolation that keeps a hostile PYTHON* environment
127
+ * from influencing a shared tool's script).
128
+ */
129
+ async ensureEnvironment(toolId, base, requires, active) {
130
+ if (!this.dataDir)
131
+ return { ok: false, error: "这个工具需要依赖,但没有可用的数据目录来存放它的虚拟环境。" };
132
+ const envDir = this.envDir(toolId), envPython = this.envPython(toolId);
133
+ const recorded = await readFile(join(envDir, "installed.json"), "utf8").then((text) => { try {
134
+ return JSON.parse(text);
135
+ }
136
+ catch {
137
+ return undefined;
138
+ } }).catch(() => undefined);
139
+ if (existsSync(envPython) && Array.isArray(recorded?.requires) && recorded.requires.length === requires.length && recorded.requires.every((item, index) => item === requires[index]))
140
+ return { ok: true };
141
+ await rm(envDir, { recursive: true, force: true });
142
+ await mkdir(envDir, { recursive: true });
143
+ const requirements = join(envDir, "requirements.txt");
144
+ await writeFile(requirements, `${requires.join("\n")}\n`, "utf8");
145
+ logLine(active, `正在准备 Python 虚拟环境(${requires.join("、")})…\n`);
146
+ const created = await runCapture(base.path, ["-m", "venv", join(envDir, ".venv")], MAX_ENV_TIMEOUT_MS, (text) => logLine(active, text));
147
+ if (!created.ok)
148
+ return { ok: false, error: `创建 Python 虚拟环境失败:${created.error || `解释器 ${base.path} 不可用`}` };
149
+ const installed = await runCapture(envPython, ["-m", "pip", "install", "--disable-pip-version-check", "--no-input", "-r", requirements], MAX_ENV_TIMEOUT_MS, (text) => logLine(active, text));
150
+ if (!installed.ok)
151
+ return { ok: false, error: `安装工具依赖失败:${installed.error || `pip 退出码 ${installed.code}`}(依赖:${requires.join("、")})` };
152
+ await writeFile(join(envDir, "installed.json"), JSON.stringify({ requires, python: base.path, builtAt: new Date().toISOString() }, null, 2), "utf8");
153
+ return { ok: true };
154
+ }
155
+ launch(id, active, python, input) {
156
+ // -I ignores user Python configuration and user site packages. No shell means params never become code.
157
+ const child = spawn(python.path, ["-I", "-u", input.script], { cwd: input.cwd, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"], env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMP: process.env.TMP, PYTHONIOENCODING: "utf-8", PYTHONNOUSERSITE: "1", SUPER_PI_TOOL_ID: input.toolId } });
158
+ active.child = child;
19
159
  child.stdout.on("data", (data) => { active.stdout = clipped(`${active.stdout}${data.toString("utf8")}`); });
20
- child.stderr.on("data", log);
160
+ child.stderr.on("data", (data) => logLine(active, data.toString("utf8")));
21
161
  child.once("error", (error) => this.finish(id, "failed", error.message));
22
162
  child.once("close", (code) => {
23
163
  if (active.run.status !== "running")
@@ -38,7 +178,6 @@ export class ToolRunner {
38
178
  }
39
179
  });
40
180
  child.stdin.end(JSON.stringify(input.params));
41
- return copy(run);
42
181
  }
43
182
  get(id, toolId) { const active = this.runs.get(id); return active?.run.toolId === toolId ? copy(active.run) : undefined; }
44
183
  cancel(id, toolId) { const active = this.runs.get(id); if (!active || active.run.toolId !== toolId)
@@ -47,17 +186,78 @@ export class ToolRunner {
47
186
  this.finish(id, "cancelled");
48
187
  } return copy(active.run); }
49
188
  finish(id, status, error) { const active = this.runs.get(id); if (!active || active.run.status !== "running")
50
- return; clearTimeout(active.timer); if (status !== "succeeded" && active.stdout)
189
+ return; if (active.timer)
190
+ clearTimeout(active.timer); if (status !== "succeeded" && active.stdout)
51
191
  active.run.logs = clipped(`${active.run.logs}${active.stdout}`); active.run.status = status; active.run.finishedAt = new Date().toISOString(); if (error)
52
- active.run.error = error; if (!active.child.killed) {
192
+ active.run.error = error; if (active.child && !active.child.killed) {
53
193
  try {
54
194
  active.child.kill();
55
195
  }
56
196
  catch { }
57
197
  } }
58
198
  }
199
+ /**
200
+ * Runs a Python process with NO SHELL - the same rule as a tool run, because what is being installed
201
+ * is code too. There is no command string anywhere on this path, only an argv.
202
+ */
203
+ function runCapture(python, args, timeoutMs, onOutput) {
204
+ return new Promise((resolve) => {
205
+ let stdout = "", stderr = "", settled = false;
206
+ const child = spawn(python, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMP: process.env.TMP, PYTHONIOENCODING: "utf-8", PYTHONNOUSERSITE: "1" } });
207
+ const timer = setTimeout(() => { if (settled)
208
+ return; settled = true; try {
209
+ child.kill();
210
+ }
211
+ catch { } resolve({ ok: false, code: null, stdout, error: `timed out after ${timeoutMs}ms` }); }, timeoutMs);
212
+ timer.unref();
213
+ child.stdout.on("data", (data) => { const text = data.toString("utf8"); stdout += text; onOutput?.(text); });
214
+ child.stderr.on("data", (data) => { const text = data.toString("utf8"); stderr += text; onOutput?.(text); });
215
+ child.once("error", (error) => { if (settled)
216
+ return; settled = true; clearTimeout(timer); resolve({ ok: false, code: null, stdout, error: error.message }); });
217
+ child.once("close", (code) => { if (settled)
218
+ return; settled = true; clearTimeout(timer); if (code === 0)
219
+ resolve({ ok: true, stdout, code });
220
+ else
221
+ resolve({ ok: false, code, stdout, error: stderr.split("\n").filter((line) => line.trim()).slice(-4).join("\n") }); });
222
+ });
223
+ }
224
+ /**
225
+ * A SMALL, DELIBERATELY SMALL PEP 440 READER: `>=`, `>`, `<=`, `<`, `==`, comma-separated. Anything it
226
+ * cannot parse counts as NOT satisfied, because a version check that guesses is worse than one that
227
+ * refuses. A tool needing something exotic declares no range at all and relies on the venv, not here.
228
+ */
229
+ export function satisfiesRange(version, range) {
230
+ const actual = version.split(".").map((part) => Number.parseInt(part, 10));
231
+ if (actual.some((part) => Number.isNaN(part)))
232
+ return false;
233
+ for (const term of range.split(",")) {
234
+ const match = /^(>=|<=|==|>|<)?\s*(\d+(?:\.\d+)*)$/.exec(term.trim());
235
+ if (!match)
236
+ return false;
237
+ const comparison = compareVersions(actual, match[2].split(".").map((part) => Number.parseInt(part, 10)));
238
+ const operator = match[1] ?? "==";
239
+ if (operator === ">=" && comparison < 0)
240
+ return false;
241
+ if (operator === ">" && comparison <= 0)
242
+ return false;
243
+ if (operator === "<=" && comparison > 0)
244
+ return false;
245
+ if (operator === "<" && comparison >= 0)
246
+ return false;
247
+ if (operator === "==" && comparison !== 0)
248
+ return false;
249
+ }
250
+ return true;
251
+ }
252
+ function compareVersions(left, right) { for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
253
+ const a = left[index] ?? 0, b = right[index] ?? 0;
254
+ if (a !== b)
255
+ return a < b ? -1 : 1;
256
+ } return 0; }
257
+ function majorMinor(version) { return version.split(".").slice(0, 2).join("."); }
59
258
  function boundedTimeout(value) { return Math.max(1_000, Math.min(MAX_TIMEOUT_MS, Number.isFinite(value) ? Math.floor(value) : 60_000)); }
60
259
  function clipped(text) { return text.length > MAX_LOG_BYTES ? text.slice(-MAX_LOG_BYTES) : text; }
260
+ function logLine(active, text) { active.run.logs = clipped(`${active.run.logs}${text}`); }
61
261
  function copy(run) { return { ...run, result: run.result ? { ...run.result } : undefined }; }
62
262
  export function validateParams(schema, params) {
63
263
  if (!params || Array.isArray(params) || typeof params !== "object")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -90,6 +90,9 @@
90
90
  "scripts/knowledge-base.test.mjs",
91
91
  "scripts/tool-runner.test.mjs",
92
92
  "scripts/tool-runtime-api.test.mjs",
93
+ "scripts/tool-sharing.test.mjs",
94
+ "scripts/tool-env.test.mjs",
95
+ "scripts/tool-llm.test.mjs",
93
96
  "scripts/extension-load.test.mjs",
94
97
  "scripts/static-client-assets.test.mjs",
95
98
  "scripts/cli.test.mjs",