@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.
- 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 +10 -8
- package/dist/client/assets/index-BfK8kaCv.css +1 -0
- package/dist/client/assets/index-Dh3QFthH.js +73 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.js +48 -9
- 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/store.d.ts +19 -0
- package/dist/server/store.js +31 -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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A MOUNTED TOOL ASKING THE MODEL A QUESTION - bookkeeping, ceilings and the consent gate's server half.
|
|
3
|
+
*
|
|
4
|
+
* WHY A TOOL GETS THIS AT ALL. A tool author who wants a model in their tool should not be writing a
|
|
5
|
+
* client: no API key to store, no provider endpoint, no retry, no streaming, no cost ceiling. Pi already
|
|
6
|
+
* holds the credentials and the model configuration, so Super Pi owns the call and the tool asks for it
|
|
7
|
+
* over the same relative API it already uses for its Python jobs.
|
|
8
|
+
*
|
|
9
|
+
* WHY IT IS A COMPLETION AND NOT AN AGENT. `askOnce` spawns Pi with `--no-extensions --no-skills
|
|
10
|
+
* --no-mcp --no-context-files --no-prompt-templates`, so the model answering a tool's prompt has no
|
|
11
|
+
* tools, no files and no history. A tool that could make the agent run things would be handing a
|
|
12
|
+
* sandboxed iframe the user's whole machine, and the iframe is exactly the thing that must not have it.
|
|
13
|
+
*
|
|
14
|
+
* WHY EVERY CEILING IS HERE AND NOT IN THE FRAME. The caller is an opaque sandbox on someone else's
|
|
15
|
+
* machine. Prompt size, answer size, duration, and how many calls may be in flight at once are all
|
|
16
|
+
* decided by this file, because "the tool asked nicely" is not a limit.
|
|
17
|
+
*/
|
|
18
|
+
/** 32KB of prompt is a paragraph of a book; more than that is a tool trying to move a transcript. */
|
|
19
|
+
export const MAX_TOOL_LLM_PROMPT_BYTES = 32_000;
|
|
20
|
+
/** Answers are clipped rather than refused: a truncated answer is still an answer, and the model may be
|
|
21
|
+
* mid-sentence when the ceiling lands. The clip is visible in `chars` being lower than expected. */
|
|
22
|
+
export const MAX_TOOL_LLM_RESPONSE_BYTES = 64_000;
|
|
23
|
+
export const MAX_TOOL_LLM_TIMEOUT_MS = 5 * 60_000;
|
|
24
|
+
const DEFAULT_TOOL_LLM_TIMEOUT_MS = 60_000;
|
|
25
|
+
/** A tool that fires a hundred calls in a loop gets refused, loudly, instead of quietly costing money. */
|
|
26
|
+
const MAX_IN_FLIGHT_PER_TOOL = 3;
|
|
27
|
+
const MAX_IN_FLIGHT_TOTAL = 8;
|
|
28
|
+
export class ToolLlmService {
|
|
29
|
+
runtime;
|
|
30
|
+
cwd;
|
|
31
|
+
runs = new Map();
|
|
32
|
+
constructor(runtime, cwd) {
|
|
33
|
+
this.runtime = runtime;
|
|
34
|
+
this.cwd = cwd;
|
|
35
|
+
}
|
|
36
|
+
/** WHY A MISSING RUNTIME IS A SENTENCE AND NOT A 500. The welcome runtime has no Pi behind it, and a
|
|
37
|
+
* tool asking a question there should be told the machine cannot answer it. */
|
|
38
|
+
get available() { return typeof this.runtime?.askOnce === "function"; }
|
|
39
|
+
start(request) {
|
|
40
|
+
if (!this.available)
|
|
41
|
+
throw new Error("这台机器上没有可用的 Pi 运行时,工具无法调用模型。");
|
|
42
|
+
const prompt = typeof request.prompt === "string" ? request.prompt.trim() : "";
|
|
43
|
+
if (!prompt)
|
|
44
|
+
throw new Error("请提供要问模型的内容。");
|
|
45
|
+
if (Buffer.byteLength(prompt) > MAX_TOOL_LLM_PROMPT_BYTES)
|
|
46
|
+
throw new Error(`提问太长(上限 ${Math.round(MAX_TOOL_LLM_PROMPT_BYTES / 1024)}KB)。`);
|
|
47
|
+
const system = typeof request.system === "string" ? request.system.trim() : "";
|
|
48
|
+
if (system && Buffer.byteLength(system) > MAX_TOOL_LLM_PROMPT_BYTES)
|
|
49
|
+
throw new Error("系统提示太长。");
|
|
50
|
+
const running = [...this.runs.values()].filter((active) => active.run.status === "running");
|
|
51
|
+
if (running.length >= MAX_IN_FLIGHT_TOTAL)
|
|
52
|
+
throw new Error("已经有太多模型请求在排队,请稍后再试。");
|
|
53
|
+
if (running.filter((active) => active.run.toolId === request.toolId).length >= MAX_IN_FLIGHT_PER_TOOL)
|
|
54
|
+
throw new Error("这个工具同时在等的模型请求已经到上限了。");
|
|
55
|
+
const timeoutMs = Number.isFinite(request.timeoutMs) ? Math.max(1_000, Math.min(MAX_TOOL_LLM_TIMEOUT_MS, Math.floor(request.timeoutMs))) : DEFAULT_TOOL_LLM_TIMEOUT_MS;
|
|
56
|
+
const id = `toolllm_${randomId()}`;
|
|
57
|
+
const run = { id, toolId: request.toolId, status: "running", startedAt: new Date().toISOString(), text: "", promptChars: prompt.length };
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
// The deadline is this service's own, not the frame's: a frame that stops polling does not get to keep
|
|
60
|
+
// a Pi process and a model call alive.
|
|
61
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
62
|
+
timer.unref();
|
|
63
|
+
const active = { run, controller, timer };
|
|
64
|
+
this.runs.set(id, active);
|
|
65
|
+
void (async () => {
|
|
66
|
+
try {
|
|
67
|
+
// Narrowed by `available` in `start`, and captured here: the service outlives any single call.
|
|
68
|
+
const ask = this.runtime?.askOnce?.bind(this.runtime);
|
|
69
|
+
if (!ask)
|
|
70
|
+
throw new Error("这台机器上没有可用的 Pi 运行时,工具无法调用模型。");
|
|
71
|
+
const answer = await ask({
|
|
72
|
+
prompt: system ? `${system}\n\n---\n\n${prompt}` : prompt,
|
|
73
|
+
timeoutMs,
|
|
74
|
+
cwd: this.cwd,
|
|
75
|
+
signal: controller.signal,
|
|
76
|
+
});
|
|
77
|
+
if (active.run.status !== "running")
|
|
78
|
+
return;
|
|
79
|
+
active.run.text = clip(answer);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (active.run.status !== "running")
|
|
83
|
+
return;
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
active.run.status = controller.signal.aborted && active.run.finishedAt === undefined ? "timed_out" : "failed";
|
|
86
|
+
active.run.error = controller.signal.aborted ? `模型在 ${Math.round(timeoutMs / 1000)} 秒内没有返回` : message;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
if (active.run.status === "running")
|
|
91
|
+
active.run.status = "succeeded";
|
|
92
|
+
active.run.finishedAt ??= new Date().toISOString();
|
|
93
|
+
active.run.elapsedMs = Date.now() - Date.parse(active.run.startedAt);
|
|
94
|
+
// Bounded memory: a tool that made a thousand calls must not leave a thousand answers in the map.
|
|
95
|
+
if (this.runs.size > 64) {
|
|
96
|
+
const oldest = [...this.runs.entries()].filter(([, entry]) => entry.run.status !== "running").sort((a, b) => Date.parse(a[1].run.finishedAt ?? "") - Date.parse(b[1].run.finishedAt ?? "")).slice(0, this.runs.size - 64);
|
|
97
|
+
for (const [key] of oldest)
|
|
98
|
+
this.runs.delete(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
return copy(run);
|
|
103
|
+
}
|
|
104
|
+
get(id, toolId) { const active = this.runs.get(id); return active?.run.toolId === toolId ? copy(active.run) : undefined; }
|
|
105
|
+
cancel(id, toolId) {
|
|
106
|
+
const active = this.runs.get(id);
|
|
107
|
+
if (!active || active.run.toolId !== toolId)
|
|
108
|
+
return undefined;
|
|
109
|
+
if (active.run.status === "running") {
|
|
110
|
+
active.controller.abort();
|
|
111
|
+
active.run.status = "cancelled";
|
|
112
|
+
active.run.error = "已取消";
|
|
113
|
+
}
|
|
114
|
+
return copy(active.run);
|
|
115
|
+
}
|
|
116
|
+
async wait(id, toolId, signal) {
|
|
117
|
+
const deadline = Date.now() + MAX_TOOL_LLM_TIMEOUT_MS + 5_000;
|
|
118
|
+
for (;;) {
|
|
119
|
+
const run = this.get(id, toolId);
|
|
120
|
+
if (!run)
|
|
121
|
+
throw new Error("这次模型请求不存在或已经过期。");
|
|
122
|
+
if (run.status !== "running")
|
|
123
|
+
return run;
|
|
124
|
+
if (signal?.aborted)
|
|
125
|
+
throw new Error("已取消");
|
|
126
|
+
if (Date.now() > deadline)
|
|
127
|
+
throw new Error("等待模型回答超时。");
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function clip(text) {
|
|
133
|
+
const buffer = Buffer.from(text, "utf8");
|
|
134
|
+
if (buffer.length <= MAX_TOOL_LLM_RESPONSE_BYTES)
|
|
135
|
+
return text;
|
|
136
|
+
return buffer.subarray(0, MAX_TOOL_LLM_RESPONSE_BYTES).toString("utf8");
|
|
137
|
+
}
|
|
138
|
+
function copy(run) { return { ...run }; }
|
|
139
|
+
function randomId() { return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2); }
|
|
@@ -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;
|