@evandu/super-pi 0.1.85 → 0.1.86

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.
@@ -3,6 +3,36 @@ export type ToolAsset = {
3
3
  path: string;
4
4
  content: string;
5
5
  };
6
+ /**
7
+ * WHAT A TOOL SAYS ABOUT THE PYTHON IT NEEDS, PLUS WHAT THE MACHINE THAT REGISTERED IT ACTUALLY HAD.
8
+ *
9
+ * `requires` is ALWAYS `pkg==version` and that is the whole point: an isolated venv decides WHERE the
10
+ * packages live, and the pin decides WHICH packages are there. Only the pin makes two machines agree,
11
+ * so a range is refused by name at import rather than warned about.
12
+ */
13
+ export type ToolEnvironment = {
14
+ /** PEP 440 range such as ">=3.10,<4". Omitted means any Python this machine can resolve. */
15
+ requiresPython?: string;
16
+ requires?: string[];
17
+ /** MEASURED where the tool was registered, never guessed. Travels in the export bundle. */
18
+ fingerprint?: ToolFingerprint;
19
+ };
20
+ export type ToolFingerprint = {
21
+ python?: string;
22
+ platform?: string;
23
+ packages?: string[];
24
+ };
25
+ /**
26
+ * WHAT A TOOL IS ALLOWED TO ASK OF THE MACHINE IT RUNS ON.
27
+ *
28
+ * `llm` is a GRANT, not a hint: `POST /tools/<id>/api/llm` refuses a tool that does not carry it, so
29
+ * "this tool can spend your model quota" is something a person was shown before they accepted the file
30
+ * and stays true afterwards. A tool that needs a model has to say so - which is also what keeps the
31
+ * review card honest, because the warning is read off the record rather than guessed from the files.
32
+ */
33
+ export type ToolPermissions = {
34
+ llm?: boolean;
35
+ };
6
36
  export type MountedTool = {
7
37
  id: string;
8
38
  slug: string;
@@ -10,11 +40,27 @@ export type MountedTool = {
10
40
  description?: string;
11
41
  entrypoint: string;
12
42
  script?: ToolScript;
43
+ environment?: ToolEnvironment;
44
+ permissions?: ToolPermissions;
13
45
  source: "user" | "builtin";
14
46
  enabled: boolean;
15
47
  createdAt: string;
16
48
  updatedAt: string;
17
49
  };
50
+ export declare const MAX_ASSETS = 32;
51
+ export declare const MAX_ASSET_BYTES = 512000;
52
+ /**
53
+ * THE HTTP BODY CAP, AND WHY IT IS DECLARED HERE RATHER THAN IN THE ROUTE.
54
+ *
55
+ * JSON escaping inflates the payload - every quote and every newline in a tool's HTML becomes two
56
+ * characters - so a body cap that merely matched MAX_ASSET_BYTES would refuse tools the registry
57
+ * itself would have accepted, and the real ceiling would depend on how much HTML a tool happened to
58
+ * contain. Doubling plus a fixed overhead leaves that inflation room, and keeping the number beside
59
+ * MAX_ASSET_BYTES keeps ONE number authoritative instead of two that disagree silently.
60
+ */
61
+ export declare const MAX_TOOL_BODY_BYTES: number;
62
+ export declare const EXPORT_FORMAT = "super-pi.tool";
63
+ export declare const EXPORT_VERSION = 1;
18
64
  /** Global registry: a tool belongs to Super Pi, never to the workspace that created it. */
19
65
  export declare class ToolRegistry {
20
66
  private readonly file;
@@ -31,6 +77,8 @@ export declare class ToolRegistry {
31
77
  description?: string;
32
78
  entrypoint?: string;
33
79
  script?: ToolScript;
80
+ environment?: ToolEnvironment;
81
+ permissions?: ToolPermissions;
34
82
  files: ToolAsset[];
35
83
  }): Promise<MountedTool>;
36
84
  remove(id: string): Promise<boolean>;
@@ -39,4 +87,21 @@ export declare class ToolRegistry {
39
87
  file: string;
40
88
  root: string;
41
89
  } | undefined>;
90
+ /**
91
+ * THE EXPORT, AND THE ONLY PRODUCER OF A SHAREABLE BUNDLE.
92
+ *
93
+ * The client cannot assemble one (it never receives file content), and there is no CLI, script, or
94
+ * agent path on purpose: handing out a tool's source is something a person clicks.
95
+ *
96
+ * The bundle is FLAT, so the exported file is itself a valid `tool_register` payload - the same
97
+ * shape `POST /api/tools` takes. Nothing machine-local travels: no id (a fresh one is minted on
98
+ * import), no timestamps, and no way to reach Super Pi. A tool addresses its own runtime RELATIVELY
99
+ * (`./api/run`), the server derives which tool that is from the mount URL, and `SUPER_PI_TOOL_ID`
100
+ * is handed to Python at run time - so there is no binding inside the files that an import would
101
+ * have to rewrite, and nothing to leak when the file travels.
102
+ */
103
+ exportBundle(id: string): Promise<{
104
+ bundle: Record<string, unknown>;
105
+ skipped: string[];
106
+ } | undefined>;
42
107
  }
