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