@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.
- package/README.md +79 -2
- package/agents/coder.md +10 -0
- package/agents/critic.md +20 -0
- package/agents/patcher.md +23 -0
- package/agents/planner.md +18 -0
- package/agents/reviewer.md +13 -0
- package/agents/scout.md +23 -0
- package/agents/super-pi-task/core.ts +485 -0
- package/agents/super-pi-task/index.ts +141 -0
- package/agents/verifier.md +21 -0
- package/bin/super-pi.js +17 -0
- package/dist/client/assets/index-BMQsEdio.js +24 -0
- package/dist/client/assets/index-CsbZzuMy.css +1 -0
- package/dist/client/index.html +14 -0
- package/dist/extension.d.ts +15 -0
- package/dist/extension.js +111 -0
- package/dist/server/agents.d.ts +177 -0
- package/dist/server/agents.js +484 -0
- package/dist/server/application/catalog-service.d.ts +44 -0
- package/dist/server/application/catalog-service.js +94 -0
- package/dist/server/application/conversation-service.d.ts +73 -0
- package/dist/server/application/conversation-service.js +202 -0
- package/dist/server/application/extension-ui-service.d.ts +28 -0
- package/dist/server/application/extension-ui-service.js +73 -0
- package/dist/server/application/run-service.d.ts +115 -0
- package/dist/server/application/run-service.js +341 -0
- package/dist/server/application/workspace-service.d.ts +45 -0
- package/dist/server/application/workspace-service.js +106 -0
- package/dist/server/command-handler.d.ts +22 -0
- package/dist/server/command-handler.js +137 -0
- package/dist/server/files.d.ts +202 -0
- package/dist/server/files.js +629 -0
- package/dist/server/http-server.d.ts +20 -0
- package/dist/server/http-server.js +313 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +103 -0
- package/dist/server/pi-command.d.ts +65 -0
- package/dist/server/pi-command.js +183 -0
- package/dist/server/runtime.d.ts +819 -0
- package/dist/server/runtime.js +1470 -0
- package/dist/server/server-context.d.ts +46 -0
- package/dist/server/server-context.js +1 -0
- package/dist/server/store.d.ts +424 -0
- package/dist/server/store.js +1121 -0
- package/dist/server/subagent-runs.d.ts +142 -0
- package/dist/server/subagent-runs.js +315 -0
- package/dist/server/subagents.d.ts +194 -0
- package/dist/server/subagents.js +542 -0
- package/dist/server/task-tool.d.ts +196 -0
- package/dist/server/task-tool.js +285 -0
- package/dist/server/tool-calls.d.ts +62 -0
- package/dist/server/tool-calls.js +141 -0
- package/dist/server/types.d.ts +920 -0
- package/dist/server/types.js +11 -0
- package/dist/server/upload.d.ts +37 -0
- package/dist/server/upload.js +169 -0
- package/dist/server/ws-server.d.ts +45 -0
- package/dist/server/ws-server.js +116 -0
- 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;
|