@workerdeck/server 0.6.0 → 0.7.0
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 +5 -1
- package/build/index.d.mts +42 -0
- package/build/index.mjs +609 -2
- package/build/index.mjs.map +1 -1
- package/package.json +5 -5
package/build/index.mjs
CHANGED
|
@@ -1,13 +1,369 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { createServer } from "node:http";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, join, resolve, sep } from "node:path";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
6
|
import { WebSocketServer } from "ws";
|
|
6
7
|
import { listSessions } from "@anthropic-ai/claude-agent-sdk";
|
|
7
8
|
import { BrowserBridgeExecutor, SessionRunner, checkClaudeAuth } from "@workerdeck/core";
|
|
8
9
|
import { JobQueue } from "@workerdeck/queue";
|
|
9
10
|
import { PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, supportsPermissionMode } from "@workerdeck/protocol";
|
|
10
11
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
12
|
+
//#region src/host-files.ts
|
|
13
|
+
/**
|
|
14
|
+
* Built once at startup from operator config. Roots are canonicalized here
|
|
15
|
+
* because resolution produces realpath'd targets: a root that is itself a
|
|
16
|
+
* symlink (`/tmp` -> `/private/tmp` on macOS) would otherwise contain nothing.
|
|
17
|
+
* A misdeclared root throws rather than silently guarding the wrong tree —
|
|
18
|
+
* same stance as profile config dirs in server.ts. An empty list is legal and
|
|
19
|
+
* refuses everything; "no roots means allow all" is `cwdAllowed`'s contract,
|
|
20
|
+
* never this module's.
|
|
21
|
+
*/
|
|
22
|
+
function createHostFileRoots(roots) {
|
|
23
|
+
return { roots: roots.map((configured) => {
|
|
24
|
+
if (invalidRequest(configured)) throw new Error(`createHostFileRoots: root must be an absolute path: ${JSON.stringify(configured)}`);
|
|
25
|
+
let canonical;
|
|
26
|
+
try {
|
|
27
|
+
canonical = realpathSync(configured);
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error(`createHostFileRoots: root does not exist: ${configured}`);
|
|
30
|
+
}
|
|
31
|
+
if (!lstatSync(canonical).isDirectory()) throw new Error(`createHostFileRoots: root is not a directory: ${configured}`);
|
|
32
|
+
return {
|
|
33
|
+
configured,
|
|
34
|
+
canonical
|
|
35
|
+
};
|
|
36
|
+
}) };
|
|
37
|
+
}
|
|
38
|
+
function refuse(status, error) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
status,
|
|
42
|
+
error
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** The uniform filesystem refusal — see the disclosure policy in the header.
|
|
46
|
+
* The string is deliberately constant: a distinct message is as much an oracle
|
|
47
|
+
* as a distinct status. */
|
|
48
|
+
function notFound() {
|
|
49
|
+
return refuse(404, "not found");
|
|
50
|
+
}
|
|
51
|
+
/** NUL is rejected before any fs call — Node throws a TypeError on NUL paths,
|
|
52
|
+
* and that must surface as a refusal, not a 500. Relative paths are refused
|
|
53
|
+
* outright rather than resolved against a cwd this API never promised. */
|
|
54
|
+
function invalidRequest(requested) {
|
|
55
|
+
return requested.length === 0 || requested.includes("\0") || !isAbsolute(requested);
|
|
56
|
+
}
|
|
57
|
+
/** Both sides are realpath output, so this is a pure lexical question — but a
|
|
58
|
+
* bare prefix check gets the boundary wrong (`/x/app` would swallow
|
|
59
|
+
* `/x/application`). `relative` answers it exactly: inside iff the walk from
|
|
60
|
+
* root to candidate is empty or never has to leave through `..`. */
|
|
61
|
+
function contained(rootCanonical, candidate) {
|
|
62
|
+
const rel = relative(rootCanonical, candidate);
|
|
63
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
64
|
+
}
|
|
65
|
+
function rootContaining(roots, canonical) {
|
|
66
|
+
return roots.roots.find((root) => contained(root.canonical, canonical));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* For read/list: the target must exist. realpath is handed the request whole —
|
|
70
|
+
* no lexical `..` collapsing first, because `root/link/..` is lexically `root`
|
|
71
|
+
* but physically the link target's parent, and only the physical answer is the
|
|
72
|
+
* true one. Symlinks that canonicalize *inside* a root are followed and served:
|
|
73
|
+
* containment is a property of the canonical target, not of the route to it —
|
|
74
|
+
* the operator granted the whole subtree, so nothing new becomes reachable.
|
|
75
|
+
*/
|
|
76
|
+
function resolveExisting(roots, requested) {
|
|
77
|
+
if (invalidRequest(requested)) return refuse(403, "invalid path");
|
|
78
|
+
let canonical;
|
|
79
|
+
try {
|
|
80
|
+
canonical = realpathSync(requested);
|
|
81
|
+
} catch {
|
|
82
|
+
return notFound();
|
|
83
|
+
}
|
|
84
|
+
const root = rootContaining(roots, canonical);
|
|
85
|
+
if (!root) return notFound();
|
|
86
|
+
let target;
|
|
87
|
+
try {
|
|
88
|
+
target = lstatSync(canonical);
|
|
89
|
+
} catch {
|
|
90
|
+
return notFound();
|
|
91
|
+
}
|
|
92
|
+
if (target.isFile()) return {
|
|
93
|
+
ok: true,
|
|
94
|
+
path: canonical,
|
|
95
|
+
root: root.canonical,
|
|
96
|
+
kind: "file"
|
|
97
|
+
};
|
|
98
|
+
if (target.isDirectory()) return {
|
|
99
|
+
ok: true,
|
|
100
|
+
path: canonical,
|
|
101
|
+
root: root.canonical,
|
|
102
|
+
kind: "dir"
|
|
103
|
+
};
|
|
104
|
+
return refuse(403, "not a regular file or directory");
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* For write: the target may not exist, so realpath cannot be asked directly.
|
|
108
|
+
* An existing target reuses read semantics — writing *through* a symlink that
|
|
109
|
+
* canonicalizes inside a root is allowed (`root/link -> root/real.txt` edits
|
|
110
|
+
* real.txt), same reasoning as {@link resolveExisting}. A missing target
|
|
111
|
+
* canonicalizes its immediate parent and re-checks: only the final component
|
|
112
|
+
* may be new, and anything already sitting there — in practice a dangling
|
|
113
|
+
* symlink — is refused, because open(2) with O_CREAT follows it and would
|
|
114
|
+
* create the file wherever it points. That refusal is `not found`, not 403: a
|
|
115
|
+
* link to an existing outside file already answers 404 via the exists branch,
|
|
116
|
+
* so a distinct status for the dangling case would hand back exactly the
|
|
117
|
+
* existence bit the uniform 404 exists to withhold.
|
|
118
|
+
*/
|
|
119
|
+
function resolveForWrite(roots, requested) {
|
|
120
|
+
if (invalidRequest(requested)) return refuse(403, "invalid path");
|
|
121
|
+
try {
|
|
122
|
+
const canonical = realpathSync(requested);
|
|
123
|
+
const root = rootContaining(roots, canonical);
|
|
124
|
+
if (!root) return notFound();
|
|
125
|
+
const target = lstatSync(canonical);
|
|
126
|
+
if (target.isDirectory()) return refuse(403, "is a directory");
|
|
127
|
+
if (!target.isFile()) return refuse(403, "not a regular file");
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
path: canonical,
|
|
131
|
+
root: root.canonical,
|
|
132
|
+
kind: "file"
|
|
133
|
+
};
|
|
134
|
+
} catch {}
|
|
135
|
+
const base = basename(requested);
|
|
136
|
+
if (base === "" || base === "." || base === "..") return refuse(403, "invalid path");
|
|
137
|
+
let parent;
|
|
138
|
+
try {
|
|
139
|
+
parent = realpathSync(dirname(requested));
|
|
140
|
+
} catch {
|
|
141
|
+
return notFound();
|
|
142
|
+
}
|
|
143
|
+
const root = rootContaining(roots, parent);
|
|
144
|
+
if (!root) return notFound();
|
|
145
|
+
try {
|
|
146
|
+
if (!lstatSync(parent).isDirectory()) return notFound();
|
|
147
|
+
} catch {
|
|
148
|
+
return notFound();
|
|
149
|
+
}
|
|
150
|
+
const path = join(parent, base);
|
|
151
|
+
if (!contained(root.canonical, path)) return notFound();
|
|
152
|
+
try {
|
|
153
|
+
lstatSync(path);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (err.code === "ENOENT") return {
|
|
156
|
+
ok: true,
|
|
157
|
+
path,
|
|
158
|
+
root: root.canonical,
|
|
159
|
+
kind: "file"
|
|
160
|
+
};
|
|
161
|
+
return notFound();
|
|
162
|
+
}
|
|
163
|
+
return notFound();
|
|
164
|
+
}
|
|
165
|
+
/** lstat semantics on purpose: a listing shows a symlink AS a symlink — the
|
|
166
|
+
* server never follows one while rendering a directory. Following happens only
|
|
167
|
+
* when the entry is itself requested, through {@link resolveExisting}, which
|
|
168
|
+
* refuses it if it escapes. `readdir(withFileTypes)` already answers without
|
|
169
|
+
* following, so this is classification, not I/O. */
|
|
170
|
+
function entryKind(entry) {
|
|
171
|
+
if (entry.isSymbolicLink()) return "symlink";
|
|
172
|
+
if (entry.isFile()) return "file";
|
|
173
|
+
if (entry.isDirectory()) return "dir";
|
|
174
|
+
return "other";
|
|
175
|
+
}
|
|
176
|
+
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
177
|
+
const O_NONBLOCK = constants.O_NONBLOCK ?? 0;
|
|
178
|
+
/**
|
|
179
|
+
* The open half of the resolve→open discipline; pass `ResolveOutcome.path`,
|
|
180
|
+
* never the requested string. O_NOFOLLOW turns a final component swapped for a
|
|
181
|
+
* symlink inside the race window into ELOOP instead of a follow; O_NONBLOCK
|
|
182
|
+
* makes a swapped-in fifo open instantly instead of parking the request until a
|
|
183
|
+
* writer appears (it is inert for regular files); the fstat gate refuses
|
|
184
|
+
* anything that is not a plain file before a byte is read — `/dev/zero` would
|
|
185
|
+
* otherwise be an unbounded read.
|
|
186
|
+
*/
|
|
187
|
+
function readContained(path) {
|
|
188
|
+
let fd;
|
|
189
|
+
try {
|
|
190
|
+
fd = openSync(path, constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return err.code === "ENOENT" ? notFound() : refuse(403, "refused");
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
if (!fstatSync(fd).isFile()) return refuse(403, "not a regular file");
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
data: readFileSync(fd)
|
|
199
|
+
};
|
|
200
|
+
} catch {
|
|
201
|
+
return refuse(403, "refused");
|
|
202
|
+
} finally {
|
|
203
|
+
closeSync(fd);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* O_CREAT|O_NOFOLLOW refuses (ELOOP) a symlink planted at the final component
|
|
208
|
+
* after resolve — the exact swap that would land the write at the link's
|
|
209
|
+
* target. Truncation happens via ftruncate only AFTER the fd is proven to be a
|
|
210
|
+
* regular file, so a swapped-in device or fifo is never truncated or written;
|
|
211
|
+
* O_NONBLOCK turns the reader-less-fifo open from a hang into ENXIO.
|
|
212
|
+
*/
|
|
213
|
+
function writeContained(path, data) {
|
|
214
|
+
let fd;
|
|
215
|
+
try {
|
|
216
|
+
fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | O_NOFOLLOW | O_NONBLOCK, 420);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return err.code === "ENOENT" ? notFound() : refuse(403, "refused");
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
if (!fstatSync(fd).isFile()) return refuse(403, "not a regular file");
|
|
222
|
+
ftruncateSync(fd);
|
|
223
|
+
writeFileSync(fd, data);
|
|
224
|
+
return { ok: true };
|
|
225
|
+
} catch {
|
|
226
|
+
return refuse(403, "refused");
|
|
227
|
+
} finally {
|
|
228
|
+
closeSync(fd);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/host-file-search.ts
|
|
233
|
+
/**
|
|
234
|
+
* The recursive half of the host-file routes: what `@file` autocomplete needs and
|
|
235
|
+
* `/fs/list` deliberately isn't. Listing answers "what is in this directory"; this
|
|
236
|
+
* answers "which file in this tree did you mean", which is a different query and a
|
|
237
|
+
* different cost model.
|
|
238
|
+
*
|
|
239
|
+
* Kept out of `host-files.ts` on purpose. That module is the audited containment
|
|
240
|
+
* core; this one walks *inside* an already-resolved, already-contained directory
|
|
241
|
+
* and never resolves a path of its own. Its one security-relevant rule is that it
|
|
242
|
+
* does not follow symlinks — see the walk below.
|
|
243
|
+
*/
|
|
244
|
+
/**
|
|
245
|
+
* Directories a source tree keeps that nobody types `@` looking for, and that are
|
|
246
|
+
* usually most of the entries on disk. Skipping them is what makes the walk cheap
|
|
247
|
+
* enough to run per keystroke; the operator can replace the list via
|
|
248
|
+
* `hostFiles.ignore`.
|
|
249
|
+
*/
|
|
250
|
+
const DEFAULT_IGNORED_DIRS = [
|
|
251
|
+
".git",
|
|
252
|
+
".hg",
|
|
253
|
+
".svn",
|
|
254
|
+
"node_modules",
|
|
255
|
+
".next",
|
|
256
|
+
".nuxt",
|
|
257
|
+
".svelte-kit",
|
|
258
|
+
".turbo",
|
|
259
|
+
".cache",
|
|
260
|
+
"dist",
|
|
261
|
+
"build",
|
|
262
|
+
"out",
|
|
263
|
+
"target",
|
|
264
|
+
".venv",
|
|
265
|
+
"venv",
|
|
266
|
+
"__pycache__",
|
|
267
|
+
".pytest_cache",
|
|
268
|
+
".gradle",
|
|
269
|
+
"Pods",
|
|
270
|
+
"DerivedData"
|
|
271
|
+
];
|
|
272
|
+
/**
|
|
273
|
+
* Breadth-first so shallow files rank first before scoring even runs — for a bare
|
|
274
|
+
* `@` that ordering *is* the ranking, and for a query it breaks ties the way a
|
|
275
|
+
* person expects (`src/index.ts` over `src/a/b/c/index.ts`).
|
|
276
|
+
*
|
|
277
|
+
* Symlinks are skipped outright, as files and as directories. As directories it is
|
|
278
|
+
* the difference between a bounded walk and an unbounded one (a cycle, or a link
|
|
279
|
+
* to `/`); as files it keeps this function's output within the tree it was handed,
|
|
280
|
+
* so nothing it offers can be a path that `resolveExisting` would later refuse.
|
|
281
|
+
* A tree that genuinely lives behind symlinks is not autocompletable — an accepted
|
|
282
|
+
* cost for not having to re-derive containment here.
|
|
283
|
+
*/
|
|
284
|
+
function searchFiles(base, options = {}) {
|
|
285
|
+
const limit = options.limit ?? 50;
|
|
286
|
+
const maxScanned = options.maxScanned ?? 2e4;
|
|
287
|
+
const ignore = new Set(options.ignore ?? DEFAULT_IGNORED_DIRS);
|
|
288
|
+
const needle = (options.query ?? "").toLowerCase();
|
|
289
|
+
const found = [];
|
|
290
|
+
const queue = [{
|
|
291
|
+
dir: base,
|
|
292
|
+
depth: 0
|
|
293
|
+
}];
|
|
294
|
+
let scanned = 0;
|
|
295
|
+
let exhausted = true;
|
|
296
|
+
while (queue.length > 0) {
|
|
297
|
+
const { dir, depth } = queue.shift();
|
|
298
|
+
let entries;
|
|
299
|
+
try {
|
|
300
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
301
|
+
} catch {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
for (const entry of entries) {
|
|
305
|
+
if (++scanned > maxScanned) {
|
|
306
|
+
exhausted = false;
|
|
307
|
+
queue.length = 0;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
const kind = entryKind(entry);
|
|
311
|
+
if (kind === "dir") {
|
|
312
|
+
if (!ignore.has(entry.name)) queue.push({
|
|
313
|
+
dir: join(dir, entry.name),
|
|
314
|
+
depth: depth + 1
|
|
315
|
+
});
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (kind !== "file") continue;
|
|
319
|
+
const path = join(dir, entry.name);
|
|
320
|
+
const rel = relative(base, path);
|
|
321
|
+
const score = scoreMatch(rel, entry.name, needle);
|
|
322
|
+
if (score !== null) found.push({
|
|
323
|
+
file: {
|
|
324
|
+
path,
|
|
325
|
+
relative: rel
|
|
326
|
+
},
|
|
327
|
+
score,
|
|
328
|
+
depth
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
found.sort((a, b) => b.score - a.score || a.depth - b.depth || a.file.relative.length - b.file.relative.length || a.file.relative.localeCompare(b.file.relative));
|
|
333
|
+
return {
|
|
334
|
+
matches: found.slice(0, limit).map((f) => f.file),
|
|
335
|
+
truncated: !exhausted || found.length > limit
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Subsequence matching, like every `@`-picker worth using: `seslist` finds
|
|
340
|
+
* `SessionListView.swift`. Returns null for no match.
|
|
341
|
+
*
|
|
342
|
+
* Scored so the two things people actually mean win — a hit in the filename beats
|
|
343
|
+
* one buried in the directory path, and characters typed consecutively beat the
|
|
344
|
+
* same characters scattered — rather than trying to be a ranking engine.
|
|
345
|
+
*/
|
|
346
|
+
function scoreMatch(relativePath, name, needle) {
|
|
347
|
+
if (needle === "") return 0;
|
|
348
|
+
const inName = subsequenceScore(name.toLowerCase(), needle);
|
|
349
|
+
if (inName !== null) return inName + 1e3;
|
|
350
|
+
return subsequenceScore(relativePath.toLowerCase(), needle);
|
|
351
|
+
}
|
|
352
|
+
function subsequenceScore(haystack, needle) {
|
|
353
|
+
let score = 0;
|
|
354
|
+
let from = 0;
|
|
355
|
+
let previous = -2;
|
|
356
|
+
for (const char of needle) {
|
|
357
|
+
const at = haystack.indexOf(char, from);
|
|
358
|
+
if (at === -1) return null;
|
|
359
|
+
if (at === previous + 1) score += 8;
|
|
360
|
+
if (at === 0) score += 4;
|
|
361
|
+
from = at + 1;
|
|
362
|
+
previous = at;
|
|
363
|
+
}
|
|
364
|
+
return score - haystack.length / 100;
|
|
365
|
+
}
|
|
366
|
+
//#endregion
|
|
11
367
|
//#region src/registry.ts
|
|
12
368
|
/** In-memory session table. Terminal sessions stay listed until removed or the process exits. */
|
|
13
369
|
var SessionRegistry = class {
|
|
@@ -831,6 +1187,21 @@ const CONTENT_TYPES = {
|
|
|
831
1187
|
xml: "application/xml; charset=utf-8",
|
|
832
1188
|
svg: "image/svg+xml; charset=utf-8"
|
|
833
1189
|
};
|
|
1190
|
+
/** sha256 hex — the currency of the conditional-write protocol on `/fs/write`. */
|
|
1191
|
+
function hashBytes(bytes) {
|
|
1192
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* The file's text, or null if it isn't text. Decoding never fails in Node — invalid
|
|
1196
|
+
* bytes become U+FFFD — so the only honest test is a round trip: if re-encoding the
|
|
1197
|
+
* decoded string reproduces the original bytes, nothing was lost and the client can
|
|
1198
|
+
* safely edit and send it back. Anything else ships base64, which an editor can
|
|
1199
|
+
* refuse to open rather than silently corrupt on save.
|
|
1200
|
+
*/
|
|
1201
|
+
function asUtf8(bytes) {
|
|
1202
|
+
const text = bytes.toString("utf8");
|
|
1203
|
+
return Buffer.from(text, "utf8").equals(bytes) ? text : null;
|
|
1204
|
+
}
|
|
834
1205
|
function contentTypeFor(filename) {
|
|
835
1206
|
return CONTENT_TYPES[filename.includes(".") ? filename.split(".").pop().toLowerCase() : ""] ?? "text/plain; charset=utf-8";
|
|
836
1207
|
}
|
|
@@ -1248,6 +1619,234 @@ function createWorkerServer(options = {}) {
|
|
|
1248
1619
|
}
|
|
1249
1620
|
return null;
|
|
1250
1621
|
};
|
|
1622
|
+
const hostFileRootPaths = options.hostFiles?.roots ?? options.allowedCwdRoots;
|
|
1623
|
+
const hostFiles = hostFileRootPaths?.length ? createHostFileRoots(hostFileRootPaths) : null;
|
|
1624
|
+
const hostFilesWritable = options.hostFiles?.write === true;
|
|
1625
|
+
const maxHostFileBytes = options.hostFiles?.maxFileBytes ?? 1024 * 1024;
|
|
1626
|
+
const maxHostDirEntries = options.hostFiles?.maxEntries ?? 5e3;
|
|
1627
|
+
/**
|
|
1628
|
+
* `{basePath}/fs/*` — the operator's real tree. Authorized by the auth key alone
|
|
1629
|
+
* and deliberately outside the agent permission flow: the caller is the operator.
|
|
1630
|
+
*
|
|
1631
|
+
* Every path in here goes through `host-files.ts` first, which canonicalizes and
|
|
1632
|
+
* *then* re-checks containment. The naive prefix compare `cwdAllowed` does would
|
|
1633
|
+
* be wrong at this door — the agent writes into these trees, and a symlink it
|
|
1634
|
+
* created is a path the operator never typed.
|
|
1635
|
+
*/
|
|
1636
|
+
const handleHostFiles = async (req, res, pathname) => {
|
|
1637
|
+
if (!hostFiles) {
|
|
1638
|
+
json(res, 404, { error: "host file access is not configured on this server" });
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
const route = pathname.slice((basePath + "/fs/").length);
|
|
1642
|
+
const url = new URL(req.url ?? "/", "http://internal");
|
|
1643
|
+
const requested = url.searchParams.get("path");
|
|
1644
|
+
if (route === "roots") {
|
|
1645
|
+
if (req.method !== "GET") {
|
|
1646
|
+
json(res, 405, { error: "method not allowed" });
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
json(res, 200, {
|
|
1650
|
+
roots: hostFiles.roots.map(({ canonical }) => ({
|
|
1651
|
+
path: canonical,
|
|
1652
|
+
name: basename(canonical) || canonical
|
|
1653
|
+
})),
|
|
1654
|
+
canWrite: hostFilesWritable
|
|
1655
|
+
});
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
if (route === "find") {
|
|
1659
|
+
if (req.method !== "GET") {
|
|
1660
|
+
json(res, 405, { error: "method not allowed" });
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
if (!requested) {
|
|
1664
|
+
json(res, 400, { error: "path is required" });
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
const resolved = resolveExisting(hostFiles, requested);
|
|
1668
|
+
if (!resolved.ok) {
|
|
1669
|
+
json(res, resolved.status, { error: resolved.error });
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
if (resolved.kind !== "dir") {
|
|
1673
|
+
json(res, 400, { error: "not a directory" });
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
const asked = Number(url.searchParams.get("limit") ?? "");
|
|
1677
|
+
const limit = Number.isFinite(asked) && asked > 0 ? Math.min(asked, 200) : 50;
|
|
1678
|
+
const result = searchFiles(resolved.path, {
|
|
1679
|
+
query: url.searchParams.get("q") ?? "",
|
|
1680
|
+
limit,
|
|
1681
|
+
ignore: options.hostFiles?.ignore
|
|
1682
|
+
});
|
|
1683
|
+
json(res, 200, {
|
|
1684
|
+
base: resolved.path,
|
|
1685
|
+
...result
|
|
1686
|
+
});
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
if (route === "list" || route === "read") {
|
|
1690
|
+
if (req.method !== "GET") {
|
|
1691
|
+
json(res, 405, { error: "method not allowed" });
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
if (!requested) {
|
|
1695
|
+
json(res, 400, { error: "path is required" });
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
const resolved = resolveExisting(hostFiles, requested);
|
|
1699
|
+
if (!resolved.ok) {
|
|
1700
|
+
json(res, resolved.status, { error: resolved.error });
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (route === "list") {
|
|
1704
|
+
if (resolved.kind !== "dir") {
|
|
1705
|
+
json(res, 400, { error: "not a directory" });
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
let names;
|
|
1709
|
+
try {
|
|
1710
|
+
names = readdirSync(resolved.path, { withFileTypes: true });
|
|
1711
|
+
} catch {
|
|
1712
|
+
json(res, 403, { error: "directory is not readable" });
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
const truncated = names.length > maxHostDirEntries;
|
|
1716
|
+
const entries = names.slice(0, maxHostDirEntries).map((entry) => {
|
|
1717
|
+
const path = join(resolved.path, entry.name);
|
|
1718
|
+
const type = entryKind(entry);
|
|
1719
|
+
let bytes;
|
|
1720
|
+
let modifiedAt;
|
|
1721
|
+
if (type === "file") try {
|
|
1722
|
+
const s = lstatSync(path);
|
|
1723
|
+
bytes = s.size;
|
|
1724
|
+
modifiedAt = s.mtimeMs;
|
|
1725
|
+
} catch {}
|
|
1726
|
+
return {
|
|
1727
|
+
name: entry.name,
|
|
1728
|
+
path,
|
|
1729
|
+
type,
|
|
1730
|
+
bytes,
|
|
1731
|
+
modifiedAt
|
|
1732
|
+
};
|
|
1733
|
+
});
|
|
1734
|
+
entries.sort((a, b) => {
|
|
1735
|
+
const rank = (t) => t === "dir" ? 0 : 1;
|
|
1736
|
+
return rank(a.type) - rank(b.type) || a.name.localeCompare(b.name);
|
|
1737
|
+
});
|
|
1738
|
+
json(res, 200, {
|
|
1739
|
+
path: resolved.path,
|
|
1740
|
+
entries,
|
|
1741
|
+
...truncated ? { truncated } : {}
|
|
1742
|
+
});
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
if (resolved.kind !== "file") {
|
|
1746
|
+
json(res, 400, { error: "not a regular file" });
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
let modifiedAt = 0;
|
|
1750
|
+
try {
|
|
1751
|
+
const stats = lstatSync(resolved.path);
|
|
1752
|
+
if (stats.size > maxHostFileBytes) {
|
|
1753
|
+
json(res, 413, { error: `file is larger than ${maxHostFileBytes} bytes` });
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
modifiedAt = stats.mtimeMs;
|
|
1757
|
+
} catch {
|
|
1758
|
+
json(res, 404, { error: "not found" });
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
const read = readContained(resolved.path);
|
|
1762
|
+
if (!read.ok) {
|
|
1763
|
+
json(res, read.status, { error: read.error });
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
if (read.data.length > maxHostFileBytes) {
|
|
1767
|
+
json(res, 413, { error: `file is larger than ${maxHostFileBytes} bytes` });
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
const text = asUtf8(read.data);
|
|
1771
|
+
json(res, 200, {
|
|
1772
|
+
path: resolved.path,
|
|
1773
|
+
content: text ?? read.data.toString("base64"),
|
|
1774
|
+
encoding: text === null ? "base64" : "utf8",
|
|
1775
|
+
bytes: read.data.length,
|
|
1776
|
+
hash: hashBytes(read.data),
|
|
1777
|
+
modifiedAt
|
|
1778
|
+
});
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
if (route === "write") {
|
|
1782
|
+
if (req.method !== "PUT") {
|
|
1783
|
+
json(res, 405, { error: "method not allowed" });
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
if (!hostFilesWritable) {
|
|
1787
|
+
json(res, 403, { error: "host file writes are not enabled on this server" });
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
const body = await readJsonBody(req, maxBodyBytes);
|
|
1791
|
+
if (!body.path || typeof body.path !== "string") {
|
|
1792
|
+
json(res, 400, { error: "path is required" });
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
if (typeof body.content !== "string") {
|
|
1796
|
+
json(res, 400, { error: "content is required" });
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
if (body.encoding !== void 0 && body.encoding !== "utf8" && body.encoding !== "base64") {
|
|
1800
|
+
json(res, 400, { error: "encoding must be 'utf8' or 'base64'" });
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
const resolved = resolveForWrite(hostFiles, body.path);
|
|
1804
|
+
if (!resolved.ok) {
|
|
1805
|
+
json(res, resolved.status, { error: resolved.error });
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
const next = Buffer.from(body.content, body.encoding ?? "utf8");
|
|
1809
|
+
if (next.length > maxHostFileBytes) {
|
|
1810
|
+
json(res, 413, { error: `content is larger than ${maxHostFileBytes} bytes` });
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
const current = readContained(resolved.path);
|
|
1814
|
+
if (!current.ok && current.status !== 404) {
|
|
1815
|
+
json(res, current.status, { error: current.error });
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
const existing = current.ok ? current.data : null;
|
|
1819
|
+
if (existing && !body.expectedHash) {
|
|
1820
|
+
json(res, 409, { error: "file exists — pass expectedHash to overwrite it" });
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
if (existing && hashBytes(existing) !== body.expectedHash) {
|
|
1824
|
+
json(res, 409, { error: "file changed on disk since it was read" });
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
if (!existing && body.expectedHash) {
|
|
1828
|
+
json(res, 409, { error: "file no longer exists" });
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
const written = writeContained(resolved.path, next);
|
|
1832
|
+
if (!written.ok) {
|
|
1833
|
+
json(res, written.status, { error: written.error });
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
let writtenAt = 0;
|
|
1837
|
+
try {
|
|
1838
|
+
writtenAt = lstatSync(resolved.path).mtimeMs;
|
|
1839
|
+
} catch {}
|
|
1840
|
+
json(res, 200, {
|
|
1841
|
+
path: resolved.path,
|
|
1842
|
+
bytes: next.length,
|
|
1843
|
+
hash: hashBytes(next),
|
|
1844
|
+
modifiedAt: writtenAt
|
|
1845
|
+
});
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
json(res, 404, { error: "not found" });
|
|
1849
|
+
};
|
|
1251
1850
|
const listSdkSessions = options.listSdkSessions ?? defaultSdkSessionLister;
|
|
1252
1851
|
const handleSdkSessions = async (req, res) => {
|
|
1253
1852
|
if (req.method !== "GET") {
|
|
@@ -1526,6 +2125,14 @@ function createWorkerServer(options = {}) {
|
|
|
1526
2125
|
await handleSdkSessions(req, res);
|
|
1527
2126
|
return;
|
|
1528
2127
|
}
|
|
2128
|
+
if (pathname.startsWith(basePath + "/fs/")) {
|
|
2129
|
+
if (!(await authenticate(req)).ok) {
|
|
2130
|
+
json(res, 401, { error: "unauthorized" });
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
await handleHostFiles(req, res, pathname);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
1529
2136
|
const route = parseRoute(req.url ?? "/");
|
|
1530
2137
|
if (!route || route.ws) {
|
|
1531
2138
|
json(res, 404, { error: "not found" });
|