@workerdeck/server 0.23.0 → 1.0.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
@@ -2,13 +2,29 @@ import { createServer } from "node:http";
2
2
  import { WebSocketServer } from "ws";
3
3
  import { BrowserBridgeExecutor, SessionRunner, attachmentKind, checkClaudeAuth, createEngineSession, getEngineAdapter, normalizeMediaType } from "@workerdeck/core";
4
4
  import { JobQueue } from "@workerdeck/queue";
5
- import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, imagePartRef, supportsPermissionMode } from "@workerdeck/protocol";
5
+ import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, imagePartRef, sessionState, supportsPermissionMode } from "@workerdeck/protocol";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { closeSync, constants, createReadStream, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
11
11
  //#region src/lib/http.ts
12
+ const CONTENT_TYPES = {
13
+ json: "application/json; charset=utf-8",
14
+ md: "text/markdown; charset=utf-8",
15
+ html: "text/html; charset=utf-8",
16
+ csv: "text/csv; charset=utf-8",
17
+ xml: "application/xml; charset=utf-8",
18
+ svg: "image/svg+xml; charset=utf-8"
19
+ };
20
+ function untrustedDownloadHeaders(filename, contentType, byteLength) {
21
+ return {
22
+ "content-type": contentType,
23
+ "content-length": byteLength,
24
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
25
+ "x-content-type-options": "nosniff"
26
+ };
27
+ }
12
28
  function json(res, status, body) {
13
29
  const payload = JSON.stringify(body);
14
30
  res.writeHead(status, {
@@ -18,18 +34,10 @@ function json(res, status, body) {
18
34
  res.end(payload);
19
35
  }
20
36
  async function readJsonBody(req, maxBytes) {
21
- const chunks = [];
22
- let size = 0;
23
- for await (const chunk of req) {
24
- size += chunk.length;
25
- if (size > maxBytes) throw new Error("request body too large");
26
- chunks.push(chunk);
27
- }
28
- if (size === 0) return {};
29
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
37
+ const body = await readRawBody(req, maxBytes);
38
+ if (body.length === 0) return {};
39
+ return JSON.parse(body.toString("utf8"));
30
40
  }
31
- /** Body as bytes, refusing anything over `maxBytes`. Attachments are the one
32
- * thing this server takes that isn't JSON. */
33
41
  async function readRawBody(req, maxBytes) {
34
42
  const chunks = [];
35
43
  let size = 0;
@@ -40,27 +48,9 @@ async function readRawBody(req, maxBytes) {
40
48
  }
41
49
  return Buffer.concat(chunks);
42
50
  }
43
- /** Conservative content types for VFS downloads: text formats the agent actually
44
- * produces; anything unrecognized ships as plain text (the VFS is string-backed). */
45
- const CONTENT_TYPES = {
46
- json: "application/json; charset=utf-8",
47
- md: "text/markdown; charset=utf-8",
48
- html: "text/html; charset=utf-8",
49
- csv: "text/csv; charset=utf-8",
50
- xml: "application/xml; charset=utf-8",
51
- svg: "image/svg+xml; charset=utf-8"
52
- };
53
- /** sha256 hex — the currency of the conditional-write protocol on `/fs/write`. */
54
51
  function hashBytes(bytes) {
55
52
  return createHash("sha256").update(bytes).digest("hex");
56
53
  }
57
- /**
58
- * The file's text, or null if it isn't text. Decoding never fails in Node — invalid
59
- * bytes become U+FFFD — so the only honest test is a round trip: if re-encoding the
60
- * decoded string reproduces the original bytes, nothing was lost and the client can
61
- * safely edit and send it back. Anything else ships base64, which an editor can
62
- * refuse to open rather than silently corrupt on save.
63
- */
64
54
  function asUtf8(bytes) {
65
55
  const text = bytes.toString("utf8");
66
56
  return Buffer.from(text, "utf8").equals(bytes) ? text : null;
@@ -70,26 +60,15 @@ function contentTypeFor(filename) {
70
60
  }
71
61
  //#endregion
72
62
  //#region src/lib/profile-env.ts
73
- /**
74
- * Pure profile/environment/path rules: which engine a profile runs, where the
75
- * CLI's config resolution lands, the CLAUDE_CONFIG_DIR pin, and the cwd-roots
76
- * policy. No state, no I/O beyond reads of the filesystem the rules are about.
77
- */
78
- /** A profile runs the model-agnostic engine rather than Claude Code. `engine` is
79
- * optional so profiles written before provider support keep meaning 'claude'. */
80
63
  function isProviderProfile(profile) {
81
64
  return profile.engine === "provider";
82
65
  }
83
- /** The engine a profile runs, absent meaning 'claude' (pre-provider profiles). */
84
66
  function engineOf(profile) {
85
67
  return profile?.engine ?? "claude";
86
68
  }
87
- /** Where the CLI's own resolution lands for a given environment: an explicit
88
- * CLAUDE_CONFIG_DIR, else ~/.claude. */
89
69
  function cliConfigDir(env) {
90
70
  return env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
91
71
  }
92
- /** Auto-created profile when none are declared: the operator's own config dir. */
93
72
  function detectDefaultProfiles() {
94
73
  const dir = cliConfigDir(process.env);
95
74
  return existsSync(dir) ? [{
@@ -97,9 +76,6 @@ function detectDefaultProfiles() {
97
76
  configDir: dir
98
77
  }] : [];
99
78
  }
100
- /** Compare config dirs by what they name on disk: declared paths arrive with
101
- * trailing slashes or symlinked prefixes (`/var` vs `/private/var` on macOS); a
102
- * path that doesn't exist falls back to plain normalization. */
103
79
  function canonicalDir(path) {
104
80
  try {
105
81
  return realpathSync(path);
@@ -107,18 +83,6 @@ function canonicalDir(path) {
107
83
  return resolve(path);
108
84
  }
109
85
  }
110
- /**
111
- * The env a Claude session under `profile` is spawned with, starting from
112
- * `base` (the host hook's env, else the server's own). The pin is skipped when
113
- * `base` would already land the CLI in the profile's dir, and that skip is
114
- * load-bearing, not an optimisation: CLAUDE_CONFIG_DIR *set at all* switches
115
- * the CLI's credential source to `<dir>/.credentials.json` — on macOS a
116
- * claude.ai login lives in the login Keychain, consulted only while the
117
- * variable is UNSET, so pinning even the CLI's own default `~/.claude` turns a
118
- * working login into "Not logged in". When `base` names a *different* dir than
119
- * the profile, the pin stands: the profile must win over hook- or operator-set
120
- * env, or sessions under two profiles quietly collapse into one identity.
121
- */
122
86
  function claudeSessionEnv(profile, base) {
123
87
  return canonicalDir(profile.configDir) === canonicalDir(cliConfigDir(base)) ? base : {
124
88
  ...base,
@@ -133,14 +97,6 @@ function cwdAllowed(cwd, roots) {
133
97
  return resolved === r || resolved.startsWith(r + sep);
134
98
  });
135
99
  }
136
- /**
137
- * Curated, view-only snapshot of a profile's config dir for GET /profiles/:name.
138
- * Best-effort: a missing or unparseable settings.json just omits the settings block.
139
- * Env var VALUES are never read into the response — names only.
140
- *
141
- * Provider profiles have no config dir, so the snapshot is empty for them: their
142
- * configuration is the `provider` block already on ProfileInfo.
143
- */
144
100
  function readProfileConfig(profile) {
145
101
  const dir = profile.configDir;
146
102
  if (!dir) return {
@@ -286,7 +242,7 @@ async function handleExecutionResult(ctx, req, res, pathname, auth) {
286
242
  const owner = parking.sessionFor(executionId);
287
243
  const info = owner === void 0 ? void 0 : registry.get(owner)?.info() ?? (await parking.get(owner))?.info;
288
244
  const profile = info?.profile;
289
- if (owner === void 0 || auth.allowedProfiles !== void 0 && profile !== void 0 && !auth.allowedProfiles.includes(profile) || info !== void 0 && !authSvc.canSee(auth, info)) {
245
+ if (owner === void 0 || info === void 0 || auth.allowedProfiles !== void 0 && profile !== void 0 && !auth.allowedProfiles.includes(profile) || !authSvc.canSee(auth, info)) {
290
246
  json(res, 404, { error: "execution not found" });
291
247
  return;
292
248
  }
@@ -300,15 +256,8 @@ async function handleExecutionResult(ctx, req, res, pathname, auth) {
300
256
  }
301
257
  //#endregion
302
258
  //#region src/services/host-files.ts
303
- /**
304
- * Built once at startup from operator config. Roots are canonicalized here
305
- * because resolution produces realpath'd targets: a root that is itself a
306
- * symlink (`/tmp` -> `/private/tmp` on macOS) would otherwise contain nothing.
307
- * A misdeclared root throws rather than silently guarding the wrong tree —
308
- * same stance as profile config dirs in server.ts. An empty list is legal and
309
- * refuses everything; "no roots means allow all" is `cwdAllowed`'s contract,
310
- * never this module's.
311
- */
259
+ const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
260
+ const O_NONBLOCK = constants.O_NONBLOCK ?? 0;
312
261
  function createHostFileRoots(roots) {
313
262
  return { roots: roots.map((configured) => {
314
263
  if (invalidRequest(configured)) throw new Error(`createHostFileRoots: root must be an absolute path: ${JSON.stringify(configured)}`);
@@ -332,24 +281,12 @@ function refuse(status, error) {
332
281
  error
333
282
  };
334
283
  }
335
- /** The uniform filesystem refusal — see the disclosure policy in the header.
336
- * The string is deliberately constant: a distinct message is as much an oracle
337
- * as a distinct status. */
338
284
  function notFound() {
339
285
  return refuse(404, "not found");
340
286
  }
341
- /** NUL is rejected before any fs call — Node throws a TypeError on NUL paths,
342
- * and that must surface as a refusal, not a 500. Relative paths are refused
343
- * outright rather than resolved against a cwd this API never promised. */
344
287
  function invalidRequest(requested) {
345
288
  return requested.length === 0 || requested.includes("\0") || !isAbsolute(requested);
346
289
  }
347
- /** Both sides are realpath output, so this is a pure lexical question — but a
348
- * bare prefix check gets the boundary wrong (`/x/app` would swallow
349
- * `/x/application`). `relative` answers it exactly: inside iff the walk from
350
- * root to candidate is empty or never has to leave through `..`. Exported for
351
- * the project-icon resolver (`project-info.ts`), which makes the same claim
352
- * against a project root; both callers must hand it realpath output only. */
353
290
  function contained(rootCanonical, candidate) {
354
291
  const rel = relative(rootCanonical, candidate);
355
292
  return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
@@ -357,14 +294,6 @@ function contained(rootCanonical, candidate) {
357
294
  function rootContaining(roots, canonical) {
358
295
  return roots.roots.find((root) => contained(root.canonical, canonical));
359
296
  }
360
- /**
361
- * For read/list: the target must exist. realpath is handed the request whole —
362
- * no lexical `..` collapsing first, because `root/link/..` is lexically `root`
363
- * but physically the link target's parent, and only the physical answer is the
364
- * true one. Symlinks that canonicalize *inside* a root are followed and served:
365
- * containment is a property of the canonical target, not of the route to it —
366
- * the operator granted the whole subtree, so nothing new becomes reachable.
367
- */
368
297
  function resolveExisting(roots, requested) {
369
298
  if (invalidRequest(requested)) return refuse(403, "invalid path");
370
299
  let canonical;
@@ -395,19 +324,6 @@ function resolveExisting(roots, requested) {
395
324
  };
396
325
  return refuse(403, "not a regular file or directory");
397
326
  }
398
- /**
399
- * For write: the target may not exist, so realpath cannot be asked directly.
400
- * An existing target reuses read semantics — writing *through* a symlink that
401
- * canonicalizes inside a root is allowed (`root/link -> root/real.txt` edits
402
- * real.txt), same reasoning as {@link resolveExisting}. A missing target
403
- * canonicalizes its immediate parent and re-checks: only the final component
404
- * may be new, and anything already sitting there — in practice a dangling
405
- * symlink — is refused, because open(2) with O_CREAT follows it and would
406
- * create the file wherever it points. That refusal is `not found`, not 403: a
407
- * link to an existing outside file already answers 404 via the exists branch,
408
- * so a distinct status for the dangling case would hand back exactly the
409
- * existence bit the uniform 404 exists to withhold.
410
- */
411
327
  function resolveForWrite(roots, requested) {
412
328
  if (invalidRequest(requested)) return refuse(403, "invalid path");
413
329
  try {
@@ -454,28 +370,12 @@ function resolveForWrite(roots, requested) {
454
370
  }
455
371
  return notFound();
456
372
  }
457
- /** lstat semantics on purpose: a listing shows a symlink AS a symlink — the
458
- * server never follows one while rendering a directory. Following happens only
459
- * when the entry is itself requested, through {@link resolveExisting}, which
460
- * refuses it if it escapes. `readdir(withFileTypes)` already answers without
461
- * following, so this is classification, not I/O. */
462
373
  function entryKind(entry) {
463
374
  if (entry.isSymbolicLink()) return "symlink";
464
375
  if (entry.isFile()) return "file";
465
376
  if (entry.isDirectory()) return "dir";
466
377
  return "other";
467
378
  }
468
- const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
469
- const O_NONBLOCK = constants.O_NONBLOCK ?? 0;
470
- /**
471
- * The open half of the resolve→open discipline; pass `ResolveOutcome.path`,
472
- * never the requested string. O_NOFOLLOW turns a final component swapped for a
473
- * symlink inside the race window into ELOOP instead of a follow; O_NONBLOCK
474
- * makes a swapped-in fifo open instantly instead of parking the request until a
475
- * writer appears (it is inert for regular files); the fstat gate refuses
476
- * anything that is not a plain file before a byte is read — `/dev/zero` would
477
- * otherwise be an unbounded read.
478
- */
479
379
  function readContained(path) {
480
380
  let fd;
481
381
  try {
@@ -495,13 +395,6 @@ function readContained(path) {
495
395
  closeSync(fd);
496
396
  }
497
397
  }
498
- /**
499
- * O_CREAT|O_NOFOLLOW refuses (ELOOP) a symlink planted at the final component
500
- * after resolve — the exact swap that would land the write at the link's
501
- * target. Truncation happens via ftruncate only AFTER the fd is proven to be a
502
- * regular file, so a swapped-in device or fifo is never truncated or written;
503
- * O_NONBLOCK turns the reader-less-fifo open from a hang into ENXIO.
504
- */
505
398
  function writeContained(path, data) {
506
399
  let fd;
507
400
  try {
@@ -522,23 +415,6 @@ function writeContained(path, data) {
522
415
  }
523
416
  //#endregion
524
417
  //#region src/services/host-file-search.ts
525
- /**
526
- * The recursive half of the host-file routes: what `@file` autocomplete needs and
527
- * `/fs/list` deliberately isn't. Listing answers "what is in this directory"; this
528
- * answers "which file in this tree did you mean", which is a different query and a
529
- * different cost model.
530
- *
531
- * Kept out of `host-files.ts` on purpose. That module is the audited containment
532
- * core; this one walks *inside* an already-resolved, already-contained directory
533
- * and never resolves a path of its own. Its one security-relevant rule is that it
534
- * does not follow symlinks — see the walk below.
535
- */
536
- /**
537
- * Directories a source tree keeps that nobody types `@` looking for, and that are
538
- * usually most of the entries on disk. Skipping them is what makes the walk cheap
539
- * enough to run per keystroke; the operator can replace the list via
540
- * `hostFiles.ignore`.
541
- */
542
418
  const DEFAULT_IGNORED_DIRS = [
543
419
  ".git",
544
420
  ".hg",
@@ -562,18 +438,6 @@ const DEFAULT_IGNORED_DIRS = [
562
438
  "DerivedData",
563
439
  ".build"
564
440
  ];
565
- /**
566
- * Breadth-first so shallow files rank first before scoring even runs — for a bare
567
- * `@` that ordering *is* the ranking, and for a query it breaks ties the way a
568
- * person expects (`src/index.ts` over `src/a/b/c/index.ts`).
569
- *
570
- * Symlinks are skipped outright, as files and as directories. As directories it is
571
- * the difference between a bounded walk and an unbounded one (a cycle, or a link
572
- * to `/`); as files it keeps this function's output within the tree it was handed,
573
- * so nothing it offers can be a path that `resolveExisting` would later refuse.
574
- * A tree that genuinely lives behind symlinks is not autocompletable — an accepted
575
- * cost for not having to re-derive containment here.
576
- */
577
441
  function searchFiles(base, options = {}) {
578
442
  const limit = options.limit ?? 50;
579
443
  const maxScanned = options.maxScanned ?? 2e4;
@@ -628,14 +492,6 @@ function searchFiles(base, options = {}) {
628
492
  truncated: !exhausted || found.length > limit
629
493
  };
630
494
  }
631
- /**
632
- * Subsequence matching, like every `@`-picker worth using: `seslist` finds
633
- * `SessionListView.swift`. Returns null for no match.
634
- *
635
- * Scored so the two things people actually mean win — a hit in the filename beats
636
- * one buried in the directory path, and characters typed consecutively beat the
637
- * same characters scattered — rather than trying to be a ranking engine.
638
- */
639
495
  function scoreMatch(relativePath, name, needle) {
640
496
  if (needle === "") return 0;
641
497
  const inName = subsequenceScore(name.toLowerCase(), needle);
@@ -658,15 +514,9 @@ function subsequenceScore(haystack, needle) {
658
514
  }
659
515
  //#endregion
660
516
  //#region src/routes/host-files.ts
661
- /**
662
- * `{basePath}/fs/*` the operator's real tree. Authorized by the auth key alone
663
- * and deliberately outside the agent permission flow: the caller is the operator.
664
- *
665
- * Every path in here goes through `services/host-files.ts` first, which
666
- * canonicalizes and *then* re-checks containment. The naive prefix compare
667
- * `cwdAllowed` does would be wrong at this door — the agent writes into these
668
- * trees, and a symlink it created is a path the operator never typed.
669
- */
517
+ function kindRank(type) {
518
+ return type === "dir" ? 0 : 1;
519
+ }
670
520
  async function handleHostFiles(ctx, req, res, pathname) {
671
521
  const { basePath, hostFiles, hostFilesWritable, maxHostFileBytes, maxHostDirEntries } = ctx;
672
522
  if (!hostFiles) {
@@ -766,10 +616,7 @@ async function handleHostFiles(ctx, req, res, pathname) {
766
616
  modifiedAt
767
617
  };
768
618
  });
769
- entries.sort((a, b) => {
770
- const rank = (t) => t === "dir" ? 0 : 1;
771
- return rank(a.type) - rank(b.type) || a.name.localeCompare(b.name);
772
- });
619
+ entries.sort((a, b) => kindRank(a.type) - kindRank(b.type) || a.name.localeCompare(b.name));
773
620
  json(res, 200, {
774
621
  path: resolved.path,
775
622
  entries,
@@ -883,9 +730,44 @@ async function handleHostFiles(ctx, req, res, pathname) {
883
730
  json(res, 404, { error: "not found" });
884
731
  }
885
732
  //#endregion
733
+ //#region src/routes/create-vet.ts
734
+ /**
735
+ * The one create-validation ladder, run by both create doors — `POST /sessions` and the
736
+ * `session` block of `POST /jobs`. The scope design claims the two are indistinguishable, so
737
+ * the order and the refusals have to come from a single place rather than two copies that can
738
+ * drift. Mutates `req`: strips inert fields and pins the resolved profile name.
739
+ */
740
+ function vetCreateRequest(ctx, req, auth) {
741
+ const { availability, factory } = ctx;
742
+ const refusedScope = factory.applyScope(req, auth);
743
+ if (refusedScope) return refusedScope;
744
+ const refused = factory.applyBypassPolicy(req);
745
+ if (refused) return {
746
+ status: 403,
747
+ error: refused
748
+ };
749
+ const resolved = factory.resolveProfile(req.profile, auth.allowedProfiles);
750
+ if (!resolved.ok) return {
751
+ status: resolved.status,
752
+ error: resolved.error
753
+ };
754
+ const unavailable = availability.checkAvailable(resolved.profile);
755
+ if (unavailable) return unavailable;
756
+ const refusedCwd = factory.checkCwd(req, resolved.profile);
757
+ if (refusedCwd) return refusedCwd;
758
+ const badRequest = factory.checkPermissionMode(req.permissionMode, resolved.profile) ?? factory.checkEngineGrants(req, resolved.profile);
759
+ if (badRequest) return {
760
+ status: 400,
761
+ error: badRequest
762
+ };
763
+ factory.stripInertFields(req, resolved.profile);
764
+ req.profile = resolved.profile?.name;
765
+ return null;
766
+ }
767
+ //#endregion
886
768
  //#region src/routes/jobs.ts
887
769
  async function handleJobs(ctx, req, res, pathname, auth) {
888
- const { auth: authSvc, availability, basePath, factory, queue } = ctx;
770
+ const { auth: authSvc, basePath, queue } = ctx;
889
771
  if (!queue) {
890
772
  json(res, 404, { error: "job queue not configured" });
891
773
  return;
@@ -918,38 +800,11 @@ async function handleJobs(ctx, req, res, pathname, auth) {
918
800
  json(res, 400, { error: "session.prompt is required" });
919
801
  return;
920
802
  }
921
- const refusedScope = factory.applyScope(body.session, auth);
922
- if (refusedScope) {
923
- json(res, refusedScope.status, { error: refusedScope.error });
924
- return;
925
- }
926
- const refused = factory.applyBypassPolicy(body.session);
927
- if (refused) {
928
- json(res, 403, { error: refused });
929
- return;
930
- }
931
- const resolved = factory.resolveProfile(body.session.profile, auth.allowedProfiles);
932
- if (!resolved.ok) {
933
- json(res, resolved.status, { error: resolved.error });
934
- return;
935
- }
936
- const unavailable = availability.checkAvailable(resolved.profile);
937
- if (unavailable) {
938
- json(res, unavailable.status, { error: unavailable.error });
803
+ const refusal = vetCreateRequest(ctx, body.session, auth);
804
+ if (refusal) {
805
+ json(res, refusal.status, { error: refusal.error });
939
806
  return;
940
807
  }
941
- const refusedCwd = factory.checkCwd(body.session, resolved.profile);
942
- if (refusedCwd) {
943
- json(res, refusedCwd.status, { error: refusedCwd.error });
944
- return;
945
- }
946
- const badRequest = factory.checkPermissionMode(body.session.permissionMode, resolved.profile) ?? factory.checkEngineGrants(body.session, resolved.profile);
947
- if (badRequest) {
948
- json(res, 400, { error: badRequest });
949
- return;
950
- }
951
- factory.stripInertFields(body.session, resolved.profile);
952
- body.session.profile = resolved.profile?.name;
953
808
  try {
954
809
  json(res, 201, { job: await queue.submit(body) });
955
810
  } catch (error) {
@@ -1066,6 +921,10 @@ async function handleProfiles(ctx, req, res, pathname, auth) {
1066
921
  }
1067
922
  //#endregion
1068
923
  //#region src/routes/sdk-sessions.ts
924
+ function withinRoots(sessions, roots, limit, offset = 0) {
925
+ const allowed = sessions.filter((s) => s.cwd !== void 0 && cwdAllowed(s.cwd, roots)).sort((a, b) => b.lastModified - a.lastModified);
926
+ return limit === void 0 ? allowed.slice(offset) : allowed.slice(offset, offset + limit);
927
+ }
1069
928
  async function handleSdkSessions(ctx, req, res, auth) {
1070
929
  const { adapterFor, factory, profiles } = ctx;
1071
930
  if (req.method !== "GET") {
@@ -1122,18 +981,14 @@ async function handleSdkSessions(ctx, req, res, auth) {
1122
981
  json(res, 500, { error: error instanceof Error ? error.message : "failed to list sessions" });
1123
982
  }
1124
983
  }
1125
- /** The sessions whose `cwd` is inside the roots, newest first, then paged. A
1126
- * summary with no `cwd` cannot be shown to be inside them, so it is dropped. */
1127
- function withinRoots(sessions, roots, limit, offset = 0) {
1128
- const allowed = sessions.filter((s) => s.cwd !== void 0 && cwdAllowed(s.cwd, roots)).sort((a, b) => b.lastModified - a.lastModified);
1129
- return limit === void 0 ? allowed.slice(offset) : allowed.slice(offset, offset + limit);
1130
- }
1131
984
  //#endregion
1132
985
  //#region src/services/session-store.ts
1133
- /** A live record is refreshed in place on wake; a park is consumed by it. */
1134
- const isLiveRecord = (record) => record.kind === "live";
1135
- const isDormant = (record) => record.kind === "dormant";
1136
- /** Single-process, no persistence: parks survive a client disconnect, not a restart. */
986
+ function isLiveRecord(record) {
987
+ return record.kind === "live";
988
+ }
989
+ function isDormant(record) {
990
+ return record.kind === "dormant";
991
+ }
1137
992
  var MemorySessionStore = class {
1138
993
  #records = /* @__PURE__ */ new Map();
1139
994
  save(record) {
@@ -1150,31 +1005,12 @@ var MemorySessionStore = class {
1150
1005
  return Promise.resolve(this.#records.delete(id));
1151
1006
  }
1152
1007
  };
1153
- /**
1154
- * Config fields that must not be written to durable storage: two are functions
1155
- * (JSON drops them silently), `extraOptions` is SDK `Options` and may hold hooks
1156
- * and callbacks, and `env` is a credential-bearing map — the same rule
1157
- * `profile-store.ts` follows, for the same reason.
1158
- *
1159
- * Dropping them costs a rehydrated session nothing, but the reason differs by
1160
- * record kind and both halves matter. A provider session's credentials are
1161
- * resolved by `createEngineRunner` from the operator's environment on every
1162
- * build, wake included — so a parked record never needed them. A **dormant**
1163
- * record is a claude or codex session, which does consume `env` (the profile's
1164
- * `CLAUDE_CONFIG_DIR` pin lives there), and that is precisely why waking one
1165
- * feeds its config back through the server's `buildRunnerConfig` instead of
1166
- * handing it to the engine as-is: the pin and the host hook's injections are
1167
- * re-derived from the profile, never read back off disk. Persisting them would
1168
- * be a credential map in a file *and* a stale one.
1169
- */
1170
1008
  const EPHEMERAL_CONFIG_KEYS = [
1171
1009
  "queryFn",
1172
1010
  "historyFn",
1173
1011
  "extraOptions",
1174
1012
  "env"
1175
1013
  ];
1176
- /** The record as it may be persisted: same session, config narrowed to what is
1177
- * safe and meaningful to keep (see {@link EPHEMERAL_CONFIG_KEYS}). */
1178
1014
  function toDurableRecord(record) {
1179
1015
  const config = { ...record.config };
1180
1016
  for (const key of EPHEMERAL_CONFIG_KEYS) delete config[key];
@@ -1183,29 +1019,7 @@ function toDurableRecord(record) {
1183
1019
  config
1184
1020
  };
1185
1021
  }
1186
- /** Bump when the on-disk shape changes incompatibly; records written by another
1187
- * version are ignored rather than half-read into a broken session. */
1188
1022
  const FORMAT_VERSION = 1;
1189
- /**
1190
- * Durable single-host store: one JSON file per parked session under `dir`, written
1191
- * through a temp file and a rename so a crash mid-write cannot truncate a session.
1192
- * `hydrate()` at `listen()` picks them up, re-indexes their executions, and re-arms
1193
- * the watchdogs, so a restart no longer loses parked work.
1194
- *
1195
- * Know what is on that disk: **the record holds the session's entire transcript** —
1196
- * prompts, model output, and tool I/O — in plaintext. Put it somewhere with the same
1197
- * protection as the SDK's own transcripts (`~/.claude/projects`), not in a directory
1198
- * that gets served, synced, or backed up somewhere looser.
1199
- *
1200
- * Single-process by design, exactly like the bundled queue adapter and profile
1201
- * store: two servers sharing one directory would both hydrate the same records and
1202
- * race to rebuild them. That is what the seam is for.
1203
- *
1204
- * Nothing here reaps: a record leaves only when its session wakes or is deleted.
1205
- * An execution dispatched without a deadline (a `DeferredExecutor` with no
1206
- * `timeoutMs`) has no watchdog to end the wait, so its record — and its transcript
1207
- * — stays until `DELETE /sessions/:id`. Give deferred calls a deadline, or sweep.
1208
- */
1209
1023
  function createFileSessionStore(options = {}) {
1210
1024
  const dir = options.dir ?? join(process.cwd(), ".workerdeck", "parked");
1211
1025
  const fileFor = (id) => join(dir, `${encodeURIComponent(id)}.json`);
@@ -1245,7 +1059,7 @@ function createFileSessionStore(options = {}) {
1245
1059
  path,
1246
1060
  op: "save"
1247
1061
  });
1248
- throw new Error(`parked session '${record.id}' is not JSON-serializable — a host-injected value reached its config or snapshot: ${String(error)}`);
1062
+ throw new Error(`parked session '${record.id}' is not JSON-serializable — a host-injected value reached its config or snapshot: ${String(error)}`, { cause: error });
1249
1063
  }
1250
1064
  try {
1251
1065
  await mkdir(dir, {
@@ -1303,9 +1117,9 @@ function createFileSessionStore(options = {}) {
1303
1117
  }
1304
1118
  };
1305
1119
  }
1306
- const isMissing = (error) => error.code === "ENOENT";
1307
- /** Shape-check a parsed file. A record missing any of these could not be rebuilt,
1308
- * and half-restoring one is worse than skipping it. */
1120
+ function isMissing(error) {
1121
+ return error.code === "ENOENT";
1122
+ }
1309
1123
  function parseRecord(value) {
1310
1124
  if (!value || typeof value !== "object") return null;
1311
1125
  const envelope = value;
@@ -1363,12 +1177,7 @@ async function handleAttachments(ctx, req, res, sessionId, session, attachmentId
1363
1177
  return;
1364
1178
  }
1365
1179
  const bytes = Buffer.from(found.data, "base64");
1366
- res.writeHead(200, {
1367
- "content-type": found.mediaType,
1368
- "content-length": bytes.length,
1369
- "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(found.name)}`,
1370
- "x-content-type-options": "nosniff"
1371
- });
1180
+ res.writeHead(200, untrustedDownloadHeaders(found.name, found.mediaType, bytes.length));
1372
1181
  res.end(bytes);
1373
1182
  return;
1374
1183
  }
@@ -1414,21 +1223,6 @@ async function handleMcp(ctx, req, res, runner, serverName) {
1414
1223
  }
1415
1224
  //#endregion
1416
1225
  //#region src/routes/produced-files.ts
1417
- /**
1418
- * `{basePath}/sessions/:id/produced[/:fileId]` — files this session's ENGINE
1419
- * wrote on the host (codex's generated images), listed and served.
1420
- *
1421
- * The one route here with no root allowlist and no byte cap, and the comment
1422
- * on `ProducedFileStore` is the argument for why that is right rather
1423
- * than lax: the allowlist is the exact set of paths this session's own runner
1424
- * announced producing. It is emphatically NOT a hole in `/fs/*` — a path the
1425
- * *agent* named is not a produced file and never enters this store.
1426
- *
1427
- * Everything else matches the attachment download: `nosniff` and an attachment
1428
- * disposition, because these bytes are model-authored and must not render as a
1429
- * document on the gateway's origin. (`<img src>` is unaffected — disposition
1430
- * does not apply to subresources, which is the whole point.)
1431
- */
1432
1226
  async function handleProducedFiles(ctx, req, res, sessionId, fileId) {
1433
1227
  const { producedFiles } = ctx;
1434
1228
  if (req.method !== "GET") {
@@ -1461,12 +1255,7 @@ async function handleProducedFiles(ctx, req, res, sessionId, fileId) {
1461
1255
  return;
1462
1256
  }
1463
1257
  const filename = basename(found.path) || "file";
1464
- res.writeHead(200, {
1465
- "content-type": found.mediaType ?? contentTypeFor(filename),
1466
- "content-length": stat.size,
1467
- "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
1468
- "x-content-type-options": "nosniff"
1469
- });
1258
+ res.writeHead(200, untrustedDownloadHeaders(filename, found.mediaType ?? contentTypeFor(filename), stat.size));
1470
1259
  await new Promise((done) => {
1471
1260
  const stream = createReadStream(found.path);
1472
1261
  stream.on("error", () => {
@@ -1479,59 +1268,11 @@ async function handleProducedFiles(ctx, req, res, sessionId, fileId) {
1479
1268
  }
1480
1269
  //#endregion
1481
1270
  //#region src/services/project-info.ts
1482
- /**
1483
- * Project identity discovery: the `.workerdeck.json` ancestor walk behind
1484
- * `SessionInfo.project`, and the read side of `GET /sessions/:id/project/icon`.
1485
- *
1486
- * The gateway resolves this — not each client — because the file lives on the
1487
- * gateway's filesystem, which a phone or a remote browser cannot see. It is
1488
- * stamped onto `SessionInfo` at **serve time** (`withProject`), never persisted:
1489
- * a copy captured into a parking record would replay a stale name forever,
1490
- * where a serve-time read picks up an edited file on every session at once
1491
- * within the TTL. That is the profile tracker's 0%-after-reset placement
1492
- * argument, applied to a filesystem fact instead of a clock.
1493
- *
1494
- * Every failure degrades to "no project" and never to an error: a session must
1495
- * not fail, or even warn, because of a display declaration. A malformed,
1496
- * oversized, or symlinked `.workerdeck.json` is *skipped and the walk
1497
- * continues* — a broken file in `packages/ui` must not shadow the repo root's
1498
- * valid one — and inside a valid file each field degrades on its own (junk
1499
- * name → the root's basename, junk icon → no icon).
1500
- *
1501
- * The icon is the security surface, because its path comes out of a config
1502
- * file the *agent* can write (the session cwd is the agent's working tree).
1503
- * The rules are `host-files.ts`'s, not `cwdAllowed`'s (docs/GOTCHAS.md §Host
1504
- * filesystem): the declared path is resolved against the project root and then
1505
- * realpath'd **whole**, containment is decided on the canonical result only
1506
- * (so `"icon": "../../../../etc/key.png"` and a planted `icon.png → ~/.ssh/…`
1507
- * symlink both fail the same check), the open goes through `readContained`
1508
- * (O_NOFOLLOW, fstat-before-io), and the media type comes from the *declared*
1509
- * extension — png and svg only, by decision. A refused icon is
1510
- * indistinguishable on the wire and on the route from a never-declared one:
1511
- * `icon` absent, the route 404s. The one disclosure this feature accepts is
1512
- * inherent to it: the walk reads ancestors of a vetted cwd, so a project file
1513
- * an operator placed *above* their roots (`~/.workerdeck.json`) applies to
1514
- * everything under it — nearest-wins from the cwd, exactly git's own
1515
- * discovery, and the file is a display declaration by definition.
1516
- *
1517
- * Cache: per exact cwd string, TTL'd, with negative results cached at the same
1518
- * price — `GET /sessions` polls at 1.2s while anything is working and the hit
1519
- * path must be a Map lookup, never a walk. Keyed by cwd rather than by root
1520
- * because the root is not known until the walk has run. Bounded by sweeping
1521
- * expired entries once the map outgrows any plausible live session count.
1522
- */
1523
1271
  const PROJECT_FILE = ".workerdeck.json";
1524
- /** A config file, not a document — anything bigger is skipped as malformed. */
1525
1272
  const MAX_PROJECT_FILE_BYTES = 64 * 1024;
1526
- /** Display name clip — a list row's width, not a document's. */
1527
1273
  const MAX_NAME_CHARS = 80;
1528
- /** lucide's naming: lowercase kebab-case. Shape-only — the gateway has no icon
1529
- * catalog and must not grow one; an unknown-but-well-formed name ships and the
1530
- * client falls back (a stale row, never withheld state). */
1531
1274
  const GLYPH_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
1532
1275
  const DEFAULT_TTL_MS = 3e4;
1533
- /** Sweep threshold: above this many cached cwds, expired entries are evicted
1534
- * on the next resolve so dead sessions' keys do not accumulate forever. */
1535
1276
  const SWEEP_ABOVE = 256;
1536
1277
  var ProjectInfoService = class {
1537
1278
  #ttlMs;
@@ -1539,11 +1280,6 @@ var ProjectInfoService = class {
1539
1280
  constructor(options = {}) {
1540
1281
  this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
1541
1282
  }
1542
- /**
1543
- * The serve-time decoration: `info` with `project` stamped on, or the same
1544
- * object untouched when there is nothing to add — the common case on a list
1545
- * poll, so no allocation for it (replaySlice's same-object rule).
1546
- */
1547
1283
  withProject(info) {
1548
1284
  if (!info.cwd) return info;
1549
1285
  const project = this.#resolve(info.cwd).project;
@@ -1552,11 +1288,8 @@ var ProjectInfoService = class {
1552
1288
  project
1553
1289
  } : info;
1554
1290
  }
1555
- /** The icon route's read side: the canonical, contained icon file for this
1556
- * session's cwd — resolved from the gateway's own cache, never from anything
1557
- * the client named. Undefined = no project, no icon, or an icon refused. */
1558
1291
  iconFor(cwd) {
1559
- if (!cwd) return void 0;
1292
+ if (!cwd) return;
1560
1293
  return this.#resolve(cwd).icon;
1561
1294
  }
1562
1295
  #resolve(cwd) {
@@ -1574,10 +1307,6 @@ var ProjectInfoService = class {
1574
1307
  return fresh;
1575
1308
  }
1576
1309
  };
1577
- /** The ancestor walk: realpath the cwd (a lexical walk over `/tmp/x` would
1578
- * miss the file at `/private/tmp/x`, and canonicalizing here is what makes
1579
- * `root` — the grouping key — spell identically for every cwd inside one
1580
- * project), then nearest `.workerdeck.json` wins, to the filesystem root. */
1581
1310
  function discover(cwd) {
1582
1311
  if (!isAbsolute(cwd) || cwd.includes("\0")) return {};
1583
1312
  let dir;
@@ -1594,8 +1323,6 @@ function discover(cwd) {
1594
1323
  dir = parent;
1595
1324
  }
1596
1325
  }
1597
- /** One directory's verdict: a project record, or undefined to keep walking —
1598
- * which is the same answer for "absent" and for every malformed shape. */
1599
1326
  function tryLoad(file, root) {
1600
1327
  let stat;
1601
1328
  try {
@@ -1603,16 +1330,16 @@ function tryLoad(file, root) {
1603
1330
  } catch {
1604
1331
  return;
1605
1332
  }
1606
- if (!stat.isFile() || stat.size > MAX_PROJECT_FILE_BYTES) return void 0;
1333
+ if (!stat.isFile() || stat.size > MAX_PROJECT_FILE_BYTES) return;
1607
1334
  const read = readContained(file);
1608
- if (!read.ok) return void 0;
1335
+ if (!read.ok) return;
1609
1336
  let parsed;
1610
1337
  try {
1611
1338
  parsed = JSON.parse(read.data.toString("utf8"));
1612
1339
  } catch {
1613
1340
  return;
1614
1341
  }
1615
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
1342
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return;
1616
1343
  const raw = parsed;
1617
1344
  const name = typeof raw.name === "string" && raw.name.trim() ? raw.name.trim().slice(0, MAX_NAME_CHARS) : basename(root);
1618
1345
  const icon = classifyIcon(raw.icon, root);
@@ -1625,41 +1352,36 @@ function tryLoad(file, root) {
1625
1352
  ...icon?.resolved ? { icon: icon.resolved } : {}
1626
1353
  };
1627
1354
  }
1628
- /**
1629
- * The one-string icon rule (documented on protocol's `ProjectInfo`): ends in
1630
- * `.png`/`.svg` → repo-relative image path, else lucide-shaped glyph name,
1631
- * else ignored. Total and collision-free — a glyph name contains no dot.
1632
- */
1633
1355
  function classifyIcon(value, root) {
1634
- if (typeof value !== "string") return void 0;
1356
+ if (typeof value !== "string") return;
1635
1357
  const declared = value.trim();
1636
- if (!declared || declared.includes("\0") || declared.length > 512) return void 0;
1358
+ if (!declared || declared.includes("\0") || declared.length > 512) return;
1637
1359
  const lower = declared.toLowerCase();
1638
1360
  const mediaType = lower.endsWith(".png") ? "image/png" : lower.endsWith(".svg") ? "image/svg+xml" : void 0;
1639
1361
  if (!mediaType) {
1640
- if (!GLYPH_RE.test(declared) || declared.length > 64) return void 0;
1362
+ if (!GLYPH_RE.test(declared) || declared.length > 64) return;
1641
1363
  return { wire: {
1642
1364
  type: "glyph",
1643
1365
  name: declared
1644
1366
  } };
1645
1367
  }
1646
- if (isAbsolute(declared) || declared.includes("\\")) return void 0;
1368
+ if (isAbsolute(declared) || declared.includes("\\")) return;
1647
1369
  let canonical;
1648
1370
  try {
1649
1371
  canonical = realpathSync(resolve(root, declared));
1650
1372
  } catch {
1651
1373
  return;
1652
1374
  }
1653
- if (!contained(root, canonical)) return void 0;
1375
+ if (!contained(root, canonical)) return;
1654
1376
  let stat;
1655
1377
  try {
1656
1378
  stat = lstatSync(canonical);
1657
1379
  } catch {
1658
1380
  return;
1659
1381
  }
1660
- if (!stat.isFile() || stat.size === 0 || stat.size > 524288) return void 0;
1382
+ if (!stat.isFile() || stat.size === 0 || stat.size > 524288) return;
1661
1383
  const read = readContained(canonical);
1662
- if (!read.ok || read.data.length > 524288) return void 0;
1384
+ if (!read.ok || read.data.length > 524288) return;
1663
1385
  const hash = createHash("sha256").update(read.data).digest("hex");
1664
1386
  return {
1665
1387
  wire: {
@@ -1763,7 +1485,7 @@ function handleToolResult(req, res, lookup, seq) {
1763
1485
  //#endregion
1764
1486
  //#region src/routes/sessions.ts
1765
1487
  async function handleSessions(ctx, req, res, route, auth) {
1766
- const { attachmentStore, auth: authSvc, availability, bridge, factory, parking, producedFiles, projects, registry } = ctx;
1488
+ const { attachmentStore, auth: authSvc, bridge, factory, parking, producedFiles, projects, registry } = ctx;
1767
1489
  if (!route.id) {
1768
1490
  if (req.method === "GET") {
1769
1491
  json(res, 200, { sessions: [...registry.list(), ...await parking.listInfo()].filter((session) => authSvc.canSee(auth, session)).map((session) => projects.withProject(session)) });
@@ -1771,38 +1493,11 @@ async function handleSessions(ctx, req, res, route, auth) {
1771
1493
  }
1772
1494
  if (req.method === "POST") {
1773
1495
  const body = await readJsonBody(req, ctx.maxBodyBytes);
1774
- const refusedScope = factory.applyScope(body, auth);
1775
- if (refusedScope) {
1776
- json(res, refusedScope.status, { error: refusedScope.error });
1777
- return;
1778
- }
1779
- const refused = factory.applyBypassPolicy(body);
1780
- if (refused) {
1781
- json(res, 403, { error: refused });
1782
- return;
1783
- }
1784
- const resolved = factory.resolveProfile(body.profile, auth.allowedProfiles);
1785
- if (!resolved.ok) {
1786
- json(res, resolved.status, { error: resolved.error });
1787
- return;
1788
- }
1789
- const unavailable = availability.checkAvailable(resolved.profile);
1790
- if (unavailable) {
1791
- json(res, unavailable.status, { error: unavailable.error });
1496
+ const refusal = vetCreateRequest(ctx, body, auth);
1497
+ if (refusal) {
1498
+ json(res, refusal.status, { error: refusal.error });
1792
1499
  return;
1793
1500
  }
1794
- const refusedCwd = factory.checkCwd(body, resolved.profile);
1795
- if (refusedCwd) {
1796
- json(res, refusedCwd.status, { error: refusedCwd.error });
1797
- return;
1798
- }
1799
- const badRequest = factory.checkPermissionMode(body.permissionMode, resolved.profile) ?? factory.checkEngineGrants(body, resolved.profile);
1800
- if (badRequest) {
1801
- json(res, 400, { error: badRequest });
1802
- return;
1803
- }
1804
- factory.stripInertFields(body, resolved.profile);
1805
- body.profile = resolved.profile?.name;
1806
1501
  const runner = await factory.createRunner(factory.buildRunnerConfig(body));
1807
1502
  factory.watchAuthSource(runner);
1808
1503
  json(res, 201, { session: projects.withProject(runner.info()) });
@@ -1860,12 +1555,7 @@ async function handleSessions(ctx, req, res, route, auth) {
1860
1555
  return;
1861
1556
  }
1862
1557
  const filename = route.filePath.split("/").pop() || "file";
1863
- res.writeHead(200, {
1864
- "content-type": contentTypeFor(filename),
1865
- "content-length": Buffer.byteLength(content),
1866
- "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
1867
- "x-content-type-options": "nosniff"
1868
- });
1558
+ res.writeHead(200, untrustedDownloadHeaders(filename, contentTypeFor(filename), Buffer.byteLength(content)));
1869
1559
  res.end(content);
1870
1560
  return;
1871
1561
  }
@@ -2050,20 +1740,6 @@ async function handleCommand(ctx, frame, runner) {
2050
1740
  //#region src/services/attachments.ts
2051
1741
  const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
2052
1742
  const DEFAULT_MAX_SESSION_BYTES = 64 * 1024 * 1024;
2053
- /**
2054
- * Per-session hold for files the user attached to a message.
2055
- *
2056
- * In memory, and deliberately so. An attachment is only *needed* for the instant
2057
- * between the upload and the message that names it; everything after that is
2058
- * convenience (a client re-rendering a thumbnail after a reattach). That is the
2059
- * same bargain `GET /sessions/:id/files` makes — the session's lifetime, no
2060
- * durability tier — and it keeps the gateway from accumulating a photo library
2061
- * on disk that nobody asked it to look after.
2062
- *
2063
- * Both caps are enforced here rather than at the route, so a host embedding the
2064
- * server cannot forget one: a single file that is too big is a 413, and so is a
2065
- * session whose total would go over.
2066
- */
2067
1743
  var AttachmentStore = class {
2068
1744
  #bySession = /* @__PURE__ */ new Map();
2069
1745
  #maxFileBytes;
@@ -2121,18 +1797,9 @@ var AttachmentStore = class {
2121
1797
  attachment: ref(attachment)
2122
1798
  };
2123
1799
  }
2124
- /** The stored record, bytes included — for the download route and for the send
2125
- * path that turns ids into content blocks. */
2126
1800
  get(sessionId, id) {
2127
1801
  return this.#bySession.get(sessionId)?.get(id);
2128
1802
  }
2129
- /**
2130
- * Resolve the ids a `user_message` named, in the order given.
2131
- *
2132
- * Missing ids are reported rather than skipped: a message that quietly lost its
2133
- * picture reads as the model ignoring it, which is a far worse failure than a
2134
- * command that errors.
2135
- */
2136
1803
  resolve(sessionId, ids) {
2137
1804
  const held = this.#bySession.get(sessionId);
2138
1805
  const attachments = [];
@@ -2162,11 +1829,6 @@ function ref(attachment) {
2162
1829
  bytes: attachment.bytes
2163
1830
  };
2164
1831
  }
2165
- /**
2166
- * A display name, not a path. The name is echoed back to clients and put in front
2167
- * of the model in the text-attachment envelope, so directory separators, control
2168
- * characters and unbounded length all come off here.
2169
- */
2170
1832
  function safeName(name) {
2171
1833
  const cleaned = (name.split(/[/\\]/).pop() ?? "").replace(/[\u0000-\u001f\u007f"<>]/g, "").trim();
2172
1834
  if (cleaned === "" || cleaned === "." || cleaned === "..") return "attachment";
@@ -2174,26 +1836,14 @@ function safeName(name) {
2174
1836
  }
2175
1837
  //#endregion
2176
1838
  //#region src/lib/scope.ts
2177
- /**
2178
- * The scope rules — opaque tags assigned at create, immutable after, and the
2179
- * only intra-deployment scoping primitive there is. WorkerDeck stores and
2180
- * enforces the tags; the embedder's `authorizeSession` decides what they mean.
2181
- */
2182
- /** Most tags one session (or one principal) may carry, and the longest a key or
2183
- * value may be. Not a security property — a bound so an opaque map cannot become
2184
- * an unbounded store that every list response then carries. */
2185
1839
  const MAX_SCOPE_KEYS = 16;
2186
1840
  const MAX_SCOPE_LEN = 200;
2187
- /** A `Record<string, string>` or nothing. Duck-typed the same way
2188
- * `allowedProfiles` is: a malformed value is ignored, never half-applied. */
2189
1841
  function readScope(value) {
2190
- if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
1842
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return;
2191
1843
  const entries = Object.entries(value);
2192
- if (entries.some(([, v]) => typeof v !== "string")) return void 0;
1844
+ if (entries.some(([, v]) => typeof v !== "string")) return;
2193
1845
  return Object.fromEntries(entries);
2194
1846
  }
2195
- /** Validate a caller-supplied scope. Returns an error string, or null when it is
2196
- * well-formed (including when it is absent). */
2197
1847
  function checkScope(value) {
2198
1848
  if (value === void 0) return null;
2199
1849
  const scope = readScope(value);
@@ -2206,23 +1856,11 @@ function checkScope(value) {
2206
1856
  }
2207
1857
  return null;
2208
1858
  }
2209
- /** Key-order-independent equality — a host runner that rebuilt the record
2210
- * rather than echoing the reference must still pass the build-time check. */
2211
1859
  function sameScope(a, b) {
2212
1860
  const left = Object.entries(a ?? {}).sort(([x], [y]) => x < y ? -1 : 1);
2213
1861
  const right = Object.entries(b ?? {}).sort(([x], [y]) => x < y ? -1 : 1);
2214
1862
  return left.length === right.length && left.every(([key, value], i) => right[i][0] === key && right[i][1] === value);
2215
1863
  }
2216
- /**
2217
- * The default visibility rule, used whenever the host supplies no
2218
- * `authorizeSession`: every key the principal pins must match the session's, and
2219
- * an unset principal scope sees everything.
2220
- *
2221
- * The asymmetry is intended — a session may carry tags the principal says
2222
- * nothing about (an app that tags `{space, user, conversation}` while the
2223
- * principal only pins `{space, user}` still works), but a session missing a key
2224
- * the principal pins is not this caller's.
2225
- */
2226
1864
  function scopeMatches(principal, session) {
2227
1865
  if (!principal) return true;
2228
1866
  return Object.entries(principal).every(([key, value]) => session?.[key] === value);
@@ -2246,7 +1884,6 @@ function createAuthService(deps) {
2246
1884
  canManageProfiles: principal.canManageProfiles === true
2247
1885
  };
2248
1886
  };
2249
- /** May this caller see — and therefore drive — this session? */
2250
1887
  const canSee = (auth, session) => {
2251
1888
  if (!options.authorizeSession) return scopeMatches(auth.scope, session.scope);
2252
1889
  try {
@@ -2255,20 +1892,6 @@ function createAuthService(deps) {
2255
1892
  return false;
2256
1893
  }
2257
1894
  };
2258
- /**
2259
- * The job flavour of {@link canSee}. Once the run has started, the live
2260
- * session's info is the real subject and the host's rule decides on it. Before
2261
- * that (queued) and after (finished, session gone) there is no session to
2262
- * hand over, so the predicate gets a **stub** built from what the job records:
2263
- * its scope, its profile, its cwd.
2264
- *
2265
- * A stub rather than a fallback to the default rule, which is what this did
2266
- * first and was wrong: a host policy *narrower* than plain tag-match (tags
2267
- * plus a role, say) would have had queued jobs admitted — and cancelable — by
2268
- * a peer it rejects. The predicate must be the only rule wherever it exists.
2269
- * A host reading fields a queued job cannot have (model, status detail) gets
2270
- * `undefined` and should treat the id and the scope as the load-bearing ones.
2271
- */
2272
1895
  const canSeeJob = (auth, job) => {
2273
1896
  const live = job.sessionId ? refs.registry.get(job.sessionId)?.info() : void 0;
2274
1897
  if (live) return canSee(auth, live);
@@ -2284,25 +1907,6 @@ function createAuthService(deps) {
2284
1907
  scope: job.scope
2285
1908
  });
2286
1909
  };
2287
- /**
2288
- * Is this caller the operator, rather than someone embedded inside a scope?
2289
- *
2290
- * It decides the surfaces that answer about the **gateway** instead of about
2291
- * one session — the host filesystem, the engine's own on-disk session store,
2292
- * the queue and its firehose. There is nothing to filter on those and no
2293
- * honest way to narrow them, so a non-operator is refused outright (404, like
2294
- * every other miss).
2295
- *
2296
- * Two ways to be one, and the second exists because the first is not enough.
2297
- * A principal carrying `scope` is an end user; a principal carrying neither
2298
- * `scope` nor a policy is the operator — that is the unscoped default every
2299
- * existing deployment relies on. But a host may write `authorizeSession` over
2300
- * its *own* principal shape and never set `scope` at all, and reading that as
2301
- * "everyone is the operator" is how a locked-down gateway ends up serving its
2302
- * filesystem to end users. So **declaring a policy withdraws the default**,
2303
- * and such a host marks its operator principals explicitly with
2304
- * `operator: true` (`operator: false` forces the other way, at any time).
2305
- */
2306
1910
  const isOperator = (auth) => auth.operator ?? (auth.scope === void 0 && !options.authorizeSession);
2307
1911
  return {
2308
1912
  authenticate,
@@ -2313,32 +1917,14 @@ function createAuthService(deps) {
2313
1917
  }
2314
1918
  //#endregion
2315
1919
  //#region src/services/availability.ts
2316
- /**
2317
- * Availability, per profile: the adapter's probe run over the env the real
2318
- * assembly path produces (so anything the host hook injects — a
2319
- * CLAUDE_CODE_OAUTH_TOKEN, say — counts as logged in). Cached, and served on
2320
- * `GET /profiles` as `available`/`unavailableReason`.
2321
- *
2322
- * Gated on `checkCredentials` like the old claude-only preflight (this is a
2323
- * library; `pnpm test` must spawn nothing unless a test injects fake
2324
- * adapters or probes). 'unknown' stays out of the cache's answers: a probe
2325
- * that couldn't run is not evidence of a missing login. **Display-only**
2326
- * downstream — session create against an unavailable profile still proceeds
2327
- * and fails with the engine's own error, because the probe can be stale in
2328
- * both directions and refusing on it would turn a probe bug into an outage.
2329
- * (`requireAvailableProfile` is the one deliberate exception, and only on a
2330
- * definite `false`.)
2331
- */
2332
1920
  const AVAILABILITY_TTL_MS = 6e4;
2333
1921
  var AvailabilityTracker = class {
2334
1922
  #verdicts = /* @__PURE__ */ new Map();
2335
- /** Profiles already warned about on the console, so re-probes don't spam. */
2336
1923
  #warned = /* @__PURE__ */ new Set();
2337
1924
  #opts;
2338
1925
  constructor(opts) {
2339
1926
  this.#opts = opts;
2340
1927
  }
2341
- /** The cached verdict, if any probe has answered. */
2342
1928
  get(name) {
2343
1929
  return this.#verdicts.get(name)?.verdict;
2344
1930
  }
@@ -2367,11 +1953,6 @@ var AvailabilityTracker = class {
2367
1953
  if (verdict.available === true) this.#warned.delete(profile.name);
2368
1954
  }).catch(() => {});
2369
1955
  }
2370
- /**
2371
- * The create-time half of `requireAvailableProfile`. Only a definite `false`
2372
- * refuses: an unprobed profile ('unknown', or probes turned off entirely) is
2373
- * not evidence of anything and must not become a closed door.
2374
- */
2375
1956
  checkAvailable(profile) {
2376
1957
  if (!this.#opts.requireAvailableProfile || !profile) return null;
2377
1958
  const verdict = this.get(profile.name);
@@ -2381,13 +1962,9 @@ var AvailabilityTracker = class {
2381
1962
  error: `profile '${profile.name}' is unavailable: ${verdict.reason ?? "no usable credentials"}`
2382
1963
  };
2383
1964
  }
2384
- /** Launch-time sweep, concurrent and fire-and-forget. */
2385
1965
  preflight(profiles) {
2386
1966
  for (const profile of profiles) this.probe(profile);
2387
1967
  }
2388
- /** Lazy re-probe on reads, so an operator who just ran `codex login` (or
2389
- * exported a key) sees the profile go green without a restart. Serves the
2390
- * cached verdict now; the refreshed one lands on the next request. */
2391
1968
  refresh(profiles) {
2392
1969
  if (!this.#opts.checkCredentials) return;
2393
1970
  const now = Date.now();
@@ -2399,32 +1976,18 @@ var AvailabilityTracker = class {
2399
1976
  };
2400
1977
  //#endregion
2401
1978
  //#region src/services/bridge.ts
2402
- /**
2403
- * Routes tool executions between a session and the browser tabs attached to it.
2404
- *
2405
- * A session may have several clients attached (dashboard plus embedded panel);
2406
- * the bridge asks the **first attached** one, which is the closest thing to "the
2407
- * client driving this session". If none is attached, dispatch fails fast rather
2408
- * than hanging — an autonomous job simply never bridges, it uses the server
2409
- * executor instead.
2410
- */
2411
1979
  var BridgeHub = class {
2412
1980
  #sessions = /* @__PURE__ */ new Map();
2413
1981
  #options;
2414
1982
  constructor(options = {}) {
2415
1983
  this.#options = options;
2416
1984
  }
2417
- /** The executor to hand a runner for this session. Created on first use and
2418
- * reused, so results routed back always reach the same pending table. */
2419
1985
  executorFor(sessionId) {
2420
1986
  return this.#bridge(sessionId).executor;
2421
1987
  }
2422
- /** How many clients are watching this session. Parking consults it: a session
2423
- * someone is watching stays live. */
2424
1988
  attachedCount(sessionId) {
2425
1989
  return this.#sessions.get(sessionId)?.sockets.length ?? 0;
2426
1990
  }
2427
- /** Register an attached client. Returns a detach function. */
2428
1991
  attach(sessionId, send) {
2429
1992
  const bridge = this.#bridge(sessionId);
2430
1993
  bridge.sockets.push(send);
@@ -2433,14 +1996,9 @@ var BridgeHub = class {
2433
1996
  if (index >= 0) bridge.sockets.splice(index, 1);
2434
1997
  };
2435
1998
  }
2436
- /**
2437
- * Deliver a client's answer to a bridged call. Returns false when the id is
2438
- * unknown or already settled — late and duplicate answers are ignored.
2439
- */
2440
1999
  resolve(sessionId, executionId, answer) {
2441
2000
  return this.#sessions.get(sessionId)?.executor.resolve(executionId, answer) ?? false;
2442
2001
  }
2443
- /** Drop a session's bridge, failing anything still in flight. */
2444
2002
  remove(sessionId) {
2445
2003
  const bridge = this.#sessions.get(sessionId);
2446
2004
  if (!bridge) return;
@@ -2478,38 +2036,15 @@ var BridgeHub = class {
2478
2036
  };
2479
2037
  //#endregion
2480
2038
  //#region src/services/notifications.ts
2481
- /**
2482
- * Turns session events into the handful of notifications a human away from the
2483
- * screen cares about, and delivers them to a webhook and/or a local observer.
2484
- *
2485
- * This is the *primitive*, deliberately transport-agnostic: the server stays
2486
- * credential-free and knows nothing about APNs, Slack or email. Turning a
2487
- * notification into a push is a forwarder's job (the turnkey CLI's), and one that
2488
- * needs credentials, so it does not live here.
2489
- *
2490
- * Delivery is best-effort and ordered per session, mirroring the job queue's
2491
- * webhook behaviour — a consumer that missed one can always attach to the session
2492
- * WS with `afterSeq` and see the truth.
2493
- */
2494
2039
  var SessionNotifier = class {
2495
2040
  #options;
2496
- /** Per-session delivery chain, so a session's notifications arrive in order. */
2497
2041
  #chains = /* @__PURE__ */ new Map();
2498
2042
  constructor(options) {
2499
2043
  this.#options = options;
2500
2044
  }
2501
- /** True when nothing is listening — lets the caller skip subscribing at all. */
2502
2045
  get idle() {
2503
2046
  return !this.#options.webhook && !this.#options.onNotification;
2504
2047
  }
2505
- /**
2506
- * Subscribe to a runner for its lifetime.
2507
- *
2508
- * `afterSeq` defaults to whatever the runner has already emitted, which is what
2509
- * makes this safe on a *rehydrated* session: `subscribe` replays the log from
2510
- * `afterSeq`, so subscribing at 0 to a session rebuilt from a park would
2511
- * re-announce every permission request it ever made.
2512
- */
2513
2048
  watch(runner, afterSeq = runner.info().lastSeq) {
2514
2049
  if (this.idle) return;
2515
2050
  runner.subscribe((event) => {
@@ -2573,12 +2108,6 @@ var SessionNotifier = class {
2573
2108
  if (this.#chains.get(runner.id) === next) this.#chains.delete(runner.id);
2574
2109
  });
2575
2110
  }
2576
- /**
2577
- * Best-effort POST with exponential backoff. Deliberately a near-copy of the
2578
- * queue's job-webhook delivery rather than a shared helper: the two channels
2579
- * have different payloads and different consumers, and coupling them would mean
2580
- * a change to job deliveries silently changing session deliveries.
2581
- */
2582
2111
  async #deliver(webhook, notification) {
2583
2112
  const attempts = this.#options.attempts ?? 3;
2584
2113
  const baseDelay = this.#options.retryDelayMs ?? 500;
@@ -2599,100 +2128,27 @@ var SessionNotifier = class {
2599
2128
  };
2600
2129
  //#endregion
2601
2130
  //#region src/services/parking.ts
2602
- /**
2603
- * Two ways a session outlives its runner, behind one door.
2604
- *
2605
- * **Parking** is deferred execution's other half: a session waiting on work no
2606
- * process in this server is doing.
2607
- *
2608
- * **Dormancy** is the restart story for the engines that cannot park. Every live
2609
- * claude or codex session leaves a small record naming its engine session id, so
2610
- * a gateway that comes back up lists them and resumes one the first time someone
2611
- * attaches. Both kinds live in the same store and come back through the same
2612
- * `ensureLive`, which is why there is one class here and not two.
2613
- *
2614
- * The runner announces the moment with `status_changed: 'parked'` — emitted only
2615
- * once every dispatch of the batch has been handed over, so the snapshot can never
2616
- * miss a call that was still being dispatched. From there this class snapshots,
2617
- * evicts, and persists; delivering a result rebuilds the runner under the same id
2618
- * and hands the result to it. The session's identity, event log, and seq numbering
2619
- * survive intact, so a client reattaching with `afterSeq` sees one unbroken stream.
2620
- */
2621
2131
  var SessionParkManager = class {
2622
2132
  #options;
2623
- /** executionId → sessionId, for routing a result to its session. Kept in memory
2624
- * across the park; rebuilt from the store by {@link hydrate}. */
2625
2133
  #owners = /* @__PURE__ */ new Map();
2626
- /** Executions already settled, kept until their session ends so a late or
2627
- * duplicate delivery answers "already settled" instead of "never heard of it". */
2628
2134
  #settled = /* @__PURE__ */ new Map();
2629
2135
  #timers = /* @__PURE__ */ new Map();
2630
- /** One resume per session, ever: two results arriving together must not build
2631
- * two runners under the same id (the second would orphan the first, leaking the
2632
- * MCP connection the park existed to release). */
2633
2136
  #resuming = /* @__PURE__ */ new Map();
2634
2137
  #detachTimers = /* @__PURE__ */ new Map();
2635
- /** The config each live session was built from — what a rebuild needs, and the
2636
- * one thing a runner doesn't carry on its public surface. */
2637
2138
  #configs = /* @__PURE__ */ new Map();
2638
- /**
2639
- * Sessions this process has written a dormant record for. Its only job is to
2640
- * tell "the engine has not named its session *yet*" apart from "the engine
2641
- * had named it and no longer has one" (a `conversation_reset` on an engine
2642
- * whose fresh id is not known until its next turn) — the first is the normal
2643
- * startup window and must cost no store write, the second must delete a
2644
- * record that has gone stale. It is accurate for exactly the sessions that
2645
- * matter: a woken session's runner is rebuilt with `resume` set, so it names
2646
- * its engine session immediately and re-enters the set on its first save.
2647
- */
2648
2139
  #remembered = /* @__PURE__ */ new Set();
2649
- /**
2650
- * In-flight store work per session, so operations on one record run in order.
2651
- *
2652
- * Load-bearing with any store whose writes are real I/O. `#park` must evict the
2653
- * runner *before* the save completes (an attach between `park()` and `evict()`
2654
- * would bind a client to an inert runner), which leaves a window where the
2655
- * session is in neither the registry nor the store. A delivery arriving inside it
2656
- * would read past the write: `store.get` misses, the result is answered 404, the
2657
- * execution is filed as settled with its watchdog cleared — and then the record
2658
- * lands on disk with nothing left alive that could ever wake it. A `discard`
2659
- * inside the same window would delete nothing and leave the save to resurrect a
2660
- * session the caller was told was closed.
2661
- */
2662
2140
  #storeOps = /* @__PURE__ */ new Map();
2663
2141
  #closed = false;
2664
2142
  constructor(options) {
2665
2143
  this.#options = options;
2666
2144
  }
2667
- /** Record the config a session was created with. Only sessions the host
2668
- * remembers can be parked — there is no way to rebuild the others. */
2669
2145
  remember(sessionId, config) {
2670
2146
  this.#configs.set(sessionId, config);
2671
2147
  }
2672
- /**
2673
- * Re-save a live session's dormant record because something outside the event
2674
- * stream changed it.
2675
- *
2676
- * `#rememberDormant` is otherwise driven by `status_changed` and `system_init`
2677
- * alone, and a rename (`PATCH /sessions/:id`) fires neither — so without this
2678
- * a renamed session that is never touched again keeps its old title on disk
2679
- * and comes back under it. Safe to call for anything: every gate in
2680
- * `#rememberDormant` still applies, so a session that cannot be resumed, has
2681
- * no engine session yet, or is no longer the registry's writes nothing.
2682
- */
2683
2148
  touch(runner) {
2684
2149
  this.#rememberDormant(runner);
2685
2150
  this.#persistLive(runner);
2686
2151
  }
2687
- /**
2688
- * Adopt the store's contents (a durable store after a restart): re-index the
2689
- * executions and re-arm their watchdogs, no deadline sooner than the grace
2690
- * window — nothing could have been delivered while the process was down.
2691
- *
2692
- * Dormant records need nothing here, which is the point of them. They list
2693
- * from the store (`listInfo`) and come back on first attach (`ensureLive`), so
2694
- * a boot with fifty remembered sessions spawns nothing at all.
2695
- */
2696
2152
  async hydrate() {
2697
2153
  const floor = Date.now() + (this.#options.expiredGraceMs ?? 6e4);
2698
2154
  for (const record of await this.#options.store.list()) {
@@ -2700,12 +2156,6 @@ var SessionParkManager = class {
2700
2156
  for (const execution of record.executions) this.#track(record.id, execution, floor);
2701
2157
  }
2702
2158
  }
2703
- /**
2704
- * Follow a session's lifecycle: index its deferred executions, park it when the
2705
- * engine says the turn has come to rest on them, and clean up when it ends.
2706
- * `afterSeq` skips a rehydrated runner's replayed history (re-arming a watchdog
2707
- * from an event whose deadline already passed would fail the execution instantly).
2708
- */
2709
2159
  watch(runner, afterSeq = 0) {
2710
2160
  return runner.subscribe((event) => {
2711
2161
  switch (event.type) {
@@ -2748,7 +2198,6 @@ var SessionParkManager = class {
2748
2198
  }
2749
2199
  }, afterSeq);
2750
2200
  }
2751
- /** A client detached: park the session if that was the last one watching. */
2752
2201
  onDetach(sessionId) {
2753
2202
  if (this.#closed) return;
2754
2203
  const runner = this.#options.registry.get(sessionId);
@@ -2761,34 +2210,21 @@ var SessionParkManager = class {
2761
2210
  timer.unref?.();
2762
2211
  this.#detachTimers.set(sessionId, timer);
2763
2212
  }
2764
- /** Which session this execution belongs to — still waiting, or already settled. */
2765
2213
  sessionFor(executionId) {
2766
2214
  return this.#owners.get(executionId) ?? this.#settled.get(executionId);
2767
2215
  }
2768
- /** The stored session's record, for the read paths (GET, list, attach). */
2769
2216
  get(id) {
2770
2217
  return this.#queue(id, () => this.#options.store.get(id));
2771
2218
  }
2772
- /** Every stored session's info, to merge into `GET {basePath}/sessions`. */
2773
2219
  async listInfo() {
2774
2220
  await Promise.all(this.#storeOps.values());
2775
2221
  return (await this.#options.store.list()).filter((record) => this.#options.registry.get(record.id) === void 0).map((record) => record.info);
2776
2222
  }
2777
- /** The live runner for a session, rehydrating a parked one on demand. Undefined
2778
- * when the session is neither live nor parked. */
2779
2223
  async ensureLive(id) {
2780
2224
  const live = this.#options.registry.get(id);
2781
2225
  if (live) return live;
2782
2226
  return this.#resume(id);
2783
2227
  }
2784
- /**
2785
- * Deliver a deferred execution's result. Rehydrates the session if needed and
2786
- * folds the result into its agent loop.
2787
- *
2788
- * Undefined = no session is waiting on that id. `applied: false` = it was already
2789
- * settled: a duplicate delivery, or one racing the watchdog. Both are expected,
2790
- * neither is an error.
2791
- */
2792
2228
  async submitResult(executionId, result) {
2793
2229
  const sessionId = this.#owners.get(executionId);
2794
2230
  if (sessionId === void 0) {
@@ -2811,7 +2247,6 @@ var SessionParkManager = class {
2811
2247
  sessionId
2812
2248
  };
2813
2249
  }
2814
- /** Drop a parked session for good: the run is over (closed, canceled, killed). */
2815
2250
  async discard(sessionId) {
2816
2251
  clearTimeout(this.#detachTimers.get(sessionId));
2817
2252
  this.#detachTimers.delete(sessionId);
@@ -2828,21 +2263,6 @@ var SessionParkManager = class {
2828
2263
  this.#timers.clear();
2829
2264
  this.#detachTimers.clear();
2830
2265
  }
2831
- /**
2832
- * Write (or refresh) the dormant record that lets this session survive a
2833
- * restart. Cheap and repeated on purpose — driven off `system_init` and every
2834
- * non-park status change — because the alternative is a shutdown hook, and a
2835
- * shutdown hook is exactly what a `kill -9`, an OOM or a pulled power cable
2836
- * do not run.
2837
- *
2838
- * Four gates, each of which would otherwise produce a record that is worse
2839
- * than none: the engine must be able to resume at all (a provider session
2840
- * would come back with an empty transcript — it has `park()` instead), it must
2841
- * have named its session, the host must remember the config to rebuild from,
2842
- * and the runner must still be the registry's. That last one is what keeps a
2843
- * park from being overwritten: `#park` evicts before it saves, so a late event
2844
- * from an evicted runner finds itself a stranger here and writes nothing.
2845
- */
2846
2266
  async #rememberDormant(runner) {
2847
2267
  if (this.#closed) return;
2848
2268
  const info = runner.info();
@@ -2880,17 +2300,6 @@ var SessionParkManager = class {
2880
2300
  });
2881
2301
  }
2882
2302
  }
2883
- /**
2884
- * Drop a dormant record that has stopped being true, leaving the live session
2885
- * alone — the narrow counterpart to {@link ParkingService.discard}, which also
2886
- * forgets the config and the session's executions and would therefore make a
2887
- * clear cost the session its ability to go dormant ever again.
2888
- *
2889
- * Only ever called behind {@link ParkingService.#rememberDormant}'s guards,
2890
- * which is what keeps it off a parked record: a park evicts the runner from
2891
- * the registry before it saves, and the ownership guard turns a late event
2892
- * from an evicted runner into a no-op.
2893
- */
2894
2303
  async #forgetDormant(sessionId) {
2895
2304
  this.#remembered.delete(sessionId);
2896
2305
  try {
@@ -2902,26 +2311,6 @@ var SessionParkManager = class {
2902
2311
  });
2903
2312
  }
2904
2313
  }
2905
- /**
2906
- * Write a live session's snapshot through to the store, so a restart can
2907
- * rebuild it. The counterpart to {@link #rememberDormant} for the engine that
2908
- * has no engine-side session to resume from — same discipline, different
2909
- * mechanism: that one remembers *where the transcript is*, this one carries it.
2910
- *
2911
- * The gates are the same four, plus the option and the engine's ability. The
2912
- * `registry.get(runner.id) !== runner` check is doing the same work it does
2913
- * there: a runner that has been evicted (parked, or replaced by a rebuild)
2914
- * finds itself a stranger here and writes nothing, so a late event cannot
2915
- * overwrite a park with a stale live record.
2916
- *
2917
- * **This must not run synchronously inside the event listener**, and that is
2918
- * easy to lose. `turn_result` is emitted from inside the turn, *before* the
2919
- * `finally` that clears the runner's abort controller — so a `snapshot()`
2920
- * called straight from the listener would see a turn in flight and refuse,
2921
- * every single time, silently. `#queue`'s microtask hop is what puts the call
2922
- * after it. A refactor that "simplifies" this into a direct call produces a
2923
- * write-through that never writes and nothing that says so.
2924
- */
2925
2314
  async #persistLive(runner) {
2926
2315
  if (this.#closed || !this.#options.persistLive || !runner.snapshot) return;
2927
2316
  const config = this.#configs.get(runner.id);
@@ -3009,7 +2398,7 @@ var SessionParkManager = class {
3009
2398
  }
3010
2399
  async #rebuild(id) {
3011
2400
  const record = await this.#queue(id, () => this.#options.store.get(id));
3012
- if (!record) return void 0;
2401
+ if (!record) return;
3013
2402
  let runner;
3014
2403
  try {
3015
2404
  runner = await this.#options.rebuild(record);
@@ -3037,9 +2426,6 @@ var SessionParkManager = class {
3037
2426
  runner.start();
3038
2427
  return runner;
3039
2428
  }
3040
- /** Run a store operation after whatever is already in flight for this session.
3041
- * The chain is per session and drops itself once idle; a failed operation never
3042
- * poisons the ones behind it (each caller handles its own). */
3043
2429
  #queue(sessionId, op) {
3044
2430
  const result = (this.#storeOps.get(sessionId) ?? Promise.resolve()).then(op);
3045
2431
  const settled = result.then(() => {}, () => {});
@@ -3084,40 +2470,8 @@ var SessionParkManager = class {
3084
2470
  };
3085
2471
  //#endregion
3086
2472
  //#region src/services/produced-files.ts
3087
- /**
3088
- * The paths this gateway will serve from `GET /sessions/:id/produced/:fileId`.
3089
- *
3090
- * **This is the whole access-control model, so it is worth being precise about
3091
- * what it is.** The store is an allowlist built from one source and one only:
3092
- * `file_produced` events, which a runner emits about a file its own engine just
3093
- * wrote. It is not a directory grant. Nothing else can add to it — not a
3094
- * request, not a config, and in particular not the agent, whose own path claims
3095
- * go through `/fs/*` and that route's root allowlist.
3096
- *
3097
- * That is why the route needs neither `hostFiles.roots` nor `maxFileBytes`:
3098
- * "somewhere under a root the operator declared" is a guess about which paths
3099
- * are safe, while "the exact path this session's runner reported producing" is
3100
- * a fact about one file. A 2 MB generated PNG is the common case, and making
3101
- * the operator raise a byte cap to see their own picture was the bug this
3102
- * replaces.
3103
- *
3104
- * Lifetime is the session's, like `AttachmentStore`'s: in memory, dropped when
3105
- * the session is removed. The bytes are never held here — only the path, so a
3106
- * gateway serving a long session accumulates a few hundred bytes per picture
3107
- * rather than the pictures.
3108
- */
3109
2473
  var ProducedFileStore = class {
3110
2474
  #bySession = /* @__PURE__ */ new Map();
3111
- /**
3112
- * Register a runner's produced files for its lifetime.
3113
- *
3114
- * Subscribes from seq 0, which is the opposite of what `SessionNotifier` wants
3115
- * and correct for the same reason: registration is idempotent (a `fileId` is
3116
- * derived from its path, so re-registering overwrites with itself), and a
3117
- * session rebuilt from a park must re-learn every file it produced before the
3118
- * park — otherwise a client's transcript keeps rendering image cards whose
3119
- * bytes have quietly become unreachable.
3120
- */
3121
2475
  watch(runner) {
3122
2476
  runner.subscribe((event) => {
3123
2477
  if (event.type !== "file_produced") return;
@@ -3135,7 +2489,6 @@ var ProducedFileStore = class {
3135
2489
  get(sessionId, fileId) {
3136
2490
  return this.#bySession.get(sessionId)?.get(fileId);
3137
2491
  }
3138
- /** Everything one session has produced, newest registration last. */
3139
2492
  list(sessionId) {
3140
2493
  return [...this.#bySession.get(sessionId)?.values() ?? []];
3141
2494
  }
@@ -3145,19 +2498,9 @@ var ProducedFileStore = class {
3145
2498
  };
3146
2499
  //#endregion
3147
2500
  //#region src/services/profiles.ts
3148
- /**
3149
- * The profile directory: startup-declared profiles unioned with store-managed
3150
- * ones, validation shared by startup (throws) and the management routes (400s),
3151
- * and the response decoration (`forResponse`) every profile answer goes through.
3152
- *
3153
- * Declared profiles are code — never persisted, never editable over HTTP. The
3154
- * store-managed set is mirrored in memory so every lookup on the request path
3155
- * stays synchronous; `refreshStored()` reloads it after each mutation.
3156
- */
3157
2501
  var ProfileService = class {
3158
2502
  #declared;
3159
2503
  #declaredByName;
3160
- /** Store-managed profiles, mirrored in memory — see the module doc. */
3161
2504
  #stored = /* @__PURE__ */ new Map();
3162
2505
  #opts;
3163
2506
  constructor(opts) {
@@ -3166,11 +2509,6 @@ var ProfileService = class {
3166
2509
  this.#declaredByName = new Map(opts.declared.map((p) => [p.name, p]));
3167
2510
  if (this.#declaredByName.size !== opts.declared.length) throw new Error("createWorkerServer: duplicate profile names in `profiles`");
3168
2511
  }
3169
- /**
3170
- * Everything wrong with a profile that the server can tell without running it.
3171
- * Shared by startup (where it throws) and the management routes (where it 400s),
3172
- * so a profile created over HTTP can never be one startup would have refused.
3173
- */
3174
2512
  validate(p) {
3175
2513
  const { adapterFor, disableBypassPermissions, hasEngineRunnerFactory } = this.#opts;
3176
2514
  if (isProviderProfile(p)) {
@@ -3185,31 +2523,17 @@ var ProfileService = class {
3185
2523
  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(", ")})`;
3186
2524
  return null;
3187
2525
  }
3188
- /** Reload the in-memory mirror of the store — once at `listen()`, and after
3189
- * each management-route mutation. Single-process, like the bundled queue. */
3190
2526
  async refreshStored() {
3191
2527
  if (!this.#opts.store) return;
3192
2528
  this.#stored.clear();
3193
2529
  for (const p of await this.#opts.store.list()) this.#stored.set(p.name, p);
3194
2530
  }
3195
- /** Response-only marker so a UI knows which rows it may edit. Declared profiles
3196
- * are code; only store-backed ones can be changed over the API. */
3197
2531
  withManagedFlag(p) {
3198
2532
  return this.#declaredByName.has(p.name) ? p : {
3199
2533
  ...p,
3200
2534
  managed: true
3201
2535
  };
3202
2536
  }
3203
- /**
3204
- * Response shape for a profile: the managed marker, the engine's capability
3205
- * record, its static model catalog (correct from the first request — no
3206
- * warm-up session, no process spawned), the availability verdict when one
3207
- * has been probed, the learned default model (the one thing a static
3208
- * catalog cannot know: a claude profile's default is the operator's CLI
3209
- * config, so it stays absent until a session on the profile reports it),
3210
- * and the plan usage learned from the profile's sessions' rate_limit events.
3211
- * Read-only decoration — never persisted.
3212
- */
3213
2537
  forResponse(p) {
3214
2538
  const { adapterFor, decorate } = this.#opts;
3215
2539
  const adapter = adapterFor(p.engine);
@@ -3229,16 +2553,12 @@ var ProfileService = class {
3229
2553
  if (usage) base.usage = usage;
3230
2554
  return base;
3231
2555
  }
3232
- /** Declared profiles first: a name collision means the code wins, and the stored
3233
- * one is unreachable rather than silently overriding server options. */
3234
2556
  all() {
3235
2557
  return [...this.#declared, ...[...this.#stored.values()].filter((p) => !this.#declaredByName.has(p.name))];
3236
2558
  }
3237
2559
  get(name) {
3238
2560
  return this.#declaredByName.get(name) ?? this.#stored.get(name);
3239
2561
  }
3240
- /** Profile management is doubly opt-in: the operator wires a store, and the host
3241
- * marks the principal. Neither on its own is enough. */
3242
2562
  manageGuard(auth) {
3243
2563
  if (!this.#opts.store) return {
3244
2564
  status: 404,
@@ -3250,19 +2570,12 @@ var ProfileService = class {
3250
2570
  };
3251
2571
  return null;
3252
2572
  }
3253
- /** Startup-declared profiles are code. Editing one over HTTP would make the
3254
- * server options lie about what is actually running. */
3255
2573
  declaredGuard(profile) {
3256
2574
  return this.#declaredByName.has(profile.name) ? {
3257
2575
  status: 403,
3258
2576
  error: `profile '${profile.name}' is declared in server options and cannot be changed over the API — edit the \`profiles\` option instead`
3259
2577
  } : null;
3260
2578
  }
3261
- /**
3262
- * A managed Claude profile names a config directory, and that directory is a
3263
- * credential store. Bound it to operator-declared roots; unset roots means the
3264
- * management routes create provider profiles only.
3265
- */
3266
2579
  configDirGuard(profile) {
3267
2580
  if (isProviderProfile(profile)) return null;
3268
2581
  const roots = this.#opts.allowedConfigDirRoots;
@@ -3275,9 +2588,6 @@ var ProfileService = class {
3275
2588
  error: "configDir is outside the allowed roots"
3276
2589
  };
3277
2590
  }
3278
- /** Validate and persist a managed profile. Shared by create and update so a
3279
- * PATCH can never leave behind a profile a POST would have refused. Returns
3280
- * the saved profile (managed-flagged) or a refusal. */
3281
2591
  async saveManaged(incoming) {
3282
2592
  const { managed: _clientClaim, ...profile } = incoming;
3283
2593
  const refused = this.configDirGuard(profile);
@@ -3301,35 +2611,8 @@ var ProfileService = class {
3301
2611
  };
3302
2612
  //#endregion
3303
2613
  //#region src/services/profile-usage.ts
3304
- /**
3305
- * The gateway's single plan-usage state per profile, fed from every session's
3306
- * `rate_limit` events and served on `GET /profiles` (`ProfileInfo.usage`).
3307
- *
3308
- * Why this exists at all: usage had only ever lived in session transcripts, so
3309
- * a client attaching to a session that idled since yesterday replayed
3310
- * yesterday's reading as if current — and a session opened today knew nothing
3311
- * of what a sibling session on the same account spent an hour ago. The profile
3312
- * is the account boundary (one config dir / codex home / provider key = one
3313
- * plan), so the newest reading across all of a profile's sessions is the one
3314
- * usage state that is ever worth showing. No history: last-write-wins per
3315
- * window, exactly the reducer's rule on the client side.
3316
- *
3317
- * Last-write-wins goes by the **event's own clock**, not arrival order:
3318
- * `watch()` subscribes from seq 0 (a replayed log is how a rebuilt session's
3319
- * readings arrive at all), and a replayed yesterday-reading must not clobber
3320
- * the fresher one another session on the same profile reported live. All
3321
- * events are stamped by this gateway's clock at emit time, so the comparison
3322
- * is sound across sessions.
3323
- *
3324
- * In-memory on purpose, like the learned default models and the availability
3325
- * cache: display-only state may start empty after a restart (absent = unknown,
3326
- * never 0%), and the first session to report refills it.
3327
- */
3328
2614
  var ProfileUsageTracker = class {
3329
- /** profile name → rateLimitType → newest reading. */
3330
2615
  #profiles = /* @__PURE__ */ new Map();
3331
- /** Follow a runner's `rate_limit` events for its lifetime. Sessions without a
3332
- * profile have no account to attribute usage to and are skipped. */
3333
2616
  watch(runner) {
3334
2617
  const profile = runner.info().profile;
3335
2618
  if (!profile) return;
@@ -3347,31 +2630,14 @@ var ProfileUsageTracker = class {
3347
2630
  });
3348
2631
  });
3349
2632
  }
3350
- /**
3351
- * The profile's windows as they should be served *now*. Undefined until any
3352
- * session on the profile has reported (unknown, never 0%).
3353
- *
3354
- * The 0%-after-reset inference lives here — at serve time — and nowhere
3355
- * else, because it is a function of the wall clock: a window whose own
3356
- * `resetsAt` has passed with no newer reading has provably rolled, so the
3357
- * pre-reset utilization is no longer merely stale but *wrong*. It cannot be
3358
- * a producer's job (the producers only relay what the engine said, and the
3359
- * whole problem is the engine's silence; a fabricated 0% event would be
3360
- * replayed from transcripts forever as if reported) and must not be every
3361
- * renderer's (N clients would each reimplement the clock math). The held
3362
- * reading stays untouched, so a late fresh report still lands by ts, and the
3363
- * served zero is labeled `inferredReset` — it is a floor, not a report: the
3364
- * account may have been used outside this gateway since the reset.
3365
- */
3366
2633
  usage(profile, now = Date.now()) {
3367
2634
  const windows = this.#profiles.get(profile);
3368
- if (!windows || windows.size === 0) return void 0;
2635
+ if (!windows || windows.size === 0) return;
3369
2636
  const out = {};
3370
2637
  for (const [type, held] of windows) out[type] = serveWindow(held, now);
3371
2638
  return out;
3372
2639
  }
3373
2640
  };
3374
- /** `resetsAt` is epoch **seconds** (protocol contract); `now` is epoch ms. */
3375
2641
  function serveWindow(held, now) {
3376
2642
  const resetsAt = held.info.resetsAt;
3377
2643
  if (resetsAt !== void 0 && resetsAt * 1e3 <= now) return {
@@ -3390,7 +2656,6 @@ function serveWindow(held, now) {
3390
2656
  }
3391
2657
  //#endregion
3392
2658
  //#region src/services/registry.ts
3393
- /** In-memory session table. Terminal sessions stay listed until removed or the process exits. */
3394
2659
  var SessionRegistry = class {
3395
2660
  #sessions = /* @__PURE__ */ new Map();
3396
2661
  #options;
@@ -3400,19 +2665,14 @@ var SessionRegistry = class {
3400
2665
  create(config) {
3401
2666
  return this.adopt(new SessionRunner(config));
3402
2667
  }
3403
- /** Build and list a Claude-engine runner without starting it, so watchers can
3404
- * subscribe first. Call `start()` once they have. */
3405
2668
  prepare(config) {
3406
2669
  return this.register(new SessionRunner(config));
3407
2670
  }
3408
- /** Register an already-built runner (a non-Claude engine) and start it. */
3409
2671
  adopt(runner) {
3410
2672
  this.register(runner);
3411
2673
  runner.start();
3412
2674
  return runner;
3413
2675
  }
3414
- /** List a runner without starting it — for a rehydrated session, whose watchers
3415
- * must be subscribed before it comes back up. */
3416
2676
  register(runner) {
3417
2677
  const existing = this.#sessions.get(runner.id);
3418
2678
  this.#sessions.set(runner.id, runner);
@@ -3431,8 +2691,6 @@ var SessionRegistry = class {
3431
2691
  runner.close("server");
3432
2692
  return this.#sessions.delete(id);
3433
2693
  }
3434
- /** Drop a runner WITHOUT closing it: the session isn't ending, it parked and
3435
- * lives on in its snapshot. Closing here would tell every client it was over. */
3436
2694
  evict(id) {
3437
2695
  return this.#sessions.delete(id);
3438
2696
  }
@@ -3442,50 +2700,19 @@ var SessionRegistry = class {
3442
2700
  };
3443
2701
  //#endregion
3444
2702
  //#region src/services/session-factory.ts
3445
- /**
3446
- * The create pipeline: everything between a `CreateSessionRequest` arriving and
3447
- * a `Runner` running — policy checks (bypass, permission mode, engine grants,
3448
- * scope, cwd), profile resolution, config assembly (`buildRunnerConfig`), and
3449
- * the one chokepoint that builds runners for create, dormant rebuild and parked
3450
- * rebuild alike (`buildRunner`).
3451
- *
3452
- * Registry/parking/bridge are handed in as late-bound refs because construction
3453
- * is mutually recursive with them (parking's rebuild callback calls
3454
- * `buildRunner`; `createRunner` registers and watches). The refs are filled
3455
- * during assembly, before the server accepts a request.
3456
- */
3457
2703
  function createSessionFactory(deps) {
3458
2704
  const { adapterFor, profiles, refs } = deps;
3459
- /** Profiles (by name; '' = none) whose oauth notice has been logged. */
3460
2705
  const subscriptionNoticeShown = /* @__PURE__ */ new Set();
3461
- /** Enforce the server's bypass policy on a create request. Returns a 403 message
3462
- * for an explicit bypass-mode request; strips the pre-authorization capability
3463
- * silently (see the option's doc for why). */
3464
2706
  const applyBypassPolicy = (req) => {
3465
2707
  if (!deps.disableBypassPermissions) return null;
3466
2708
  if (req.permissionMode === "bypassPermissions") return "bypassPermissions is disabled on this server (disableBypassPermissions)";
3467
2709
  delete req.allowDangerouslySkipPermissions;
3468
2710
  return null;
3469
2711
  };
3470
- /** Reject a permission mode the resolved profile's engine has no meaning for.
3471
- * The create form already filters what it offers, but the API is the boundary:
3472
- * a provider session asked for 'plan' should be told so, not silently coerced
3473
- * into 'default' by whatever assembles its runner. Returns an error message. */
3474
2712
  const checkPermissionMode = (mode, profile) => {
3475
2713
  if (mode === void 0 || supportsPermissionMode(profile?.engine, mode)) return null;
3476
2714
  return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${engineOf(profile)}') — supported: ` + adapterFor(profile?.engine).capabilities.permissionModes.join(", ");
3477
2715
  };
3478
- /**
3479
- * Refuse the request fields the resolved profile's engine cannot honor —
3480
- * read off its capability record, so the create form's filtering and the
3481
- * API boundary can never disagree. Refusing beats coercing: a caller who
3482
- * asked for something the engine has no meaning for should be told, not
3483
- * left wondering where the option went. Also enforces the provider grant
3484
- * rules (capabilities narrow, never widen; MCP servers are the profile's to
3485
- * declare — MCP tools are authoritative, server-side, with server
3486
- * credentials, so honoring a client-supplied server would let a caller
3487
- * point an authoritative tool anywhere it liked).
3488
- */
3489
2716
  const checkEngineGrants = (req, profile) => {
3490
2717
  const engine = engineOf(profile);
3491
2718
  const caps = adapterFor(profile?.engine).capabilities;
@@ -3503,22 +2730,9 @@ function createSessionFactory(deps) {
3503
2730
  if (ungranted.length === 0) return null;
3504
2731
  return `profile '${profile.name}' does not grant: ${ungranted.join(", ")} (granted: ${granted.join(", ") || "none"}) — a request may narrow capabilities, not widen them`;
3505
2732
  };
3506
- /** Drop request fields that are meaningless (not wrong) for the engine —
3507
- * today just `questionBehavior` where no approval channel exists, so job
3508
- * webhooks never grow phantom permission_requested expectations. */
3509
2733
  const stripInertFields = (req, profile) => {
3510
2734
  if (!adapterFor(profile?.engine).capabilities.interactiveApprovals) delete req.questionBehavior;
3511
2735
  };
3512
- /**
3513
- * Validate the request's scope and merge the principal's into it.
3514
- *
3515
- * A scoped principal's keys are *filled in* when the request omits them and
3516
- * *refused* when the request disagrees: a caller inside a scope may narrow
3517
- * itself with extra tags, never claim to be somewhere else. That makes an
3518
- * embedder's stamping proxy defense in depth rather than the only line — a
3519
- * request that slipped past it still cannot create a session in another
3520
- * scope. An unscoped principal (the operator) may write any tags.
3521
- */
3522
2736
  const applyScope = (req, auth) => {
3523
2737
  const invalid = checkScope(req.scope);
3524
2738
  if (invalid) return {
@@ -3543,17 +2757,6 @@ function createSessionFactory(deps) {
3543
2757
  req.scope = merged;
3544
2758
  return null;
3545
2759
  };
3546
- /**
3547
- * `cwd`, required or not depending on the engine's capability record — the
3548
- * record rather than the engine name, so a host engine that has no host
3549
- * filesystem gets the same treatment without this file learning its name.
3550
- *
3551
- * When one *is* supplied it is validated even for an engine that will not read
3552
- * it: a path the caller went out of their way to name should not be quietly
3553
- * exempt from the operator's roots. And note what this check is not — for a
3554
- * filesystem-less engine `allowedCwdRoots` guards nothing at all. The
3555
- * boundary there is the capability wiring, not a path prefix.
3556
- */
3557
2760
  const checkCwd = (req, profile) => {
3558
2761
  if (req.cwd !== void 0 && typeof req.cwd !== "string") return {
3559
2762
  status: 400,
@@ -3568,21 +2771,10 @@ function createSessionFactory(deps) {
3568
2771
  error: "cwd is outside the allowed roots"
3569
2772
  };
3570
2773
  };
3571
- /**
3572
- * Re-stamp the request's scope onto whatever the host's `buildRunnerConfig`
3573
- * returned. The hook is host code and may rewrite the config wholesale; a
3574
- * hook that dropped `scope` would silently *widen* a session's visibility,
3575
- * which is the one direction a bug here must not go. Same posture as the
3576
- * profile's env pin winning over the hook.
3577
- */
3578
2774
  const withScope = (config, scope) => scope === void 0 ? config : {
3579
2775
  ...config,
3580
2776
  scope
3581
2777
  };
3582
- /** Profile-aware config hook: fill the profile's defaults into unset request fields,
3583
- * run the host hook, then pin CLAUDE_CONFIG_DIR — the profile wins even when the
3584
- * host hook set its own env (see `claudeSessionEnv` for the one case the pin is
3585
- * skipped, and why). Handed to the queue too, so jobs inherit profiles. */
3586
2778
  const buildRunnerConfig = (req) => {
3587
2779
  const profile = req.profile !== void 0 ? profiles.get(req.profile) : void 0;
3588
2780
  if (!profile) return withScope(deps.hostBuildRunnerConfig(req), req.scope);
@@ -3599,7 +2791,6 @@ function createSessionFactory(deps) {
3599
2791
  env
3600
2792
  };
3601
2793
  };
3602
- /** The env a probe should test: exactly what the real assembly path produces. */
3603
2794
  const sessionEnvFor = (profile) => {
3604
2795
  try {
3605
2796
  return buildRunnerConfig({
@@ -3610,12 +2801,6 @@ function createSessionFactory(deps) {
3610
2801
  return engineOf(profile) === "claude" ? claudeSessionEnv(profile, process.env) : process.env;
3611
2802
  }
3612
2803
  };
3613
- /** Build a runner for a session, choosing the engine from its profile. Async
3614
- * because the engine factory may be: a provider session can need an awaited
3615
- * assembly step (per-session MCP connect) before it has a runner at all.
3616
- *
3617
- * `restore` rebuilds a parked session rather than creating a new one — same id,
3618
- * same log, mid-task. */
3619
2804
  const buildRunner = async (config, restore, id) => {
3620
2805
  const name = config.profile;
3621
2806
  const profile = name !== void 0 ? profiles.get(name) : void 0;
@@ -3643,9 +2828,6 @@ function createSessionFactory(deps) {
3643
2828
  runner.start();
3644
2829
  return runner;
3645
2830
  };
3646
- /** Resolve a request's profile: required when several are declared, implicit with
3647
- * exactly one, scoped by the principal's allowedProfiles. Returns the resolved
3648
- * profile (undefined when the server declares none) or a response-ready error. */
3649
2831
  const resolveProfile = (name, allowedProfiles) => {
3650
2832
  if (name !== void 0 && typeof name !== "string") return {
3651
2833
  ok: false,
@@ -3713,30 +2895,40 @@ function createSessionFactory(deps) {
3713
2895
  }
3714
2896
  //#endregion
3715
2897
  //#region src/server.ts
2898
+ /** How long a client gets to acknowledge the shutdown close frame before its socket is torn down. */
2899
+ const SOCKET_CLOSE_GRACE_MS = 250;
3716
2900
  /**
3717
- * `createWorkerServer` the assembly. Option types live in `options.ts`, the
3718
- * shared-state record routes take in `context.ts`, per-route behaviour in
3719
- * `routes/`, the stateful pieces in `services/`, and the pure rules in `lib/`.
3720
- * This file only wires them together and dispatches requests.
2901
+ * Split live sessions into "will finish by itself" and "needs a person".
2902
+ *
2903
+ * `sessionState` is the vocabulary the dashboard, the session list and `workerdeck guard` already sort by, and it
2904
+ * draws exactly the line a drain needs: `working` covers starting/running and running subagents, while `attention`
2905
+ * covers a pending approval. Re-spelling that set here is how the two definitions would drift apart.
3721
2906
  */
2907
+ function surveyDrain(registry) {
2908
+ const working = [];
2909
+ const awaitingHuman = [];
2910
+ for (const info of registry.list()) {
2911
+ const state = sessionState(info);
2912
+ if (state === "working") working.push(info.id);
2913
+ else if (state === "attention") awaitingHuman.push(info.id);
2914
+ }
2915
+ return {
2916
+ working,
2917
+ awaitingHuman,
2918
+ timedOut: false
2919
+ };
2920
+ }
2921
+ function sameDrain(a, b) {
2922
+ return a.working.join() === b.working.join() && a.awaitingHuman.join() === b.awaitingHuman.join();
2923
+ }
3722
2924
  function createWorkerServer(options = {}) {
3723
2925
  if (!options.authenticate && !options.allowUnauthenticated) throw new Error("createWorkerServer: provide `authenticate` or explicitly set `allowUnauthenticated: true`");
3724
2926
  const basePath = options.basePath ?? "/v1";
3725
2927
  const fallback = options.fallback;
3726
2928
  const corsOrigins = options.cors?.origins.length ? new Set(options.cors.origins) : void 0;
3727
2929
  const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
3728
- /** The engine's adapter, honoring the test-only `engines` override. */
3729
2930
  const adapterFor = (engine) => options.engines?.[engine ?? "claude"] ?? getEngineAdapter(engine);
3730
- /**
3731
- * What each claude profile's *default* model resolves to, learned from the
3732
- * `capabilities` events of sessions that ran on it. The model *list* is the
3733
- * adapter's static catalog now; the default is the one thing a catalog
3734
- * cannot know (it is the operator's CLI config), so it alone is still
3735
- * learned — and still absent on a cold server, the accepted regression.
3736
- */
3737
2931
  const profileDefaultModels = /* @__PURE__ */ new Map();
3738
- /** The single plan-usage state per profile, fed from every session's
3739
- * `rate_limit` events and served by `forResponse` (see ProfileUsageTracker). */
3740
2932
  const profileUsage = new ProfileUsageTracker();
3741
2933
  const profiles = new ProfileService({
3742
2934
  declared: options.profiles ?? detectDefaultProfiles(),
@@ -3825,6 +3017,8 @@ function createWorkerServer(options = {}) {
3825
3017
  refs
3826
3018
  });
3827
3019
  const wss = new WebSocketServer({ noServer: true });
3020
+ let closing;
3021
+ let draining = false;
3828
3022
  const queueSockets = /* @__PURE__ */ new Set();
3829
3023
  const sendQueueFrame = (ws, frame) => {
3830
3024
  if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame));
@@ -3967,6 +3161,10 @@ function createWorkerServer(options = {}) {
3967
3161
  json(res, 404, { error: "not found" });
3968
3162
  return;
3969
3163
  }
3164
+ if (draining && req.method === "POST" && route.id === void 0) {
3165
+ json(res, 503, { error: "server is shutting down" });
3166
+ return;
3167
+ }
3970
3168
  const authCtx = await auth.authenticate(req);
3971
3169
  if (!authCtx.ok) {
3972
3170
  json(res, 401, { error: "unauthorized" });
@@ -4057,53 +3255,50 @@ function createWorkerServer(options = {}) {
4057
3255
  });
4058
3256
  });
4059
3257
  },
4060
- close: () => new Promise((resolve) => {
4061
- queue?.close();
4062
- parking.close();
4063
- registry.closeAll();
4064
- for (const ws of queueSockets) ws.close();
4065
- queueSockets.clear();
4066
- wss.close();
4067
- server.close(() => resolve());
4068
- server.closeAllConnections();
4069
- })
3258
+ drain: async (drainOptions = {}) => {
3259
+ const { timeoutMs = 3e4, pollMs = 250, onProgress } = drainOptions;
3260
+ draining = true;
3261
+ const deadline = Date.now() + timeoutMs;
3262
+ let report = surveyDrain(registry);
3263
+ onProgress?.(report);
3264
+ while (report.working.length > 0 && Date.now() < deadline) {
3265
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
3266
+ const next = surveyDrain(registry);
3267
+ if (!sameDrain(next, report)) onProgress?.(next);
3268
+ report = next;
3269
+ }
3270
+ report = {
3271
+ ...surveyDrain(registry),
3272
+ timedOut: false
3273
+ };
3274
+ report.timedOut = report.working.length > 0;
3275
+ onProgress?.(report);
3276
+ return report;
3277
+ },
3278
+ close: () => {
3279
+ closing ??= new Promise((resolve) => {
3280
+ queue?.close();
3281
+ parking.close();
3282
+ registry.closeAll();
3283
+ for (const ws of wss.clients) ws.close(1001, "server shutting down");
3284
+ queueSockets.clear();
3285
+ const force = setTimeout(() => {
3286
+ for (const ws of wss.clients) ws.terminate();
3287
+ }, SOCKET_CLOSE_GRACE_MS);
3288
+ force.unref();
3289
+ wss.close();
3290
+ server.close(() => {
3291
+ clearTimeout(force);
3292
+ resolve();
3293
+ });
3294
+ server.closeAllConnections();
3295
+ });
3296
+ return closing;
3297
+ }
4070
3298
  };
4071
3299
  }
4072
3300
  //#endregion
4073
3301
  //#region src/lib/sandboxed-profile.ts
4074
- /**
4075
- * A `provider` profile that grants a session nothing but the sandbox: the
4076
- * QuickJS guest, the in-memory VFS, and the model.
4077
- *
4078
- * This adds no mechanism. `capabilities: []` and `mcpServers: []` already mean
4079
- * what they mean, and `createToolContext` already withholds a tool whose backend
4080
- * the host did not inject. What the helper buys is that the locked-down profile
4081
- * is one call rather than three fields an operator has to get right together —
4082
- * the failure mode being a profile that *looks* sandboxed and still grants
4083
- * `deliver_file` because nobody wrote the empty array.
4084
- *
4085
- * What a session under it can do:
4086
- * - run untrusted JavaScript in the WASM guest, under the interpreter's own
4087
- * timeout and memory limits (`eval_script`),
4088
- * - read and write the session's in-memory VFS, which is a map and not a
4089
- * filesystem — no host path is reachable from it.
4090
- *
4091
- * What it cannot do: read or write a host path, spawn a process, reach the
4092
- * network (`web_fetch`/`download`/`web_search` are capabilities, and none is
4093
- * granted), deliver a file, or use an MCP server.
4094
- *
4095
- * Two things this helper does **not** do, because they are not a profile's to
4096
- * decide. It does not authorize anyone — visibility is
4097
- * `CreateSessionRequest.scope` plus the gateway's `authorizeSession`. And it
4098
- * does not make the model's *input* trustworthy: content the loop reads is
4099
- * attacker-influenced by default, and a sandbox bounds what a tool can reach,
4100
- * not what a prompt can talk the model into asking for.
4101
- *
4102
- * @param name Profile name clients name in `CreateSessionRequest.profile`.
4103
- * @param provider Which model to run (credentials stay in the operator's
4104
- * environment and are resolved by the host's `createEngineRunner` — never
4105
- * here, and never on the wire).
4106
- */
4107
3302
  function sandboxedProviderProfile(name, provider, options = {}) {
4108
3303
  return {
4109
3304
  name,
@@ -4120,30 +3315,6 @@ function sandboxedProviderProfile(name, provider, options = {}) {
4120
3315
  }
4121
3316
  //#endregion
4122
3317
  //#region src/lib/provider-runner.ts
4123
- /**
4124
- * Build a provider-engine runner from the server's `createEngineRunner` context.
4125
- *
4126
- * `createEngineRunner` is a blank sheet: it hands you a context and wants a
4127
- * `Runner`, and four of the five things a correct one must do are invisible in
4128
- * the types — forward `restore`, adopt `id`, seed the VFS only when *not*
4129
- * restoring, and dispose per-session resources. Each is a runtime-only failure
4130
- * (a woken session that starts empty, a refused rebuild, an overwritten
4131
- * filesystem, a connection leaked per session), and each is handled here.
4132
- *
4133
- * ```ts
4134
- * createEngineRunner: (ctx) =>
4135
- * createProviderRunner(ctx, {
4136
- * model: (id) => openai(id ?? 'gpt-5.6-luna'),
4137
- * executor: quickjs,
4138
- * capabilities: { webFetch: {} },
4139
- * mcp,
4140
- * onClose: () => mcp.close(),
4141
- * }),
4142
- * ```
4143
- *
4144
- * The hook itself stays open for anything this does not cover — this is the
4145
- * 80% case, not a replacement for it.
4146
- */
4147
3318
  async function createProviderRunner(ctx, options) {
4148
3319
  const { config, profile, bridge, restore, id } = ctx;
4149
3320
  const resolveModel = (modelId) => typeof options.model === "function" ? options.model(modelId) : options.model;
@@ -4179,7 +3350,6 @@ async function createProviderRunner(ctx, options) {
4179
3350
  }
4180
3351
  //#endregion
4181
3352
  //#region src/services/profile-store.ts
4182
- /** Non-durable store for tests and ephemeral deployments. */
4183
3353
  function createMemoryProfileStore(seed = []) {
4184
3354
  const profiles = new Map(seed.map((p) => [p.name, p]));
4185
3355
  return {
@@ -4188,14 +3358,6 @@ function createMemoryProfileStore(seed = []) {
4188
3358
  delete: (name) => void profiles.delete(name)
4189
3359
  };
4190
3360
  }
4191
- /**
4192
- * JSON-file store: one array of profiles at `path` (default
4193
- * `<cwd>/.workerdeck/profiles.json`). Writes go through a temp file and a
4194
- * rename so a crash mid-write cannot truncate the operator's profile list.
4195
- *
4196
- * Single-process by design, exactly like the bundled queue adapter — two servers
4197
- * sharing one file would race. That is what the seam is for.
4198
- */
4199
3361
  function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profiles.json")) {
4200
3362
  const read = () => {
4201
3363
  try {