@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/build/index.mjs CHANGED
@@ -1,13 +1,369 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
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" });