@workerdeck/server 0.23.0 → 1.1.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.d.mts +63 -844
- package/build/index.mjs +187 -1022
- package/build/index.mjs.map +1 -1
- package/package.json +13 -13
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
|
|
22
|
-
|
|
23
|
-
|
|
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,56 +48,28 @@ 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;
|
|
67
57
|
}
|
|
68
58
|
function contentTypeFor(filename) {
|
|
69
|
-
|
|
59
|
+
const ext = filename.includes(".") ? filename.split(".").pop().toLowerCase() : "";
|
|
60
|
+
return CONTENT_TYPES[ext] ?? "text/plain; charset=utf-8";
|
|
70
61
|
}
|
|
71
62
|
//#endregion
|
|
72
63
|
//#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
64
|
function isProviderProfile(profile) {
|
|
81
65
|
return profile.engine === "provider";
|
|
82
66
|
}
|
|
83
|
-
/** The engine a profile runs, absent meaning 'claude' (pre-provider profiles). */
|
|
84
67
|
function engineOf(profile) {
|
|
85
68
|
return profile?.engine ?? "claude";
|
|
86
69
|
}
|
|
87
|
-
/** Where the CLI's own resolution lands for a given environment: an explicit
|
|
88
|
-
* CLAUDE_CONFIG_DIR, else ~/.claude. */
|
|
89
70
|
function cliConfigDir(env) {
|
|
90
71
|
return env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
91
72
|
}
|
|
92
|
-
/** Auto-created profile when none are declared: the operator's own config dir. */
|
|
93
73
|
function detectDefaultProfiles() {
|
|
94
74
|
const dir = cliConfigDir(process.env);
|
|
95
75
|
return existsSync(dir) ? [{
|
|
@@ -97,9 +77,6 @@ function detectDefaultProfiles() {
|
|
|
97
77
|
configDir: dir
|
|
98
78
|
}] : [];
|
|
99
79
|
}
|
|
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
80
|
function canonicalDir(path) {
|
|
104
81
|
try {
|
|
105
82
|
return realpathSync(path);
|
|
@@ -107,18 +84,6 @@ function canonicalDir(path) {
|
|
|
107
84
|
return resolve(path);
|
|
108
85
|
}
|
|
109
86
|
}
|
|
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
87
|
function claudeSessionEnv(profile, base) {
|
|
123
88
|
return canonicalDir(profile.configDir) === canonicalDir(cliConfigDir(base)) ? base : {
|
|
124
89
|
...base,
|
|
@@ -133,14 +98,6 @@ function cwdAllowed(cwd, roots) {
|
|
|
133
98
|
return resolved === r || resolved.startsWith(r + sep);
|
|
134
99
|
});
|
|
135
100
|
}
|
|
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
101
|
function readProfileConfig(profile) {
|
|
145
102
|
const dir = profile.configDir;
|
|
146
103
|
if (!dir) return {
|
|
@@ -286,7 +243,7 @@ async function handleExecutionResult(ctx, req, res, pathname, auth) {
|
|
|
286
243
|
const owner = parking.sessionFor(executionId);
|
|
287
244
|
const info = owner === void 0 ? void 0 : registry.get(owner)?.info() ?? (await parking.get(owner))?.info;
|
|
288
245
|
const profile = info?.profile;
|
|
289
|
-
if (owner === void 0 || auth.allowedProfiles !== void 0 && profile !== void 0 && !auth.allowedProfiles.includes(profile) ||
|
|
246
|
+
if (owner === void 0 || info === void 0 || auth.allowedProfiles !== void 0 && profile !== void 0 && !auth.allowedProfiles.includes(profile) || !authSvc.canSee(auth, info)) {
|
|
290
247
|
json(res, 404, { error: "execution not found" });
|
|
291
248
|
return;
|
|
292
249
|
}
|
|
@@ -300,15 +257,8 @@ async function handleExecutionResult(ctx, req, res, pathname, auth) {
|
|
|
300
257
|
}
|
|
301
258
|
//#endregion
|
|
302
259
|
//#region src/services/host-files.ts
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
*/
|
|
260
|
+
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
261
|
+
const O_NONBLOCK = constants.O_NONBLOCK ?? 0;
|
|
312
262
|
function createHostFileRoots(roots) {
|
|
313
263
|
return { roots: roots.map((configured) => {
|
|
314
264
|
if (invalidRequest(configured)) throw new Error(`createHostFileRoots: root must be an absolute path: ${JSON.stringify(configured)}`);
|
|
@@ -332,24 +282,12 @@ function refuse(status, error) {
|
|
|
332
282
|
error
|
|
333
283
|
};
|
|
334
284
|
}
|
|
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
285
|
function notFound() {
|
|
339
286
|
return refuse(404, "not found");
|
|
340
287
|
}
|
|
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
288
|
function invalidRequest(requested) {
|
|
345
289
|
return requested.length === 0 || requested.includes("\0") || !isAbsolute(requested);
|
|
346
290
|
}
|
|
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
291
|
function contained(rootCanonical, candidate) {
|
|
354
292
|
const rel = relative(rootCanonical, candidate);
|
|
355
293
|
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
@@ -357,14 +295,6 @@ function contained(rootCanonical, candidate) {
|
|
|
357
295
|
function rootContaining(roots, canonical) {
|
|
358
296
|
return roots.roots.find((root) => contained(root.canonical, canonical));
|
|
359
297
|
}
|
|
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
298
|
function resolveExisting(roots, requested) {
|
|
369
299
|
if (invalidRequest(requested)) return refuse(403, "invalid path");
|
|
370
300
|
let canonical;
|
|
@@ -395,19 +325,6 @@ function resolveExisting(roots, requested) {
|
|
|
395
325
|
};
|
|
396
326
|
return refuse(403, "not a regular file or directory");
|
|
397
327
|
}
|
|
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
328
|
function resolveForWrite(roots, requested) {
|
|
412
329
|
if (invalidRequest(requested)) return refuse(403, "invalid path");
|
|
413
330
|
try {
|
|
@@ -454,28 +371,12 @@ function resolveForWrite(roots, requested) {
|
|
|
454
371
|
}
|
|
455
372
|
return notFound();
|
|
456
373
|
}
|
|
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
374
|
function entryKind(entry) {
|
|
463
375
|
if (entry.isSymbolicLink()) return "symlink";
|
|
464
376
|
if (entry.isFile()) return "file";
|
|
465
377
|
if (entry.isDirectory()) return "dir";
|
|
466
378
|
return "other";
|
|
467
379
|
}
|
|
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
380
|
function readContained(path) {
|
|
480
381
|
let fd;
|
|
481
382
|
try {
|
|
@@ -495,13 +396,6 @@ function readContained(path) {
|
|
|
495
396
|
closeSync(fd);
|
|
496
397
|
}
|
|
497
398
|
}
|
|
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
399
|
function writeContained(path, data) {
|
|
506
400
|
let fd;
|
|
507
401
|
try {
|
|
@@ -522,23 +416,6 @@ function writeContained(path, data) {
|
|
|
522
416
|
}
|
|
523
417
|
//#endregion
|
|
524
418
|
//#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
419
|
const DEFAULT_IGNORED_DIRS = [
|
|
543
420
|
".git",
|
|
544
421
|
".hg",
|
|
@@ -562,18 +439,6 @@ const DEFAULT_IGNORED_DIRS = [
|
|
|
562
439
|
"DerivedData",
|
|
563
440
|
".build"
|
|
564
441
|
];
|
|
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
442
|
function searchFiles(base, options = {}) {
|
|
578
443
|
const limit = options.limit ?? 50;
|
|
579
444
|
const maxScanned = options.maxScanned ?? 2e4;
|
|
@@ -628,14 +493,6 @@ function searchFiles(base, options = {}) {
|
|
|
628
493
|
truncated: !exhausted || found.length > limit
|
|
629
494
|
};
|
|
630
495
|
}
|
|
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
496
|
function scoreMatch(relativePath, name, needle) {
|
|
640
497
|
if (needle === "") return 0;
|
|
641
498
|
const inName = subsequenceScore(name.toLowerCase(), needle);
|
|
@@ -658,15 +515,9 @@ function subsequenceScore(haystack, needle) {
|
|
|
658
515
|
}
|
|
659
516
|
//#endregion
|
|
660
517
|
//#region src/routes/host-files.ts
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
*/
|
|
518
|
+
function kindRank(type) {
|
|
519
|
+
return type === "dir" ? 0 : 1;
|
|
520
|
+
}
|
|
670
521
|
async function handleHostFiles(ctx, req, res, pathname) {
|
|
671
522
|
const { basePath, hostFiles, hostFilesWritable, maxHostFileBytes, maxHostDirEntries } = ctx;
|
|
672
523
|
if (!hostFiles) {
|
|
@@ -766,10 +617,7 @@ async function handleHostFiles(ctx, req, res, pathname) {
|
|
|
766
617
|
modifiedAt
|
|
767
618
|
};
|
|
768
619
|
});
|
|
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
|
-
});
|
|
620
|
+
entries.sort((a, b) => kindRank(a.type) - kindRank(b.type) || a.name.localeCompare(b.name));
|
|
773
621
|
json(res, 200, {
|
|
774
622
|
path: resolved.path,
|
|
775
623
|
entries,
|
|
@@ -883,9 +731,44 @@ async function handleHostFiles(ctx, req, res, pathname) {
|
|
|
883
731
|
json(res, 404, { error: "not found" });
|
|
884
732
|
}
|
|
885
733
|
//#endregion
|
|
734
|
+
//#region src/routes/create-vet.ts
|
|
735
|
+
/**
|
|
736
|
+
* The one create-validation ladder, run by both create doors — `POST /sessions` and the
|
|
737
|
+
* `session` block of `POST /jobs`. The scope design claims the two are indistinguishable, so
|
|
738
|
+
* the order and the refusals have to come from a single place rather than two copies that can
|
|
739
|
+
* drift. Mutates `req`: strips inert fields and pins the resolved profile name.
|
|
740
|
+
*/
|
|
741
|
+
function vetCreateRequest(ctx, req, auth) {
|
|
742
|
+
const { availability, factory } = ctx;
|
|
743
|
+
const refusedScope = factory.applyScope(req, auth);
|
|
744
|
+
if (refusedScope) return refusedScope;
|
|
745
|
+
const refused = factory.applyBypassPolicy(req);
|
|
746
|
+
if (refused) return {
|
|
747
|
+
status: 403,
|
|
748
|
+
error: refused
|
|
749
|
+
};
|
|
750
|
+
const resolved = factory.resolveProfile(req.profile, auth.allowedProfiles);
|
|
751
|
+
if (!resolved.ok) return {
|
|
752
|
+
status: resolved.status,
|
|
753
|
+
error: resolved.error
|
|
754
|
+
};
|
|
755
|
+
const unavailable = availability.checkAvailable(resolved.profile);
|
|
756
|
+
if (unavailable) return unavailable;
|
|
757
|
+
const refusedCwd = factory.checkCwd(req, resolved.profile);
|
|
758
|
+
if (refusedCwd) return refusedCwd;
|
|
759
|
+
const badRequest = factory.checkPermissionMode(req.permissionMode, resolved.profile) ?? factory.checkEngineGrants(req, resolved.profile);
|
|
760
|
+
if (badRequest) return {
|
|
761
|
+
status: 400,
|
|
762
|
+
error: badRequest
|
|
763
|
+
};
|
|
764
|
+
factory.stripInertFields(req, resolved.profile);
|
|
765
|
+
req.profile = resolved.profile?.name;
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
//#endregion
|
|
886
769
|
//#region src/routes/jobs.ts
|
|
887
770
|
async function handleJobs(ctx, req, res, pathname, auth) {
|
|
888
|
-
const { auth: authSvc,
|
|
771
|
+
const { auth: authSvc, basePath, queue } = ctx;
|
|
889
772
|
if (!queue) {
|
|
890
773
|
json(res, 404, { error: "job queue not configured" });
|
|
891
774
|
return;
|
|
@@ -918,38 +801,11 @@ async function handleJobs(ctx, req, res, pathname, auth) {
|
|
|
918
801
|
json(res, 400, { error: "session.prompt is required" });
|
|
919
802
|
return;
|
|
920
803
|
}
|
|
921
|
-
const
|
|
922
|
-
if (
|
|
923
|
-
json(res,
|
|
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 });
|
|
939
|
-
return;
|
|
940
|
-
}
|
|
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 });
|
|
804
|
+
const refusal = vetCreateRequest(ctx, body.session, auth);
|
|
805
|
+
if (refusal) {
|
|
806
|
+
json(res, refusal.status, { error: refusal.error });
|
|
949
807
|
return;
|
|
950
808
|
}
|
|
951
|
-
factory.stripInertFields(body.session, resolved.profile);
|
|
952
|
-
body.session.profile = resolved.profile?.name;
|
|
953
809
|
try {
|
|
954
810
|
json(res, 201, { job: await queue.submit(body) });
|
|
955
811
|
} catch (error) {
|
|
@@ -1066,6 +922,10 @@ async function handleProfiles(ctx, req, res, pathname, auth) {
|
|
|
1066
922
|
}
|
|
1067
923
|
//#endregion
|
|
1068
924
|
//#region src/routes/sdk-sessions.ts
|
|
925
|
+
function withinRoots(sessions, roots, limit, offset = 0) {
|
|
926
|
+
const allowed = sessions.filter((s) => s.cwd !== void 0 && cwdAllowed(s.cwd, roots)).sort((a, b) => b.lastModified - a.lastModified);
|
|
927
|
+
return limit === void 0 ? allowed.slice(offset) : allowed.slice(offset, offset + limit);
|
|
928
|
+
}
|
|
1069
929
|
async function handleSdkSessions(ctx, req, res, auth) {
|
|
1070
930
|
const { adapterFor, factory, profiles } = ctx;
|
|
1071
931
|
if (req.method !== "GET") {
|
|
@@ -1104,14 +964,16 @@ async function handleSdkSessions(ctx, req, res, auth) {
|
|
|
1104
964
|
});
|
|
1105
965
|
};
|
|
1106
966
|
try {
|
|
1107
|
-
if (roots && roots.length > 0)
|
|
1108
|
-
if (
|
|
1109
|
-
|
|
967
|
+
if (roots && roots.length > 0) {
|
|
968
|
+
if (dir) {
|
|
969
|
+
if (!cwdAllowed(dir, roots)) {
|
|
970
|
+
json(res, 403, { error: "dir is outside the allowed roots" });
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
} else {
|
|
974
|
+
json(res, 200, { sdkSessions: withinRoots(await lister({}), roots, limit, offset) });
|
|
1110
975
|
return;
|
|
1111
976
|
}
|
|
1112
|
-
} else {
|
|
1113
|
-
json(res, 200, { sdkSessions: withinRoots(await lister({}), roots, limit, offset) });
|
|
1114
|
-
return;
|
|
1115
977
|
}
|
|
1116
978
|
json(res, 200, { sdkSessions: await lister({
|
|
1117
979
|
dir,
|
|
@@ -1122,18 +984,14 @@ async function handleSdkSessions(ctx, req, res, auth) {
|
|
|
1122
984
|
json(res, 500, { error: error instanceof Error ? error.message : "failed to list sessions" });
|
|
1123
985
|
}
|
|
1124
986
|
}
|
|
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
987
|
//#endregion
|
|
1132
988
|
//#region src/services/session-store.ts
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
989
|
+
function isLiveRecord(record) {
|
|
990
|
+
return record.kind === "live";
|
|
991
|
+
}
|
|
992
|
+
function isDormant(record) {
|
|
993
|
+
return record.kind === "dormant";
|
|
994
|
+
}
|
|
1137
995
|
var MemorySessionStore = class {
|
|
1138
996
|
#records = /* @__PURE__ */ new Map();
|
|
1139
997
|
save(record) {
|
|
@@ -1150,31 +1008,12 @@ var MemorySessionStore = class {
|
|
|
1150
1008
|
return Promise.resolve(this.#records.delete(id));
|
|
1151
1009
|
}
|
|
1152
1010
|
};
|
|
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
1011
|
const EPHEMERAL_CONFIG_KEYS = [
|
|
1171
1012
|
"queryFn",
|
|
1172
1013
|
"historyFn",
|
|
1173
1014
|
"extraOptions",
|
|
1174
1015
|
"env"
|
|
1175
1016
|
];
|
|
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
1017
|
function toDurableRecord(record) {
|
|
1179
1018
|
const config = { ...record.config };
|
|
1180
1019
|
for (const key of EPHEMERAL_CONFIG_KEYS) delete config[key];
|
|
@@ -1183,29 +1022,7 @@ function toDurableRecord(record) {
|
|
|
1183
1022
|
config
|
|
1184
1023
|
};
|
|
1185
1024
|
}
|
|
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
1025
|
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
1026
|
function createFileSessionStore(options = {}) {
|
|
1210
1027
|
const dir = options.dir ?? join(process.cwd(), ".workerdeck", "parked");
|
|
1211
1028
|
const fileFor = (id) => join(dir, `${encodeURIComponent(id)}.json`);
|
|
@@ -1245,7 +1062,7 @@ function createFileSessionStore(options = {}) {
|
|
|
1245
1062
|
path,
|
|
1246
1063
|
op: "save"
|
|
1247
1064
|
});
|
|
1248
|
-
throw new Error(`parked session '${record.id}' is not JSON-serializable — a host-injected value reached its config or snapshot: ${String(error)}
|
|
1065
|
+
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
1066
|
}
|
|
1250
1067
|
try {
|
|
1251
1068
|
await mkdir(dir, {
|
|
@@ -1303,9 +1120,9 @@ function createFileSessionStore(options = {}) {
|
|
|
1303
1120
|
}
|
|
1304
1121
|
};
|
|
1305
1122
|
}
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1123
|
+
function isMissing(error) {
|
|
1124
|
+
return error.code === "ENOENT";
|
|
1125
|
+
}
|
|
1309
1126
|
function parseRecord(value) {
|
|
1310
1127
|
if (!value || typeof value !== "object") return null;
|
|
1311
1128
|
const envelope = value;
|
|
@@ -1363,12 +1180,7 @@ async function handleAttachments(ctx, req, res, sessionId, session, attachmentId
|
|
|
1363
1180
|
return;
|
|
1364
1181
|
}
|
|
1365
1182
|
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
|
-
});
|
|
1183
|
+
res.writeHead(200, untrustedDownloadHeaders(found.name, found.mediaType, bytes.length));
|
|
1372
1184
|
res.end(bytes);
|
|
1373
1185
|
return;
|
|
1374
1186
|
}
|
|
@@ -1414,21 +1226,6 @@ async function handleMcp(ctx, req, res, runner, serverName) {
|
|
|
1414
1226
|
}
|
|
1415
1227
|
//#endregion
|
|
1416
1228
|
//#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
1229
|
async function handleProducedFiles(ctx, req, res, sessionId, fileId) {
|
|
1433
1230
|
const { producedFiles } = ctx;
|
|
1434
1231
|
if (req.method !== "GET") {
|
|
@@ -1461,12 +1258,7 @@ async function handleProducedFiles(ctx, req, res, sessionId, fileId) {
|
|
|
1461
1258
|
return;
|
|
1462
1259
|
}
|
|
1463
1260
|
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
|
-
});
|
|
1261
|
+
res.writeHead(200, untrustedDownloadHeaders(filename, found.mediaType ?? contentTypeFor(filename), stat.size));
|
|
1470
1262
|
await new Promise((done) => {
|
|
1471
1263
|
const stream = createReadStream(found.path);
|
|
1472
1264
|
stream.on("error", () => {
|
|
@@ -1479,59 +1271,11 @@ async function handleProducedFiles(ctx, req, res, sessionId, fileId) {
|
|
|
1479
1271
|
}
|
|
1480
1272
|
//#endregion
|
|
1481
1273
|
//#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
1274
|
const PROJECT_FILE = ".workerdeck.json";
|
|
1524
|
-
|
|
1525
|
-
const MAX_PROJECT_FILE_BYTES = 64 * 1024;
|
|
1526
|
-
/** Display name clip — a list row's width, not a document's. */
|
|
1275
|
+
const MAX_PROJECT_FILE_BYTES = 65536;
|
|
1527
1276
|
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
1277
|
const GLYPH_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
1532
1278
|
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
1279
|
const SWEEP_ABOVE = 256;
|
|
1536
1280
|
var ProjectInfoService = class {
|
|
1537
1281
|
#ttlMs;
|
|
@@ -1539,11 +1283,6 @@ var ProjectInfoService = class {
|
|
|
1539
1283
|
constructor(options = {}) {
|
|
1540
1284
|
this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
1541
1285
|
}
|
|
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
1286
|
withProject(info) {
|
|
1548
1287
|
if (!info.cwd) return info;
|
|
1549
1288
|
const project = this.#resolve(info.cwd).project;
|
|
@@ -1552,11 +1291,8 @@ var ProjectInfoService = class {
|
|
|
1552
1291
|
project
|
|
1553
1292
|
} : info;
|
|
1554
1293
|
}
|
|
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
1294
|
iconFor(cwd) {
|
|
1559
|
-
if (!cwd) return
|
|
1295
|
+
if (!cwd) return;
|
|
1560
1296
|
return this.#resolve(cwd).icon;
|
|
1561
1297
|
}
|
|
1562
1298
|
#resolve(cwd) {
|
|
@@ -1574,10 +1310,6 @@ var ProjectInfoService = class {
|
|
|
1574
1310
|
return fresh;
|
|
1575
1311
|
}
|
|
1576
1312
|
};
|
|
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
1313
|
function discover(cwd) {
|
|
1582
1314
|
if (!isAbsolute(cwd) || cwd.includes("\0")) return {};
|
|
1583
1315
|
let dir;
|
|
@@ -1594,8 +1326,6 @@ function discover(cwd) {
|
|
|
1594
1326
|
dir = parent;
|
|
1595
1327
|
}
|
|
1596
1328
|
}
|
|
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
1329
|
function tryLoad(file, root) {
|
|
1600
1330
|
let stat;
|
|
1601
1331
|
try {
|
|
@@ -1603,16 +1333,16 @@ function tryLoad(file, root) {
|
|
|
1603
1333
|
} catch {
|
|
1604
1334
|
return;
|
|
1605
1335
|
}
|
|
1606
|
-
if (!stat.isFile() || stat.size > MAX_PROJECT_FILE_BYTES) return
|
|
1336
|
+
if (!stat.isFile() || stat.size > MAX_PROJECT_FILE_BYTES) return;
|
|
1607
1337
|
const read = readContained(file);
|
|
1608
|
-
if (!read.ok) return
|
|
1338
|
+
if (!read.ok) return;
|
|
1609
1339
|
let parsed;
|
|
1610
1340
|
try {
|
|
1611
1341
|
parsed = JSON.parse(read.data.toString("utf8"));
|
|
1612
1342
|
} catch {
|
|
1613
1343
|
return;
|
|
1614
1344
|
}
|
|
1615
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return
|
|
1345
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return;
|
|
1616
1346
|
const raw = parsed;
|
|
1617
1347
|
const name = typeof raw.name === "string" && raw.name.trim() ? raw.name.trim().slice(0, MAX_NAME_CHARS) : basename(root);
|
|
1618
1348
|
const icon = classifyIcon(raw.icon, root);
|
|
@@ -1625,41 +1355,36 @@ function tryLoad(file, root) {
|
|
|
1625
1355
|
...icon?.resolved ? { icon: icon.resolved } : {}
|
|
1626
1356
|
};
|
|
1627
1357
|
}
|
|
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
1358
|
function classifyIcon(value, root) {
|
|
1634
|
-
if (typeof value !== "string") return
|
|
1359
|
+
if (typeof value !== "string") return;
|
|
1635
1360
|
const declared = value.trim();
|
|
1636
|
-
if (!declared || declared.includes("\0") || declared.length > 512) return
|
|
1361
|
+
if (!declared || declared.includes("\0") || declared.length > 512) return;
|
|
1637
1362
|
const lower = declared.toLowerCase();
|
|
1638
1363
|
const mediaType = lower.endsWith(".png") ? "image/png" : lower.endsWith(".svg") ? "image/svg+xml" : void 0;
|
|
1639
1364
|
if (!mediaType) {
|
|
1640
|
-
if (!GLYPH_RE.test(declared) || declared.length > 64) return
|
|
1365
|
+
if (!GLYPH_RE.test(declared) || declared.length > 64) return;
|
|
1641
1366
|
return { wire: {
|
|
1642
1367
|
type: "glyph",
|
|
1643
1368
|
name: declared
|
|
1644
1369
|
} };
|
|
1645
1370
|
}
|
|
1646
|
-
if (isAbsolute(declared) || declared.includes("\\")) return
|
|
1371
|
+
if (isAbsolute(declared) || declared.includes("\\")) return;
|
|
1647
1372
|
let canonical;
|
|
1648
1373
|
try {
|
|
1649
1374
|
canonical = realpathSync(resolve(root, declared));
|
|
1650
1375
|
} catch {
|
|
1651
1376
|
return;
|
|
1652
1377
|
}
|
|
1653
|
-
if (!contained(root, canonical)) return
|
|
1378
|
+
if (!contained(root, canonical)) return;
|
|
1654
1379
|
let stat;
|
|
1655
1380
|
try {
|
|
1656
1381
|
stat = lstatSync(canonical);
|
|
1657
1382
|
} catch {
|
|
1658
1383
|
return;
|
|
1659
1384
|
}
|
|
1660
|
-
if (!stat.isFile() || stat.size === 0 || stat.size > 524288) return
|
|
1385
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > 524288) return;
|
|
1661
1386
|
const read = readContained(canonical);
|
|
1662
|
-
if (!read.ok || read.data.length > 524288) return
|
|
1387
|
+
if (!read.ok || read.data.length > 524288) return;
|
|
1663
1388
|
const hash = createHash("sha256").update(read.data).digest("hex");
|
|
1664
1389
|
return {
|
|
1665
1390
|
wire: {
|
|
@@ -1763,7 +1488,7 @@ function handleToolResult(req, res, lookup, seq) {
|
|
|
1763
1488
|
//#endregion
|
|
1764
1489
|
//#region src/routes/sessions.ts
|
|
1765
1490
|
async function handleSessions(ctx, req, res, route, auth) {
|
|
1766
|
-
const { attachmentStore, auth: authSvc,
|
|
1491
|
+
const { attachmentStore, auth: authSvc, bridge, factory, parking, producedFiles, projects, registry } = ctx;
|
|
1767
1492
|
if (!route.id) {
|
|
1768
1493
|
if (req.method === "GET") {
|
|
1769
1494
|
json(res, 200, { sessions: [...registry.list(), ...await parking.listInfo()].filter((session) => authSvc.canSee(auth, session)).map((session) => projects.withProject(session)) });
|
|
@@ -1771,38 +1496,11 @@ async function handleSessions(ctx, req, res, route, auth) {
|
|
|
1771
1496
|
}
|
|
1772
1497
|
if (req.method === "POST") {
|
|
1773
1498
|
const body = await readJsonBody(req, ctx.maxBodyBytes);
|
|
1774
|
-
const
|
|
1775
|
-
if (
|
|
1776
|
-
json(res,
|
|
1499
|
+
const refusal = vetCreateRequest(ctx, body, auth);
|
|
1500
|
+
if (refusal) {
|
|
1501
|
+
json(res, refusal.status, { error: refusal.error });
|
|
1777
1502
|
return;
|
|
1778
1503
|
}
|
|
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 });
|
|
1792
|
-
return;
|
|
1793
|
-
}
|
|
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
1504
|
const runner = await factory.createRunner(factory.buildRunnerConfig(body));
|
|
1807
1505
|
factory.watchAuthSource(runner);
|
|
1808
1506
|
json(res, 201, { session: projects.withProject(runner.info()) });
|
|
@@ -1860,12 +1558,7 @@ async function handleSessions(ctx, req, res, route, auth) {
|
|
|
1860
1558
|
return;
|
|
1861
1559
|
}
|
|
1862
1560
|
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
|
-
});
|
|
1561
|
+
res.writeHead(200, untrustedDownloadHeaders(filename, contentTypeFor(filename), Buffer.byteLength(content)));
|
|
1869
1562
|
res.end(content);
|
|
1870
1563
|
return;
|
|
1871
1564
|
}
|
|
@@ -2048,22 +1741,8 @@ async function handleCommand(ctx, frame, runner) {
|
|
|
2048
1741
|
}
|
|
2049
1742
|
//#endregion
|
|
2050
1743
|
//#region src/services/attachments.ts
|
|
2051
|
-
const DEFAULT_MAX_FILE_BYTES =
|
|
2052
|
-
const DEFAULT_MAX_SESSION_BYTES =
|
|
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
|
-
*/
|
|
1744
|
+
const DEFAULT_MAX_FILE_BYTES = 10485760;
|
|
1745
|
+
const DEFAULT_MAX_SESSION_BYTES = 67108864;
|
|
2067
1746
|
var AttachmentStore = class {
|
|
2068
1747
|
#bySession = /* @__PURE__ */ new Map();
|
|
2069
1748
|
#maxFileBytes;
|
|
@@ -2121,18 +1800,9 @@ var AttachmentStore = class {
|
|
|
2121
1800
|
attachment: ref(attachment)
|
|
2122
1801
|
};
|
|
2123
1802
|
}
|
|
2124
|
-
/** The stored record, bytes included — for the download route and for the send
|
|
2125
|
-
* path that turns ids into content blocks. */
|
|
2126
1803
|
get(sessionId, id) {
|
|
2127
1804
|
return this.#bySession.get(sessionId)?.get(id);
|
|
2128
1805
|
}
|
|
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
1806
|
resolve(sessionId, ids) {
|
|
2137
1807
|
const held = this.#bySession.get(sessionId);
|
|
2138
1808
|
const attachments = [];
|
|
@@ -2162,11 +1832,6 @@ function ref(attachment) {
|
|
|
2162
1832
|
bytes: attachment.bytes
|
|
2163
1833
|
};
|
|
2164
1834
|
}
|
|
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
1835
|
function safeName(name) {
|
|
2171
1836
|
const cleaned = (name.split(/[/\\]/).pop() ?? "").replace(/[\u0000-\u001f\u007f"<>]/g, "").trim();
|
|
2172
1837
|
if (cleaned === "" || cleaned === "." || cleaned === "..") return "attachment";
|
|
@@ -2174,26 +1839,14 @@ function safeName(name) {
|
|
|
2174
1839
|
}
|
|
2175
1840
|
//#endregion
|
|
2176
1841
|
//#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
1842
|
const MAX_SCOPE_KEYS = 16;
|
|
2186
1843
|
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
1844
|
function readScope(value) {
|
|
2190
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return
|
|
1845
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return;
|
|
2191
1846
|
const entries = Object.entries(value);
|
|
2192
|
-
if (entries.some(([, v]) => typeof v !== "string")) return
|
|
1847
|
+
if (entries.some(([, v]) => typeof v !== "string")) return;
|
|
2193
1848
|
return Object.fromEntries(entries);
|
|
2194
1849
|
}
|
|
2195
|
-
/** Validate a caller-supplied scope. Returns an error string, or null when it is
|
|
2196
|
-
* well-formed (including when it is absent). */
|
|
2197
1850
|
function checkScope(value) {
|
|
2198
1851
|
if (value === void 0) return null;
|
|
2199
1852
|
const scope = readScope(value);
|
|
@@ -2206,23 +1859,11 @@ function checkScope(value) {
|
|
|
2206
1859
|
}
|
|
2207
1860
|
return null;
|
|
2208
1861
|
}
|
|
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
1862
|
function sameScope(a, b) {
|
|
2212
1863
|
const left = Object.entries(a ?? {}).sort(([x], [y]) => x < y ? -1 : 1);
|
|
2213
1864
|
const right = Object.entries(b ?? {}).sort(([x], [y]) => x < y ? -1 : 1);
|
|
2214
1865
|
return left.length === right.length && left.every(([key, value], i) => right[i][0] === key && right[i][1] === value);
|
|
2215
1866
|
}
|
|
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
1867
|
function scopeMatches(principal, session) {
|
|
2227
1868
|
if (!principal) return true;
|
|
2228
1869
|
return Object.entries(principal).every(([key, value]) => session?.[key] === value);
|
|
@@ -2246,7 +1887,6 @@ function createAuthService(deps) {
|
|
|
2246
1887
|
canManageProfiles: principal.canManageProfiles === true
|
|
2247
1888
|
};
|
|
2248
1889
|
};
|
|
2249
|
-
/** May this caller see — and therefore drive — this session? */
|
|
2250
1890
|
const canSee = (auth, session) => {
|
|
2251
1891
|
if (!options.authorizeSession) return scopeMatches(auth.scope, session.scope);
|
|
2252
1892
|
try {
|
|
@@ -2255,20 +1895,6 @@ function createAuthService(deps) {
|
|
|
2255
1895
|
return false;
|
|
2256
1896
|
}
|
|
2257
1897
|
};
|
|
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
1898
|
const canSeeJob = (auth, job) => {
|
|
2273
1899
|
const live = job.sessionId ? refs.registry.get(job.sessionId)?.info() : void 0;
|
|
2274
1900
|
if (live) return canSee(auth, live);
|
|
@@ -2284,25 +1910,6 @@ function createAuthService(deps) {
|
|
|
2284
1910
|
scope: job.scope
|
|
2285
1911
|
});
|
|
2286
1912
|
};
|
|
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
1913
|
const isOperator = (auth) => auth.operator ?? (auth.scope === void 0 && !options.authorizeSession);
|
|
2307
1914
|
return {
|
|
2308
1915
|
authenticate,
|
|
@@ -2313,32 +1920,14 @@ function createAuthService(deps) {
|
|
|
2313
1920
|
}
|
|
2314
1921
|
//#endregion
|
|
2315
1922
|
//#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
1923
|
const AVAILABILITY_TTL_MS = 6e4;
|
|
2333
1924
|
var AvailabilityTracker = class {
|
|
2334
1925
|
#verdicts = /* @__PURE__ */ new Map();
|
|
2335
|
-
/** Profiles already warned about on the console, so re-probes don't spam. */
|
|
2336
1926
|
#warned = /* @__PURE__ */ new Set();
|
|
2337
1927
|
#opts;
|
|
2338
1928
|
constructor(opts) {
|
|
2339
1929
|
this.#opts = opts;
|
|
2340
1930
|
}
|
|
2341
|
-
/** The cached verdict, if any probe has answered. */
|
|
2342
1931
|
get(name) {
|
|
2343
1932
|
return this.#verdicts.get(name)?.verdict;
|
|
2344
1933
|
}
|
|
@@ -2367,11 +1956,6 @@ var AvailabilityTracker = class {
|
|
|
2367
1956
|
if (verdict.available === true) this.#warned.delete(profile.name);
|
|
2368
1957
|
}).catch(() => {});
|
|
2369
1958
|
}
|
|
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
1959
|
checkAvailable(profile) {
|
|
2376
1960
|
if (!this.#opts.requireAvailableProfile || !profile) return null;
|
|
2377
1961
|
const verdict = this.get(profile.name);
|
|
@@ -2381,13 +1965,9 @@ var AvailabilityTracker = class {
|
|
|
2381
1965
|
error: `profile '${profile.name}' is unavailable: ${verdict.reason ?? "no usable credentials"}`
|
|
2382
1966
|
};
|
|
2383
1967
|
}
|
|
2384
|
-
/** Launch-time sweep, concurrent and fire-and-forget. */
|
|
2385
1968
|
preflight(profiles) {
|
|
2386
1969
|
for (const profile of profiles) this.probe(profile);
|
|
2387
1970
|
}
|
|
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
1971
|
refresh(profiles) {
|
|
2392
1972
|
if (!this.#opts.checkCredentials) return;
|
|
2393
1973
|
const now = Date.now();
|
|
@@ -2399,32 +1979,18 @@ var AvailabilityTracker = class {
|
|
|
2399
1979
|
};
|
|
2400
1980
|
//#endregion
|
|
2401
1981
|
//#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
1982
|
var BridgeHub = class {
|
|
2412
1983
|
#sessions = /* @__PURE__ */ new Map();
|
|
2413
1984
|
#options;
|
|
2414
1985
|
constructor(options = {}) {
|
|
2415
1986
|
this.#options = options;
|
|
2416
1987
|
}
|
|
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
1988
|
executorFor(sessionId) {
|
|
2420
1989
|
return this.#bridge(sessionId).executor;
|
|
2421
1990
|
}
|
|
2422
|
-
/** How many clients are watching this session. Parking consults it: a session
|
|
2423
|
-
* someone is watching stays live. */
|
|
2424
1991
|
attachedCount(sessionId) {
|
|
2425
1992
|
return this.#sessions.get(sessionId)?.sockets.length ?? 0;
|
|
2426
1993
|
}
|
|
2427
|
-
/** Register an attached client. Returns a detach function. */
|
|
2428
1994
|
attach(sessionId, send) {
|
|
2429
1995
|
const bridge = this.#bridge(sessionId);
|
|
2430
1996
|
bridge.sockets.push(send);
|
|
@@ -2433,14 +1999,9 @@ var BridgeHub = class {
|
|
|
2433
1999
|
if (index >= 0) bridge.sockets.splice(index, 1);
|
|
2434
2000
|
};
|
|
2435
2001
|
}
|
|
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
2002
|
resolve(sessionId, executionId, answer) {
|
|
2441
2003
|
return this.#sessions.get(sessionId)?.executor.resolve(executionId, answer) ?? false;
|
|
2442
2004
|
}
|
|
2443
|
-
/** Drop a session's bridge, failing anything still in flight. */
|
|
2444
2005
|
remove(sessionId) {
|
|
2445
2006
|
const bridge = this.#sessions.get(sessionId);
|
|
2446
2007
|
if (!bridge) return;
|
|
@@ -2478,38 +2039,15 @@ var BridgeHub = class {
|
|
|
2478
2039
|
};
|
|
2479
2040
|
//#endregion
|
|
2480
2041
|
//#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
2042
|
var SessionNotifier = class {
|
|
2495
2043
|
#options;
|
|
2496
|
-
/** Per-session delivery chain, so a session's notifications arrive in order. */
|
|
2497
2044
|
#chains = /* @__PURE__ */ new Map();
|
|
2498
2045
|
constructor(options) {
|
|
2499
2046
|
this.#options = options;
|
|
2500
2047
|
}
|
|
2501
|
-
/** True when nothing is listening — lets the caller skip subscribing at all. */
|
|
2502
2048
|
get idle() {
|
|
2503
2049
|
return !this.#options.webhook && !this.#options.onNotification;
|
|
2504
2050
|
}
|
|
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
2051
|
watch(runner, afterSeq = runner.info().lastSeq) {
|
|
2514
2052
|
if (this.idle) return;
|
|
2515
2053
|
runner.subscribe((event) => {
|
|
@@ -2573,12 +2111,6 @@ var SessionNotifier = class {
|
|
|
2573
2111
|
if (this.#chains.get(runner.id) === next) this.#chains.delete(runner.id);
|
|
2574
2112
|
});
|
|
2575
2113
|
}
|
|
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
2114
|
async #deliver(webhook, notification) {
|
|
2583
2115
|
const attempts = this.#options.attempts ?? 3;
|
|
2584
2116
|
const baseDelay = this.#options.retryDelayMs ?? 500;
|
|
@@ -2599,100 +2131,27 @@ var SessionNotifier = class {
|
|
|
2599
2131
|
};
|
|
2600
2132
|
//#endregion
|
|
2601
2133
|
//#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
2134
|
var SessionParkManager = class {
|
|
2622
2135
|
#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
2136
|
#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
2137
|
#settled = /* @__PURE__ */ new Map();
|
|
2629
2138
|
#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
2139
|
#resuming = /* @__PURE__ */ new Map();
|
|
2634
2140
|
#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
2141
|
#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
2142
|
#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
2143
|
#storeOps = /* @__PURE__ */ new Map();
|
|
2663
2144
|
#closed = false;
|
|
2664
2145
|
constructor(options) {
|
|
2665
2146
|
this.#options = options;
|
|
2666
2147
|
}
|
|
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
2148
|
remember(sessionId, config) {
|
|
2670
2149
|
this.#configs.set(sessionId, config);
|
|
2671
2150
|
}
|
|
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
2151
|
touch(runner) {
|
|
2684
2152
|
this.#rememberDormant(runner);
|
|
2685
2153
|
this.#persistLive(runner);
|
|
2686
2154
|
}
|
|
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
2155
|
async hydrate() {
|
|
2697
2156
|
const floor = Date.now() + (this.#options.expiredGraceMs ?? 6e4);
|
|
2698
2157
|
for (const record of await this.#options.store.list()) {
|
|
@@ -2700,12 +2159,6 @@ var SessionParkManager = class {
|
|
|
2700
2159
|
for (const execution of record.executions) this.#track(record.id, execution, floor);
|
|
2701
2160
|
}
|
|
2702
2161
|
}
|
|
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
2162
|
watch(runner, afterSeq = 0) {
|
|
2710
2163
|
return runner.subscribe((event) => {
|
|
2711
2164
|
switch (event.type) {
|
|
@@ -2748,7 +2201,6 @@ var SessionParkManager = class {
|
|
|
2748
2201
|
}
|
|
2749
2202
|
}, afterSeq);
|
|
2750
2203
|
}
|
|
2751
|
-
/** A client detached: park the session if that was the last one watching. */
|
|
2752
2204
|
onDetach(sessionId) {
|
|
2753
2205
|
if (this.#closed) return;
|
|
2754
2206
|
const runner = this.#options.registry.get(sessionId);
|
|
@@ -2761,34 +2213,21 @@ var SessionParkManager = class {
|
|
|
2761
2213
|
timer.unref?.();
|
|
2762
2214
|
this.#detachTimers.set(sessionId, timer);
|
|
2763
2215
|
}
|
|
2764
|
-
/** Which session this execution belongs to — still waiting, or already settled. */
|
|
2765
2216
|
sessionFor(executionId) {
|
|
2766
2217
|
return this.#owners.get(executionId) ?? this.#settled.get(executionId);
|
|
2767
2218
|
}
|
|
2768
|
-
/** The stored session's record, for the read paths (GET, list, attach). */
|
|
2769
2219
|
get(id) {
|
|
2770
2220
|
return this.#queue(id, () => this.#options.store.get(id));
|
|
2771
2221
|
}
|
|
2772
|
-
/** Every stored session's info, to merge into `GET {basePath}/sessions`. */
|
|
2773
2222
|
async listInfo() {
|
|
2774
2223
|
await Promise.all(this.#storeOps.values());
|
|
2775
2224
|
return (await this.#options.store.list()).filter((record) => this.#options.registry.get(record.id) === void 0).map((record) => record.info);
|
|
2776
2225
|
}
|
|
2777
|
-
/** The live runner for a session, rehydrating a parked one on demand. Undefined
|
|
2778
|
-
* when the session is neither live nor parked. */
|
|
2779
2226
|
async ensureLive(id) {
|
|
2780
2227
|
const live = this.#options.registry.get(id);
|
|
2781
2228
|
if (live) return live;
|
|
2782
2229
|
return this.#resume(id);
|
|
2783
2230
|
}
|
|
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
2231
|
async submitResult(executionId, result) {
|
|
2793
2232
|
const sessionId = this.#owners.get(executionId);
|
|
2794
2233
|
if (sessionId === void 0) {
|
|
@@ -2811,7 +2250,6 @@ var SessionParkManager = class {
|
|
|
2811
2250
|
sessionId
|
|
2812
2251
|
};
|
|
2813
2252
|
}
|
|
2814
|
-
/** Drop a parked session for good: the run is over (closed, canceled, killed). */
|
|
2815
2253
|
async discard(sessionId) {
|
|
2816
2254
|
clearTimeout(this.#detachTimers.get(sessionId));
|
|
2817
2255
|
this.#detachTimers.delete(sessionId);
|
|
@@ -2828,21 +2266,6 @@ var SessionParkManager = class {
|
|
|
2828
2266
|
this.#timers.clear();
|
|
2829
2267
|
this.#detachTimers.clear();
|
|
2830
2268
|
}
|
|
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
2269
|
async #rememberDormant(runner) {
|
|
2847
2270
|
if (this.#closed) return;
|
|
2848
2271
|
const info = runner.info();
|
|
@@ -2880,17 +2303,6 @@ var SessionParkManager = class {
|
|
|
2880
2303
|
});
|
|
2881
2304
|
}
|
|
2882
2305
|
}
|
|
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
2306
|
async #forgetDormant(sessionId) {
|
|
2895
2307
|
this.#remembered.delete(sessionId);
|
|
2896
2308
|
try {
|
|
@@ -2902,26 +2314,6 @@ var SessionParkManager = class {
|
|
|
2902
2314
|
});
|
|
2903
2315
|
}
|
|
2904
2316
|
}
|
|
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
2317
|
async #persistLive(runner) {
|
|
2926
2318
|
if (this.#closed || !this.#options.persistLive || !runner.snapshot) return;
|
|
2927
2319
|
const config = this.#configs.get(runner.id);
|
|
@@ -3009,7 +2401,7 @@ var SessionParkManager = class {
|
|
|
3009
2401
|
}
|
|
3010
2402
|
async #rebuild(id) {
|
|
3011
2403
|
const record = await this.#queue(id, () => this.#options.store.get(id));
|
|
3012
|
-
if (!record) return
|
|
2404
|
+
if (!record) return;
|
|
3013
2405
|
let runner;
|
|
3014
2406
|
try {
|
|
3015
2407
|
runner = await this.#options.rebuild(record);
|
|
@@ -3037,9 +2429,6 @@ var SessionParkManager = class {
|
|
|
3037
2429
|
runner.start();
|
|
3038
2430
|
return runner;
|
|
3039
2431
|
}
|
|
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
2432
|
#queue(sessionId, op) {
|
|
3044
2433
|
const result = (this.#storeOps.get(sessionId) ?? Promise.resolve()).then(op);
|
|
3045
2434
|
const settled = result.then(() => {}, () => {});
|
|
@@ -3084,40 +2473,8 @@ var SessionParkManager = class {
|
|
|
3084
2473
|
};
|
|
3085
2474
|
//#endregion
|
|
3086
2475
|
//#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
2476
|
var ProducedFileStore = class {
|
|
3110
2477
|
#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
2478
|
watch(runner) {
|
|
3122
2479
|
runner.subscribe((event) => {
|
|
3123
2480
|
if (event.type !== "file_produced") return;
|
|
@@ -3135,7 +2492,6 @@ var ProducedFileStore = class {
|
|
|
3135
2492
|
get(sessionId, fileId) {
|
|
3136
2493
|
return this.#bySession.get(sessionId)?.get(fileId);
|
|
3137
2494
|
}
|
|
3138
|
-
/** Everything one session has produced, newest registration last. */
|
|
3139
2495
|
list(sessionId) {
|
|
3140
2496
|
return [...this.#bySession.get(sessionId)?.values() ?? []];
|
|
3141
2497
|
}
|
|
@@ -3145,19 +2501,9 @@ var ProducedFileStore = class {
|
|
|
3145
2501
|
};
|
|
3146
2502
|
//#endregion
|
|
3147
2503
|
//#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
2504
|
var ProfileService = class {
|
|
3158
2505
|
#declared;
|
|
3159
2506
|
#declaredByName;
|
|
3160
|
-
/** Store-managed profiles, mirrored in memory — see the module doc. */
|
|
3161
2507
|
#stored = /* @__PURE__ */ new Map();
|
|
3162
2508
|
#opts;
|
|
3163
2509
|
constructor(opts) {
|
|
@@ -3166,11 +2512,6 @@ var ProfileService = class {
|
|
|
3166
2512
|
this.#declaredByName = new Map(opts.declared.map((p) => [p.name, p]));
|
|
3167
2513
|
if (this.#declaredByName.size !== opts.declared.length) throw new Error("createWorkerServer: duplicate profile names in `profiles`");
|
|
3168
2514
|
}
|
|
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
2515
|
validate(p) {
|
|
3175
2516
|
const { adapterFor, disableBypassPermissions, hasEngineRunnerFactory } = this.#opts;
|
|
3176
2517
|
if (isProviderProfile(p)) {
|
|
@@ -3185,31 +2526,17 @@ var ProfileService = class {
|
|
|
3185
2526
|
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
2527
|
return null;
|
|
3187
2528
|
}
|
|
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
2529
|
async refreshStored() {
|
|
3191
2530
|
if (!this.#opts.store) return;
|
|
3192
2531
|
this.#stored.clear();
|
|
3193
2532
|
for (const p of await this.#opts.store.list()) this.#stored.set(p.name, p);
|
|
3194
2533
|
}
|
|
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
2534
|
withManagedFlag(p) {
|
|
3198
2535
|
return this.#declaredByName.has(p.name) ? p : {
|
|
3199
2536
|
...p,
|
|
3200
2537
|
managed: true
|
|
3201
2538
|
};
|
|
3202
2539
|
}
|
|
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
2540
|
forResponse(p) {
|
|
3214
2541
|
const { adapterFor, decorate } = this.#opts;
|
|
3215
2542
|
const adapter = adapterFor(p.engine);
|
|
@@ -3229,16 +2556,12 @@ var ProfileService = class {
|
|
|
3229
2556
|
if (usage) base.usage = usage;
|
|
3230
2557
|
return base;
|
|
3231
2558
|
}
|
|
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
2559
|
all() {
|
|
3235
2560
|
return [...this.#declared, ...[...this.#stored.values()].filter((p) => !this.#declaredByName.has(p.name))];
|
|
3236
2561
|
}
|
|
3237
2562
|
get(name) {
|
|
3238
2563
|
return this.#declaredByName.get(name) ?? this.#stored.get(name);
|
|
3239
2564
|
}
|
|
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
2565
|
manageGuard(auth) {
|
|
3243
2566
|
if (!this.#opts.store) return {
|
|
3244
2567
|
status: 404,
|
|
@@ -3250,19 +2573,12 @@ var ProfileService = class {
|
|
|
3250
2573
|
};
|
|
3251
2574
|
return null;
|
|
3252
2575
|
}
|
|
3253
|
-
/** Startup-declared profiles are code. Editing one over HTTP would make the
|
|
3254
|
-
* server options lie about what is actually running. */
|
|
3255
2576
|
declaredGuard(profile) {
|
|
3256
2577
|
return this.#declaredByName.has(profile.name) ? {
|
|
3257
2578
|
status: 403,
|
|
3258
2579
|
error: `profile '${profile.name}' is declared in server options and cannot be changed over the API — edit the \`profiles\` option instead`
|
|
3259
2580
|
} : null;
|
|
3260
2581
|
}
|
|
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
2582
|
configDirGuard(profile) {
|
|
3267
2583
|
if (isProviderProfile(profile)) return null;
|
|
3268
2584
|
const roots = this.#opts.allowedConfigDirRoots;
|
|
@@ -3275,9 +2591,6 @@ var ProfileService = class {
|
|
|
3275
2591
|
error: "configDir is outside the allowed roots"
|
|
3276
2592
|
};
|
|
3277
2593
|
}
|
|
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
2594
|
async saveManaged(incoming) {
|
|
3282
2595
|
const { managed: _clientClaim, ...profile } = incoming;
|
|
3283
2596
|
const refused = this.configDirGuard(profile);
|
|
@@ -3301,35 +2614,8 @@ var ProfileService = class {
|
|
|
3301
2614
|
};
|
|
3302
2615
|
//#endregion
|
|
3303
2616
|
//#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
2617
|
var ProfileUsageTracker = class {
|
|
3329
|
-
/** profile name → rateLimitType → newest reading. */
|
|
3330
2618
|
#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
2619
|
watch(runner) {
|
|
3334
2620
|
const profile = runner.info().profile;
|
|
3335
2621
|
if (!profile) return;
|
|
@@ -3347,31 +2633,14 @@ var ProfileUsageTracker = class {
|
|
|
3347
2633
|
});
|
|
3348
2634
|
});
|
|
3349
2635
|
}
|
|
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
2636
|
usage(profile, now = Date.now()) {
|
|
3367
2637
|
const windows = this.#profiles.get(profile);
|
|
3368
|
-
if (!windows || windows.size === 0) return
|
|
2638
|
+
if (!windows || windows.size === 0) return;
|
|
3369
2639
|
const out = {};
|
|
3370
2640
|
for (const [type, held] of windows) out[type] = serveWindow(held, now);
|
|
3371
2641
|
return out;
|
|
3372
2642
|
}
|
|
3373
2643
|
};
|
|
3374
|
-
/** `resetsAt` is epoch **seconds** (protocol contract); `now` is epoch ms. */
|
|
3375
2644
|
function serveWindow(held, now) {
|
|
3376
2645
|
const resetsAt = held.info.resetsAt;
|
|
3377
2646
|
if (resetsAt !== void 0 && resetsAt * 1e3 <= now) return {
|
|
@@ -3390,7 +2659,6 @@ function serveWindow(held, now) {
|
|
|
3390
2659
|
}
|
|
3391
2660
|
//#endregion
|
|
3392
2661
|
//#region src/services/registry.ts
|
|
3393
|
-
/** In-memory session table. Terminal sessions stay listed until removed or the process exits. */
|
|
3394
2662
|
var SessionRegistry = class {
|
|
3395
2663
|
#sessions = /* @__PURE__ */ new Map();
|
|
3396
2664
|
#options;
|
|
@@ -3400,19 +2668,14 @@ var SessionRegistry = class {
|
|
|
3400
2668
|
create(config) {
|
|
3401
2669
|
return this.adopt(new SessionRunner(config));
|
|
3402
2670
|
}
|
|
3403
|
-
/** Build and list a Claude-engine runner without starting it, so watchers can
|
|
3404
|
-
* subscribe first. Call `start()` once they have. */
|
|
3405
2671
|
prepare(config) {
|
|
3406
2672
|
return this.register(new SessionRunner(config));
|
|
3407
2673
|
}
|
|
3408
|
-
/** Register an already-built runner (a non-Claude engine) and start it. */
|
|
3409
2674
|
adopt(runner) {
|
|
3410
2675
|
this.register(runner);
|
|
3411
2676
|
runner.start();
|
|
3412
2677
|
return runner;
|
|
3413
2678
|
}
|
|
3414
|
-
/** List a runner without starting it — for a rehydrated session, whose watchers
|
|
3415
|
-
* must be subscribed before it comes back up. */
|
|
3416
2679
|
register(runner) {
|
|
3417
2680
|
const existing = this.#sessions.get(runner.id);
|
|
3418
2681
|
this.#sessions.set(runner.id, runner);
|
|
@@ -3431,8 +2694,6 @@ var SessionRegistry = class {
|
|
|
3431
2694
|
runner.close("server");
|
|
3432
2695
|
return this.#sessions.delete(id);
|
|
3433
2696
|
}
|
|
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
2697
|
evict(id) {
|
|
3437
2698
|
return this.#sessions.delete(id);
|
|
3438
2699
|
}
|
|
@@ -3442,50 +2703,19 @@ var SessionRegistry = class {
|
|
|
3442
2703
|
};
|
|
3443
2704
|
//#endregion
|
|
3444
2705
|
//#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
2706
|
function createSessionFactory(deps) {
|
|
3458
2707
|
const { adapterFor, profiles, refs } = deps;
|
|
3459
|
-
/** Profiles (by name; '' = none) whose oauth notice has been logged. */
|
|
3460
2708
|
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
2709
|
const applyBypassPolicy = (req) => {
|
|
3465
2710
|
if (!deps.disableBypassPermissions) return null;
|
|
3466
2711
|
if (req.permissionMode === "bypassPermissions") return "bypassPermissions is disabled on this server (disableBypassPermissions)";
|
|
3467
2712
|
delete req.allowDangerouslySkipPermissions;
|
|
3468
2713
|
return null;
|
|
3469
2714
|
};
|
|
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
2715
|
const checkPermissionMode = (mode, profile) => {
|
|
3475
2716
|
if (mode === void 0 || supportsPermissionMode(profile?.engine, mode)) return null;
|
|
3476
2717
|
return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${engineOf(profile)}') — supported: ` + adapterFor(profile?.engine).capabilities.permissionModes.join(", ");
|
|
3477
2718
|
};
|
|
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
2719
|
const checkEngineGrants = (req, profile) => {
|
|
3490
2720
|
const engine = engineOf(profile);
|
|
3491
2721
|
const caps = adapterFor(profile?.engine).capabilities;
|
|
@@ -3503,22 +2733,9 @@ function createSessionFactory(deps) {
|
|
|
3503
2733
|
if (ungranted.length === 0) return null;
|
|
3504
2734
|
return `profile '${profile.name}' does not grant: ${ungranted.join(", ")} (granted: ${granted.join(", ") || "none"}) — a request may narrow capabilities, not widen them`;
|
|
3505
2735
|
};
|
|
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
2736
|
const stripInertFields = (req, profile) => {
|
|
3510
2737
|
if (!adapterFor(profile?.engine).capabilities.interactiveApprovals) delete req.questionBehavior;
|
|
3511
2738
|
};
|
|
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
2739
|
const applyScope = (req, auth) => {
|
|
3523
2740
|
const invalid = checkScope(req.scope);
|
|
3524
2741
|
if (invalid) return {
|
|
@@ -3543,17 +2760,6 @@ function createSessionFactory(deps) {
|
|
|
3543
2760
|
req.scope = merged;
|
|
3544
2761
|
return null;
|
|
3545
2762
|
};
|
|
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
2763
|
const checkCwd = (req, profile) => {
|
|
3558
2764
|
if (req.cwd !== void 0 && typeof req.cwd !== "string") return {
|
|
3559
2765
|
status: 400,
|
|
@@ -3568,21 +2774,10 @@ function createSessionFactory(deps) {
|
|
|
3568
2774
|
error: "cwd is outside the allowed roots"
|
|
3569
2775
|
};
|
|
3570
2776
|
};
|
|
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
2777
|
const withScope = (config, scope) => scope === void 0 ? config : {
|
|
3579
2778
|
...config,
|
|
3580
2779
|
scope
|
|
3581
2780
|
};
|
|
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
2781
|
const buildRunnerConfig = (req) => {
|
|
3587
2782
|
const profile = req.profile !== void 0 ? profiles.get(req.profile) : void 0;
|
|
3588
2783
|
if (!profile) return withScope(deps.hostBuildRunnerConfig(req), req.scope);
|
|
@@ -3599,7 +2794,6 @@ function createSessionFactory(deps) {
|
|
|
3599
2794
|
env
|
|
3600
2795
|
};
|
|
3601
2796
|
};
|
|
3602
|
-
/** The env a probe should test: exactly what the real assembly path produces. */
|
|
3603
2797
|
const sessionEnvFor = (profile) => {
|
|
3604
2798
|
try {
|
|
3605
2799
|
return buildRunnerConfig({
|
|
@@ -3610,12 +2804,6 @@ function createSessionFactory(deps) {
|
|
|
3610
2804
|
return engineOf(profile) === "claude" ? claudeSessionEnv(profile, process.env) : process.env;
|
|
3611
2805
|
}
|
|
3612
2806
|
};
|
|
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
2807
|
const buildRunner = async (config, restore, id) => {
|
|
3620
2808
|
const name = config.profile;
|
|
3621
2809
|
const profile = name !== void 0 ? profiles.get(name) : void 0;
|
|
@@ -3643,9 +2831,6 @@ function createSessionFactory(deps) {
|
|
|
3643
2831
|
runner.start();
|
|
3644
2832
|
return runner;
|
|
3645
2833
|
};
|
|
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
2834
|
const resolveProfile = (name, allowedProfiles) => {
|
|
3650
2835
|
if (name !== void 0 && typeof name !== "string") return {
|
|
3651
2836
|
ok: false,
|
|
@@ -3713,30 +2898,40 @@ function createSessionFactory(deps) {
|
|
|
3713
2898
|
}
|
|
3714
2899
|
//#endregion
|
|
3715
2900
|
//#region src/server.ts
|
|
2901
|
+
/** How long a client gets to acknowledge the shutdown close frame before its socket is torn down. */
|
|
2902
|
+
const SOCKET_CLOSE_GRACE_MS = 250;
|
|
3716
2903
|
/**
|
|
3717
|
-
*
|
|
3718
|
-
*
|
|
3719
|
-
* `
|
|
3720
|
-
*
|
|
2904
|
+
* Split live sessions into "will finish by itself" and "needs a person".
|
|
2905
|
+
*
|
|
2906
|
+
* `sessionState` is the vocabulary the dashboard, the session list and `workerdeck guard` already sort by, and it
|
|
2907
|
+
* draws exactly the line a drain needs: `working` covers starting/running and running subagents, while `attention`
|
|
2908
|
+
* covers a pending approval. Re-spelling that set here is how the two definitions would drift apart.
|
|
3721
2909
|
*/
|
|
2910
|
+
function surveyDrain(registry) {
|
|
2911
|
+
const working = [];
|
|
2912
|
+
const awaitingHuman = [];
|
|
2913
|
+
for (const info of registry.list()) {
|
|
2914
|
+
const state = sessionState(info);
|
|
2915
|
+
if (state === "working") working.push(info.id);
|
|
2916
|
+
else if (state === "attention") awaitingHuman.push(info.id);
|
|
2917
|
+
}
|
|
2918
|
+
return {
|
|
2919
|
+
working,
|
|
2920
|
+
awaitingHuman,
|
|
2921
|
+
timedOut: false
|
|
2922
|
+
};
|
|
2923
|
+
}
|
|
2924
|
+
function sameDrain(a, b) {
|
|
2925
|
+
return a.working.join() === b.working.join() && a.awaitingHuman.join() === b.awaitingHuman.join();
|
|
2926
|
+
}
|
|
3722
2927
|
function createWorkerServer(options = {}) {
|
|
3723
2928
|
if (!options.authenticate && !options.allowUnauthenticated) throw new Error("createWorkerServer: provide `authenticate` or explicitly set `allowUnauthenticated: true`");
|
|
3724
2929
|
const basePath = options.basePath ?? "/v1";
|
|
3725
2930
|
const fallback = options.fallback;
|
|
3726
2931
|
const corsOrigins = options.cors?.origins.length ? new Set(options.cors.origins) : void 0;
|
|
3727
|
-
const maxBodyBytes = options.maxBodyBytes ??
|
|
3728
|
-
/** The engine's adapter, honoring the test-only `engines` override. */
|
|
2932
|
+
const maxBodyBytes = options.maxBodyBytes ?? 1048576;
|
|
3729
2933
|
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
2934
|
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
2935
|
const profileUsage = new ProfileUsageTracker();
|
|
3741
2936
|
const profiles = new ProfileService({
|
|
3742
2937
|
declared: options.profiles ?? detectDefaultProfiles(),
|
|
@@ -3825,6 +3020,8 @@ function createWorkerServer(options = {}) {
|
|
|
3825
3020
|
refs
|
|
3826
3021
|
});
|
|
3827
3022
|
const wss = new WebSocketServer({ noServer: true });
|
|
3023
|
+
let closing;
|
|
3024
|
+
let draining = false;
|
|
3828
3025
|
const queueSockets = /* @__PURE__ */ new Set();
|
|
3829
3026
|
const sendQueueFrame = (ws, frame) => {
|
|
3830
3027
|
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame));
|
|
@@ -3880,7 +3077,7 @@ function createWorkerServer(options = {}) {
|
|
|
3880
3077
|
producedFiles,
|
|
3881
3078
|
hostFiles,
|
|
3882
3079
|
hostFilesWritable: options.hostFiles?.write === true,
|
|
3883
|
-
maxHostFileBytes: options.hostFiles?.maxFileBytes ??
|
|
3080
|
+
maxHostFileBytes: options.hostFiles?.maxFileBytes ?? 1048576,
|
|
3884
3081
|
maxHostDirEntries: options.hostFiles?.maxEntries ?? 5e3
|
|
3885
3082
|
};
|
|
3886
3083
|
const handleRequest = async (req, res) => {
|
|
@@ -3967,6 +3164,10 @@ function createWorkerServer(options = {}) {
|
|
|
3967
3164
|
json(res, 404, { error: "not found" });
|
|
3968
3165
|
return;
|
|
3969
3166
|
}
|
|
3167
|
+
if (draining && req.method === "POST" && route.id === void 0) {
|
|
3168
|
+
json(res, 503, { error: "server is shutting down" });
|
|
3169
|
+
return;
|
|
3170
|
+
}
|
|
3970
3171
|
const authCtx = await auth.authenticate(req);
|
|
3971
3172
|
if (!authCtx.ok) {
|
|
3972
3173
|
json(res, 401, { error: "unauthorized" });
|
|
@@ -4057,53 +3258,50 @@ function createWorkerServer(options = {}) {
|
|
|
4057
3258
|
});
|
|
4058
3259
|
});
|
|
4059
3260
|
},
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
3261
|
+
drain: async (drainOptions = {}) => {
|
|
3262
|
+
const { timeoutMs = 3e4, pollMs = 250, onProgress } = drainOptions;
|
|
3263
|
+
draining = true;
|
|
3264
|
+
const deadline = Date.now() + timeoutMs;
|
|
3265
|
+
let report = surveyDrain(registry);
|
|
3266
|
+
onProgress?.(report);
|
|
3267
|
+
while (report.working.length > 0 && Date.now() < deadline) {
|
|
3268
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
3269
|
+
const next = surveyDrain(registry);
|
|
3270
|
+
if (!sameDrain(next, report)) onProgress?.(next);
|
|
3271
|
+
report = next;
|
|
3272
|
+
}
|
|
3273
|
+
report = {
|
|
3274
|
+
...surveyDrain(registry),
|
|
3275
|
+
timedOut: false
|
|
3276
|
+
};
|
|
3277
|
+
report.timedOut = report.working.length > 0;
|
|
3278
|
+
onProgress?.(report);
|
|
3279
|
+
return report;
|
|
3280
|
+
},
|
|
3281
|
+
close: () => {
|
|
3282
|
+
closing ??= new Promise((resolve) => {
|
|
3283
|
+
queue?.close();
|
|
3284
|
+
parking.close();
|
|
3285
|
+
registry.closeAll();
|
|
3286
|
+
for (const ws of wss.clients) ws.close(1001, "server shutting down");
|
|
3287
|
+
queueSockets.clear();
|
|
3288
|
+
const force = setTimeout(() => {
|
|
3289
|
+
for (const ws of wss.clients) ws.terminate();
|
|
3290
|
+
}, SOCKET_CLOSE_GRACE_MS);
|
|
3291
|
+
force.unref();
|
|
3292
|
+
wss.close();
|
|
3293
|
+
server.close(() => {
|
|
3294
|
+
clearTimeout(force);
|
|
3295
|
+
resolve();
|
|
3296
|
+
});
|
|
3297
|
+
server.closeAllConnections();
|
|
3298
|
+
});
|
|
3299
|
+
return closing;
|
|
3300
|
+
}
|
|
4070
3301
|
};
|
|
4071
3302
|
}
|
|
4072
3303
|
//#endregion
|
|
4073
3304
|
//#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
3305
|
function sandboxedProviderProfile(name, provider, options = {}) {
|
|
4108
3306
|
return {
|
|
4109
3307
|
name,
|
|
@@ -4120,30 +3318,6 @@ function sandboxedProviderProfile(name, provider, options = {}) {
|
|
|
4120
3318
|
}
|
|
4121
3319
|
//#endregion
|
|
4122
3320
|
//#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
3321
|
async function createProviderRunner(ctx, options) {
|
|
4148
3322
|
const { config, profile, bridge, restore, id } = ctx;
|
|
4149
3323
|
const resolveModel = (modelId) => typeof options.model === "function" ? options.model(modelId) : options.model;
|
|
@@ -4179,7 +3353,6 @@ async function createProviderRunner(ctx, options) {
|
|
|
4179
3353
|
}
|
|
4180
3354
|
//#endregion
|
|
4181
3355
|
//#region src/services/profile-store.ts
|
|
4182
|
-
/** Non-durable store for tests and ephemeral deployments. */
|
|
4183
3356
|
function createMemoryProfileStore(seed = []) {
|
|
4184
3357
|
const profiles = new Map(seed.map((p) => [p.name, p]));
|
|
4185
3358
|
return {
|
|
@@ -4188,14 +3361,6 @@ function createMemoryProfileStore(seed = []) {
|
|
|
4188
3361
|
delete: (name) => void profiles.delete(name)
|
|
4189
3362
|
};
|
|
4190
3363
|
}
|
|
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
3364
|
function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profiles.json")) {
|
|
4200
3365
|
const read = () => {
|
|
4201
3366
|
try {
|