@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.
- package/agents/super-pi-task/index.ts +2 -2
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +9 -7
- package/dist/client/assets/index-B3QVoYJY.css +1 -0
- package/dist/client/assets/index-C_giUkvT.js +72 -0
- package/dist/client/index.html +2 -2
- package/dist/server/http-server.js +159 -7
- package/dist/server/index.js +8 -1
- package/dist/server/runtime.d.ts +34 -0
- package/dist/server/runtime.js +45 -0
- package/dist/server/server-context.d.ts +2 -0
- package/dist/server/tool-llm.d.ts +65 -0
- package/dist/server/tool-llm.js +139 -0
- package/dist/server/tool-registry.d.ts +65 -0
- package/dist/server/tool-registry.js +129 -6
- package/dist/server/tool-runner.d.ts +70 -0
- package/dist/server/tool-runner.js +210 -10
- package/package.json +4 -1
- package/dist/client/assets/index-B_jOwjyp.js +0 -72
- package/dist/client/assets/index-CNNGl4eF.css +0 -1
|
@@ -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
|
|
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
|
|
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;
|
|
90
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
const active = { run,
|
|
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
|
-
|
|
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",
|
|
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;
|
|
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.
|
|
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",
|