@@ -1,6 +1,21 @@
1
- import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, join, relative, resolve, sep } from "node:path";
3
- const MAX_TOOLS = 128, MAX_ASSETS = 32, MAX_ASSET_BYTES = 512_000, SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
3
+ export const MAX_ASSETS = 32;
4
+ export const MAX_ASSET_BYTES = 512_000;
5
+ /**
6
+ * THE HTTP BODY CAP, AND WHY IT IS DECLARED HERE RATHER THAN IN THE ROUTE.
7
+ *
8
+ * JSON escaping inflates the payload - every quote and every newline in a tool's HTML becomes two
9
+ * characters - so a body cap that merely matched MAX_ASSET_BYTES would refuse tools the registry
10
+ * itself would have accepted, and the real ceiling would depend on how much HTML a tool happened to
11
+ * contain. Doubling plus a fixed overhead leaves that inflation room, and keeping the number beside
12
+ * MAX_ASSET_BYTES keeps ONE number authoritative instead of two that disagree silently.
13
+ */
14
+ export const MAX_TOOL_BODY_BYTES = MAX_ASSET_BYTES * 2 + 64_000;
15
+ export const EXPORT_FORMAT = "super-pi.tool";
16
+ export const EXPORT_VERSION = 1;
17
+ const MAX_TOOLS = 128, SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
18
+ const PINNED_REQUIREMENT = /^[A-Za-z0-9][A-Za-z0-9_.-]*==[^=\s,;<>|'"`\\]{1,80}$/;
4
19
  /** Global registry: a tool belongs to Super Pi, never to the workspace that created it. */
5
20
  export class ToolRegistry {
6
21
  file;
@@ -38,7 +53,10 @@ export class ToolRegistry {
38
53
  const files = normalizeAssets(input.files);
39
54
  if (!files.some((file) => file.path === entrypoint))
40
55
  throw new Error("entrypoint must be included in files");
41
- const script = input.script ? normalizeScript(input.script, files) : undefined, finalDir = join(this.toolsDir, slug), stagingDir = `${finalDir}.${crypto.randomUUID()}.staging`;
56
+ const script = input.script ? normalizeScript(input.script, files) : undefined;
57
+ const environment = input.environment ? normalizeEnvironment(input.environment) : undefined;
58
+ const permissions = normalizePermissions(input.permissions);
59
+ const finalDir = join(this.toolsDir, slug), stagingDir = `${finalDir}.${crypto.randomUUID()}.staging`;
42
60
  if (await stat(finalDir).catch(() => undefined))
43
61
  throw new Error("tool directory already exists");
44
62
  try {
@@ -49,7 +67,7 @@ export class ToolRegistry {
49
67
  await rm(stagingDir, { recursive: true, force: true });
50
68
  throw error;
51
69
  }
52
- const now = new Date().toISOString(), tool = { id: `tool_${crypto.randomUUID()}`, slug, name: input.name.trim(), description: input.description?.trim().slice(0, 500), entrypoint, script, source: "user", enabled: true, createdAt: now, updatedAt: now };
70
+ const now = new Date().toISOString(), tool = { id: `tool_${crypto.randomUUID()}`, slug, name: input.name.trim(), description: input.description?.trim().slice(0, 500), entrypoint, script, environment, permissions, source: "user", enabled: true, createdAt: now, updatedAt: now };
53
71
  this.items.push(tool);
54
72
  try {
55
73
  await this.save();
@@ -67,6 +85,45 @@ export class ToolRegistry {
67
85
  return undefined; const root = join(this.toolsDir, tool.slug), file = resolve(root, resource ? safeRelative(resource) : tool.entrypoint); if (!inside(root, file))
68
86
  throw new Error("tool resource must stay inside the tool directory"); const info = await stat(file).catch(() => undefined); if (!info?.isFile())
69
87
  throw new Error("tool file does not exist"); return { tool: copy(tool), file, root }; }
88
+ /**
89
+ * THE EXPORT, AND THE ONLY PRODUCER OF A SHAREABLE BUNDLE.
90
+ *
91
+ * The client cannot assemble one (it never receives file content), and there is no CLI, script, or
92
+ * agent path on purpose: handing out a tool's source is something a person clicks.
93
+ *
94
+ * The bundle is FLAT, so the exported file is itself a valid `tool_register` payload - the same
95
+ * shape `POST /api/tools` takes. Nothing machine-local travels: no id (a fresh one is minted on
96
+ * import), no timestamps, and no way to reach Super Pi. A tool addresses its own runtime RELATIVELY
97
+ * (`./api/run`), the server derives which tool that is from the mount URL, and `SUPER_PI_TOOL_ID`
98
+ * is handed to Python at run time - so there is no binding inside the files that an import would
99
+ * have to rewrite, and nothing to leak when the file travels.
100
+ */
101
+ async exportBundle(id) {
102
+ await this.load();
103
+ const tool = this.items.find((x) => x.id === id);
104
+ if (!tool)
105
+ return undefined;
106
+ const root = join(this.toolsDir, tool.slug);
107
+ const files = [], skipped = [];
108
+ await collectAssets(root, root, files, skipped);
109
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
110
+ return {
111
+ bundle: {
112
+ format: EXPORT_FORMAT,
113
+ version: EXPORT_VERSION,
114
+ exportedAt: new Date().toISOString(),
115
+ slug: tool.slug,
116
+ name: tool.name,
117
+ ...(tool.description ? { description: tool.description } : {}),
118
+ entrypoint: tool.entrypoint,
119
+ files,
120
+ ...(tool.script ? { script: tool.script } : {}),
121
+ ...(tool.environment ? { environment: tool.environment } : {}),
122
+ ...(tool.permissions?.llm ? { permissions: { llm: true } } : {}),
123
+ },
124
+ skipped: [...skipped].sort(),
125
+ };
126
+ }
70
127
  }
71
128
  function normalizeAssets(files) { const paths = new Set(); let bytes = 0; return files.map((item) => { if (!item || typeof item.content !== "string")
72
129
  throw new Error("tool file content must be text"); const path = safeRelative(item.path); if (paths.has(path))
@@ -75,6 +132,70 @@ function normalizeAssets(files) { const paths = new Set(); let bytes = 0; return
75
132
  function normalizeScript(script, files) { const path = safeRelative(script.path); if (!path.endsWith(".py") || !files.some((x) => x.path === path))
76
133
  throw new Error("script must name an included .py file"); if (script.parameters && Object.values(script.parameters).some((x) => !isParam(x)))
77
134
  throw new Error("script parameter schema is invalid"); return { path, timeoutMs: script.timeoutMs, parameters: script.parameters }; }
135
+ /**
136
+ * THE PIN IS CHECKED HERE, ON THE WAY IN, AND A RANGE IS A REFUSAL RATHER THAN A WARNING.
137
+ * A warning would throw away exactly what the environment buys: by the time the user learned that
138
+ * `requests` had drifted, the tool had already been treated as runnable.
139
+ */
140
+ function normalizeEnvironment(environment) {
141
+ if (environment.requires !== undefined) {
142
+ if (!Array.isArray(environment.requires))
143
+ throw new Error("environment.requires 必须是数组");
144
+ if (environment.requires.length > 64)
145
+ throw new Error("工具依赖过多(上限 64 个)");
146
+ environment.requires = environment.requires.map((item) => {
147
+ if (typeof item !== "string" || !PINNED_REQUIREMENT.test(item.trim()))
148
+ throw new Error(`依赖必须钉死到精确版本(写成 pkg==version):${String(item).slice(0, 60)}`);
149
+ return item.trim();
150
+ });
151
+ }
152
+ if (environment.requiresPython !== undefined && (typeof environment.requiresPython !== "string" || !/^[\d.,<>=!~ ]{1,40}$/.test(environment.requiresPython)))
153
+ throw new Error("requiresPython 写法不合法,例如 >=3.10,<4");
154
+ return environment;
155
+ }
156
+ /** Only one permission exists, and only as a boolean. Anything else is DROPPED rather than stored, so a
157
+ * typo in a bundle cannot become a capability nobody implemented - and cannot fail the whole import
158
+ * either, which would make a stray key the difference between "installed" and "not". */
159
+ function normalizePermissions(permissions) {
160
+ if (!permissions || typeof permissions !== "object")
161
+ return undefined;
162
+ const llm = permissions.llm === true;
163
+ return llm ? { llm: true } : undefined;
164
+ }
165
+ /**
166
+ * Walks the tool directory. TWO THINGS ARE SKIPPED, and neither may be dropped quietly, because a
167
+ * bundle is a file the user is about to hand to someone else:
168
+ *
169
+ * - symlinks. `inside()` is a lexical check, so one link pointing out of the tool directory would
170
+ * be followed and its content packed into the bundle.
171
+ * - files that do not survive a utf8 round trip. The registry only ever writes text, but a
172
+ * hand-placed binary would come back as mojibake and re-import as a different tool.
173
+ *
174
+ * Both are reported in `skipped` so the caller can name them.
175
+ */
176
+ async function collectAssets(root, dir, files, skipped) {
177
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
178
+ const target = join(dir, entry.name), path = relative(root, target).split(sep).join("/");
179
+ if (entry.isSymbolicLink()) {
180
+ skipped.push(path);
181
+ continue;
182
+ }
183
+ if (entry.isDirectory()) {
184
+ await collectAssets(root, target, files, skipped);
185
+ continue;
186
+ }
187
+ if (!entry.isFile()) {
188
+ skipped.push(path);
189
+ continue;
190
+ }
191
+ const buffer = await readFile(target);
192
+ if (!Buffer.from(buffer.toString("utf8"), "utf8").equals(buffer)) {
193
+ skipped.push(path);
194
+ continue;
195
+ }
196
+ files.push({ path, content: buffer.toString("utf8") });
197
+ }
198
+ }
78
199
  async function writeAssets(root, files) { for (const asset of files) {
79
200
  const target = resolve(root, asset.path);
80
201
  if (!inside(root, target))
@@ -86,5 +207,7 @@ function safeRelative(value) { const p = value.replace(/\\/g, "/").trim(); if (!
86
207
  throw new Error("path must be relative and must not escape the tool directory"); return p; }
87
208
  function inside(root, target) { const r = relative(resolve(root), resolve(target)); return r === "" || (!r.startsWith(`..${sep}`) && r !== ".." && !r.includes(":")); }
88
209
  function isParam(value) { const x = value; return !!x && typeof x === "object" && ["string", "number", "boolean", "object", "array"].includes(x.type ?? "") && (x.required === undefined || typeof x.required === "boolean") && (x.enum === undefined || Array.isArray(x.enum)); }
89
- function isTool(value) { const x = value; return !!x && typeof x === "object" && typeof x.id === "string" && typeof x.slug === "string" && typeof x.name === "string" && typeof x.entrypoint === "string" && (x.source === "user" || x.source === "builtin"); }
90
- function copy(value) { return { ...value, script: value.script ? { ...value.script, parameters: value.script.parameters ? { ...value.script.parameters } : undefined } : undefined }; }
210
+ function isTool(value) { const x = value; if (!x || typeof x !== "object" || typeof x.id !== "string" || typeof x.slug !== "string" || typeof x.name !== "string" || typeof x.entrypoint !== "string" || (x.source !== "user" && x.source !== "builtin"))
211
+ return false; if (x.environment !== undefined && (typeof x.environment !== "object" || x.environment === null))
212
+ return false; return x.permissions === undefined || (typeof x.permissions === "object" && x.permissions !== null); }
213
+ function copy(value) { return { ...value, script: value.script ? { ...value.script, parameters: value.script.parameters ? { ...value.script.parameters } : undefined } : undefined, environment: value.environment ? { ...value.environment, requires: value.environment.requires ? [...value.environment.requires] : undefined } : undefined, permissions: value.permissions ? { ...value.permissions } : undefined }; }
@@ -1,3 +1,4 @@
1
+ import type { ToolEnvironment, ToolFingerprint } from "./tool-registry.js";
1
2
  export type ToolParameter = {
2
3
  type: "string" | "number" | "boolean" | "object" | "array";
3
4
  required?: boolean;
@@ -18,18 +19,87 @@ export type ToolRun = {
18
19
  result?: Record<string, unknown>;
19
20
  error?: string;
20
21
  };
22
+ /** Which interpreter a run will use. THE ONLY PLACE THAT DECISION IS MADE. */
23
+ export type PythonChoice = {
24
+ path: string;
25
+ kind: "tool-env" | "override" | "system";
26
+ };
27
+ /** What this machine has, MEASURED rather than assumed - it is what every tool's fingerprint is compared against. */
28
+ export type LocalPython = PythonChoice & ToolFingerprint;
29
+ /**
30
+ * WHAT THE TOOL LIST SHOWS NEXT TO EACH TOOL.
31
+ *
32
+ * `differs` is the interesting one: it does NOT block the tool (decision 6 in docs/TOOL-SHARING.md).
33
+ * An environment mismatch is usually harmless - a patch release, a different platform - and refusing
34
+ * would make a working tool unusable. This value's job is to say what is true, not to gate.
35
+ */
36
+ export type ToolEnvironmentState = {
37
+ state: "zero-deps" | "ready" | "needs-python" | "needs-env" | "differs";
38
+ python?: string;
39
+ platform?: string;
40
+ requires?: string[];
41
+ detail?: string;
42
+ };
21
43
  /** Runs a registered Python entrypoint without a shell. It intentionally accepts no command string. */
22
44
  export declare class ToolRunner {
45
+ private readonly dataDir?;
23
46
  private readonly runs;
47
+ /** Optional so the existing unit tests can build a runner with no data dir; a tool that needs no venv never touches it. */
48
+ constructor(dataDir?: string | undefined);
49
+ /**
50
+ * THE RESOLUTION ORDER, IN ONE PLACE, AND IT IS AN ORDER ON PURPOSE.
51
+ *
52
+ * 1. the tool's own venv - a tool's dependencies belong to that tool and to nothing else;
53
+ * 2. `SUPER_PI_PYTHON_COMMAND` - the user's explicit choice, already honoured before this existed;
54
+ * 3. the system interpreter - today's behaviour;
55
+ * 4. nothing usable, which is REPORTED as a sentence rather than discovered as an ENOENT.
56
+ *
57
+ * Bundling an interpreter later is a fourth rung on this chain, which is why nothing in a tool's
58
+ * files has to know about it.
59
+ */
60
+ resolvePython(toolId: string, needsVenv: boolean): PythonChoice;
61
+ /** The venv lives BESIDE the tools, not inside one: export walks the tool directory, and the mount route serves everything under it. */
62
+ envDir(toolId: string): string;
63
+ private envPython;
64
+ /** MEASURE this machine's Python. One spawn; the answer is what every fingerprint is compared to. */
65
+ describeLocalPython(): Promise<LocalPython | undefined>;
66
+ environmentState(tool: {
67
+ id: string;
68
+ environment?: ToolEnvironment;
69
+ }): Promise<ToolEnvironmentState>;
70
+ /**
71
+ * Starts a run. It RETURNS SYNCHRONOUSLY - so bad parameters still throw at the call site, exactly
72
+ * as before - while the interpreter and, if the tool needs one, its virtual environment are prepared
73
+ * in the background. The run is already `running` at that point and the build's output lands in the
74
+ * run's own `logs`, which is why the tool's existing dialog shows the progress without knowing
75
+ * anything about environments.
76
+ */
24
77
  start(input: {
25
78
  toolId: string;
26
79
  script: string;
27
80
  cwd: string;
28
81
  definition: ToolScript;
29
82
  params: Record<string, unknown>;
83
+ environment?: ToolEnvironment;
30
84
  }): ToolRun;
85
+ private prepare;
86
+ /**
87
+ * THE VENV IS PYTHON'S OWN, ON PURPOSE. `python -m venv` finds its site-packages through the
88
+ * pyvenv.cfg next to the interpreter, which is why it survives the `-I` a tool runs under - a
89
+ * PYTHONPATH-based vendor directory does not (verified: `-I` implies `-E`, so PYTHONPATH is
90
+ * ignored, and dropping `-I` would give up the isolation that keeps a hostile PYTHON* environment
91
+ * from influencing a shared tool's script).
92
+ */
93
+ private ensureEnvironment;
94
+ private launch;
31
95
  get(id: string, toolId: string): ToolRun | undefined;
32
96
  cancel(id: string, toolId: string): ToolRun | undefined;
33
97
  private finish;
34
98
  }
99
+ /**
100
+ * A SMALL, DELIBERATELY SMALL PEP 440 READER: `>=`, `>`, `<=`, `<`, `==`, comma-separated. Anything it
101
+ * cannot parse counts as NOT satisfied, because a version check that guesses is worse than one that
102
+ * refuses. A tool needing something exotic declares no range at all and relies on the venv, not here.
103
+ */
104
+ export declare function satisfiesRange(version: string, range: string): boolean;
35
105
  export declare function validateParams(schema: Record<string, ToolParameter>, params: Record<string, unknown>): void;
@@ -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.86",
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",