@evandu/super-pi 0.1.21 → 0.1.23

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.
Files changed (51) hide show
  1. package/README.md +58 -1
  2. package/agents/super-pi-memory/index.ts +34 -0
  3. package/dist/client/assets/index-BKKZnCis.css +1 -0
  4. package/dist/client/assets/index-LWO_0omC.js +50 -0
  5. package/dist/client/favicon.ico +0 -0
  6. package/dist/client/favicon.png +0 -0
  7. package/dist/client/favicon.svg +10 -0
  8. package/dist/client/index.html +5 -2
  9. package/dist/server/active-memory.d.ts +118 -0
  10. package/dist/server/active-memory.js +214 -0
  11. package/dist/server/application/conversation-service.js +1 -0
  12. package/dist/server/application/run-service.d.ts +12 -0
  13. package/dist/server/application/run-service.js +48 -4
  14. package/dist/server/command-handler.d.ts +16 -2
  15. package/dist/server/command-handler.js +24 -2
  16. package/dist/server/files.d.ts +1 -0
  17. package/dist/server/files.js +77 -46
  18. package/dist/server/http-server.d.ts +17 -1
  19. package/dist/server/http-server.js +185 -20
  20. package/dist/server/index.js +97 -29
  21. package/dist/server/memory.d.ts +25 -0
  22. package/dist/server/memory.js +88 -0
  23. package/dist/server/persistence/app-state-repository.d.ts +27 -0
  24. package/dist/server/persistence/app-state-repository.js +1 -0
  25. package/dist/server/persistence/json-state-repository.d.ts +12 -0
  26. package/dist/server/persistence/json-state-repository.js +24 -0
  27. package/dist/server/persistence/open-state-store.d.ts +3 -0
  28. package/dist/server/persistence/open-state-store.js +35 -0
  29. package/dist/server/persistence/rename.d.ts +22 -0
  30. package/dist/server/persistence/rename.js +44 -0
  31. package/dist/server/persistence/sqlite-state-repository.d.ts +13 -0
  32. package/dist/server/persistence/sqlite-state-repository.js +114 -0
  33. package/dist/server/runtime.d.ts +7 -1
  34. package/dist/server/runtime.js +23 -10
  35. package/dist/server/server-context.d.ts +12 -0
  36. package/dist/server/store.d.ts +12 -3
  37. package/dist/server/store.js +55 -82
  38. package/dist/server/subagent-runs.d.ts +2 -0
  39. package/dist/server/subagent-runs.js +6 -0
  40. package/dist/server/subagents.d.ts +2 -0
  41. package/dist/server/subagents.js +11 -3
  42. package/dist/server/terminal.d.ts +139 -0
  43. package/dist/server/terminal.js +366 -0
  44. package/dist/server/types.d.ts +69 -2
  45. package/dist/server/workflow-engine.d.ts +2 -0
  46. package/dist/server/workflow-engine.js +6 -0
  47. package/dist/server/ws-server.d.ts +11 -0
  48. package/dist/server/ws-server.js +42 -2
  49. package/package.json +2 -129
  50. package/dist/client/assets/index-B43FWHQ6.js +0 -43
  51. package/dist/client/assets/index-BY__2nV9.css +0 -1
@@ -28,8 +28,8 @@
28
28
  * directory a 404 that names itself instead of a containment failure about a symlink that does not exist.
29
29
  */
30
30
  import { createHash } from "node:crypto";
