@evandu/super-pi 0.1.75 → 0.1.77

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.
@@ -8,7 +8,7 @@
8
8
  <link rel="alternate icon" href="/favicon.ico" />
9
9
  <link rel="apple-touch-icon" href="/favicon.svg" />
10
10
  <title>Super Pi</title>
11
- <script type="module" crossorigin src="/assets/index-Bi2QvT9i.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-DiG980gH.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-CKdlMXJt.css">
13
13
  </head>
14
14
  <body>
@@ -8,6 +8,7 @@ import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES
8
8
  import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
9
9
  import { checkDiskVersion } from "./version.js";
10
10
  import { gitDiff, gitLog, gitStatus } from "./git.js";
11
+ import { verifyCapability } from "./capability.js";
11
12
  const MIME_TYPES = {
12
13
  ".css": "text/css; charset=utf-8",
13
14
  ".html": "text/html; charset=utf-8",
@@ -17,6 +18,41 @@ const MIME_TYPES = {
17
18
  ".ico": "image/x-icon",
18
19
  ".png": "image/png",
19
20
  };
21
+ /**
22
+ * The only browser API a mounted tool needs. It is served beneath the tool's own
23
+ * mount, so every request stays relative to that mount and the server can derive
24
+ * the tool identity without exposing a registry id, a workspace, or a credential.
25
+ */
26
+ function toolBrowserSdk() {
27
+ return String.raw `(() => {
28
+ const request = async (path, init) => {
29
+ const response = await fetch(path, init);
30
+ let body;
31
+ try { body = await response.json(); } catch { body = { error: "Super Pi returned an unreadable response" }; }
32
+ if (!response.ok) throw new Error(body && body.error ? body.error : "Tool request failed (HTTP " + response.status + ")");
33
+ return body;
34
+ };
35
+ const api = {
36
+ run: (params = {}) => request("./api/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ params }) }),
37
+ get: (runId) => request("./api/runs/" + encodeURIComponent(runId), { method: "GET" }),
38
+ cancel: (runId) => request("./api/runs/" + encodeURIComponent(runId), { method: "DELETE" }),
39
+ wait: async (runId, { intervalMs = 250, signal } = {}) => {
40
+ for (;;) {
41
+ if (signal && signal.aborted) throw new DOMException("Aborted", "AbortError");
42
+ const run = await api.get(runId);
43
+ if (run.status !== "running") return run;
44
+ await new Promise((resolve, reject) => {
45
+ const timer = setTimeout(resolve, Math.max(50, intervalMs));
46
+ if (signal) signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); }, { once: true });
47
+ });
48
+ }
49
+ },
50
+ runAndWait: async (params = {}, options) => { const run = await api.run(params); return api.wait(run.id, options); },
51
+ };
52
+ Object.freeze(api);
53
+ Object.defineProperty(window, "SuperPiTool", { value: api, writable: false, configurable: false });
54
+ })();`;
55
+ }
20
56
  /**
21
57
  * Checks if the remote address is a local loopback interface.
22
58
  */
@@ -132,11 +168,14 @@ export function isAuthorized(request, accessToken) {
132
168
  */
133
169
  export function createHttpServer(options) {
134
170
  const { context, onShutdown } = options;
135
- const { store, paths, accessToken, broadcaster, runService, catalogService, execService, toolRegistry } = context;
171
+ const { store, paths, accessToken, broadcaster, runService, catalogService, execService, toolRegistry, toolRunner } = context;
136
172
  const { uploadsDir, clientDir, rootDir } = paths;
137
173
  return createServer(async (request, response) => {
138
174
  // Internal tools use a narrow per-run capability, never the global management token.
139
- if (request.url?.split("?")[0] !== "/api/memory/tool" && !isAuthorized(request, accessToken)) {
175
+ const requestPath = request.url?.split("?")[0] ?? "";
176
+ // Tool registration is checked after its JSON body is parsed: an agent proves its turn capability
177
+ // there, while an interactive browser uses the ordinary server access token.
178
+ if (requestPath !== "/api/memory/tool" && requestPath !== "/api/tools" && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
140
179
  response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
141
180
  return;
142
181
  }
@@ -171,12 +210,22 @@ export function createHttpServer(options) {
171
210
  }
172
211
  }
173
212
  const body = JSON.parse(raw);
174
- const workspace = store.snapshot().workspaces.find((x) => x.id === body.workspaceId || (body.workspacePath && resolve(x.path) === resolve(body.workspacePath)));
175
- if (!workspace || !body.slug || !body.name || !body.entrypoint) {
176
- json(400, { error: "workspaceId (or workspacePath), slug, name and entrypoint are required" });
213
+ if (!isAuthorized(request, accessToken)) {
214
+ if (!body.conversationId) {
215
+ json(401, { error: "authentication or agent capability required" });
216
+ return;
217
+ }
218
+ const verdict = verifyCapability(body.conversationId, body.capability ?? request.headers["x-super-pi-capability"]);
219
+ if (!verdict.ok) {
220
+ json(403, { error: verdict.message });
221
+ return;
222
+ }
223
+ }
224
+ if (!body.slug || !body.name || !Array.isArray(body.files)) {
225
+ json(400, { error: "slug, name and files are required" });
177
226
  return;
178
227
  }
179
- const tool = await toolRegistry.register({ workspaceId: workspace.id, slug: body.slug, name: body.name, description: body.description, entrypoint: body.entrypoint }, workspace.path);
228
+ const tool = await toolRegistry.register({ slug: body.slug, name: body.name, description: body.description, entrypoint: body.entrypoint, script: body.script, files: body.files });
180
229
  broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
181
230
  json(201, tool);
182
231
  return;
@@ -195,13 +244,89 @@ export function createHttpServer(options) {
195
244
  response.writeHead(404).end("Tool not found");
196
245
  return;
197
246
  }
198
- const workspace = store.snapshot().workspaces.find((x) => x.id === listed.workspaceId);
199
- if (!workspace) {
200
- response.writeHead(404).end("Workspace not found");
247
+ // A mounted tool never learns its random registry id. Its API is instead addressed relative
248
+ // to its own mount (`./api/run`), and this route supplies that identity from the URL. Besides
249
+ // making a generated tool usable on its first render, this binds status and cancellation to the
250
+ // same tool that created a run: an opaque frame for tool A cannot inspect tool B's run ids.
251
+ const toolOrigin = request.headers.origin === "null";
252
+ const toolApi = (status, body) => {
253
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "access-control-allow-origin": "null", "vary": "Origin" });
254
+ response.end(JSON.stringify(body));
255
+ };
256
+ if (resource === "api/run") {
257
+ if (!toolOrigin) {
258
+ toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
259
+ return;
260
+ }
261
+ if (request.method === "OPTIONS") {
262
+ response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "vary": "Origin" }).end();
263
+ return;
264
+ }
265
+ if (request.method !== "POST") {
266
+ toolApi(405, { error: "method" });
267
+ return;
268
+ }
269
+ let raw = "";
270
+ for await (const chunk of request) {
271
+ raw += chunk;
272
+ if (raw.length > 64_000) {
273
+ toolApi(413, { error: "request too large" });
274
+ return;
275
+ }
276
+ }
277
+ try {
278
+ const body = JSON.parse(raw);
279
+ const resolved = await toolRegistry.resolve(id);
280
+ if (!resolved?.tool.script) {
281
+ toolApi(404, { error: "python script is not registered" });
282
+ return;
283
+ }
284
+ const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {} });
285
+ toolApi(202, run);
286
+ }
287
+ catch (error) {
288
+ toolApi(400, { error: error instanceof Error ? error.message : "tool run failed" });
289
+ }
290
+ return;
291
+ }
292
+ const runMatch = /^api\/runs\/([^/]+)$/.exec(resource);
293
+ if (runMatch) {
294
+ if (!toolOrigin) {
295
+ toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
296
+ return;
297
+ }
298
+ if (request.method === "OPTIONS") {
299
+ response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "vary": "Origin" }).end();
300
+ return;
301
+ }
302
+ const runId = decodeURIComponent(runMatch[1]);
303
+ if (request.method === "GET") {
304
+ const run = toolRunner.get(runId, id);
305
+ if (!run) {
306
+ toolApi(404, { error: "run not found" });
307
+ return;
308
+ }
309
+ toolApi(200, run);
310
+ return;
311
+ }
312
+ if (request.method === "DELETE") {
313
+ const run = toolRunner.cancel(runId, id);
314
+ if (!run) {
315
+ toolApi(404, { error: "run not found" });
316
+ return;
317
+ }
318
+ toolApi(200, run);
319
+ return;
320
+ }
321
+ toolApi(405, { error: "method" });
322
+ return;
323
+ }
324
+ if (resource === "super-pi-tool.js") {
325
+ response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }).end(toolBrowserSdk());
201
326
  return;
