@workerdeck/server 0.6.0 → 0.9.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,494 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
1
+ import { createHash, randomUUID } 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
- import { listSessions } from "@anthropic-ai/claude-agent-sdk";
7
- import { BrowserBridgeExecutor, SessionRunner, checkClaudeAuth } from "@workerdeck/core";
7
+ import { BrowserBridgeExecutor, SessionRunner, attachmentKind, checkClaudeAuth, getEngineAdapter, normalizeMediaType } from "@workerdeck/core";
8
8
  import { JobQueue } from "@workerdeck/queue";
9
- import { PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, supportsPermissionMode } from "@workerdeck/protocol";
9
+ import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, supportsPermissionMode } from "@workerdeck/protocol";
10
10
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
11
+ //#region src/host-files.ts
12
+ /**
13
+ * Built once at startup from operator config. Roots are canonicalized here
14
+ * because resolution produces realpath'd targets: a root that is itself a
15
+ * symlink (`/tmp` -> `/private/tmp` on macOS) would otherwise contain nothing.
16
+ * A misdeclared root throws rather than silently guarding the wrong tree —
17
+ * same stance as profile config dirs in server.ts. An empty list is legal and
18
+ * refuses everything; "no roots means allow all" is `cwdAllowed`'s contract,
19
+ * never this module's.
20
+ */
21
+ function createHostFileRoots(roots) {
22
+ return { roots: roots.map((configured) => {
23
+ if (invalidRequest(configured)) throw new Error(`createHostFileRoots: root must be an absolute path: ${JSON.stringify(configured)}`);
24
+ let canonical;
25
+ try {
26
+ canonical = realpathSync(configured);
27
+ } catch {
28
+ throw new Error(`createHostFileRoots: root does not exist: ${configured}`);
29
+ }
30
+ if (!lstatSync(canonical).isDirectory()) throw new Error(`createHostFileRoots: root is not a directory: ${configured}`);
31
+ return {
32
+ configured,
33
+ canonical
34
+ };
35
+ }) };
36
+ }
37
+ function refuse(status, error) {
38
+ return {
39
+ ok: false,
40
+ status,
41
+ error
42
+ };
43
+ }
44
+ /** The uniform filesystem refusal — see the disclosure policy in the header.
45
+ * The string is deliberately constant: a distinct message is as much an oracle
46
+ * as a distinct status. */
47
+ function notFound() {
48
+ return refuse(404, "not found");
49
+ }
50
+ /** NUL is rejected before any fs call — Node throws a TypeError on NUL paths,
51
+ * and that must surface as a refusal, not a 500. Relative paths are refused
52
+ * outright rather than resolved against a cwd this API never promised. */
53
+ function invalidRequest(requested) {
54
+ return requested.length === 0 || requested.includes("\0") || !isAbsolute(requested);
55
+ }
56
+ /** Both sides are realpath output, so this is a pure lexical question — but a
57
+ * bare prefix check gets the boundary wrong (`/x/app` would swallow
58
+ * `/x/application`). `relative` answers it exactly: inside iff the walk from
59
+ * root to candidate is empty or never has to leave through `..`. */
60
+ function contained(rootCanonical, candidate) {
61
+ const rel = relative(rootCanonical, candidate);
62
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
63
+ }
64
+ function rootContaining(roots, canonical) {
65
+ return roots.roots.find((root) => contained(root.canonical, canonical));
66
+ }
67
+ /**
68
+ * For read/list: the target must exist. realpath is handed the request whole —
69
+ * no lexical `..` collapsing first, because `root/link/..` is lexically `root`
70
+ * but physically the link target's parent, and only the physical answer is the
71
+ * true one. Symlinks that canonicalize *inside* a root are followed and served:
72
+ * containment is a property of the canonical target, not of the route to it —
73
+ * the operator granted the whole subtree, so nothing new becomes reachable.
74
+ */
75
+ function resolveExisting(roots, requested) {
76
+ if (invalidRequest(requested)) return refuse(403, "invalid path");
77
+ let canonical;
78
+ try {
79
+ canonical = realpathSync(requested);
80
+ } catch {
81
+ return notFound();
82
+ }
83
+ const root = rootContaining(roots, canonical);
84
+ if (!root) return notFound();
85
+ let target;
86
+ try {
87
+ target = lstatSync(canonical);
88
+ } catch {
89
+ return notFound();
90
+ }
91
+ if (target.isFile()) return {
92
+ ok: true,
93
+ path: canonical,
94
+ root: root.canonical,
95
+ kind: "file"
96
+ };
97
+ if (target.isDirectory()) return {
98
+ ok: true,
99
+ path: canonical,
100
+ root: root.canonical,
101
+ kind: "dir"
102
+ };
103
+ return refuse(403, "not a regular file or directory");
104
+ }
105
+ /**
106
+ * For write: the target may not exist, so realpath cannot be asked directly.
107
+ * An existing target reuses read semantics — writing *through* a symlink that
108
+ * canonicalizes inside a root is allowed (`root/link -> root/real.txt` edits
109
+ * real.txt), same reasoning as {@link resolveExisting}. A missing target
110
+ * canonicalizes its immediate parent and re-checks: only the final component
111
+ * may be new, and anything already sitting there — in practice a dangling
112
+ * symlink — is refused, because open(2) with O_CREAT follows it and would
113
+ * create the file wherever it points. That refusal is `not found`, not 403: a
114
+ * link to an existing outside file already answers 404 via the exists branch,
115
+ * so a distinct status for the dangling case would hand back exactly the
116
+ * existence bit the uniform 404 exists to withhold.
117
+ */
118
+ function resolveForWrite(roots, requested) {
119
+ if (invalidRequest(requested)) return refuse(403, "invalid path");
120
+ try {
121
+ const canonical = realpathSync(requested);
122
+ const root = rootContaining(roots, canonical);
123
+ if (!root) return notFound();
124
+ const target = lstatSync(canonical);
125
+ if (target.isDirectory()) return refuse(403, "is a directory");
126
+ if (!target.isFile()) return refuse(403, "not a regular file");
127
+ return {
128
+ ok: true,
129
+ path: canonical,
130
+ root: root.canonical,
131
+ kind: "file"
132
+ };
133
+ } catch {}
134
+ const base = basename(requested);
135
+ if (base === "" || base === "." || base === "..") return refuse(403, "invalid path");
136
+ let parent;
137
+ try {
138
+ parent = realpathSync(dirname(requested));
139
+ } catch {
140
+ return notFound();
141
+ }
142
+ const root = rootContaining(roots, parent);
143
+ if (!root) return notFound();
144
+ try {
145
+ if (!lstatSync(parent).isDirectory()) return notFound();
146
+ } catch {
147
+ return notFound();
148
+ }
149
+ const path = join(parent, base);
150
+ if (!contained(root.canonical, path)) return notFound();
151
+ try {
152
+ lstatSync(path);
153
+ } catch (err) {
154
+ if (err.code === "ENOENT") return {
155
+ ok: true,
156
+ path,
157
+ root: root.canonical,
158
+ kind: "file"
159
+ };
160
+ return notFound();
161
+ }
162
+ return notFound();
163
+ }
164
+ /** lstat semantics on purpose: a listing shows a symlink AS a symlink — the
165
+ * server never follows one while rendering a directory. Following happens only
166
+ * when the entry is itself requested, through {@link resolveExisting}, which
167
+ * refuses it if it escapes. `readdir(withFileTypes)` already answers without
168
+ * following, so this is classification, not I/O. */
169
+ function entryKind(entry) {
170
+ if (entry.isSymbolicLink()) return "symlink";
171
+ if (entry.isFile()) return "file";
172
+ if (entry.isDirectory()) return "dir";
173
+ return "other";
174
+ }
175
+ const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
176
+ const O_NONBLOCK = constants.O_NONBLOCK ?? 0;
177
+ /**
178
+ * The open half of the resolve→open discipline; pass `ResolveOutcome.path`,
179
+ * never the requested string. O_NOFOLLOW turns a final component swapped for a
180
+ * symlink inside the race window into ELOOP instead of a follow; O_NONBLOCK
181
+ * makes a swapped-in fifo open instantly instead of parking the request until a
182
+ * writer appears (it is inert for regular files); the fstat gate refuses
183
+ * anything that is not a plain file before a byte is read — `/dev/zero` would
184
+ * otherwise be an unbounded read.
185
+ */
186
+ function readContained(path) {
187
+ let fd;
188
+ try {
189
+ fd = openSync(path, constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
190
+ } catch (err) {
191
+ return err.code === "ENOENT" ? notFound() : refuse(403, "refused");
192
+ }
193
+ try {
194
+ if (!fstatSync(fd).isFile()) return refuse(403, "not a regular file");
195
+ return {
196
+ ok: true,
197
+ data: readFileSync(fd)
198
+ };
199
+ } catch {
200
+ return refuse(403, "refused");
201
+ } finally {
202
+ closeSync(fd);
203
+ }
204
+ }
205
+ /**
206
+ * O_CREAT|O_NOFOLLOW refuses (ELOOP) a symlink planted at the final component
207
+ * after resolve — the exact swap that would land the write at the link's
208
+ * target. Truncation happens via ftruncate only AFTER the fd is proven to be a
209
+ * regular file, so a swapped-in device or fifo is never truncated or written;
210
+ * O_NONBLOCK turns the reader-less-fifo open from a hang into ENXIO.
211
+ */
212
+ function writeContained(path, data) {
213
+ let fd;
214
+ try {
215
+ fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | O_NOFOLLOW | O_NONBLOCK, 420);
216
+ } catch (err) {
217
+ return err.code === "ENOENT" ? notFound() : refuse(403, "refused");
218
+ }
219
+ try {
220
+ if (!fstatSync(fd).isFile()) return refuse(403, "not a regular file");
221
+ ftruncateSync(fd);
222
+ writeFileSync(fd, data);
223
+ return { ok: true };
224
+ } catch {
225
+ return refuse(403, "refused");
226
+ } finally {
227
+ closeSync(fd);
228
+ }
229
+ }
230
+ //#endregion
231
+ //#region src/host-file-search.ts
232
+ /**
233
+ * The recursive half of the host-file routes: what `@file` autocomplete needs and
234
+ * `/fs/list` deliberately isn't. Listing answers "what is in this directory"; this
235
+ * answers "which file in this tree did you mean", which is a different query and a
236
+ * different cost model.
237
+ *
238
+ * Kept out of `host-files.ts` on purpose. That module is the audited containment
239
+ * core; this one walks *inside* an already-resolved, already-contained directory
240
+ * and never resolves a path of its own. Its one security-relevant rule is that it
241
+ * does not follow symlinks — see the walk below.
242
+ */
243
+ /**
244
+ * Directories a source tree keeps that nobody types `@` looking for, and that are
245
+ * usually most of the entries on disk. Skipping them is what makes the walk cheap
246
+ * enough to run per keystroke; the operator can replace the list via
247
+ * `hostFiles.ignore`.
248
+ */
249
+ const DEFAULT_IGNORED_DIRS = [
250
+ ".git",
251
+ ".hg",
252
+ ".svn",
253
+ "node_modules",
254
+ ".next",
255
+ ".nuxt",
256
+ ".svelte-kit",
257
+ ".turbo",
258
+ ".cache",
259
+ "dist",
260
+ "build",
261
+ "out",
262
+ "target",
263
+ ".venv",
264
+ "venv",
265
+ "__pycache__",
266
+ ".pytest_cache",
267
+ ".gradle",
268
+ "Pods",
269
+ "DerivedData"
270
+ ];
271
+ /**
272
+ * Breadth-first so shallow files rank first before scoring even runs — for a bare
273
+ * `@` that ordering *is* the ranking, and for a query it breaks ties the way a
274
+ * person expects (`src/index.ts` over `src/a/b/c/index.ts`).
275
+ *
276
+ * Symlinks are skipped outright, as files and as directories. As directories it is
277
+ * the difference between a bounded walk and an unbounded one (a cycle, or a link
278
+ * to `/`); as files it keeps this function's output within the tree it was handed,
279
+ * so nothing it offers can be a path that `resolveExisting` would later refuse.
280
+ * A tree that genuinely lives behind symlinks is not autocompletable — an accepted
281
+ * cost for not having to re-derive containment here.
282
+ */
283
+ function searchFiles(base, options = {}) {
284
+ const limit = options.limit ?? 50;
285
+ const maxScanned = options.maxScanned ?? 2e4;
286
+ const ignore = new Set(options.ignore ?? DEFAULT_IGNORED_DIRS);
287
+ const needle = (options.query ?? "").toLowerCase();
288
+ const found = [];
289
+ const queue = [{
290
+ dir: base,
291
+ depth: 0
292
+ }];
293
+ let scanned = 0;
294
+ let exhausted = true;
295
+ while (queue.length > 0) {
296
+ const { dir, depth } = queue.shift();
297
+ let entries;
298
+ try {
299
+ entries = readdirSync(dir, { withFileTypes: true });
300
+ } catch {
301
+ continue;
302
+ }
303
+ for (const entry of entries) {
304
+ if (++scanned > maxScanned) {
305
+ exhausted = false;
306
+ queue.length = 0;
307
+ break;
308
+ }
309
+ const kind = entryKind(entry);
310
+ if (kind === "dir") {
311
+ if (!ignore.has(entry.name)) queue.push({
312
+ dir: join(dir, entry.name),
313
+ depth: depth + 1
314
+ });
315
+ continue;
316
+ }
317
+ if (kind !== "file") continue;
318
+ const path = join(dir, entry.name);
319
+ const rel = relative(base, path);
320
+ const score = scoreMatch(rel, entry.name, needle);
321
+ if (score !== null) found.push({
322
+ file: {
323
+ path,
324
+ relative: rel
325
+ },
326
+ score,
327
+ depth
328
+ });
329
+ }
330
+ }
331
+ 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));
332
+ return {
333
+ matches: found.slice(0, limit).map((f) => f.file),
334
+ truncated: !exhausted || found.length > limit
335
+ };
336
+ }
337
+ /**
338
+ * Subsequence matching, like every `@`-picker worth using: `seslist` finds
339
+ * `SessionListView.swift`. Returns null for no match.
340
+ *
341
+ * Scored so the two things people actually mean win — a hit in the filename beats
342
+ * one buried in the directory path, and characters typed consecutively beat the
343
+ * same characters scattered — rather than trying to be a ranking engine.
344
+ */
345
+ function scoreMatch(relativePath, name, needle) {
346
+ if (needle === "") return 0;
347
+ const inName = subsequenceScore(name.toLowerCase(), needle);
348
+ if (inName !== null) return inName + 1e3;
349
+ return subsequenceScore(relativePath.toLowerCase(), needle);
350
+ }
351
+ function subsequenceScore(haystack, needle) {
352
+ let score = 0;
353
+ let from = 0;
354
+ let previous = -2;
355
+ for (const char of needle) {
356
+ const at = haystack.indexOf(char, from);
357
+ if (at === -1) return null;
358
+ if (at === previous + 1) score += 8;
359
+ if (at === 0) score += 4;
360
+ from = at + 1;
361
+ previous = at;
362
+ }
363
+ return score - haystack.length / 100;
364
+ }
365
+ //#endregion
366
+ //#region src/attachments.ts
367
+ const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
368
+ const DEFAULT_MAX_SESSION_BYTES = 64 * 1024 * 1024;
369
+ /**
370
+ * Per-session hold for files the user attached to a message.
371
+ *
372
+ * In memory, and deliberately so. An attachment is only *needed* for the instant
373
+ * between the upload and the message that names it; everything after that is
374
+ * convenience (a client re-rendering a thumbnail after a reattach). That is the
375
+ * same bargain `GET /sessions/:id/files` makes — the session's lifetime, no
376
+ * durability tier — and it keeps the gateway from accumulating a photo library
377
+ * on disk that nobody asked it to look after.
378
+ *
379
+ * Both caps are enforced here rather than at the route, so a host embedding the
380
+ * server cannot forget one: a single file that is too big is a 413, and so is a
381
+ * session whose total would go over.
382
+ */
383
+ var AttachmentStore = class {
384
+ #bySession = /* @__PURE__ */ new Map();
385
+ #maxFileBytes;
386
+ #maxSessionBytes;
387
+ constructor(options = {}) {
388
+ this.#maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
389
+ this.#maxSessionBytes = options.maxSessionBytes ?? DEFAULT_MAX_SESSION_BYTES;
390
+ }
391
+ get maxFileBytes() {
392
+ return this.#maxFileBytes;
393
+ }
394
+ put(sessionId, name, mediaType, body) {
395
+ if (body.length === 0) return {
396
+ ok: false,
397
+ error: {
398
+ code: "empty",
399
+ message: "attachment is empty"
400
+ }
401
+ };
402
+ if (body.length > this.#maxFileBytes) return {
403
+ ok: false,
404
+ error: {
405
+ code: "too_large",
406
+ message: `attachment is larger than the ${this.#maxFileBytes}-byte limit`
407
+ }
408
+ };
409
+ const type = normalizeMediaType(mediaType);
410
+ if (!attachmentKind(type)) return {
411
+ ok: false,
412
+ error: {
413
+ code: "unsupported_type",
414
+ message: `unsupported media type: ${type}`
415
+ }
416
+ };
417
+ const held = this.#bySession.get(sessionId) ?? /* @__PURE__ */ new Map();
418
+ const heldBytes = [...held.values()].reduce((sum, a) => sum + a.bytes, 0);
419
+ if (heldBytes + body.length > this.#maxSessionBytes) return {
420
+ ok: false,
421
+ error: {
422
+ code: "session_full",
423
+ message: `session is already holding ${heldBytes} bytes of attachments (limit ${this.#maxSessionBytes})`
424
+ }
425
+ };
426
+ const attachment = {
427
+ id: randomUUID(),
428
+ name: safeName(name),
429
+ mediaType: type,
430
+ bytes: body.length,
431
+ data: body.toString("base64")
432
+ };
433
+ held.set(attachment.id, attachment);
434
+ this.#bySession.set(sessionId, held);
435
+ return {
436
+ ok: true,
437
+ attachment: ref(attachment)
438
+ };
439
+ }
440
+ /** The stored record, bytes included — for the download route and for the send
441
+ * path that turns ids into content blocks. */
442
+ get(sessionId, id) {
443
+ return this.#bySession.get(sessionId)?.get(id);
444
+ }
445
+ /**
446
+ * Resolve the ids a `user_message` named, in the order given.
447
+ *
448
+ * Missing ids are reported rather than skipped: a message that quietly lost its
449
+ * picture reads as the model ignoring it, which is a far worse failure than a
450
+ * command that errors.
451
+ */
452
+ resolve(sessionId, ids) {
453
+ const held = this.#bySession.get(sessionId);
454
+ const attachments = [];
455
+ const missing = [];
456
+ for (const id of ids) {
457
+ const found = held?.get(id);
458
+ if (found) attachments.push(found);
459
+ else missing.push(id);
460
+ }
461
+ return missing.length ? {
462
+ ok: false,
463
+ missing
464
+ } : {
465
+ ok: true,
466
+ attachments
467
+ };
468
+ }
469
+ drop(sessionId) {
470
+ this.#bySession.delete(sessionId);
471
+ }
472
+ };
473
+ function ref(attachment) {
474
+ return {
475
+ id: attachment.id,
476
+ name: attachment.name,
477
+ mediaType: attachment.mediaType,
478
+ bytes: attachment.bytes
479
+ };
480
+ }
481
+ /**
482
+ * A display name, not a path. The name is echoed back to clients and put in front
483
+ * of the model in the text-attachment envelope, so directory separators, control
484
+ * characters and unbounded length all come off here.
485
+ */
486
+ function safeName(name) {
487
+ const cleaned = (name.split(/[/\\]/).pop() ?? "").replace(/[\u0000-\u001f\u007f"<>]/g, "").trim();
488
+ if (cleaned === "" || cleaned === "." || cleaned === "..") return "attachment";
489
+ return cleaned.length > 120 ? cleaned.slice(0, 120) : cleaned;
490
+ }
491
+ //#endregion
11
492
  //#region src/registry.ts
12
493
  /** In-memory session table. Terminal sessions stay listed until removed or the process exits. */
13
494
  var SessionRegistry = class {
@@ -736,18 +1217,6 @@ function parseRecord(value) {
736
1217
  }
737
1218
  //#endregion
738
1219
  //#region src/server.ts
739
- const defaultSdkSessionLister = async (options) => {
740
- return (await listSessions(options)).map((s) => ({
741
- sessionId: s.sessionId,
742
- summary: s.summary,
743
- lastModified: s.lastModified,
744
- createdAt: s.createdAt,
745
- customTitle: s.customTitle,
746
- firstPrompt: s.firstPrompt,
747
- gitBranch: s.gitBranch,
748
- cwd: s.cwd
749
- }));
750
- };
751
1220
  function json(res, status, body) {
752
1221
  const payload = JSON.stringify(body);
753
1222
  res.writeHead(status, {
@@ -767,6 +1236,18 @@ async function readJsonBody(req, maxBytes) {
767
1236
  if (size === 0) return {};
768
1237
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
769
1238
  }
1239
+ /** Body as bytes, refusing anything over `maxBytes`. Attachments are the one
1240
+ * thing this server takes that isn't JSON. */
1241
+ async function readRawBody(req, maxBytes) {
1242
+ const chunks = [];
1243
+ let size = 0;
1244
+ for await (const chunk of req) {
1245
+ size += chunk.length;
1246
+ if (size > maxBytes) throw new Error("request body too large");
1247
+ chunks.push(chunk);
1248
+ }
1249
+ return Buffer.concat(chunks);
1250
+ }
770
1251
  /**
771
1252
  * Curated, view-only snapshot of a profile's config dir for GET /profiles/:name.
772
1253
  * Best-effort: a missing or unparseable settings.json just omits the settings block.
@@ -831,6 +1312,21 @@ const CONTENT_TYPES = {
831
1312
  xml: "application/xml; charset=utf-8",
832
1313
  svg: "image/svg+xml; charset=utf-8"
833
1314
  };
1315
+ /** sha256 hex — the currency of the conditional-write protocol on `/fs/write`. */
1316
+ function hashBytes(bytes) {
1317
+ return createHash("sha256").update(bytes).digest("hex");
1318
+ }
1319
+ /**
1320
+ * The file's text, or null if it isn't text. Decoding never fails in Node — invalid
1321
+ * bytes become U+FFFD — so the only honest test is a round trip: if re-encoding the
1322
+ * decoded string reproduces the original bytes, nothing was lost and the client can
1323
+ * safely edit and send it back. Anything else ships base64, which an editor can
1324
+ * refuse to open rather than silently corrupt on save.
1325
+ */
1326
+ function asUtf8(bytes) {
1327
+ const text = bytes.toString("utf8");
1328
+ return Buffer.from(text, "utf8").equals(bytes) ? text : null;
1329
+ }
834
1330
  function contentTypeFor(filename) {
835
1331
  return CONTENT_TYPES[filename.includes(".") ? filename.split(".").pop().toLowerCase() : ""] ?? "text/plain; charset=utf-8";
836
1332
  }
@@ -839,6 +1335,10 @@ function contentTypeFor(filename) {
839
1335
  function isProviderProfile(profile) {
840
1336
  return profile.engine === "provider";
841
1337
  }
1338
+ /** The engine a profile runs, absent meaning 'claude' (pre-provider profiles). */
1339
+ function engineOf(profile) {
1340
+ return profile?.engine ?? "claude";
1341
+ }
842
1342
  /** Where the CLI's own resolution lands for a given environment: an explicit
843
1343
  * CLAUDE_CONFIG_DIR, else ~/.claude. */
844
1344
  function cliConfigDir(env) {
@@ -893,6 +1393,8 @@ function createWorkerServer(options = {}) {
893
1393
  const basePath = options.basePath ?? "/v1";
894
1394
  const fallback = options.fallback;
895
1395
  const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
1396
+ /** The engine's adapter, honoring the test-only `engines` override. */
1397
+ const adapterFor = (engine) => options.engines?.[engine ?? "claude"] ?? getEngineAdapter(engine);
896
1398
  const hostBuildRunnerConfig = options.buildRunnerConfig ?? ((req) => req);
897
1399
  const declared = options.profiles ?? detectDefaultProfiles();
898
1400
  const declaredByName = new Map(declared.map((p) => [p.name, p]));
@@ -906,10 +1408,13 @@ function createWorkerServer(options = {}) {
906
1408
  if (isProviderProfile(p)) {
907
1409
  if (!p.provider?.id) return `provider profile '${p.name}' is missing provider.id`;
908
1410
  if (!options.createEngineRunner) return `profile '${p.name}' uses engine 'provider' but no \`createEngineRunner\` was provided to build one`;
1411
+ } else if (p.engine === "codex") {
1412
+ if (p.codexHome && !existsSync(p.codexHome)) return `profile '${p.name}' codexHome does not exist: ${p.codexHome}`;
1413
+ if (p.session?.instructions) return `profile '${p.name}' declares session.instructions, which the codex engine cannot deliver — put instructions in the target repo’s AGENTS.md instead`;
909
1414
  } else if (!p.configDir || !existsSync(p.configDir)) return `profile '${p.name}' configDir does not exist: ${p.configDir}`;
910
1415
  if (options.disableBypassPermissions && p.defaults?.permissionMode === "bypassPermissions") return `profile '${p.name}' defaults to bypassPermissions but disableBypassPermissions is set`;
911
1416
  const fallbackMode = p.defaults?.permissionMode;
912
- if (fallbackMode && !supportsPermissionMode(p.engine, fallbackMode)) return `profile '${p.name}' defaults to permission mode '${fallbackMode}', which engine '${p.engine}' does not support (supported: ${PROVIDER_PERMISSION_MODES.join(", ")})`;
1417
+ if (fallbackMode && !supportsPermissionMode(p.engine, fallbackMode)) return `profile '${p.name}' defaults to permission mode '${fallbackMode}', which engine '${engineOf(p)}' does not support (supported: ${adapterFor(engineOf(p)).capabilities.permissionModes.join(", ")})`;
913
1418
  return null;
914
1419
  };
915
1420
  for (const p of options.profiles ?? []) {
@@ -933,6 +1438,31 @@ function createWorkerServer(options = {}) {
933
1438
  ...p,
934
1439
  managed: true
935
1440
  };
1441
+ /**
1442
+ * Response shape for a profile: the managed marker, the engine's capability
1443
+ * record, its static model catalog (correct from the first request — no
1444
+ * warm-up session, no process spawned), the availability verdict when one
1445
+ * has been probed, and the learned default model (the one thing a static
1446
+ * catalog cannot know: a claude profile's default is the operator's CLI
1447
+ * config, so it stays absent until a session on the profile reports it).
1448
+ * Read-only decoration — never persisted.
1449
+ */
1450
+ const forResponse = (p) => {
1451
+ const adapter = adapterFor(p.engine);
1452
+ const base = {
1453
+ ...withManagedFlag(p),
1454
+ capabilities: adapter.capabilities
1455
+ };
1456
+ if (adapter.catalog.models.length > 0) base.models = adapter.catalog.models;
1457
+ const defaultModel = profileDefaultModels.get(p.name);
1458
+ if (defaultModel) base.defaultModel = defaultModel;
1459
+ const probed = availability.get(p.name)?.verdict;
1460
+ if (probed && probed.available !== "unknown") {
1461
+ base.available = probed.available;
1462
+ if (probed.available === false) base.unavailableReason = probed.reason;
1463
+ }
1464
+ return base;
1465
+ };
936
1466
  /** Declared profiles first: a name collision means the code wins, and the stored
937
1467
  * one is unreachable rather than silently overriding server options. */
938
1468
  const allProfiles = () => [...declared, ...[...stored.values()].filter((p) => !declaredByName.has(p.name))];
@@ -1006,28 +1536,42 @@ function createWorkerServer(options = {}) {
1006
1536
  * into 'default' by whatever assembles its runner. Returns an error message. */
1007
1537
  const checkPermissionMode = (mode, profile) => {
1008
1538
  if (mode === void 0 || supportsPermissionMode(profile?.engine, mode)) return null;
1009
- return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${profile.engine}') — supported: ${PROVIDER_PERMISSION_MODES.join(", ")}`;
1539
+ return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${engineOf(profile)}') — supported: ` + adapterFor(profile?.engine).capabilities.permissionModes.join(", ");
1010
1540
  };
1011
1541
  /**
1012
- * Enforce the provider engine's grant rules on a create request. Two of them:
1013
- *
1014
- * - Capabilities narrow, never widen. A request may run with fewer than the
1015
- * profile grants; naming one it doesn't is refused rather than quietly
1016
- * downgraded, so a caller learns instead of wondering where the tool went.
1017
- * - MCP servers are the profile's to declare. MCP tools are authoritative —
1018
- * server-side, with server credentials, never bridged so honoring a
1019
- * client-supplied server would let a caller point an authoritative tool
1020
- * anywhere it liked. The profile names servers; the host holds their configs.
1542
+ * Refuse the request fields the resolved profile's engine cannot honor
1543
+ * read off its capability record, so the create form's filtering and the
1544
+ * API boundary can never disagree. Refusing beats coercing: a caller who
1545
+ * asked for something the engine has no meaning for should be told, not
1546
+ * left wondering where the option went. Also enforces the provider grant
1547
+ * rules (capabilities narrow, never widen; MCP servers are the profile's to
1548
+ * declare MCP tools are authoritative, server-side, with server
1549
+ * credentials, so honoring a client-supplied server would let a caller
1550
+ * point an authoritative tool anywhere it liked).
1021
1551
  */
1022
1552
  const checkEngineGrants = (req, profile) => {
1553
+ const engine = engineOf(profile);
1554
+ const caps = adapterFor(profile?.engine).capabilities;
1555
+ const name = profile?.name ?? "default";
1556
+ if (!caps.sessionMcpServers && req.mcpServers && Object.keys(req.mcpServers).length > 0) return `profile '${name}' runs the ${engine} engine, whose MCP servers are declared outside the session request — a request cannot add its own`;
1557
+ if (!caps.budgets && (req.maxTurns !== void 0 || req.maxBudgetUsd !== void 0)) return `the ${engine} engine does not honor maxTurns/maxBudgetUsd`;
1558
+ if (!caps.settingSources && req.settingSources !== void 0) return `the ${engine} engine does not load settingSources`;
1559
+ if (!caps.resume && req.resume !== void 0) return `the ${engine} engine cannot resume a session`;
1560
+ if (req.forkSession && engine !== "claude") return `the ${engine} engine cannot fork a resumed session`;
1561
+ if (req.reasoningEffort !== void 0 && (!caps.reasoningEfforts || caps.reasoningEfforts.length === 0)) return `the ${engine} engine does not take a reasoningEffort`;
1023
1562
  if (!profile || !isProviderProfile(profile)) return null;
1024
- if (req.mcpServers && Object.keys(req.mcpServers).length > 0) return `profile '${profile.name}' runs the provider engine, whose MCP servers are declared on the profile (session.mcpServers) — a session request cannot add its own`;
1025
1563
  const granted = profile.session?.capabilities;
1026
1564
  if (!req.capabilities || !granted) return null;
1027
1565
  const ungranted = req.capabilities.filter((c) => !granted.includes(c));
1028
1566
  if (ungranted.length === 0) return null;
1029
1567
  return `profile '${profile.name}' does not grant: ${ungranted.join(", ")} (granted: ${granted.join(", ") || "none"}) — a request may narrow capabilities, not widen them`;
1030
1568
  };
1569
+ /** Drop request fields that are meaningless (not wrong) for the engine —
1570
+ * today just `questionBehavior` where no approval channel exists, so job
1571
+ * webhooks never grow phantom permission_requested expectations. */
1572
+ const stripInertFields = (req, profile) => {
1573
+ if (!adapterFor(profile?.engine).capabilities.interactiveApprovals) delete req.questionBehavior;
1574
+ };
1031
1575
  /** Profile-aware config hook: fill the profile's defaults into unset request fields,
1032
1576
  * run the host hook, then pin CLAUDE_CONFIG_DIR — the profile wins even when the
1033
1577
  * host hook set its own env (see `claudeSessionEnv` for the one case the pin is
@@ -1040,7 +1584,7 @@ function createWorkerServer(options = {}) {
1040
1584
  model: req.model ?? profile.defaults?.model ?? profile.provider?.model,
1041
1585
  permissionMode: req.permissionMode ?? profile.defaults?.permissionMode
1042
1586
  });
1043
- if (isProviderProfile(profile)) return config;
1587
+ if (engineOf(profile) !== "claude") return config;
1044
1588
  const base = config.env ?? process.env;
1045
1589
  const env = claudeSessionEnv(profile, base);
1046
1590
  return env === base ? config : {
@@ -1064,8 +1608,11 @@ function createWorkerServer(options = {}) {
1064
1608
  bridge,
1065
1609
  restore
1066
1610
  });
1067
- if (restore) throw new Error("the Claude engine cannot rebuild a parked session");
1068
- return new Promise((resolve) => resolve(registry.prepare(config)));
1611
+ return adapterFor(profile?.engine).createRunner({
1612
+ config,
1613
+ profile,
1614
+ restore
1615
+ });
1069
1616
  };
1070
1617
  const createRunner = async (config) => {
1071
1618
  const runner = registry.register(await buildRunner(config));
@@ -1112,7 +1659,24 @@ function createWorkerServer(options = {}) {
1112
1659
  };
1113
1660
  };
1114
1661
  const notifier = new SessionNotifier(options.notifications ?? {});
1115
- const registry = new SessionRegistry({ onRegister: (runner) => notifier.watch(runner) });
1662
+ /**
1663
+ * What each claude profile's *default* model resolves to, learned from the
1664
+ * `capabilities` events of sessions that ran on it. The model *list* is the
1665
+ * adapter's static catalog now; the default is the one thing a catalog
1666
+ * cannot know (it is the operator's CLI config), so it alone is still
1667
+ * learned — and still absent on a cold server, the accepted regression.
1668
+ */
1669
+ const profileDefaultModels = /* @__PURE__ */ new Map();
1670
+ const registry = new SessionRegistry({ onRegister: (runner) => {
1671
+ notifier.watch(runner);
1672
+ const profile = runner.info().profile;
1673
+ if (!profile) return;
1674
+ runner.subscribe((event) => {
1675
+ if (event.type !== "capabilities" || !event.defaultModel) return;
1676
+ profileDefaultModels.set(profile, event.defaultModel);
1677
+ });
1678
+ } });
1679
+ const attachmentStore = new AttachmentStore(options.attachments);
1116
1680
  const bridge = new BridgeHub({
1117
1681
  ...options.bridge,
1118
1682
  onResult: (sessionId, executionId, result) => {
@@ -1185,31 +1749,70 @@ function createWorkerServer(options = {}) {
1185
1749
  });
1186
1750
  };
1187
1751
  /**
1188
- * Probe each Claude profile's credentials the way its sessions will actually
1189
- * experience them: the env the real assembly path produces, so anything the
1190
- * host hook injects (a CLAUDE_CODE_OAUTH_TOKEN, say) counts as logged in.
1191
- * Provider profiles resolve credentials in the engine factory and are not
1192
- * probed. Fire-and-forget by design — see the `checkCredentials` option doc.
1752
+ * Availability, per profile: the adapter's probe run over the env the real
1753
+ * assembly path produces (so anything the host hook injects — a
1754
+ * CLAUDE_CODE_OAUTH_TOKEN, say counts as logged in). Cached, and served on
1755
+ * `GET /profiles` as `available`/`unavailableReason`.
1756
+ *
1757
+ * Gated on `checkCredentials` like the old claude-only preflight (this is a
1758
+ * library; `pnpm test` must spawn nothing unless a test injects fake
1759
+ * adapters or probes). 'unknown' stays out of the cache's answers: a probe
1760
+ * that couldn't run is not evidence of a missing login. **Display-only**
1761
+ * downstream — session create against an unavailable profile still proceeds
1762
+ * and fails with the engine's own error, because the probe can be stale in
1763
+ * both directions and refusing on it would turn a probe bug into an outage.
1193
1764
  */
1194
- const preflightCredentials = () => {
1765
+ const availability = /* @__PURE__ */ new Map();
1766
+ const AVAILABILITY_TTL_MS = 6e4;
1767
+ /** Profiles already warned about on the console, so re-probes don't spam. */
1768
+ const availabilityWarned = /* @__PURE__ */ new Set();
1769
+ const sessionEnvFor = (profile) => {
1770
+ try {
1771
+ return buildRunnerConfig({
1772
+ cwd: process.cwd(),
1773
+ profile: profile.name
1774
+ }).env ?? process.env;
1775
+ } catch {
1776
+ return engineOf(profile) === "claude" ? claudeSessionEnv(profile, process.env) : process.env;
1777
+ }
1778
+ };
1779
+ const probeProfile = (profile) => {
1195
1780
  if (!options.checkCredentials) return;
1196
1781
  const conf = options.checkCredentials === true ? {} : options.checkCredentials;
1197
- const probe = conf.probe ?? ((env) => checkClaudeAuth(env, { timeoutMs: conf.timeoutMs }));
1198
- for (const profile of allProfiles()) {
1199
- if (isProviderProfile(profile)) continue;
1200
- let env;
1201
- try {
1202
- env = buildRunnerConfig({
1203
- cwd: process.cwd(),
1204
- profile: profile.name
1205
- }).env ?? process.env;
1206
- } catch {
1207
- env = claudeSessionEnv(profile, process.env);
1782
+ availability.set(profile.name, {
1783
+ verdict: availability.get(profile.name)?.verdict ?? { available: "unknown" },
1784
+ at: Date.now()
1785
+ });
1786
+ const adapter = adapterFor(profile.engine);
1787
+ const claudeProbe = engineOf(profile) !== "claude" ? void 0 : conf.probe ?? (conf.timeoutMs !== void 0 ? (env) => checkClaudeAuth(env, { timeoutMs: conf.timeoutMs }) : void 0);
1788
+ (claudeProbe ? claudeProbe(sessionEnvFor(profile)).then((status) => status === "logged_in" ? { available: true } : status === "logged_out" ? {
1789
+ available: false,
1790
+ reason: "no usable Claude credentials for this profile"
1791
+ } : { available: "unknown" }) : adapter.checkAvailability(profile, sessionEnvFor(profile))).then((verdict) => {
1792
+ availability.set(profile.name, {
1793
+ verdict,
1794
+ at: Date.now()
1795
+ });
1796
+ if (verdict.available === false && !availabilityWarned.has(profile.name)) {
1797
+ availabilityWarned.add(profile.name);
1798
+ console.warn(`[workerdeck] Profile '${profile.name}' is unavailable: ${verdict.reason} (\`checkCredentials: false\` disables this check)`);
1208
1799
  }
1209
- probe(env).then((status) => {
1210
- if (status !== "logged_out") return;
1211
- console.warn(`[workerdeck] Profile '${profile.name}' (${profile.configDir}) has no usable Claude credentials: \`claude auth status\` reports logged out for the environment its sessions run with, so they will fail with "Not logged in". Log in under that dir (CLAUDE_CONFIG_DIR=${profile.configDir} claude auth login), inject a long-lived token via buildRunnerConfig (CLAUDE_CODE_OAUTH_TOKEN), or set ANTHROPIC_API_KEY. \`checkCredentials: false\` disables this check.`);
1212
- }).catch(() => {});
1800
+ if (verdict.available === true) availabilityWarned.delete(profile.name);
1801
+ }).catch(() => {});
1802
+ };
1803
+ /** Launch-time sweep, concurrent and fire-and-forget. */
1804
+ const preflightCredentials = () => {
1805
+ for (const profile of allProfiles()) probeProfile(profile);
1806
+ };
1807
+ /** Lazy re-probe on reads, so an operator who just ran `codex login` (or
1808
+ * exported a key) sees the profile go green without a restart. Serves the
1809
+ * cached verdict now; the refreshed one lands on the next request. */
1810
+ const refreshAvailability = (profiles) => {
1811
+ if (!options.checkCredentials) return;
1812
+ const now = Date.now();
1813
+ for (const profile of profiles) {
1814
+ const cached = availability.get(profile.name);
1815
+ if (!cached || now - cached.at > AVAILABILITY_TTL_MS) probeProfile(profile);
1213
1816
  }
1214
1817
  };
1215
1818
  const authenticate = async (req) => {
@@ -1238,6 +1841,16 @@ function createWorkerServer(options = {}) {
1238
1841
  id: decodeURIComponent(parts[0]),
1239
1842
  permissionId: decodeURIComponent(parts[2])
1240
1843
  };
1844
+ if (parts.length <= 3 && parts[1] === "attachments") return {
1845
+ id: decodeURIComponent(parts[0]),
1846
+ attachments: true,
1847
+ attachmentId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
1848
+ };
1849
+ if (parts.length <= 3 && parts[1] === "mcp") return {
1850
+ id: decodeURIComponent(parts[0]),
1851
+ mcp: true,
1852
+ mcpServer: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
1853
+ };
1241
1854
  if (parts.length >= 2 && parts[1] === "files") {
1242
1855
  const filePath = parts.slice(2).map(decodeURIComponent).join("/");
1243
1856
  return {
@@ -1248,8 +1861,347 @@ function createWorkerServer(options = {}) {
1248
1861
  }
1249
1862
  return null;
1250
1863
  };
1251
- const listSdkSessions = options.listSdkSessions ?? defaultSdkSessionLister;
1252
- const handleSdkSessions = async (req, res) => {
1864
+ const hostFileRootPaths = options.hostFiles?.roots ?? options.allowedCwdRoots;
1865
+ const hostFiles = hostFileRootPaths?.length ? createHostFileRoots(hostFileRootPaths) : null;
1866
+ const hostFilesWritable = options.hostFiles?.write === true;
1867
+ const maxHostFileBytes = options.hostFiles?.maxFileBytes ?? 1024 * 1024;
1868
+ const maxHostDirEntries = options.hostFiles?.maxEntries ?? 5e3;
1869
+ /**
1870
+ * `{basePath}/sessions/:id/attachments` — the files a client sends with a message.
1871
+ *
1872
+ * `POST ?name=<name>` takes the raw bytes as the body and the media type from
1873
+ * the `content-type` header; there is no multipart parsing here on purpose, so
1874
+ * a phone and a browser both upload with one plain request and this file stays
1875
+ * dependency-free. `GET /:attachmentId` hands the bytes back for thumbnails.
1876
+ *
1877
+ * The download always answers `content-disposition: attachment` and `nosniff`,
1878
+ * the same as `/files`: an upload is client-supplied content served from the
1879
+ * gateway's own origin, and it must never render as a document there. (An
1880
+ * `<img src>` is unaffected — disposition does not apply to subresources.)
1881
+ */
1882
+ const handleAttachments = async (req, res, sessionId, session, attachmentId) => {
1883
+ if (req.method === "POST" && attachmentId === void 0) {
1884
+ const url = new URL(req.url ?? "/", "http://internal");
1885
+ const mediaType = req.headers["content-type"];
1886
+ if (!mediaType) {
1887
+ json(res, 400, { error: "content-type header is required" });
1888
+ return;
1889
+ }
1890
+ const accepted = (session.capabilities ?? ENGINE_CAPABILITIES[session.engine ?? "claude"]).attachments;
1891
+ const kind = attachmentKind(mediaType);
1892
+ if (kind && !accepted.includes(kind === "document" ? "pdf" : kind)) {
1893
+ json(res, 415, { error: `the ${session.engine ?? "claude"} engine does not accept ${kind} attachments` });
1894
+ return;
1895
+ }
1896
+ let body;
1897
+ try {
1898
+ body = await readRawBody(req, attachmentStore.maxFileBytes);
1899
+ } catch {
1900
+ json(res, 413, { error: "attachment is larger than the limit" });
1901
+ return;
1902
+ }
1903
+ const result = attachmentStore.put(sessionId, url.searchParams.get("name") ?? "attachment", mediaType, body);
1904
+ if (!result.ok) {
1905
+ json(res, result.error.code === "unsupported_type" ? 415 : result.error.code === "empty" ? 400 : 413, { error: result.error.message });
1906
+ return;
1907
+ }
1908
+ json(res, 201, { attachment: result.attachment });
1909
+ return;
1910
+ }
1911
+ if (req.method === "GET" && attachmentId !== void 0) {
1912
+ const found = attachmentStore.get(sessionId, attachmentId);
1913
+ if (!found) {
1914
+ json(res, 404, { error: "attachment not found" });
1915
+ return;
1916
+ }
1917
+ const bytes = Buffer.from(found.data, "base64");
1918
+ res.writeHead(200, {
1919
+ "content-type": found.mediaType,
1920
+ "content-length": bytes.length,
1921
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(found.name)}`,
1922
+ "x-content-type-options": "nosniff"
1923
+ });
1924
+ res.end(bytes);
1925
+ return;
1926
+ }
1927
+ json(res, 405, { error: "method not allowed" });
1928
+ };
1929
+ /**
1930
+ * `{basePath}/sessions/:id/mcp` — the session's MCP servers, and the three
1931
+ * things the CLI's own `/mcp` screen can do to one (reconnect, enable, disable).
1932
+ *
1933
+ * Every answer goes through `mcpStatusInfo`, which is where the servers' `env`
1934
+ * and `headers` are dropped: reading this route must not be a way to read the
1935
+ * operator's API tokens.
1936
+ */
1937
+ const handleMcp = async (req, res, runner, serverName) => {
1938
+ const listServers = async () => {
1939
+ const servers = await runner.mcpServers?.();
1940
+ if (!servers) {
1941
+ json(res, 501, { error: "this session does not report MCP servers" });
1942
+ return false;
1943
+ }
1944
+ json(res, 200, { servers });
1945
+ return true;
1946
+ };
1947
+ if (req.method === "GET" && serverName === void 0) {
1948
+ await listServers();
1949
+ return;
1950
+ }
1951
+ if (req.method === "POST" && serverName !== void 0) {
1952
+ const body = await readJsonBody(req, maxBodyBytes);
1953
+ if (body?.action !== "reconnect" && body?.action !== "enable" && body?.action !== "disable") {
1954
+ json(res, 400, { error: "action must be 'reconnect', 'enable' or 'disable'" });
1955
+ return;
1956
+ }
1957
+ try {
1958
+ if (body.action === "reconnect") await runner.reconnectMcpServer?.(serverName);
1959
+ else await runner.setMcpServerEnabled?.(serverName, body.action === "enable");
1960
+ } catch (error) {
1961
+ json(res, 400, { error: error instanceof Error ? error.message : "MCP action failed" });
1962
+ return;
1963
+ }
1964
+ await listServers();
1965
+ return;
1966
+ }
1967
+ json(res, 405, { error: "method not allowed" });
1968
+ };
1969
+ /**
1970
+ * `{basePath}/fs/*` — the operator's real tree. Authorized by the auth key alone
1971
+ * and deliberately outside the agent permission flow: the caller is the operator.
1972
+ *
1973
+ * Every path in here goes through `host-files.ts` first, which canonicalizes and
1974
+ * *then* re-checks containment. The naive prefix compare `cwdAllowed` does would
1975
+ * be wrong at this door — the agent writes into these trees, and a symlink it
1976
+ * created is a path the operator never typed.
1977
+ */
1978
+ const handleHostFiles = async (req, res, pathname) => {
1979
+ if (!hostFiles) {
1980
+ json(res, 404, { error: "host file access is not configured on this server" });
1981
+ return;
1982
+ }
1983
+ const route = pathname.slice((basePath + "/fs/").length);
1984
+ const url = new URL(req.url ?? "/", "http://internal");
1985
+ const requested = url.searchParams.get("path");
1986
+ if (route === "roots") {
1987
+ if (req.method !== "GET") {
1988
+ json(res, 405, { error: "method not allowed" });
1989
+ return;
1990
+ }
1991
+ json(res, 200, {
1992
+ roots: hostFiles.roots.map(({ canonical }) => ({
1993
+ path: canonical,
1994
+ name: basename(canonical) || canonical
1995
+ })),
1996
+ canWrite: hostFilesWritable
1997
+ });
1998
+ return;
1999
+ }
2000
+ if (route === "find") {
2001
+ if (req.method !== "GET") {
2002
+ json(res, 405, { error: "method not allowed" });
2003
+ return;
2004
+ }
2005
+ if (!requested) {
2006
+ json(res, 400, { error: "path is required" });
2007
+ return;
2008
+ }
2009
+ const resolved = resolveExisting(hostFiles, requested);
2010
+ if (!resolved.ok) {
2011
+ json(res, resolved.status, { error: resolved.error });
2012
+ return;
2013
+ }
2014
+ if (resolved.kind !== "dir") {
2015
+ json(res, 400, { error: "not a directory" });
2016
+ return;
2017
+ }
2018
+ const asked = Number(url.searchParams.get("limit") ?? "");
2019
+ const limit = Number.isFinite(asked) && asked > 0 ? Math.min(asked, 200) : 50;
2020
+ const result = searchFiles(resolved.path, {
2021
+ query: url.searchParams.get("q") ?? "",
2022
+ limit,
2023
+ ignore: options.hostFiles?.ignore
2024
+ });
2025
+ json(res, 200, {
2026
+ base: resolved.path,
2027
+ ...result
2028
+ });
2029
+ return;
2030
+ }
2031
+ if (route === "list" || route === "read") {
2032
+ if (req.method !== "GET") {
2033
+ json(res, 405, { error: "method not allowed" });
2034
+ return;
2035
+ }
2036
+ if (!requested) {
2037
+ json(res, 400, { error: "path is required" });
2038
+ return;
2039
+ }
2040
+ const resolved = resolveExisting(hostFiles, requested);
2041
+ if (!resolved.ok) {
2042
+ json(res, resolved.status, { error: resolved.error });
2043
+ return;
2044
+ }
2045
+ if (route === "list") {
2046
+ if (resolved.kind !== "dir") {
2047
+ json(res, 400, { error: "not a directory" });
2048
+ return;
2049
+ }
2050
+ let names;
2051
+ try {
2052
+ names = readdirSync(resolved.path, { withFileTypes: true });
2053
+ } catch {
2054
+ json(res, 403, { error: "directory is not readable" });
2055
+ return;
2056
+ }
2057
+ const truncated = names.length > maxHostDirEntries;
2058
+ const entries = names.slice(0, maxHostDirEntries).map((entry) => {
2059
+ const path = join(resolved.path, entry.name);
2060
+ const type = entryKind(entry);
2061
+ let bytes;
2062
+ let modifiedAt;
2063
+ if (type === "file") try {
2064
+ const s = lstatSync(path);
2065
+ bytes = s.size;
2066
+ modifiedAt = s.mtimeMs;
2067
+ } catch {}
2068
+ return {
2069
+ name: entry.name,
2070
+ path,
2071
+ type,
2072
+ bytes,
2073
+ modifiedAt
2074
+ };
2075
+ });
2076
+ entries.sort((a, b) => {
2077
+ const rank = (t) => t === "dir" ? 0 : 1;
2078
+ return rank(a.type) - rank(b.type) || a.name.localeCompare(b.name);
2079
+ });
2080
+ json(res, 200, {
2081
+ path: resolved.path,
2082
+ entries,
2083
+ ...truncated ? { truncated } : {}
2084
+ });
2085
+ return;
2086
+ }
2087
+ if (resolved.kind !== "file") {
2088
+ json(res, 400, { error: "not a regular file" });
2089
+ return;
2090
+ }
2091
+ let modifiedAt = 0;
2092
+ try {
2093
+ const stats = lstatSync(resolved.path);
2094
+ if (stats.size > maxHostFileBytes) {
2095
+ json(res, 413, { error: `file is larger than ${maxHostFileBytes} bytes` });
2096
+ return;
2097
+ }
2098
+ modifiedAt = stats.mtimeMs;
2099
+ } catch {
2100
+ json(res, 404, { error: "not found" });
2101
+ return;
2102
+ }
2103
+ const read = readContained(resolved.path);
2104
+ if (!read.ok) {
2105
+ json(res, read.status, { error: read.error });
2106
+ return;
2107
+ }
2108
+ if (read.data.length > maxHostFileBytes) {
2109
+ json(res, 413, { error: `file is larger than ${maxHostFileBytes} bytes` });
2110
+ return;
2111
+ }
2112
+ const text = asUtf8(read.data);
2113
+ json(res, 200, {
2114
+ path: resolved.path,
2115
+ content: text ?? read.data.toString("base64"),
2116
+ encoding: text === null ? "base64" : "utf8",
2117
+ bytes: read.data.length,
2118
+ hash: hashBytes(read.data),
2119
+ modifiedAt
2120
+ });
2121
+ return;
2122
+ }
2123
+ if (route === "write") {
2124
+ if (req.method !== "PUT") {
2125
+ json(res, 405, { error: "method not allowed" });
2126
+ return;
2127
+ }
2128
+ if (!hostFilesWritable) {
2129
+ json(res, 403, { error: "host file writes are not enabled on this server" });
2130
+ return;
2131
+ }
2132
+ const body = await readJsonBody(req, maxBodyBytes);
2133
+ if (!body.path || typeof body.path !== "string") {
2134
+ json(res, 400, { error: "path is required" });
2135
+ return;
2136
+ }
2137
+ if (typeof body.content !== "string") {
2138
+ json(res, 400, { error: "content is required" });
2139
+ return;
2140
+ }
2141
+ if (body.encoding !== void 0 && body.encoding !== "utf8" && body.encoding !== "base64") {
2142
+ json(res, 400, { error: "encoding must be 'utf8' or 'base64'" });
2143
+ return;
2144
+ }
2145
+ const resolved = resolveForWrite(hostFiles, body.path);
2146
+ if (!resolved.ok) {
2147
+ json(res, resolved.status, { error: resolved.error });
2148
+ return;
2149
+ }
2150
+ const next = Buffer.from(body.content, body.encoding ?? "utf8");
2151
+ if (next.length > maxHostFileBytes) {
2152
+ json(res, 413, { error: `content is larger than ${maxHostFileBytes} bytes` });
2153
+ return;
2154
+ }
2155
+ const current = readContained(resolved.path);
2156
+ if (!current.ok && current.status !== 404) {
2157
+ json(res, current.status, { error: current.error });
2158
+ return;
2159
+ }
2160
+ const existing = current.ok ? current.data : null;
2161
+ if (existing && !body.expectedHash) {
2162
+ json(res, 409, { error: "file exists — pass expectedHash to overwrite it" });
2163
+ return;
2164
+ }
2165
+ if (existing && hashBytes(existing) !== body.expectedHash) {
2166
+ json(res, 409, { error: "file changed on disk since it was read" });
2167
+ return;
2168
+ }
2169
+ if (!existing && body.expectedHash) {
2170
+ json(res, 409, { error: "file no longer exists" });
2171
+ return;
2172
+ }
2173
+ const written = writeContained(resolved.path, next);
2174
+ if (!written.ok) {
2175
+ json(res, written.status, { error: written.error });
2176
+ return;
2177
+ }
2178
+ let writtenAt = 0;
2179
+ try {
2180
+ writtenAt = lstatSync(resolved.path).mtimeMs;
2181
+ } catch {}
2182
+ json(res, 200, {
2183
+ path: resolved.path,
2184
+ bytes: next.length,
2185
+ hash: hashBytes(next),
2186
+ modifiedAt: writtenAt
2187
+ });
2188
+ return;
2189
+ }
2190
+ json(res, 404, { error: "not found" });
2191
+ };
2192
+ /**
2193
+ * `GET /sdk-sessions`, engine-aware: `?profile=` names whose on-disk store to
2194
+ * list, and the profile's engine adapter answers (for codex, over a
2195
+ * short-lived `thread/list` child — no live session involved). Absent
2196
+ * `profile`, the choice is implicit when the server declares exactly one
2197
+ * profile (the resolveProfile rule); with several, the Claude engine's
2198
+ * global store is listed — the pre-engine-aware behavior every existing
2199
+ * caller already gets, kept because old clients cannot answer a new 400.
2200
+ * The injectable `listSdkSessions` option predates the adapter layer and is
2201
+ * honored for the claude engine only (existing tests and hosts wire it),
2202
+ * exactly like the injectable claude auth probe.
2203
+ */
2204
+ const handleSdkSessions = async (req, res, auth) => {
1253
2205
  if (req.method !== "GET") {
1254
2206
  json(res, 405, { error: "method not allowed" });
1255
2207
  return;
@@ -1257,21 +2209,58 @@ function createWorkerServer(options = {}) {
1257
2209
  const url = new URL(req.url ?? "/", "http://internal");
1258
2210
  const dir = url.searchParams.get("dir") ?? void 0;
1259
2211
  const roots = options.allowedCwdRoots;
1260
- if (roots && roots.length > 0) {
1261
- if (!dir) {
1262
- json(res, 400, { error: "dir is required when allowedCwdRoots is set" });
2212
+ const limit = Number(url.searchParams.get("limit") ?? "") || void 0;
2213
+ const offset = Number(url.searchParams.get("offset") ?? "") || void 0;
2214
+ const requested = url.searchParams.get("profile") ?? void 0;
2215
+ let profile;
2216
+ if (requested !== void 0) {
2217
+ const resolved = resolveProfile(requested, auth.allowedProfiles);
2218
+ if (!resolved.ok) {
2219
+ json(res, resolved.status, { error: resolved.error });
1263
2220
  return;
1264
2221
  }
1265
- if (!cwdAllowed(dir, roots)) {
1266
- json(res, 403, { error: "dir is outside the allowed roots" });
2222
+ profile = resolved.profile;
2223
+ } else {
2224
+ const all = allProfiles();
2225
+ if (all.length === 1 && (!auth.allowedProfiles || auth.allowedProfiles.includes(all[0].name))) profile = all[0];
2226
+ }
2227
+ const adapter = adapterFor(profile?.engine);
2228
+ if (!adapter.capabilities.listSessions) {
2229
+ json(res, 400, { error: `profile '${profile?.name ?? "default"}' runs the ${engineOf(profile)} engine, which has no browsable session store` });
2230
+ return;
2231
+ }
2232
+ const lister = engineOf(profile) === "claude" && options.listSdkSessions ? options.listSdkSessions : (params) => {
2233
+ if (!adapter.listSessions) throw new Error(`the ${engineOf(profile)} engine does not implement session listing`);
2234
+ return adapter.listSessions({
2235
+ ...params,
2236
+ profile,
2237
+ env: profile ? sessionEnvFor(profile) : process.env
2238
+ });
2239
+ };
2240
+ try {
2241
+ if (roots && roots.length > 0) if (dir) {
2242
+ if (!cwdAllowed(dir, roots)) {
2243
+ json(res, 403, { error: "dir is outside the allowed roots" });
2244
+ return;
2245
+ }
2246
+ } else {
2247
+ json(res, 200, { sdkSessions: withinRoots(await lister({}), roots, limit, offset) });
1267
2248
  return;
1268
2249
  }
2250
+ json(res, 200, { sdkSessions: await lister({
2251
+ dir,
2252
+ limit,
2253
+ offset
2254
+ }) });
2255
+ } catch (error) {
2256
+ json(res, 500, { error: error instanceof Error ? error.message : "failed to list sessions" });
1269
2257
  }
1270
- json(res, 200, { sdkSessions: await listSdkSessions({
1271
- dir,
1272
- limit: Number(url.searchParams.get("limit") ?? "") || void 0,
1273
- offset: Number(url.searchParams.get("offset") ?? "") || void 0
1274
- }) });
2258
+ };
2259
+ /** The sessions whose `cwd` is inside the roots, newest first, then paged. A
2260
+ * summary with no `cwd` cannot be shown to be inside them, so it is dropped. */
2261
+ const withinRoots = (sessions, roots, limit, offset = 0) => {
2262
+ const allowed = sessions.filter((s) => s.cwd !== void 0 && cwdAllowed(s.cwd, roots)).sort((a, b) => b.lastModified - a.lastModified);
2263
+ return limit === void 0 ? allowed.slice(offset) : allowed.slice(offset, offset + limit);
1275
2264
  };
1276
2265
  const handleJobs = async (req, res, pathname, auth) => {
1277
2266
  if (!queue) {
@@ -1325,6 +2314,7 @@ function createWorkerServer(options = {}) {
1325
2314
  json(res, 400, { error: badRequest });
1326
2315
  return;
1327
2316
  }
2317
+ stripInertFields(body.session, resolved.profile);
1328
2318
  body.session.profile = resolved.profile?.name;
1329
2319
  try {
1330
2320
  json(res, 201, { job: await queue.submit(body) });
@@ -1442,8 +2432,10 @@ function createWorkerServer(options = {}) {
1442
2432
  const rest = pathname.slice((basePath + "/profiles").length).replace(/^\//, "");
1443
2433
  if (rest === "") {
1444
2434
  if (req.method === "GET") {
2435
+ const visible = auth.allowedProfiles ? allProfiles().filter((p) => auth.allowedProfiles.includes(p.name)) : allProfiles();
2436
+ refreshAvailability(visible);
1445
2437
  json(res, 200, {
1446
- profiles: (auth.allowedProfiles ? allProfiles().filter((p) => auth.allowedProfiles.includes(p.name)) : allProfiles()).map(withManagedFlag),
2438
+ profiles: visible.map(forResponse),
1447
2439
  canManage: manageGuard(auth) === null
1448
2440
  });
1449
2441
  return;
@@ -1519,11 +2511,20 @@ function createWorkerServer(options = {}) {
1519
2511
  return;
1520
2512
  }
1521
2513
  if (pathname === basePath + "/sdk-sessions") {
2514
+ const auth = await authenticate(req);
2515
+ if (!auth.ok) {
2516
+ json(res, 401, { error: "unauthorized" });
2517
+ return;
2518
+ }
2519
+ await handleSdkSessions(req, res, auth);
2520
+ return;
2521
+ }
2522
+ if (pathname.startsWith(basePath + "/fs/")) {
1522
2523
  if (!(await authenticate(req)).ok) {
1523
2524
  json(res, 401, { error: "unauthorized" });
1524
2525
  return;
1525
2526
  }
1526
- await handleSdkSessions(req, res);
2527
+ await handleHostFiles(req, res, pathname);
1527
2528
  return;
1528
2529
  }
1529
2530
  const route = parseRoute(req.url ?? "/");
@@ -1566,6 +2567,7 @@ function createWorkerServer(options = {}) {
1566
2567
  json(res, 400, { error: badRequest });
1567
2568
  return;
1568
2569
  }
2570
+ stripInertFields(body, resolved.profile);
1569
2571
  body.profile = resolved.profile?.name;
1570
2572
  const runner = await createRunner(buildRunnerConfig(body));
1571
2573
  watchAuthSource(runner);
@@ -1581,6 +2583,18 @@ function createWorkerServer(options = {}) {
1581
2583
  json(res, 404, { error: "session not found" });
1582
2584
  return;
1583
2585
  }
2586
+ if (route.attachments) {
2587
+ await handleAttachments(req, res, route.id, runner?.info() ?? parked.info, route.attachmentId);
2588
+ return;
2589
+ }
2590
+ if (route.mcp) {
2591
+ if (!runner) {
2592
+ json(res, 409, { error: "session is parked (wake it before asking about MCP)" });
2593
+ return;
2594
+ }
2595
+ await handleMcp(req, res, runner, route.mcpServer);
2596
+ return;
2597
+ }
1584
2598
  if (route.files) {
1585
2599
  if (req.method !== "GET") {
1586
2600
  json(res, 405, { error: "method not allowed" });
@@ -1646,6 +2660,7 @@ function createWorkerServer(options = {}) {
1646
2660
  registry.remove(route.id);
1647
2661
  bridge.remove(route.id);
1648
2662
  await parking.discard(route.id);
2663
+ attachmentStore.drop(route.id);
1649
2664
  json(res, 200, { session: runner?.info() ?? {
1650
2665
  ...parked.info,
1651
2666
  status: "closed"
@@ -1749,9 +2764,16 @@ function createWorkerServer(options = {}) {
1749
2764
  };
1750
2765
  const handleCommand = async (frame, runner) => {
1751
2766
  switch (frame.type) {
1752
- case "user_message":
1753
- runner.sendMessage(frame.text);
2767
+ case "user_message": {
2768
+ if (!frame.attachmentIds?.length) {
2769
+ runner.sendMessage(frame.text);
2770
+ return;
2771
+ }
2772
+ const resolved = attachmentStore.resolve(runner.id, frame.attachmentIds);
2773
+ if (!resolved.ok) throw new Error(`unknown attachment(s): ${resolved.missing.join(", ")}`);
2774
+ runner.sendMessage(frame.text, resolved.attachments);
1754
2775
  return;
2776
+ }
1755
2777
  case "permission_decision":
1756
2778
  if (frame.behavior === "allow") runner.resolvePermission(frame.requestId, {
1757
2779
  behavior: "allow",
@@ -1871,6 +2893,6 @@ function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profi
1871
2893
  };
1872
2894
  }
1873
2895
  //#endregion
1874
- export { BridgeHub, MemorySessionStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
2896
+ export { AttachmentStore, BridgeHub, MemorySessionStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
1875
2897
 
1876
2898
  //# sourceMappingURL=index.mjs.map