31
- import { readFile, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
32
- import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
31
+ import { mkdtemp, readFile, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
32
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
33
33
  /**
34
34
  * Above this a read is refused rather than served. 5MB is roughly a large source file or a long document,
35
35
  * and it is deliberately far below the point where an editor tab stops being useful: this panel is for
@@ -395,13 +395,13 @@ export async function listDirectory(root, requested) {
395
395
  let outside = false;
396
396
  if (isLink) {
397
397
  try {
398
- await guard(absolute);
398
+ await guard(toRelative(root, absolute));
399
399
  }
400
400
  catch {
401
401
  outside = true;
402
402
  }
403
403
  }
404
- const isDir = dirent.isDirectory();
404
+ const isDir = dirent.isDirectory() || (isLink && !outside && await stat(absolute).then((item) => item.isDirectory()).catch(() => false));
405
405
  entries.push({
406
406
  path: toRelative(root, absolute),
407
407
  name: dirent.name,
@@ -474,7 +474,14 @@ export async function searchByName(root, query) {
474
474
  }
475
475
  throw error;
476
476
  }
477
- for (const entry of listing.entries) {
477
+ // listDirectory intentionally caps UI listings; search must inspect the complete directory.
478
+ const fullEntries = listing.truncated
479
+ ? (await readdir(await guard(directory), { withFileTypes: true })).map((dirent) => {
480
+ const absolute = resolve(root, directory, dirent.name);
481
+ return { path: toRelative(root, absolute), name: dirent.name, kind: dirent.isDirectory() ? "directory" : dirent.isSymbolicLink() ? "symlink" : "file", text: null };
482
+ })
483
+ : listing.entries;
484
+ for (const entry of fullEntries) {
478
485
  if (truncated)
479
486
  return;
480
487
  if (entry.kind === "file") {
@@ -534,6 +541,7 @@ export async function readWorkspaceFile(root, requested) {
534
541
  throw new FilesError(415, `不支持的文件类型(二进制内容):${requested}`, "binary");
535
542
  // `fatal: true` so a truncated multi-byte sequence - a file written by something that cut a UTF-8
536
543
  // character in half - throws here instead of decoding to U+FFFD and being written back on save.
544
+ const bom = raw.length >= 3 && raw[0] === 0xef && raw[1] === 0xbb && raw[2] === 0xbf;
537
545
  let content;
538
546
  try {
539
547
  content = new TextDecoder("utf-8", { fatal: true }).decode(raw);
@@ -541,8 +549,9 @@ export async function readWorkspaceFile(root, requested) {
541
549
  catch {
542
550
  throw new FilesError(415, `不是有效的 UTF-8 文本,不支持编辑:${requested}`, "binary");
543
551
  }
544
- return { path: toRelative(root, target), content, meta, eol: content.includes("\r\n") ? "\r\n" : "\n" };
552
+ return { path: toRelative(root, target), content, meta, bom, eol: content.includes("\r\n") ? "\r\n" : "\n" };
545
553
  }
554
+ const writeLocks = new Map();
546
555
  /**
547
556
  * Write one EXISTING text file. No creation, no deletion, no rename, no directories.
548
557
  *
@@ -569,51 +578,73 @@ export async function readWorkspaceFile(root, requested) {
569
578
  */
570
579
  export async function writeWorkspaceFile(root, requested, content, baseEtag) {
571
580
  const target = await resolveInsideRoot(root, requested);
572
- const info = await stat(target).catch((error) => {
573
- const code = error?.code;
574
- if (code === "ENOENT" || code === "ENOTDIR")
575
- throw new FilesError(428, `文件不存在,本面板只允许写入已存在的文件:${requested}`, "missing_target");
576
- if (code === "EACCES" || code === "EPERM")
577
- throw new FilesError(403, `权限不足:${requested}`, "denied");
578
- throw error;
579
- });
580
- if (info.isDirectory())
581
- throw new FilesError(400, `这是一个目录,不能写入:${requested}`, "not_a_file");
582
- if (hasBinaryExtension(target))
583
- throw new FilesError(415, `拒绝写入二进制文件:${requested}`, "binary");
584
- const current = await fileMeta(target);
585
- if (!baseEtag)
586
- throw new FilesError(428, "缺少 baseEtag:保存必须带上打开时读到的版本标识,否则无法判断文件是否被改动过", "etag_required");
587
- if (baseEtag !== current.etag) {
588
- // The CURRENT etag travels with the refusal, and that is what makes an informed overwrite possible:
589
- // the UI can offer "仍然覆盖" which re-sends THIS etag, rather than a blind write that would succeed
590
- // only because the check was skipped once. The refusal is not a dead end - it is a question.
591
- throw new FilesError(409, `文件在磁盘上已被其他程序修改,未保存:${requested}`, "stale", {
592
- path: toRelative(root, target),
593
- current,
594
- yourEtag: baseEtag,
595
- });
596
- }
597
- const bytes = Buffer.byteLength(content, "utf8");
598
- if (bytes > MAX_FILE_BYTES)
599
- throw new FilesError(413, `写入内容过大(${(bytes / 1024 / 1024).toFixed(1)}MB),上限 ${MAX_FILE_BYTES / 1024 / 1024}MB`, "too_large");
600
- // A save must not turn a text file into a binary one, so the sniff that guards the read guards the
601
- // write: a NUL arriving through a paste would otherwise be written to disk and make the file unreadable
602
- // to this panel ever after.
603
- if (looksBinary(Buffer.from(content, "utf8")))
604
- throw new FilesError(415, "拒绝写入:内容包含二进制字节(\\0)", "binary");
605
- const temporary = `${target}.super-pi-${process.pid}-${Date.now().toString(36)}.tmp`;
581
+ const previous = writeLocks.get(target) ?? Promise.resolve();
582
+ let release;
583
+ const currentLock = new Promise((resolve) => { release = resolve; });
584
+ const queued = previous.then(() => currentLock);
585
+ writeLocks.set(target, queued);
586
+ await previous;
606
587
  try {
607
- await writeFile(temporary, content, "utf8");
608
- await rename(temporary, target);
588
+ const info = await stat(target).catch((error) => {
589
+ const code = error?.code;
590
+ if (code === "ENOENT" || code === "ENOTDIR")
591
+ throw new FilesError(428, `文件不存在,本面板只允许写入已存在的文件:${requested}`, "missing_target");
592
+ if (code === "EACCES" || code === "EPERM")
593
+ throw new FilesError(403, `权限不足:${requested}`, "denied");
594
+ throw error;
595
+ });
596
+ if (info.isDirectory())
597
+ throw new FilesError(400, `这是一个目录,不能写入:${requested}`, "not_a_file");
598
+ if (hasBinaryExtension(target))
599
+ throw new FilesError(415, `拒绝写入二进制文件:${requested}`, "binary");
600
+ const current = await fileMeta(target);
601
+ if (!baseEtag)
602
+ throw new FilesError(428, "缺少 baseEtag:保存必须带上打开时读到的版本标识,否则无法判断文件是否被改动过", "etag_required");
603
+ if (baseEtag !== current.etag) {
604
+ // The CURRENT etag travels with the refusal, and that is what makes an informed overwrite possible:
605
+ // the UI can offer "仍然覆盖" which re-sends THIS etag, rather than a blind write that would succeed
606
+ // only because the check was skipped once. The refusal is not a dead end - it is a question.
607
+ throw new FilesError(409, `文件在磁盘上已被其他程序修改,未保存:${requested}`, "stale", {
608
+ path: toRelative(root, target),
609
+ current,
610
+ yourEtag: baseEtag,
611
+ });
612
+ }
613
+ const bytes = Buffer.byteLength(content, "utf8");
614
+ if (bytes > MAX_FILE_BYTES)
615
+ throw new FilesError(413, `写入内容过大(${(bytes / 1024 / 1024).toFixed(1)}MB),上限 ${MAX_FILE_BYTES / 1024 / 1024}MB`, "too_large");
616
+ // A save must not turn a text file into a binary one, so the sniff that guards the read guards the
617
+ // write: a NUL arriving through a paste would otherwise be written to disk and make the file unreadable
618
+ // to this panel ever after.
619
+ if (looksBinary(Buffer.from(content, "utf8")))
620
+ throw new FilesError(415, "拒绝写入:内容包含二进制字节(\\0)", "binary");
621
+ const original = await readFile(target);
622
+ const hadBom = original.length >= 3 && original[0] === 0xef && original[1] === 0xbb && original[2] === 0xbf;
623
+ const output = hadBom ? `\uFEFF${content}` : content;
624
+ const temporary = await mkdtemp(`${target}.super-pi-${process.pid}-`);
625
+ const temporaryFile = join(temporary, basename(target));
626
+ try {
627
+ await writeFile(temporaryFile, output, "utf8");
628
+ await rename(temporaryFile, target);
629
+ }
630
+ catch (error) {
631
+ // The temporary file is the ONLY thing this function can leave behind, and it is the one thing that
632
+ // must never survive a failed save.
633
+ await unlink(temporaryFile).catch(() => { });
634
+ throw error;
635
+ }
636
+ finally {
637
+ await import("node:fs/promises").then(({ rm }) => rm(temporary, { recursive: true, force: true })).catch(() => { });
638
+ release();
639
+ if (writeLocks.get(target) === queued)
640
+ writeLocks.delete(target);
641
+ }
642
+ return { path: toRelative(root, target), meta: await fileMeta(target), created: false };
609
643
  }
610
644
  catch (error) {
611
- // The temporary file is the ONLY thing this function can leave behind, and it is the one thing that
612
- // must never survive a failed save.
613
- await unlink(temporary).catch(() => { });
645
+ release();
614
646
  throw error;
615
647
  }
616
- return { path: toRelative(root, target), meta: await fileMeta(target), created: false };
617
648
  }
618
649
  /** Every `FilesError` carries the status it should be rendered with; anything else is a real fault and
619
650
  * becomes a 500 rather than being dressed up as a refusal the user is asked to understand. */
@@ -4,6 +4,22 @@ import type { ServerContext } from "./server-context.js";
4
4
  * Checks if the remote address is a local loopback interface.
5
5
  */
6
6
  export declare function isLoopbackAddress(remoteAddress: string | undefined): boolean;
7
+ /**
8
+ * Checks if the remote address is a local loopback interface, host network interface,
9
+ * or a private network address (RFC 1918 / IPv6 ULA / link-local).
10
+ */
11
+ export declare function isLocalOrPrivateAddress(remoteAddress: string | undefined): boolean;
12
+ /**
13
+ * Returns a list of non-internal IPv4 addresses available on local network interfaces.
14
+ */
15
+ export declare function getLanIps(): string[];
16
+ /**
17
+ * Parses CLI arguments (--host, -H, --port, -p) for server configuration.
18
+ */
19
+ export declare function parseServerArgs(argv: string[]): {
20
+ host?: string;
21
+ port?: number;
22
+ };
7
23
  /**
8
24
  * Checks whether an incoming HTTP request satisfies access token authorization.
9
25
  */
@@ -12,7 +28,7 @@ export declare function isAuthorized(request: {
12
28
  }, accessToken?: string): boolean;
13
29
  export interface HttpServerOptions {
14
30
  context: ServerContext;
15
- onShutdown: (reason: string) => void;
31
+ onShutdown: (reason: string) => Promise<void>;
16
32
  }
17
33
  /**
18
34
  * Creates the HTTP server providing static client assets and all `/api/*` endpoints.
@@ -1,7 +1,7 @@
1
1
  import { createReadStream, existsSync } from "node:fs";
2
2
  import { readdir, stat } from "node:fs/promises";
3
3
  import { createServer } from "node:http";
4
- import { homedir } from "node:os";
4
+ import { homedir, networkInterfaces } from "node:os";
5
5
  import { basename, dirname, extname, join, relative, resolve } from "node:path";
6
6
  import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, searchByName, toRelative, writeWorkspaceFile, } from "./files.js";
7
7
  import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
@@ -13,6 +13,7 @@ const MIME_TYPES = {
13
13
  ".svg": "image/svg+xml",
14
14
  ".json": "application/json; charset=utf-8",
15
15
  ".ico": "image/x-icon",
16
+ ".png": "image/png",
16
17
  };
17
18
  /**
18
19
  * Checks if the remote address is a local loopback interface.
@@ -20,7 +21,103 @@ const MIME_TYPES = {
20
21
  export function isLoopbackAddress(remoteAddress) {
21
22
  if (!remoteAddress)
22
23
  return false;
23
- return ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(remoteAddress);
24
+ const normalized = remoteAddress.replace(/^::ffff:/, "");
25
+ return (normalized === "127.0.0.1" ||
26
+ normalized === "::1" ||
27
+ normalized === "localhost" ||
28
+ normalized.startsWith("127."));
29
+ }
30
+ /**
31
+ * Checks if the remote address is a local loopback interface, host network interface,
32
+ * or a private network address (RFC 1918 / IPv6 ULA / link-local).
33
+ */
34
+ export function isLocalOrPrivateAddress(remoteAddress) {
35
+ if (!remoteAddress)
36
+ return false;
37
+ if (isLoopbackAddress(remoteAddress))
38
+ return true;
39
+ const ip = remoteAddress.replace(/^::ffff:/, "");
40
+ // RFC 1918 IPv4 Private Networks:
41
+ // 10.0.0.0/8
42
+ if (ip.startsWith("10."))
43
+ return true;
44
+ // 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
45
+ if (/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip))
46
+ return true;
47
+ // 192.168.0.0/16
48
+ if (ip.startsWith("192.168."))
49
+ return true;
50
+ // IPv4 Link-Local (169.254.0.0/16)
51
+ if (ip.startsWith("169.254."))
52
+ return true;
53
+ // IPv6 Link-Local (fe80::/10)
54
+ if (/^fe[89ab][0-9a-f]/i.test(ip))
55
+ return true;
56
+ // IPv6 Unique Local Address (fc00::/7 -> fc00:: or fd00::)
57
+ if (/^f[cd][0-9a-f]{2}:/i.test(ip))
58
+ return true;
59
+ try {
60
+ const ifaces = networkInterfaces();
61
+ for (const dev of Object.values(ifaces)) {
62
+ if (!dev)
63
+ continue;
64
+ for (const details of dev) {
65
+ if (details.address === ip)
66
+ return true;
67
+ }
68
+ }
69
+ }
70
+ catch { }
71
+ return false;
72
+ }
73
+ /**
74
+ * Returns a list of non-internal IPv4 addresses available on local network interfaces.
75
+ */
76
+ export function getLanIps() {
77
+ const ips = [];
78
+ try {
79
+ const ifaces = networkInterfaces();
80
+ for (const dev of Object.values(ifaces)) {
81
+ if (!dev)
82
+ continue;
83
+ for (const details of dev) {
84
+ if (details.family === "IPv4" && !details.internal) {
85
+ ips.push(details.address);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ catch { }
91
+ return ips;
92
+ }
93
+ /**
94
+ * Parses CLI arguments (--host, -H, --port, -p) for server configuration.
95
+ */
96
+ export function parseServerArgs(argv) {
97
+ let host;
98
+ let port;
99
+ for (let i = 2; i < argv.length; i++) {
100
+ const arg = argv[i];
101
+ if (arg === "--host" || arg === "-H") {
102
+ const next = argv[++i];
103
+ if (next && !next.startsWith("-"))
104
+ host = next;
105
+ }
106
+ else if (arg.startsWith("--host=")) {
107
+ host = arg.slice("--host=".length);
108
+ }
109
+ else if (arg === "--port" || arg === "-p") {
110
+ const p = Number.parseInt(argv[++i], 10);
111
+ if (!Number.isNaN(p))
112
+ port = p;
113
+ }
114
+ else if (arg.startsWith("--port=")) {
115
+ const p = Number.parseInt(arg.slice("--port=".length), 10);
116
+ if (!Number.isNaN(p))
117
+ port = p;
118
+ }
119
+ }
120
+ return { host, port };
24
121
  }
25
122
  /**
26
123
  * Checks whether an incoming HTTP request satisfies access token authorization.
@@ -36,20 +133,62 @@ export function createHttpServer(options) {
36
133
  const { store, paths, accessToken, broadcaster, runService, catalogService } = context;
37
134
  const { uploadsDir, clientDir, rootDir } = paths;
38
135
  return createServer(async (request, response) => {
39
- if (!isAuthorized(request, accessToken)) {
136
+ // Internal tools use a narrow per-run capability, never the global management token.
137
+ if (request.url?.split("?")[0] !== "/api/memory/tool" && !isAuthorized(request, accessToken)) {
40
138
  response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
41
139
  return;
42
140
  }
43
141
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
44
142
  const remote = request.socket.remoteAddress ?? "";
143
+ /**
144
+ * THE TERMINAL IS NOT AN API ENDPOINT, and the routing above deliberately cannot reach one.
145
+ *
146
+ * Every command family in `CommandHandler` is answerable over plain HTTP except the terminal, which
147
+ * is WebSocket-only (`terminal.*` messages). `serverContext` therefore carries only the two
148
+ * predicates `/api/terminal` needs - is it on, and if not why - and NOT a handle on `TerminalService`.
149
+ * There is consequently no code path from an HTTP request to a spawned shell, which is the property
150
+ * worth having: the browser's own origin could not reach it even with a forged request.
151
+ */
152
+ if (url.pathname === "/api/memory/tool" || url.pathname === "/api/memory") {
153
+ if (!context.memory) {
154
+ response.writeHead(503).end();
155
+ return;
156
+ }
157
+ if ((url.pathname.endsWith("/tool") && request.method !== "POST") || !["GET", "POST"].includes(request.method ?? "")) {
158
+ response.writeHead(405).end();
159
+ return;
160
+ }
161
+ const origin = request.headers.origin;
162
+ if (origin && origin !== url.origin) {
163
+ response.writeHead(403).end("Origin not allowed");
164
+ return;
165
+ }
166
+ try {
167
+ let raw = "";
168
+ for await (const chunk of request) {
169
+ raw += chunk;
170
+ if (raw.length > 512000)
171
+ throw new Error("记忆请求过大");
172
+ }
173
+ const body = raw ? JSON.parse(raw) : {};
174
+ const result = url.pathname.endsWith("/tool")
175
+ ? await context.memory.tool(String(request.headers["x-memory-capability"] ?? ""), body)
176
+ : await context.memory.manage(url.searchParams.get("workspaceId") ?? "", body);
177
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
178
+ }
179
+ catch (error) {
180
+ response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
181
+ }
182
+ return;
183
+ }
45
184
  if (url.pathname === "/api/shutdown") {
46
- if (request.method !== "POST" || !isLoopbackAddress(remote)) {
47
- response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback POST");
185
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
186
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback or private network POST");
48
187
  return;
49
188
  }
50
189
  response.writeHead(202, { "content-type": "application/json; charset=utf-8" })
51
190
  .end(JSON.stringify({ status: "shutting_down", message: "Server is stopping" }));
52
- setTimeout(() => onShutdown("api-shutdown"), 50);
191
+ setTimeout(() => { void onShutdown("api-shutdown"); }, 50);
53
192
  return;
54
193
  }
55
194
  if (url.pathname === "/api/health") {
@@ -58,9 +197,27 @@ export function createHttpServer(options) {
58
197
  .end(JSON.stringify({ status: "ok", service: "super-pi", ...version }));
59
198
  return;
60
199
  }
200
+ /**
201
+ * WHETHER THE TERMINAL EXISTS, as a plain GET rather than part of the `state` snapshot.
202
+ *
203
+ * It is deployment configuration, not application state: it does not change while the server runs,
204
+ * it is identical for every workspace, and putting it on `state` would drag it into the persistence
205
+ * layer and into every existing snapshot schema for no benefit. A `GET` also means the right rail
206
+ * can decide whether to RENDER the tab at all - and not rendering it is the point, because a tab
207
+ * that is present and refuses is a tab that invites a user to keep clicking a closed door.
208
+ *
209
+ * No address check: this leaks nothing beyond "is this build configured for a shell", which is
210
+ * already printed in the server's own startup banner to whoever can see the console.
211
+ */
212
+ if (url.pathname === "/api/terminal") {
213
+ const enabled = context.terminalEnabled?.() ?? false;
214
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
215
+ .end(JSON.stringify({ enabled, reason: enabled ? "" : (context.terminalDisabledReason?.() ?? "终端未启用。") }));
216
+ return;
217
+ }
61
218
  if (url.pathname === "/api/subagent/dispatch") {
62
- if (request.method !== "POST" || !isLoopbackAddress(remote)) {
63
- response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Sub-agent dispatch requires a loopback POST");
219
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
220
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Sub-agent dispatch requires a loopback or private network POST");
64
221
  return;
65
222
  }
66
223
  let raw = "";
@@ -80,8 +237,8 @@ export function createHttpServer(options) {
80
237
  return;
81
238
  }
82
239
  if (url.pathname === "/api/workflow/run") {
83
- if (request.method !== "POST" || !isLoopbackAddress(remote)) {
84
- response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Workflow dispatch requires a loopback POST");
240
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
241
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Workflow dispatch requires a loopback or private network POST");
85
242
  return;
86
243
  }
87
244
  let raw = "";
@@ -100,8 +257,8 @@ export function createHttpServer(options) {
100
257
  return;
101
258
  }
102
259
  if (url.pathname === "/api/todos") {
103
- if (request.method !== "POST" || !isLoopbackAddress(remote)) {
104
- response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Todo writes require a loopback POST");
260
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
261
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Todo writes require a loopback or private network POST");
105
262
  return;
106
263
  }
107
264
  let raw = "";
@@ -159,13 +316,13 @@ export function createHttpServer(options) {
159
316
  return;
160
317
  }
161
318
  const file = await readWorkspaceFile(root, rel);
162
- json(200, { path: file.path, content: file.content, eol: file.eol, meta: file.meta, maxBytes: MAX_FILE_BYTES });
319
+ json(200, { path: file.path, content: file.content, eol: file.eol, bom: file.bom, meta: file.meta, maxBytes: MAX_FILE_BYTES });
163
320
  return;
164
321
  }
165
322
  if (request.method === "PUT") {
166
323
  const remoteAddr = request.socket.remoteAddress ?? "";
167
- if (!isLoopbackAddress(remoteAddr)) {
168
- json(403, { error: "File writes require a loopback connection", code: "not_loopback" });
324
+ if (!isLocalOrPrivateAddress(remoteAddr)) {
325
+ json(403, { error: "File writes require a loopback or private network connection", code: "not_loopback" });
169
326
  return;
170
327
  }
171
328
  let raw = "";
@@ -263,8 +420,8 @@ export function createHttpServer(options) {
263
420
  return;
264
421
  }
265
422
  if (url.pathname === "/api/upload") {
266
- if (request.method !== "POST" || !isLoopbackAddress(remote)) {
267
- response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Upload requires a loopback POST" }));
423
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
424
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Upload requires a loopback or private network POST" }));
268
425
  return;
269
426
  }
270
427
  const json = (status, body) => {
@@ -318,8 +475,8 @@ export function createHttpServer(options) {
318
475
  return;
319
476
  }
320
477
  if (url.pathname.startsWith("/api/upload/")) {
321
- if (request.method !== "GET" || !isLoopbackAddress(remote)) {
322
- response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Upload access requires a loopback GET" }));
478
+ if (request.method !== "GET" || !isLocalOrPrivateAddress(remote)) {
479
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Upload access requires a loopback or private network GET" }));
323
480
  return;
324
481
  }
325
482
  const pathParts = url.pathname.slice("/api/upload/".length).split("/").filter(Boolean);
@@ -356,7 +513,15 @@ export function createHttpServer(options) {
356
513
  response.writeHead(503, { "content-type": "text/plain; charset=utf-8" }).end("Super Pi client has not been built. Run npm run build.");
357
514
  return;
358
515
  }
516
+ const candidateInfo = await stat(candidate).catch(() => undefined);
517
+ if (!candidateInfo?.isFile()) {
518
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
519
+ return;
520
+ }
359
521
  response.writeHead(200, { "content-type": MIME_TYPES[extname(candidate)] ?? "application/octet-stream" });
360
- createReadStream(candidate).pipe(response);
522
+ const stream = createReadStream(candidate);
523
+ stream.on("error", () => { if (!response.headersSent)
524
+ response.writeHead(404); response.end(); });
525
+ stream.pipe(response);
361
526
  });
362
527
  }