@evandu/super-pi 0.0.0-stage → 0.1.1

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 (59) hide show
  1. package/README.md +79 -2
  2. package/agents/coder.md +10 -0
  3. package/agents/critic.md +20 -0
  4. package/agents/patcher.md +23 -0
  5. package/agents/planner.md +18 -0
  6. package/agents/reviewer.md +13 -0
  7. package/agents/scout.md +23 -0
  8. package/agents/super-pi-task/core.ts +485 -0
  9. package/agents/super-pi-task/index.ts +141 -0
  10. package/agents/verifier.md +21 -0
  11. package/bin/super-pi.js +17 -0
  12. package/dist/client/assets/index-BMQsEdio.js +24 -0
  13. package/dist/client/assets/index-CsbZzuMy.css +1 -0
  14. package/dist/client/index.html +14 -0
  15. package/dist/extension.d.ts +15 -0
  16. package/dist/extension.js +111 -0
  17. package/dist/server/agents.d.ts +177 -0
  18. package/dist/server/agents.js +484 -0
  19. package/dist/server/application/catalog-service.d.ts +44 -0
  20. package/dist/server/application/catalog-service.js +94 -0
  21. package/dist/server/application/conversation-service.d.ts +73 -0
  22. package/dist/server/application/conversation-service.js +202 -0
  23. package/dist/server/application/extension-ui-service.d.ts +28 -0
  24. package/dist/server/application/extension-ui-service.js +73 -0
  25. package/dist/server/application/run-service.d.ts +115 -0
  26. package/dist/server/application/run-service.js +341 -0
  27. package/dist/server/application/workspace-service.d.ts +45 -0
  28. package/dist/server/application/workspace-service.js +106 -0
  29. package/dist/server/command-handler.d.ts +22 -0
  30. package/dist/server/command-handler.js +137 -0
  31. package/dist/server/files.d.ts +202 -0
  32. package/dist/server/files.js +629 -0
  33. package/dist/server/http-server.d.ts +20 -0
  34. package/dist/server/http-server.js +313 -0
  35. package/dist/server/index.d.ts +1 -0
  36. package/dist/server/index.js +103 -0
  37. package/dist/server/pi-command.d.ts +65 -0
  38. package/dist/server/pi-command.js +183 -0
  39. package/dist/server/runtime.d.ts +819 -0
  40. package/dist/server/runtime.js +1470 -0
  41. package/dist/server/server-context.d.ts +46 -0
  42. package/dist/server/server-context.js +1 -0
  43. package/dist/server/store.d.ts +424 -0
  44. package/dist/server/store.js +1121 -0
  45. package/dist/server/subagent-runs.d.ts +142 -0
  46. package/dist/server/subagent-runs.js +315 -0
  47. package/dist/server/subagents.d.ts +194 -0
  48. package/dist/server/subagents.js +542 -0
  49. package/dist/server/task-tool.d.ts +196 -0
  50. package/dist/server/task-tool.js +285 -0
  51. package/dist/server/tool-calls.d.ts +62 -0
  52. package/dist/server/tool-calls.js +141 -0
  53. package/dist/server/types.d.ts +920 -0
  54. package/dist/server/types.js +11 -0
  55. package/dist/server/upload.d.ts +37 -0
  56. package/dist/server/upload.js +169 -0
  57. package/dist/server/ws-server.d.ts +45 -0
  58. package/dist/server/ws-server.js +116 -0
  59. package/package.json +115 -4