202
327
  }
203
328
  try {
204
- const resolved = await toolRegistry.resolve(id, workspace.path, resource);
329
+ const resolved = await toolRegistry.resolve(id, resource);
205
330
  if (!resolved) {
206
331
  response.writeHead(404).end("Tool not found");
207
332
  return;
@@ -23,6 +23,7 @@ import { createHttpServer, getLanIps, isLocalOrPrivateAddress, parseServerArgs }
23
23
  import { WsBroadcaster, WsServer } from "./ws-server.js";
24
24
  import { initRunningVersion } from "./version.js";
25
25
  import { ToolRegistry } from "./tool-registry.js";
26
+ import { ToolRunner } from "./tool-runner.js";
26
27
  // ------------------------------------------------------------- Environment & paths
27
28
  const rootDir = resolve(import.meta.dirname, "../..");
28
29
  initRunningVersion(rootDir);
@@ -51,6 +52,7 @@ const toolsEndpoint = `http://${internalHost}:${port}/api/tools`;
51
52
  // ------------------------------------------------------------- Core singletons
52
53
  const store = await openStateStore(dataDir, process.env.SUPER_PI_PERSISTENCE ?? "sqlite");
53
54
  const toolRegistry = new ToolRegistry(dataDir);
55
+ const toolRunner = new ToolRunner();
54
56
  const initialAgentDirs = resolveAgentDirectories({
55
57
  customDirs: store.snapshot().customAgentDirs,
56
58
  rootDir,
@@ -159,6 +161,7 @@ const serverContext = {
159
161
  catalogService,
160
162
  execService,
161
163
  toolRegistry,
164
+ toolRunner,
162
165
  terminalEnabled: () => terminalService.enabled,
163
166
  terminalDisabledReason: () => terminalDisabledReason(),
164
167
  };
@@ -379,6 +379,7 @@ export class PiRpcRuntime {
379
379
  const { agents, problems } = await readAgents(this.agents.dir, { mode: "subagent" });
380
380
  for (const problem of problems)
381
381
  console.warn(`PiRpcRuntime: ${problem.file} is not a usable sub-agent - ${problem.message}`);
382
+ const capability = mintCapability(conversationId);
382
383
  return {
383
384
  [TASK_ENDPOINT_ENV]: this.options.endpoint ?? "",
384
385
  [TASK_EXEC_ENDPOINT_ENV]: this.options.execEndpoint ?? "",
@@ -389,11 +390,12 @@ export class PiRpcRuntime {
389
390
  [WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
390
391
  SUPER_PI_QUESTION_ENDPOINT: this.options.questionEndpoint ?? "",
391
392
  SUPER_PI_TOOLS_ENDPOINT: this.options.toolsEndpoint ?? "",
393
+ SUPER_PI_TOOLS_CAPABILITY: capability,
392
394
  // MINTED HERE, AT SPAWN, rather than read from anywhere: this is the only moment the value is
393
395
  // decided, so it cannot change underneath a running child. `SubagentSupervisor` never calls this
394
396
  // function, so no sub-agent process is ever handed a capability - which is what makes a sub-agent
395
397
  // unable to dispatch, however its allowlist is broken.
396
- [TASK_CAPABILITY_ENV]: mintCapability(conversationId),
398
+ [TASK_CAPABILITY_ENV]: capability,
397
399
  };
398
400
  }
399
401
  async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, images, memoryReview, memorySourceConversationId }) {
@@ -9,6 +9,7 @@ import type { ExtensionUiService } from "./application/extension-ui-service.js";
9
9
  import type { CatalogService } from "./application/catalog-service.js";
10
10
  import type { ExecService } from "./exec-service.js";
11
11
  import type { ToolRegistry } from "./tool-registry.js";
12
+ import type { ToolRunner } from "./tool-runner.js";
12
13
  /**
13
14
  * Common filesystem paths used across the server.
14
15
  */
@@ -49,6 +50,7 @@ export interface ServerContext {
49
50
  catalogService: CatalogService;
50
51
  execService: ExecService;
51
52
  toolRegistry: ToolRegistry;
53
+ toolRunner: ToolRunner;
52
54
  /**
53
55
  * Whether the right rail's terminal tab should exist, and why not when it should not.
54
56
  *
@@ -1,18 +1,24 @@
1
+ import type { ToolScript } from "./tool-runner.js";
2
+ export type ToolAsset = {
3
+ path: string;
4
+ content: string;
5
+ };
1
6
  export type MountedTool = {
2
7
  id: string;
3
8
  slug: string;
4
9
  name: string;
5
10
  description?: string;
6
- workspaceId: string;
7
11
  entrypoint: string;
12
+ script?: ToolScript;
8
13
  source: "user" | "builtin";
9
14
  enabled: boolean;
10
15
  createdAt: string;
11
16
  updatedAt: string;
12
17
  };
18
+ /** Global registry: a tool belongs to Super Pi, never to the workspace that created it. */
13
19
  export declare class ToolRegistry {
14
- private readonly dataDir;
15
20
  private readonly file;
21
+ readonly toolsDir: string;
16
22
  private items;
17
23
  private loaded;
18
24
  constructor(dataDir: string);
@@ -20,16 +26,17 @@ export declare class ToolRegistry {
20
26
  private save;
21
27
  list(): Promise<MountedTool[]>;
22
28
  register(input: {
23
- workspaceId: string;
24
29
  slug: string;
25
30
  name: string;
26
31
  description?: string;
27
- entrypoint: string;
28
- }, workspaceRoot: string): Promise<MountedTool>;
32
+ entrypoint?: string;
33
+ script?: ToolScript;
34
+ files: ToolAsset[];
35
+ }): Promise<MountedTool>;
29
36
  remove(id: string): Promise<boolean>;
30
- setEnabled(id: string, enabled: boolean): Promise<MountedTool | undefined>;
31
- resolve(id: string, workspaceRoot: string, resource?: string): Promise<{
37
+ resolve(id: string, resource?: string): Promise<{
32
38
  tool: MountedTool;
33
39
  file: string;
40
+ root: string;
34
41
  } | undefined>;
35
42
  }
@@ -1,74 +1,90 @@
1
- import { mkdir, readFile, rename, writeFile, access, stat } from "node:fs/promises";
1
+ import { mkdir, 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;
4
- const MAX_SLUG = 64;
5
- const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
3
+ const MAX_TOOLS = 128, MAX_ASSETS = 32, MAX_ASSET_BYTES = 512_000, SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
4
+ /** Global registry: a tool belongs to Super Pi, never to the workspace that created it. */
6
5
  export class ToolRegistry {
7
- dataDir;
8
6
  file;
7
+ toolsDir;
9
8
  items = [];
10
9
  loaded = false;
11
- constructor(dataDir) {
12
- this.dataDir = dataDir;
13
- this.file = join(dataDir, "tools.json");
10
+ constructor(dataDir) { this.file = join(dataDir, "tools.json"); this.toolsDir = join(dataDir, "tools"); }
11
+ async load() { if (this.loaded)
12
+ return; this.loaded = true; try {
13
+ const parsed = JSON.parse(await readFile(this.file, "utf8"));
14
+ if (Array.isArray(parsed))
15
+ this.items = parsed.filter(isTool);
14
16
  }
15
- async load() {
16
- if (this.loaded)
17
- return;
18
- this.loaded = true;
19
- try {
20
- const parsed = JSON.parse(await readFile(this.file, "utf8"));
21
- if (Array.isArray(parsed))
22
- this.items = parsed.filter(isTool);
23
- }
24
- catch {
25
- this.items = [];
26
- }
27
- }
28
- async save() {
29
- await mkdir(this.dataDir, { recursive: true });
30
- const tmp = `${this.file}.${process.pid}.tmp`;
31
- await writeFile(tmp, JSON.stringify(this.items, null, 2), "utf8");
32
- await rename(tmp, this.file);
33
- }
34
- async list() { await this.load(); return this.items.map((x) => ({ ...x })); }
35
- async register(input, workspaceRoot) {
17
+ catch {
18
+ this.items = [];
19
+ } }
20
+ async save() { await mkdir(dirname(this.file), { recursive: true }); const tmp = `${this.file}.${process.pid}.tmp`; await writeFile(tmp, JSON.stringify(this.items, null, 2), "utf8"); await rename(tmp, this.file); }
21
+ async list() { await this.load(); return this.items.map(copy); }
22
+ async register(input) {
36
23
  await this.load();
37
24
  const slug = input.slug.trim().toLowerCase();
38
25
  if (!SLUG.test(slug))
39
- throw new Error("slug 只能包含小写字母、数字和连字符");
26
+ throw new Error("slug must contain lowercase letters, digits, and hyphens only");
40
27
  if (!input.name.trim() || input.name.length > 120)
41
- throw new Error("工具名称无效");
42
- const entrypoint = safeRelative(input.entrypoint);
43
- const root = resolve(workspaceRoot);
44
- const file = resolve(root, entrypoint);
45
- if (!isInside(root, file))
46
- throw new Error("entrypoint 必须位于工作区内");
47
- const info = await stat(file).catch(() => undefined);
48
- if (!info?.isFile())
49
- throw new Error("entrypoint 文件不存在");
50
- if (!entrypoint.endsWith(".html"))
51
- throw new Error("entrypoint 必须是 HTML 文件");
52
- if (this.items.some((x) => x.workspaceId === input.workspaceId && x.slug === slug))
53
- throw new Error("工具 slug 已存在");
28
+ throw new Error("tool name is invalid");
29
+ if (this.items.some((x) => x.slug === slug))
30
+ throw new Error("tool slug already exists");
54
31
  if (this.items.length >= MAX_TOOLS)
55
- throw new Error("工具数量已达到上限");
56
- const now = new Date().toISOString();
57
- const tool = { id: `tool_${crypto.randomUUID()}`, slug, name: input.name.trim(), description: input.description?.trim().slice(0, 500), workspaceId: input.workspaceId, entrypoint, source: "user", enabled: true, createdAt: now, updatedAt: now };
32
+ throw new Error("tool limit reached");
33
+ if (!Array.isArray(input.files) || !input.files.length || input.files.length > MAX_ASSETS)
34
+ throw new Error("tool must contain 1-32 files");
35
+ const entrypoint = safeRelative(input.entrypoint ?? "index.html");
36
+ if (!entrypoint.endsWith(".html"))
37
+ throw new Error("entrypoint must be an HTML file");
38
+ const files = normalizeAssets(input.files);
39
+ if (!files.some((file) => file.path === entrypoint))
40
+ 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`;
42
+ if (await stat(finalDir).catch(() => undefined))
43
+ throw new Error("tool directory already exists");
44
+ try {
45
+ await writeAssets(stagingDir, files);
46
+ await rename(stagingDir, finalDir);
47
+ }
48
+ catch (error) {
49
+ await rm(stagingDir, { recursive: true, force: true });
50
+ throw error;
51
+ }
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 };
58
53
  this.items.push(tool);
59
- await this.save();
60
- return { ...tool };
54
+ try {
55
+ await this.save();
56
+ }
57
+ catch (error) {
58
+ this.items.pop();
59
+ await rm(finalDir, { recursive: true, force: true });
60
+ throw error;
61
+ }
62
+ return copy(tool);
61
63
  }
62
- async remove(id) { await this.load(); const before = this.items.length; this.items = this.items.filter((x) => x.id !== id); if (this.items.length !== before)
63
- await this.save(); return before !== this.items.length; }
64
- async setEnabled(id, enabled) { await this.load(); const item = this.items.find((x) => x.id === id); if (!item)
65
- return undefined; item.enabled = enabled; item.updatedAt = new Date().toISOString(); await this.save(); return { ...item }; }
66
- async resolve(id, workspaceRoot, resource = "") { await this.load(); const tool = this.items.find((x) => x.id === id && x.enabled); if (!tool)
67
- return undefined; const root = resolve(workspaceRoot); const entrypoint = resolve(root, tool.entrypoint); if (!isInside(root, entrypoint))
68
- return undefined; const file = resource ? resolve(dirname(entrypoint), safeRelative(resource)) : entrypoint; if (!isInside(dirname(entrypoint), file))
69
- throw new Error("工具资源必须位于工具目录内"); await access(file).catch(() => { throw new Error("工具文件不存在"); }); return { tool: { ...tool }, file }; }
64
+ async remove(id) { await this.load(); const i = this.items.findIndex((x) => x.id === id); if (i < 0)
65
+ return false; const [tool] = this.items.splice(i, 1); await this.save(); await rm(join(this.toolsDir, tool.slug), { recursive: true, force: true }); return true; }
66
+ async resolve(id, resource = "") { await this.load(); const tool = this.items.find((x) => x.id === id && x.enabled); if (!tool)
67
+ return undefined; const root = join(this.toolsDir, tool.slug), file = resolve(root, resource ? safeRelative(resource) : tool.entrypoint); if (!inside(root, file))
68
+ throw new Error("tool resource must stay inside the tool directory"); const info = await stat(file).catch(() => undefined); if (!info?.isFile())
69
+ throw new Error("tool file does not exist"); return { tool: copy(tool), file, root }; }
70
70
  }
71
+ function normalizeAssets(files) { const paths = new Set(); let bytes = 0; return files.map((item) => { if (!item || typeof item.content !== "string")
72
+ throw new Error("tool file content must be text"); const path = safeRelative(item.path); if (paths.has(path))
73
+ throw new Error(`duplicate tool file: ${path}`); paths.add(path); bytes += Buffer.byteLength(item.content); if (bytes > MAX_ASSET_BYTES)
74
+ throw new Error("tool files exceed 512KB"); return { path, content: item.content }; }); }
75
+ function normalizeScript(script, files) { const path = safeRelative(script.path); if (!path.endsWith(".py") || !files.some((x) => x.path === path))
76
+ throw new Error("script must name an included .py file"); if (script.parameters && Object.values(script.parameters).some((x) => !isParam(x)))
77
+ throw new Error("script parameter schema is invalid"); return { path, timeoutMs: script.timeoutMs, parameters: script.parameters }; }
78
+ async function writeAssets(root, files) { for (const asset of files) {
79
+ const target = resolve(root, asset.path);
80
+ if (!inside(root, target))
81
+ throw new Error("tool file must stay inside its tool directory");
82
+ await mkdir(dirname(target), { recursive: true });
83
+ await writeFile(target, asset.content, "utf8");
84
+ } }
71
85
  function safeRelative(value) { const p = value.replace(/\\/g, "/").trim(); if (!p || p.startsWith("/") || p.includes("\0") || p.split("/").includes(".."))
72
- throw new Error("路径必须是工作区内的相对路径"); return p; }
73
- function isInside(root, target) { const r = relative(root, target); return r === "" || (!r.startsWith(".." + sep) && r !== ".." && !r.includes(":")); }
74
- function isTool(value) { const x = value; return !!x && typeof x === "object" && typeof x.id === "string" && typeof x.slug === "string" && typeof x.workspaceId === "string" && typeof x.entrypoint === "string" && (x.source === "user" || x.source === "builtin"); }
86
+ throw new Error("path must be relative and must not escape the tool directory"); return p; }
87
+ function inside(root, target) { const r = relative(resolve(root), resolve(target)); return r === "" || (!r.startsWith(`..${sep}`) && r !== ".." && !r.includes(":")); }
88
+ 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 }; }
@@ -0,0 +1,35 @@
1
+ export type ToolParameter = {
2
+ type: "string" | "number" | "boolean" | "object" | "array";
3
+ required?: boolean;
4
+ enum?: Array<string | number | boolean>;
5
+ };
6
+ export type ToolScript = {
7
+ path: string;
8
+ timeoutMs?: number;
9
+ parameters?: Record<string, ToolParameter>;
10
+ };
11
+ export type ToolRun = {
12
+ id: string;
13
+ toolId: string;
14
+ status: "running" | "succeeded" | "failed" | "cancelled" | "timed_out";
15
+ startedAt: string;
16
+ finishedAt?: string;
17
+ logs: string;
18
+ result?: Record<string, unknown>;
19
+ error?: string;
20
+ };
21
+ /** Runs a registered Python entrypoint without a shell. It intentionally accepts no command string. */
22
+ export declare class ToolRunner {
23
+ private readonly runs;
24
+ start(input: {
25
+ toolId: string;
26
+ script: string;
27
+ cwd: string;
28
+ definition: ToolScript;
29
+ params: Record<string, unknown>;
30
+ }): ToolRun;
31
+ get(id: string, toolId: string): ToolRun | undefined;
32
+ cancel(id: string, toolId: string): ToolRun | undefined;
33
+ private finish;
34
+ }
35
+ export declare function validateParams(schema: Record<string, ToolParameter>, params: Record<string, unknown>): void;
@@ -0,0 +1,81 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ const MAX_LOG_BYTES = 512_000;
4
+ const MAX_TIMEOUT_MS = 10 * 60_000;
5
+ /** Runs a registered Python entrypoint without a shell. It intentionally accepts no command string. */
6
+ export class ToolRunner {
7
+ runs = new Map();
8
+ start(input) {
9
+ validateParams(input.definition.parameters ?? {}, input.params);
10
+ const python = process.env.SUPER_PI_PYTHON_COMMAND?.trim() || (process.platform === "win32" ? "python.exe" : "python3");
11
+ const id = `toolrun_${randomUUID()}`;
12
+ 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();
17
+ this.runs.set(id, active);
18
+ const log = (data) => { active.run.logs = clipped(`${active.run.logs}${data.toString("utf8")}`); };
19
+ child.stdout.on("data", (data) => { active.stdout = clipped(`${active.stdout}${data.toString("utf8")}`); });
20
+ child.stderr.on("data", log);
21
+ child.once("error", (error) => this.finish(id, "failed", error.message));
22
+ child.once("exit", (code) => {
23
+ if (active.run.status !== "running")
24
+ return;
25
+ if (code !== 0) {
26
+ this.finish(id, "failed", `Python exited with code ${code ?? "unknown"}`);
27
+ return;
28
+ }
29
+ try {
30
+ const parsed = JSON.parse(active.stdout);
31
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object")
32
+ throw new Error("stdout must be a JSON object");
33
+ active.run.result = parsed;
34
+ this.finish(id, "succeeded");
35
+ }
36
+ catch (error) {
37
+ active.run.logs = clipped(`${active.run.logs}${active.stdout}`);
38
+ this.finish(id, "failed", `脚本没有输出一个 JSON 对象:${error instanceof Error ? error.message : "invalid output"}`);
39
+ }
40
+ });
41
+ child.stdin.end(JSON.stringify(input.params));
42
+ return copy(run);
43
+ }
44
+ get(id, toolId) { const active = this.runs.get(id); return active?.run.toolId === toolId ? copy(active.run) : undefined; }
45
+ cancel(id, toolId) { const active = this.runs.get(id); if (!active || active.run.toolId !== toolId)
46
+ return undefined; if (active.run.status === "running") {
47
+ active.cancelled = true;
48
+ this.finish(id, "cancelled");
49
+ } return copy(active.run); }
50
+ finish(id, status, error) { const active = this.runs.get(id); if (!active || active.run.status !== "running")
51
+ return; clearTimeout(active.timer); active.run.status = status; active.run.finishedAt = new Date().toISOString(); if (error)
52
+ active.run.error = error; if (!active.child.killed) {
53
+ try {
54
+ active.child.kill();
55
+ }
56
+ catch { }
57
+ } }
58
+ }
59
+ function boundedTimeout(value) { return Math.max(1_000, Math.min(MAX_TIMEOUT_MS, Number.isFinite(value) ? Math.floor(value) : 60_000)); }
60
+ function clipped(text) { return text.length > MAX_LOG_BYTES ? text.slice(-MAX_LOG_BYTES) : text; }
61
+ function copy(run) { return { ...run, result: run.result ? { ...run.result } : undefined }; }
62
+ export function validateParams(schema, params) {
63
+ if (!params || Array.isArray(params) || typeof params !== "object")
64
+ throw new Error("params 必须是对象");
65
+ for (const key of Object.keys(params))
66
+ if (!schema[key])
67
+ throw new Error(`不允许的参数:${key}`);
68
+ for (const [key, rule] of Object.entries(schema)) {
69
+ const value = params[key];
70
+ if (value === undefined) {
71
+ if (rule.required)
72
+ throw new Error(`缺少参数:${key}`);
73
+ continue;
74
+ }
75
+ const actual = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
76
+ if (actual !== rule.type)
77
+ throw new Error(`参数 ${key} 必须是 ${rule.type}`);
78
+ if (rule.enum && !rule.enum.includes(value))
79
+ throw new Error(`参数 ${key} 不在允许值中`);
80
+ }
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -84,6 +84,8 @@
84
84
  "scripts/agent-config-regression.test.mjs",
85
85
  "scripts/model-filter.test.mjs",
86
86
  "scripts/active-memory.test.mjs",
87
+ "scripts/tool-runner.test.mjs",
88
+ "scripts/tool-runtime-api.test.mjs",
87
89
  "scripts/extension-load.test.mjs"
88
90
  ],
89
91
  "check:browser": [