@@ -0,0 +1,629 @@
1
+ /**
2
+ * Workspace file browsing and editing.
3
+ *
4
+ * ONE RULE GOVERNS THIS FILE, and it is the reason the module exists rather than four functions inside
5
+ * `index.ts`: a path that arrives from a browser is UNTRUSTED, and the question "does this resolve to
6
+ * somewhere inside the workspace" has to be answered in exactly one place. Every read, every write,
7
+ * every directory listing and every search walks through `resolveInsideRoot`. A check that is written
8
+ * twice is a check that is correct once.
9
+ *
10
+ * WHAT THE WORKSPACE ROOT IS, because the requirement says to find this out rather than assume it: it is
11
+ * `Workspace.path`, the directory the user picked when they created the workspace (`store.ts`
12
+ * `createWorkspace` stores `path` verbatim; the left rail's directory picker reads `/api/fs/dirs`, which
13
+ * is why that endpoint exists and why it is deliberately NOT reused here - see the note on it below).
14
+ * It is the same directory Pi runs in (`index.ts` `turnContext` hands `workspace.path` to every turn as
15
+ * `cwd`), which is the property that makes "the Files tab shows me what the agent is working on" true
16
+ * rather than aspirational.
17
+ *
18
+ * `/api/fs/dirs` IS NOT A PREDECESSOR OF ANYTHING IN HERE. It resolves whatever absolute path it is
19
+ * handed, because picking a workspace directory requires naming a directory that does not exist yet -
20
+ * it is a file picker, not a file reader, and reusing it would import its deliberate absence of
21
+ * containment straight into the one surface that must have it.
22
+ *
23
+ * THE ROOT IS ALSO A SUBJECT OF THIS FILE. `Workspace.path` is a stored string, and a stored string can
24
+ * name a directory that is no longer there - deleted, renamed, on a drive that is no longer mounted. The
25
+ * picker cannot create that state (`/api/fs/dirs` only ever lists directories it has just `stat`ed), but
26
+ * time can. So "does the workspace root exist" is answered here, once, in `realpathOfRoot`, and it is
27
+ * answered before any question is asked about the path INSIDE it. That ordering is what makes a missing
28
+ * directory a 404 that names itself instead of a containment failure about a symlink that does not exist.
29
+ */
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";
33
+ /**
34
+ * Above this a read is refused rather than served. 5MB is roughly a large source file or a long document,
35
+ * and it is deliberately far below the point where an editor tab stops being useful: this panel is for
36
+ * reading and editing code and markdown, and a file too big for that has not become more editable by
37
+ * being loaded whole. The size is checked from `stat` BEFORE the read, so an oversized file costs one
38
+ * `stat` rather than a full buffer.
39
+ */
40
+ export const MAX_FILE_BYTES = 5 * 1024 * 1024;
41
+ /** Cap on one directory listing, so a `node_modules` or a generated assets folder cannot produce a
42
+ * multi-megabyte response. The count is reported as `truncated` rather than applied silently. */
43
+ export const MAX_DIR_ENTRIES = 2000;
44
+ /** Cap on a recursive filename search, for the same reason: a scan of a large repository has to end
45
+ * somewhere. The UI says the list was cut rather than pretending it is complete. */
46
+ export const MAX_SEARCH_RESULTS = 500;
47
+ /** How deep the recursive search walks. Twelve levels is past any real source tree; the cap is what
48
+ * turns a pathologically deep dependency tree from a hang into a result. */
49
+ export const MAX_SEARCH_DEPTH = 12;
50
+ /** Directories the recursive search skips. The TREE does not skip them - a directory the user cannot
51
+ * browse is a directory they cannot inspect a file in, and the binary handling below is only reachable
52
+ * if `node_modules` is reachable. A recursive filename search, by contrast, returns thousands of matches
53
+ * from these and nothing a person searched for, so they are excluded from the scan and the exclusion is
54
+ * reported in the response rather than being invisible. */
55
+ const SEARCH_SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".super-pi", ".next", ".cache", "coverage", ".venv"]);
56
+ /**
57
+ * Extensions that are binary by definition, checked BEFORE any bytes are read.
58
+ *
59
+ * This is a fast path, not the real test: a `.ts` file containing PNG bytes still has to be caught, and
60
+ * it is caught by `looksBinary` below. What this set buys is refusing the obvious cases without opening
61
+ * them - and, more importantly, refusing them with a MESSAGE that names the reason rather than "invalid
62
+ * UTF-8", because the person clicking the file wants to know it is an image.
63
+ */
64
+ const BINARY_EXTENSIONS = new Set([
65
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".avif", ".tif", ".tiff", ".heic",
66
+ ".psd", ".ai", ".eps",
67
+ ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".iso", ".dmg", ".jar", ".war", ".whl",
68
+ ".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".obj", ".a", ".lib", ".class", ".wasm", ".node",
69
+ ".pyd", ".msi", ".apk", ".deb", ".rpm",
70
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp",
71
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
72
+ ".mp3", ".wav", ".flac", ".ogg", ".mp4", ".mov", ".avi", ".mkv", ".webm",
73
+ ".sqlite", ".sqlite3", ".db", ".pyc", ".pyo", ".pack", ".idx",
74
+ ]);
75
+ /**
76
+ * Extensions this panel OPENS without needing a sniff. It is a hint used only to label a file in the tree
77
+ * before it is read, and never as a permission: a `.png` is refused by name even if a sniffing pass would
78
+ * have liked it, and a `.txt` holding PNG bytes is still refused by the sniff.
79
+ */
80
+ export const TEXT_EXTENSIONS = new Set([
81
+ ".md", ".markdown", ".mdx", ".txt", ".text", ".log",
82
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts",
83
+ ".json", ".jsonc", ".json5", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf",
84
+ ".css", ".scss", ".less", ".html", ".htm", ".xml", ".svg", ".vue", ".svelte", ".astro",
85
+ ".py", ".rb", ".go", ".rs", ".java", ".kt", ".kts", ".c", ".h", ".cc", ".cpp", ".hpp", ".cs", ".swift",
86
+ ".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd",
87
+ ".sql", ".graphql", ".gql", ".proto", ".tf", ".tfvars", ".lua", ".r", ".jl", ".ex", ".exs", ".erl",
88
+ ".diff", ".patch", ".csv", ".tsv", ".lock",
89
+ ]);
90
+ /** The HTTP failure, carried as a status so the route can render it without a second switch. `details` is
91
+ * for the cases where the CLIENT can act on more than the sentence - today that is a conflict, where the
92
+ * current etag is what lets the UI offer a deliberate "overwrite anyway" instead of a blind one. */
93
+ export class FilesError extends Error {
94
+ status;
95
+ code;
96
+ details;
97
+ constructor(status, message, code, details) {
98
+ super(message);
99
+ this.status = status;
100
+ this.code = code;
101
+ this.details = details;
102
+ this.name = "FilesError";
103
+ }
104
+ }
105
+ /**
106
+ * Whether `candidate` is `root` itself or something under it.
107
+ *
108
+ * THE COMPARISON IS NOT `startsWith(root)`, because that is the traversal bug this whole file exists to
109
+ * avoid: with root `/home/u/proj`, the string `/home/u/proj-secrets` also starts with `/home/u/proj`.
110
+ * `relative()` asks the question in the form that can be answered exactly - it returns a path RELATIVE to
111
+ * the root, so "escaped" becomes "the relative path begins by going up", which is true for
112
+ * `/home/u/proj/../etc` and false for `/home/u/proj/etc`. `isAbsolute(relative)` catches the case
113
+ * `relative` cannot express: two different Windows drives, where there is no relative path between them
114
+ * and it hands back an absolute one.
115
+ */
116
+ export function contains(root, candidate) {
117
+ const rel = relative(root, candidate);
118
+ if (rel === "")
119
+ return true;
120
+ // Two drives on Windows, or a relative path that came back absolute. Either way there is no in-bounds
121
+ // answer, so it is a refusal rather than a comparison.
122
+ if (isAbsolute(rel))
123
+ return false;
124
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !rel.startsWith("../");
125
+ }
126
+ /**
127
+ * The syntactic half of the containment check: reject the SHAPES of a path that could escape, before
128
+ * anything is resolved.
129
+ *
130
+ * Every branch here is platform-independent on purpose. A server deployed on Linux must refuse `C:\...`
131
+ * and `\\server\share`, because a client is free to send them and a Linux build that only knows about `/`
132
+ * would happily `resolve` `C:\Windows` into a harmless-looking relative path under the root and answer
133
+ * 200 to a request that should be 403. The tests run on Windows and would not catch that.
134
+ *
135
+ * The backslash rewrite is the same decision, and it is a real trade rather than a free win: `\` is a
136
+ * legal CHARACTER in a POSIX filename, so a file literally named `a\b` is unreachable through this API.
137
+ * The alternative - honouring `\` only on Windows - buys that filename at the price of the same request
138
+ * meaning different things on two hosts, which is the more expensive of the two mistakes for a
139
+ * security check. Every escape form in the requirement is spelled with a backslash, and the check is
140
+ * worth more than the filename.
141
+ */
142
+ function rejectUnsafeShape(requested) {
143
+ // A NUL truncates the path inside every syscall it reaches - `readFile("a\0../../etc")` is `readFile("a")`
144
+ // as far as libuv is concerned, and the string this function validated is not the string the OS acts on.
145
+ // Refused before anything is normalised.
146
+ if (requested.includes("\0"))
147
+ throw new FilesError(400, "路径包含非法的空字符", "bad_path");
148
+ // UNC: `\\server\share\...` and its POSIX-spelled twin `//server/share`. Named explicitly because
149
+ // `isAbsolute` is true for the first on Windows and false for the second on Linux.
150
+ if (/^[\\/]{2}/.test(requested))
151
+ throw new FilesError(403, "拒绝 UNC 路径:路径必须在 workspace 内", "outside_workspace");
152
+ // A drive letter in EITHER position: `C:\x` is absolute, `C:x` is drive-relative and resolves against
153
+ // that drive's current directory, which is not a location the caller gets to choose. The colon is also
154
+ // how NTFS alternate data streams are addressed (`notes.md:hidden`), which is a second file hiding
155
+ // behind one path - handled by the segment check below.
156
+ if (/^[A-Za-z]:/.test(requested))
157
+ throw new FilesError(403, "拒绝 Windows 盘符路径:路径必须在 workspace 内", "outside_workspace");
158
+ // Rooted, POSIX or Windows: `/etc/passwd` and `\Windows`. Both are absolute, and the leading-slash
159
+ // forms are spelled out so the refusal does not depend on the host's own path parser agreeing.
160
+ if (requested.startsWith("/") || requested.startsWith("\\"))
161
+ throw new FilesError(403, "拒绝绝对路径:路径必须在 workspace 内", "outside_workspace");
162
+ return requested.replace(/\\/g, "/");
163
+ }
164
+ /**
165
+ * `realpath` of a workspace ROOT, where "does not exist" is a FINDING rather than a fallback.
166
+ *
167
+ * This function exists because of a measured failure, not a hypothetical one. With a workspace whose
168
+ * `path` does not exist, `nearestExistingAncestor` climbs to the nearest directory that DOES exist, gets
169
+ * handed back something above the root, and the containment test concludes - correctly, for what it was
170
+ * asked - that the target escaped. The route therefore answered:
171
+ *
172
+ * 403 {"error":"路径经符号链接后超出 workspace 范围,已拒绝:","code":"outside_workspace"}
173
+ *
174
+ * A security error, about a symlink that does not exist, naming NO PATH, for a workspace whose directory
175
+ * is simply not there. The user cannot act on that sentence, and the trailing colon shows the other half
176
+ * of the bug: the root is requested as `""`, so the message interpolates an empty string. A missing
177
+ * directory is a 404 that says which directory, and it is decided HERE - once, before the symlink half -
178
+ * so every read, write, listing and search says the same true thing about the same root.
179
+ *
180
+ * `realpath` rather than `stat` because the canonical form is needed anyway one line later, and a second
181
+ * syscall to learn something the first one already told us would be a race. `EACCES`/`EPERM` are named
182
+ * separately: a directory that exists but cannot be entered is a different mistake from one that was
183
+ * deleted, and the user needs to be able to tell them apart.
184
+ */
185
+ async function realpathOfRoot(base) {
186
+ try {
187
+ return await realpath(base);
188
+ }
189
+ catch (error) {
190
+ const code = error.code;
191
+ if (code === "ENOENT" || code === "ENOTDIR")
192
+ throw new FilesError(404, `工作区路径不存在:${base}`, "workspace_missing", { path: base });
193
+ if (code === "EACCES" || code === "EPERM")
194
+ throw new FilesError(403, `工作区路径无法访问(权限不足):${base}`, "denied", { path: base });
195
+ throw error;
196
+ }
197
+ }
198
+ /**
199
+ * The nearest ANCESTOR of `target` that exists on disk, canonically.
200
+ *
201
+ * Needed because a WRITE targets a file that may legitimately not exist yet, and the symlink half of the
202
+ * check still has to run on it. Climbing is safe to stop at: by the time the walk has climbed, every
203
+ * segment below the one it stopped on has been shown not to exist, and a segment that does not exist
204
+ * cannot itself be a symlink out of the tree.
205
+ */
206
+ async function nearestExistingAncestor(target) {
207
+ let current = target;
208
+ for (;;) {
209
+ try {
210
+ return await realpath(current);
211
+ }
212
+ catch (error) {
213
+ const code = error.code;
214
+ // ENOENT and ENOTDIR are the only two that mean "climb". EACCES and ELOOP are real problems and are
215
+ // allowed to surface as themselves rather than being turned into a climb that hides them.
216
+ if (code !== "ENOENT" && code !== "ENOTDIR")
217
+ throw error;
218
+ const parent = dirname(current);
219
+ if (parent === current)
220
+ throw error;
221
+ current = parent;
222
+ }
223
+ }
224
+ }
225
+ /** A guard bound to one workspace root. `resolveInsideRoot` is the one-shot form and is what callers are
226
+ * meant to use; the factory exists for the two places that check many paths against the SAME root in one
227
+ * request, so the canonical form of the root is resolved once instead of once per entry. */
228
+ export function createRootGuard(root) {
229
+ const base = resolve(root);
230
+ let realBase;
231
+ return async (requested) => {
232
+ const raw = (requested ?? "").trim();
233
+ // Empty means "the workspace itself", which is what the tree's root row and a listing of the top
234
+ // level both send. It is not an error and it is not a wildcard.
235
+ const target = raw === "" ? base : resolve(base, rejectUnsafeShape(raw));
236
+ // (1) LEXICAL, on the literal string. Catches `../`, `..\`, `/etc/passwd`, `C:\Windows`,
237
+ // `\\host\share` and anything else the caller SHAPED to escape, before any syscall touches the disk.
238
+ if (!contains(base, target))
239
+ throw new FilesError(403, `路径超出 workspace 范围,已拒绝:${raw}`, "outside_workspace");
240
+ // NTFS alternate data streams address a SECOND file behind one path. Windows only, because that is
241
+ // the only platform where the syntax means anything - a POSIX filename may legally contain a colon,
242
+ // and this check would refuse files that exist and are perfectly safe to read.
243
+ if (process.platform === "win32") {
244
+ const inside = target.slice(base.length);
245
+ if (inside.split(/[\\/]/).some((segment) => segment.includes(":"))) {
246
+ throw new FilesError(403, `拒绝 NTFS 数据流路径:${raw}`, "outside_workspace");
247
+ }
248
+ }
249
+ // (2) SYMLINK, on the resolved path. Catches a link INSIDE the workspace that points OUT of it - the
250
+ // one escape that is invisible to the string, because `workspace/link/etc/passwd` is a perfectly
251
+ // in-bounds spelling of a file somewhere else entirely. Both sides are canonicalised with `realpath`
252
+ // before comparison, because the root itself is very often reached through a link (on macOS
253
+ // `/var` -> `/private/var`, and a Windows worktree or junction is routine), so comparing a canonical
254
+ // target against an uncanonical root would reject every legitimate path.
255
+ // THE ROOT IS DECIDED FIRST, BEFORE ANY WORK ON THE TARGET, and both halves of that are deliberate.
256
+ //
257
+ // `realpathOfRoot` throws 404 `workspace_missing` for a workspace whose directory is gone, so the
258
+ // climb below can only ever be asked about a directory that exists - which is what makes the
259
+ // comparison after it mean "this path left the workspace" instead of "the workspace is not there".
260
+ //
261
+ // AWAITING IT BEFORE `nearestExistingAncestor` IS ALSO LOAD-BEARING. The first version asked for the
262
+ // target's canonical form first, and a workspace on a drive letter that does not exist (`Z:\nope`)
263
+ // then died inside the climb - it reaches the drive root, finds `dirname` unchanged, and rethrows the
264
+ // raw Node error. That surfaced as `500 {"error":"ENOENT: no such file or directory, realpath 'Z:\\'"}`:
265
+ // a missing workspace reported as an internal fault, leaking a libuv message. Resolving the root
266
+ // first means the 404 is always the answer, because the root is always known before the climb starts.
267
+ //
268
+ // THE `.catch(() => {})` IS LOAD-BEARING TOO, and it is there because that same first version CRASHED
269
+ // THE PROCESS on the ordinary missing-directory case. `realBase` is a PROMISE, and creating it and
270
+ // then awaiting anything else hands the event loop a turn with a rejected promise that has no handler
271
+ // yet; Node's default for an unhandled rejection is to terminate, so the server died with an uncaught
272
+ // `FilesError` instead of answering the 404. This attaches a handler at creation time and discards
273
+ // nothing - the original promise still rejects, and `await` below still receives it.
274
+ if (realBase === undefined) {
275
+ const pending = realpathOfRoot(base);
276
+ pending.catch(() => { });
277
+ realBase = pending;
278
+ }
279
+ const canonicalRoot = await realBase;
280
+ const realTarget = await nearestExistingAncestor(target);
281
+ if (!contains(canonicalRoot, realTarget))
282
+ throw new FilesError(403, `路径经符号链接后超出 workspace 范围,已拒绝:${raw}`, "outside_workspace");
283
+ return target;
284
+ };
285
+ }
286
+ /**
287
+ * THE CONTAINMENT CHECK. Every read, write, listing and search in this file goes through it, and the
288
+ * route layer is not allowed to do its own path arithmetic.
289
+ *
290
+ * `requested` arrives already percent-decoded, because `URLSearchParams` decodes once when the query is
291
+ * parsed. That is the right number of decodes: `%2e%2e%2f` is `../` by the time it gets here and is
292
+ * refused by the lexical test, while decoding a SECOND time would turn the harmless literal `%2e%2e%2f`
293
+ * into a live traversal - inventing an attack the URL parser does not actually have. The route decodes
294
+ * nothing further and never passes the raw query string down.
295
+ */
296
+ export function resolveInsideRoot(root, requested) {
297
+ return createRootGuard(root)(requested);
298
+ }
299
+ /**
300
+ * The identity of a file's CURRENT bytes, as far as a filesystem can cheaply say.
301
+ *
302
+ * Built from size and the nanosecond modification time rather than a content hash: hashing every file on
303
+ * every read would make opening a tab cost a full pass over the file, and the two fields together already
304
+ * change on any edit a human would call an edit. `bigint: true` is what makes this exact - the
305
+ * millisecond float `stat` reports by default rounds two edits inside the same millisecond into one
306
+ * value, and an external-change check that misses a change is worse than no check at all, because the
307
+ * user has been told it is safe to save.
308
+ */
309
+ export async function fileEtag(path) {
310
+ const info = await stat(path, { bigint: true });
311
+ return createHash("sha1").update(`${info.size}:${info.mtimeNs}:${info.ino}`).digest("hex").slice(0, 20);
312
+ }
313
+ export async function fileMeta(path) {
314
+ const [info, etag] = await Promise.all([stat(path), fileEtag(path)]);
315
+ return { etag, size: Number(info.size), modifiedAt: info.mtime.toISOString() };
316
+ }
317
+ /**
318
+ * Whether these bytes are text.
319
+ *
320
+ * The NUL test is the one that matters and it is the same one git uses for its "binary file" heuristic: a
321
+ * NUL byte cannot appear in valid UTF-8 text, while every common binary container has one within the
322
+ * first block. The sample is bounded so classifying a large file does not turn into reading all of it.
323
+ */
324
+ const BINARY_SNIFF_BYTES = 4096;
325
+ export function looksBinary(sample) {
326
+ const limit = Math.min(sample.length, BINARY_SNIFF_BYTES);
327
+ for (let i = 0; i < limit; i++)
328
+ if (sample[i] === 0)
329
+ return true;
330
+ return false;
331
+ }
332
+ /**
333
+ * Whether the NAME alone says this is binary.
334
+ *
335
+ * `endsWith` is the whole test, and the dot is what makes it a suffix test rather than a substring one:
336
+ * `"tool.bin".endsWith(".bin")` is true and `"foobin".endsWith(".bin")` is false, because the dot has to be
337
+ * one of the characters being matched. An earlier version added a "the character before the suffix must be
338
+ * a dot" guard on top of this, which was wrong - it compared the index of the dot AGAINST ITSELF minus
339
+ * one, so `"photo.png"` was checked at the `o` and came back false. Every binary file was therefore
340
+ * reaching the byte sniff instead of being refused by name, and the tests only ever passed because the
341
+ * fixture binaries also contained NUL bytes. A check that is dead code while a second check quietly does
342
+ * its job is exactly the kind of thing that survives review, which is why `files-api.test.mjs` asserts
343
+ * the extension path with a fixture whose CONTENT is text.
344
+ */
345
+ export function hasBinaryExtension(name) {
346
+ const lower = basename(name).toLowerCase();
347
+ for (const binary of BINARY_EXTENSIONS) {
348
+ if (lower.endsWith(binary))
349
+ return true;
350
+ }
351
+ return false;
352
+ }
353
+ /** Whether a file is worth putting in an editor tab at all, decided from its NAME alone, for the tree's
354
+ * own labelling. The authoritative answer comes from `readWorkspaceFile`, which reads bytes. */
355
+ export function isTextName(name) {
356
+ if (hasBinaryExtension(name))
357
+ return false;
358
+ const lower = basename(name).toLowerCase();
359
+ if (lower === "dockerfile" || lower === "makefile" || lower === "license" || lower === "readme")
360
+ return true;
361
+ const dot = lower.lastIndexOf(".");
362
+ if (dot <= 0)
363
+ return true; // extensionless files are usually source or config; the sniff decides
364
+ return TEXT_EXTENSIONS.has(lower.slice(dot));
365
+ }
366
+ /** The `/`-separated relative form used on the wire and in every comparison. */
367
+ export function toRelative(root, absolute) {
368
+ return relative(root, absolute).split(sep).join("/");
369
+ }
370
+ /** One directory's contents. Directories sort before files, then case-insensitively by name with numeric
371
+ * ordering, so `src2` lands between `src` and `src10` rather than after every `srcN`. */
372
+ export async function listDirectory(root, requested) {
373
+ const guard = createRootGuard(root);
374
+ const target = await guard(requested);
375
+ const info = await stat(target).catch((error) => {
376
+ const code = error?.code;
377
+ if (code === "ENOENT" || code === "ENOTDIR")
378
+ throw new FilesError(404, `目录不存在:${toRelative(root, target) || "/"}`, "not_found");
379
+ if (code === "EACCES" || code === "EPERM")
380
+ throw new FilesError(403, `权限不足:${toRelative(root, target) || "/"}`, "denied");
381
+ throw error;
382
+ });
383
+ if (!info.isDirectory())
384
+ throw new FilesError(400, `不是目录:${toRelative(root, target)}`, "not_a_directory");
385
+ const dirents = await readdir(target, { withFileTypes: true });
386
+ const entries = [];
387
+ for (const dirent of dirents) {
388
+ const absolute = resolve(target, dirent.name);
389
+ const isLink = dirent.isSymbolicLink();
390
+ // ONLY a link can leave the workspace. A regular file or directory reached through a path whose
391
+ // ancestors have all already been resolved cannot be anywhere else - there is no other way to get out
392
+ // - so running the symlink half of the guard on every entry of a 2000-file directory would be a few
393
+ // thousand `realpath` calls to re-derive what `isDirectory()` just said. A link is the exception, and
394
+ // it is the one worth a syscall.
395
+ let outside = false;
396
+ if (isLink) {
397
+ try {
398
+ await guard(absolute);
399
+ }
400
+ catch {
401
+ outside = true;
402
+ }
403
+ }
404
+ const isDir = dirent.isDirectory();
405
+ entries.push({
406
+ path: toRelative(root, absolute),
407
+ name: dirent.name,
408
+ kind: outside ? "symlink" : isDir ? "directory" : "file",
409
+ text: isDir ? null : !outside && isTextName(dirent.name),
410
+ ...(isLink ? { symlink: true } : {}),
411
+ ...(outside ? { outside: true } : {}),
412
+ });
413
+ }
414
+ entries.sort((a, b) => {
415
+ const aFile = a.kind === "file" ? 1 : 0;
416
+ const bFile = b.kind === "file" ? 1 : 0;
417
+ if (aFile !== bFile)
418
+ return aFile - bFile;
419
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
420
+ });
421
+ const relativePath = toRelative(root, target);
422
+ const truncated = entries.length > MAX_DIR_ENTRIES;
423
+ return {
424
+ path: relativePath,
425
+ parent: relativePath === "" ? null : relativePath.split("/").slice(0, -1).join("/"),
426
+ entries: truncated ? entries.slice(0, MAX_DIR_ENTRIES) : entries,
427
+ truncated,
428
+ };
429
+ }
430
+ /**
431
+ * A recursive FILENAME search. Filename only, on purpose: content search is a different feature with
432
+ * different costs - it reads every candidate file, honours the size cap on each one, and it is the thing
433
+ * that turns a panel into a hang - and the requirement is explicit that this round does not need it.
434
+ *
435
+ * Every directory it descends into goes through the SAME guard as every other path here, so the search
436
+ * cannot be walked out of the workspace by a link, and it stops at `MAX_SEARCH_DEPTH` and
437
+ * `MAX_SEARCH_RESULTS` so a repository with a vendored dependency tree returns a truncated answer rather
438
+ * than never returning.
439
+ */
440
+ export async function searchByName(root, query) {
441
+ const needle = query.trim().toLowerCase();
442
+ if (!needle)
443
+ return { hits: [], truncated: false, skipped: [] };
444
+ const guard = createRootGuard(root);
445
+ const hits = [];
446
+ const skipped = [];
447
+ let truncated = false;
448
+ const walk = async (directory, depth) => {
449
+ if (truncated)
450
+ return;
451
+ if (depth > MAX_SEARCH_DEPTH) {
452
+ truncated = true;
453
+ return;
454
+ }
455
+ let listing;
456
+ try {
457
+ listing = await listDirectory(root, directory);
458
+ }
459
+ catch (error) {
460
+ // THE ROOT IS NOT ONE OF THE SKIPPABLE DIRECTORIES. Skipping a subdirectory that cannot be read is
461
+ // honest - the response says `skipped: [...]` - but skipping the workspace itself produced
462
+ // `200 {"mode":"search","hits":[],"skipped":["/"]}`, which is byte-for-byte the response for
463
+ // "I searched and nothing matched". Measured, on a workspace whose path did not exist. A workspace
464
+ // that is not there is therefore rethrown as the 404 it is: the whole point of reporting `skipped`
465
+ // is that the client can tell "no matches" from "I could not see everything", and a missing root
466
+ // is the case where neither is true.
467
+ if (!directory && error instanceof FilesError)
468
+ throw error;
469
+ // An unreadable directory is skipped and REPORTED, not swallowed: a search that silently skips half
470
+ // a repository and reports "no matches" is worse than one that says where it could not look.
471
+ if (error instanceof FilesError && (error.status === 403 || error.status === 404)) {
472
+ skipped.push(directory || "/");
473
+ return;
474
+ }
475
+ throw error;
476
+ }
477
+ for (const entry of listing.entries) {
478
+ if (truncated)
479
+ return;
480
+ if (entry.kind === "file") {
481
+ if (entry.name.toLowerCase().includes(needle)) {
482
+ hits.push({ path: entry.path, name: entry.name, directory: entry.path.split("/").slice(0, -1).join("/") });
483
+ if (hits.length >= MAX_SEARCH_RESULTS) {
484
+ truncated = true;
485
+ return;
486
+ }
487
+ }
488
+ continue;
489
+ }
490
+ // A link is never descended: following one is how a search becomes an infinite walk, and the tree
491
+ // already refuses to expand it.
492
+ if (entry.kind === "symlink")
493
+ continue;
494
+ if (SEARCH_SKIP_DIRS.has(entry.name)) {
495
+ skipped.push(entry.path);
496
+ continue;
497
+ }
498
+ await walk(entry.path, depth + 1);
499
+ }
500
+ };
501
+ await walk("", 0);
502
+ hits.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: "base" }));
503
+ return { hits, truncated, skipped };
504
+ }
505
+ /**
506
+ * Read one file, or refuse it with a reason a person can act on.
507
+ *
508
+ * ORDER MATTERS and is the whole reason the refusals are trustworthy: extension, then SIZE from `stat`,
509
+ * then a sniff, then a fatal decode. The size check precedes the read so an oversized file costs a `stat`
510
+ * rather than a buffer, and the sniff precedes the decode so binary bytes are never handed to
511
+ * `TextDecoder` as if they were text - a decode that "succeeds" on binary produces U+FFFD throughout,
512
+ * which is mojibake, and mojibake that then gets SAVED is how a text editor destroys a binary file.
513
+ */
514
+ export async function readWorkspaceFile(root, requested) {
515
+ const target = await resolveInsideRoot(root, requested);
516
+ const info = await stat(target).catch((error) => {
517
+ const code = error?.code;
518
+ if (code === "ENOENT" || code === "ENOTDIR")
519
+ throw new FilesError(404, `文件不存在:${requested}`, "not_found");
520
+ if (code === "EACCES" || code === "EPERM")
521
+ throw new FilesError(403, `权限不足:${requested}`, "denied");
522
+ throw error;
523
+ });
524
+ if (info.isDirectory())
525
+ throw new FilesError(400, `这是一个目录,不是文件:${requested}`, "not_a_file");
526
+ if (hasBinaryExtension(target))
527
+ throw new FilesError(415, `不支持的文件类型(二进制):${requested}`, "binary");
528
+ const meta = await fileMeta(target);
529
+ if (meta.size > MAX_FILE_BYTES) {
530
+ throw new FilesError(413, `文件过大(${(meta.size / 1024 / 1024).toFixed(1)}MB),上限 ${MAX_FILE_BYTES / 1024 / 1024}MB:${requested}`, "too_large");
531
+ }
532
+ const raw = await readFile(target);
533
+ if (looksBinary(raw))
534
+ throw new FilesError(415, `不支持的文件类型(二进制内容):${requested}`, "binary");
535
+ // `fatal: true` so a truncated multi-byte sequence - a file written by something that cut a UTF-8
536
+ // character in half - throws here instead of decoding to U+FFFD and being written back on save.
537
+ let content;
538
+ try {
539
+ content = new TextDecoder("utf-8", { fatal: true }).decode(raw);
540
+ }
541
+ catch {
542
+ throw new FilesError(415, `不是有效的 UTF-8 文本,不支持编辑:${requested}`, "binary");
543
+ }
544
+ return { path: toRelative(root, target), content, meta, eol: content.includes("\r\n") ? "\r\n" : "\n" };
545
+ }
546
+ /**
547
+ * Write one EXISTING text file. No creation, no deletion, no rename, no directories.
548
+ *
549
+ * THE SCOPE CALL, with its reasoning, because it is a decision and not an omission:
550
+ *
551
+ * This panel's stated job is to BROWSE and EDIT what is in the workspace. Creation is refused even though
552
+ * the brief leaves it open, because creation is the operation where a mistake has no undo: a bad `path`
553
+ * parameter or a bug that loses one character writes a file the user never asked for and never has to
554
+ * clean up, whereas a refused write leaves nothing behind. Deletion is deferred by the brief and is
555
+ * deferred here for the same reason plus the one that matters more - it is irreversible.
556
+ *
557
+ * The symmetry is deliberate: create and delete are the two ends of the same axis, and shipping one
558
+ * without the other is how a "conservative, no surprises" surface becomes a half-built file manager.
559
+ *
560
+ * What IS refused regardless: a write to a file that does not exist (428), a write with no `baseEtag` at
561
+ * all (428 - without one the server cannot tell a save from a blind overwrite, and "may I overwrite" is
562
+ * not a question this endpoint is allowed to skip), a write whose `baseEtag` no longer matches the file
563
+ * on disk (409 `stale`), and a write that would push the file past the size cap.
564
+ *
565
+ * The write itself is a whole-file replace through a temporary file in the SAME directory and a rename,
566
+ * so an interrupted save cannot leave a half-written file where a whole one used to be. The temporary name
567
+ * is derived from the target's OWN name rather than living in the system temp directory, because a rename
568
+ * across filesystems is not atomic and would silently become a copy.
569
+ */
570
+ export async function writeWorkspaceFile(root, requested, content, baseEtag) {
571
+ 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`;
606
+ try {
607
+ await writeFile(temporary, content, "utf8");
608
+ await rename(temporary, target);
609
+ }
610
+ 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(() => { });
614
+ throw error;
615
+ }
616
+ return { path: toRelative(root, target), meta: await fileMeta(target), created: false };
617
+ }
618
+ /** Every `FilesError` carries the status it should be rendered with; anything else is a real fault and
619
+ * becomes a 500 rather than being dressed up as a refusal the user is asked to understand. */
620
+ export function describe(error) {
621
+ if (error instanceof FilesError)
622
+ return { status: error.status, body: { error: error.message, code: error.code, ...(error.details ? { details: error.details } : {}) } };
623
+ const code = error?.code;
624
+ if (code === "EACCES" || code === "EPERM")
625
+ return { status: 403, body: { error: "权限不足,无法访问该路径", code: "denied" } };
626
+ if (code === "ELOOP")
627
+ return { status: 403, body: { error: "符号链接循环,已拒绝", code: "outside_workspace" } };
628
+ return { status: 500, body: { error: error instanceof Error ? error.message : "文件操作失败", code: "internal" } };
629
+ }
@@ -0,0 +1,20 @@
1
+ import { type Server as NodeHttpServer } from "node:http";
2
+ import type { ServerContext } from "./server-context.js";
3
+ /**
4
+ * Checks if the remote address is a local loopback interface.
5
+ */
6
+ export declare function isLoopbackAddress(remoteAddress: string | undefined): boolean;
7
+ /**
8
+ * Checks whether an incoming HTTP request satisfies access token authorization.
9
+ */
10
+ export declare function isAuthorized(request: {
11
+ headers: Record<string, string | string[] | undefined>;
12
+ }, accessToken?: string): boolean;
13
+ export interface HttpServerOptions {
14
+ context: ServerContext;
15
+ onShutdown: (reason: string) => void;
16
+ }
17
+ /**
18
+ * Creates the HTTP server providing static client assets and all `/api/*` endpoints.
19
+ */
20
+ export declare function createHttpServer(options: HttpServerOptions): NodeHttpServer;