@workerdeck/server 0.7.0 → 0.10.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 +145 -5
- package/build/index.mjs +628 -73
- package/build/index.mjs.map +1 -1
- package/package.json +6 -6
package/build/index.mjs
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { closeSync, constants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, createReadStream, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { WebSocketServer } from "ws";
|
|
7
|
-
import {
|
|
8
|
-
import { BrowserBridgeExecutor, SessionRunner, checkClaudeAuth } from "@workerdeck/core";
|
|
7
|
+
import { BrowserBridgeExecutor, SessionRunner, attachmentKind, checkClaudeAuth, getEngineAdapter, normalizeMediaType } from "@workerdeck/core";
|
|
9
8
|
import { JobQueue } from "@workerdeck/queue";
|
|
10
|
-
import {
|
|
9
|
+
import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, supportsPermissionMode } from "@workerdeck/protocol";
|
|
11
10
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
12
11
|
//#region src/host-files.ts
|
|
13
12
|
/**
|
|
@@ -267,7 +266,8 @@ const DEFAULT_IGNORED_DIRS = [
|
|
|
267
266
|
".pytest_cache",
|
|
268
267
|
".gradle",
|
|
269
268
|
"Pods",
|
|
270
|
-
"DerivedData"
|
|
269
|
+
"DerivedData",
|
|
270
|
+
".build"
|
|
271
271
|
];
|
|
272
272
|
/**
|
|
273
273
|
* Breadth-first so shallow files rank first before scoring even runs — for a bare
|
|
@@ -364,6 +364,193 @@ function subsequenceScore(haystack, needle) {
|
|
|
364
364
|
return score - haystack.length / 100;
|
|
365
365
|
}
|
|
366
366
|
//#endregion
|
|
367
|
+
//#region src/attachments.ts
|
|
368
|
+
const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
369
|
+
const DEFAULT_MAX_SESSION_BYTES = 64 * 1024 * 1024;
|
|
370
|
+
/**
|
|
371
|
+
* Per-session hold for files the user attached to a message.
|
|
372
|
+
*
|
|
373
|
+
* In memory, and deliberately so. An attachment is only *needed* for the instant
|
|
374
|
+
* between the upload and the message that names it; everything after that is
|
|
375
|
+
* convenience (a client re-rendering a thumbnail after a reattach). That is the
|
|
376
|
+
* same bargain `GET /sessions/:id/files` makes — the session's lifetime, no
|
|
377
|
+
* durability tier — and it keeps the gateway from accumulating a photo library
|
|
378
|
+
* on disk that nobody asked it to look after.
|
|
379
|
+
*
|
|
380
|
+
* Both caps are enforced here rather than at the route, so a host embedding the
|
|
381
|
+
* server cannot forget one: a single file that is too big is a 413, and so is a
|
|
382
|
+
* session whose total would go over.
|
|
383
|
+
*/
|
|
384
|
+
var AttachmentStore = class {
|
|
385
|
+
#bySession = /* @__PURE__ */ new Map();
|
|
386
|
+
#maxFileBytes;
|
|
387
|
+
#maxSessionBytes;
|
|
388
|
+
constructor(options = {}) {
|
|
389
|
+
this.#maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
|
|
390
|
+
this.#maxSessionBytes = options.maxSessionBytes ?? DEFAULT_MAX_SESSION_BYTES;
|
|
391
|
+
}
|
|
392
|
+
get maxFileBytes() {
|
|
393
|
+
return this.#maxFileBytes;
|
|
394
|
+
}
|
|
395
|
+
put(sessionId, name, mediaType, body) {
|
|
396
|
+
if (body.length === 0) return {
|
|
397
|
+
ok: false,
|
|
398
|
+
error: {
|
|
399
|
+
code: "empty",
|
|
400
|
+
message: "attachment is empty"
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
if (body.length > this.#maxFileBytes) return {
|
|
404
|
+
ok: false,
|
|
405
|
+
error: {
|
|
406
|
+
code: "too_large",
|
|
407
|
+
message: `attachment is larger than the ${this.#maxFileBytes}-byte limit`
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const type = normalizeMediaType(mediaType);
|
|
411
|
+
if (!attachmentKind(type)) return {
|
|
412
|
+
ok: false,
|
|
413
|
+
error: {
|
|
414
|
+
code: "unsupported_type",
|
|
415
|
+
message: `unsupported media type: ${type}`
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
const held = this.#bySession.get(sessionId) ?? /* @__PURE__ */ new Map();
|
|
419
|
+
const heldBytes = [...held.values()].reduce((sum, a) => sum + a.bytes, 0);
|
|
420
|
+
if (heldBytes + body.length > this.#maxSessionBytes) return {
|
|
421
|
+
ok: false,
|
|
422
|
+
error: {
|
|
423
|
+
code: "session_full",
|
|
424
|
+
message: `session is already holding ${heldBytes} bytes of attachments (limit ${this.#maxSessionBytes})`
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
const attachment = {
|
|
428
|
+
id: randomUUID(),
|
|
429
|
+
name: safeName(name),
|
|
430
|
+
mediaType: type,
|
|
431
|
+
bytes: body.length,
|
|
432
|
+
data: body.toString("base64")
|
|
433
|
+
};
|
|
434
|
+
held.set(attachment.id, attachment);
|
|
435
|
+
this.#bySession.set(sessionId, held);
|
|
436
|
+
return {
|
|
437
|
+
ok: true,
|
|
438
|
+
attachment: ref(attachment)
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/** The stored record, bytes included — for the download route and for the send
|
|
442
|
+
* path that turns ids into content blocks. */
|
|
443
|
+
get(sessionId, id) {
|
|
444
|
+
return this.#bySession.get(sessionId)?.get(id);
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Resolve the ids a `user_message` named, in the order given.
|
|
448
|
+
*
|
|
449
|
+
* Missing ids are reported rather than skipped: a message that quietly lost its
|
|
450
|
+
* picture reads as the model ignoring it, which is a far worse failure than a
|
|
451
|
+
* command that errors.
|
|
452
|
+
*/
|
|
453
|
+
resolve(sessionId, ids) {
|
|
454
|
+
const held = this.#bySession.get(sessionId);
|
|
455
|
+
const attachments = [];
|
|
456
|
+
const missing = [];
|
|
457
|
+
for (const id of ids) {
|
|
458
|
+
const found = held?.get(id);
|
|
459
|
+
if (found) attachments.push(found);
|
|
460
|
+
else missing.push(id);
|
|
461
|
+
}
|
|
462
|
+
return missing.length ? {
|
|
463
|
+
ok: false,
|
|
464
|
+
missing
|
|
465
|
+
} : {
|
|
466
|
+
ok: true,
|
|
467
|
+
attachments
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
drop(sessionId) {
|
|
471
|
+
this.#bySession.delete(sessionId);
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
function ref(attachment) {
|
|
475
|
+
return {
|
|
476
|
+
id: attachment.id,
|
|
477
|
+
name: attachment.name,
|
|
478
|
+
mediaType: attachment.mediaType,
|
|
479
|
+
bytes: attachment.bytes
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* A display name, not a path. The name is echoed back to clients and put in front
|
|
484
|
+
* of the model in the text-attachment envelope, so directory separators, control
|
|
485
|
+
* characters and unbounded length all come off here.
|
|
486
|
+
*/
|
|
487
|
+
function safeName(name) {
|
|
488
|
+
const cleaned = (name.split(/[/\\]/).pop() ?? "").replace(/[\u0000-\u001f\u007f"<>]/g, "").trim();
|
|
489
|
+
if (cleaned === "" || cleaned === "." || cleaned === "..") return "attachment";
|
|
490
|
+
return cleaned.length > 120 ? cleaned.slice(0, 120) : cleaned;
|
|
491
|
+
}
|
|
492
|
+
//#endregion
|
|
493
|
+
//#region src/produced-files.ts
|
|
494
|
+
/**
|
|
495
|
+
* The paths this gateway will serve from `GET /sessions/:id/produced/:fileId`.
|
|
496
|
+
*
|
|
497
|
+
* **This is the whole access-control model, so it is worth being precise about
|
|
498
|
+
* what it is.** The store is an allowlist built from one source and one only:
|
|
499
|
+
* `file_produced` events, which a runner emits about a file its own engine just
|
|
500
|
+
* wrote. It is not a directory grant. Nothing else can add to it — not a
|
|
501
|
+
* request, not a config, and in particular not the agent, whose own path claims
|
|
502
|
+
* go through `/fs/*` and that route's root allowlist.
|
|
503
|
+
*
|
|
504
|
+
* That is why the route needs neither `hostFiles.roots` nor `maxFileBytes`:
|
|
505
|
+
* "somewhere under a root the operator declared" is a guess about which paths
|
|
506
|
+
* are safe, while "the exact path this session's runner reported producing" is
|
|
507
|
+
* a fact about one file. A 2 MB generated PNG is the common case, and making
|
|
508
|
+
* the operator raise a byte cap to see their own picture was the bug this
|
|
509
|
+
* replaces.
|
|
510
|
+
*
|
|
511
|
+
* Lifetime is the session's, like `AttachmentStore`'s: in memory, dropped when
|
|
512
|
+
* the session is removed. The bytes are never held here — only the path, so a
|
|
513
|
+
* gateway serving a long session accumulates a few hundred bytes per picture
|
|
514
|
+
* rather than the pictures.
|
|
515
|
+
*/
|
|
516
|
+
var ProducedFileStore = class {
|
|
517
|
+
#bySession = /* @__PURE__ */ new Map();
|
|
518
|
+
/**
|
|
519
|
+
* Register a runner's produced files for its lifetime.
|
|
520
|
+
*
|
|
521
|
+
* Subscribes from seq 0, which is the opposite of what `SessionNotifier` wants
|
|
522
|
+
* and correct for the same reason: registration is idempotent (a `fileId` is
|
|
523
|
+
* derived from its path, so re-registering overwrites with itself), and a
|
|
524
|
+
* session rebuilt from a park must re-learn every file it produced before the
|
|
525
|
+
* park — otherwise a client's transcript keeps rendering image cards whose
|
|
526
|
+
* bytes have quietly become unreachable.
|
|
527
|
+
*/
|
|
528
|
+
watch(runner) {
|
|
529
|
+
runner.subscribe((event) => {
|
|
530
|
+
if (event.type !== "file_produced") return;
|
|
531
|
+
const held = this.#bySession.get(runner.id) ?? /* @__PURE__ */ new Map();
|
|
532
|
+
held.set(event.fileId, {
|
|
533
|
+
fileId: event.fileId,
|
|
534
|
+
path: event.path,
|
|
535
|
+
...event.mediaType ? { mediaType: event.mediaType } : {},
|
|
536
|
+
...event.bytes !== void 0 ? { bytes: event.bytes } : {},
|
|
537
|
+
sessionId: runner.id
|
|
538
|
+
});
|
|
539
|
+
this.#bySession.set(runner.id, held);
|
|
540
|
+
}, 0);
|
|
541
|
+
}
|
|
542
|
+
get(sessionId, fileId) {
|
|
543
|
+
return this.#bySession.get(sessionId)?.get(fileId);
|
|
544
|
+
}
|
|
545
|
+
/** Everything one session has produced, newest registration last. */
|
|
546
|
+
list(sessionId) {
|
|
547
|
+
return [...this.#bySession.get(sessionId)?.values() ?? []];
|
|
548
|
+
}
|
|
549
|
+
drop(sessionId) {
|
|
550
|
+
this.#bySession.delete(sessionId);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
//#endregion
|
|
367
554
|
//#region src/registry.ts
|
|
368
555
|
/** In-memory session table. Terminal sessions stay listed until removed or the process exits. */
|
|
369
556
|
var SessionRegistry = class {
|
|
@@ -1092,18 +1279,6 @@ function parseRecord(value) {
|
|
|
1092
1279
|
}
|
|
1093
1280
|
//#endregion
|
|
1094
1281
|
//#region src/server.ts
|
|
1095
|
-
const defaultSdkSessionLister = async (options) => {
|
|
1096
|
-
return (await listSessions(options)).map((s) => ({
|
|
1097
|
-
sessionId: s.sessionId,
|
|
1098
|
-
summary: s.summary,
|
|
1099
|
-
lastModified: s.lastModified,
|
|
1100
|
-
createdAt: s.createdAt,
|
|
1101
|
-
customTitle: s.customTitle,
|
|
1102
|
-
firstPrompt: s.firstPrompt,
|
|
1103
|
-
gitBranch: s.gitBranch,
|
|
1104
|
-
cwd: s.cwd
|
|
1105
|
-
}));
|
|
1106
|
-
};
|
|
1107
1282
|
function json(res, status, body) {
|
|
1108
1283
|
const payload = JSON.stringify(body);
|
|
1109
1284
|
res.writeHead(status, {
|
|
@@ -1123,6 +1298,18 @@ async function readJsonBody(req, maxBytes) {
|
|
|
1123
1298
|
if (size === 0) return {};
|
|
1124
1299
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
1125
1300
|
}
|
|
1301
|
+
/** Body as bytes, refusing anything over `maxBytes`. Attachments are the one
|
|
1302
|
+
* thing this server takes that isn't JSON. */
|
|
1303
|
+
async function readRawBody(req, maxBytes) {
|
|
1304
|
+
const chunks = [];
|
|
1305
|
+
let size = 0;
|
|
1306
|
+
for await (const chunk of req) {
|
|
1307
|
+
size += chunk.length;
|
|
1308
|
+
if (size > maxBytes) throw new Error("request body too large");
|
|
1309
|
+
chunks.push(chunk);
|
|
1310
|
+
}
|
|
1311
|
+
return Buffer.concat(chunks);
|
|
1312
|
+
}
|
|
1126
1313
|
/**
|
|
1127
1314
|
* Curated, view-only snapshot of a profile's config dir for GET /profiles/:name.
|
|
1128
1315
|
* Best-effort: a missing or unparseable settings.json just omits the settings block.
|
|
@@ -1210,6 +1397,10 @@ function contentTypeFor(filename) {
|
|
|
1210
1397
|
function isProviderProfile(profile) {
|
|
1211
1398
|
return profile.engine === "provider";
|
|
1212
1399
|
}
|
|
1400
|
+
/** The engine a profile runs, absent meaning 'claude' (pre-provider profiles). */
|
|
1401
|
+
function engineOf(profile) {
|
|
1402
|
+
return profile?.engine ?? "claude";
|
|
1403
|
+
}
|
|
1213
1404
|
/** Where the CLI's own resolution lands for a given environment: an explicit
|
|
1214
1405
|
* CLAUDE_CONFIG_DIR, else ~/.claude. */
|
|
1215
1406
|
function cliConfigDir(env) {
|
|
@@ -1264,6 +1455,8 @@ function createWorkerServer(options = {}) {
|
|
|
1264
1455
|
const basePath = options.basePath ?? "/v1";
|
|
1265
1456
|
const fallback = options.fallback;
|
|
1266
1457
|
const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
|
|
1458
|
+
/** The engine's adapter, honoring the test-only `engines` override. */
|
|
1459
|
+
const adapterFor = (engine) => options.engines?.[engine ?? "claude"] ?? getEngineAdapter(engine);
|
|
1267
1460
|
const hostBuildRunnerConfig = options.buildRunnerConfig ?? ((req) => req);
|
|
1268
1461
|
const declared = options.profiles ?? detectDefaultProfiles();
|
|
1269
1462
|
const declaredByName = new Map(declared.map((p) => [p.name, p]));
|
|
@@ -1277,10 +1470,13 @@ function createWorkerServer(options = {}) {
|
|
|
1277
1470
|
if (isProviderProfile(p)) {
|
|
1278
1471
|
if (!p.provider?.id) return `provider profile '${p.name}' is missing provider.id`;
|
|
1279
1472
|
if (!options.createEngineRunner) return `profile '${p.name}' uses engine 'provider' but no \`createEngineRunner\` was provided to build one`;
|
|
1473
|
+
} else if (p.engine === "codex") {
|
|
1474
|
+
if (p.codexHome && !existsSync(p.codexHome)) return `profile '${p.name}' codexHome does not exist: ${p.codexHome}`;
|
|
1475
|
+
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`;
|
|
1280
1476
|
} else if (!p.configDir || !existsSync(p.configDir)) return `profile '${p.name}' configDir does not exist: ${p.configDir}`;
|
|
1281
1477
|
if (options.disableBypassPermissions && p.defaults?.permissionMode === "bypassPermissions") return `profile '${p.name}' defaults to bypassPermissions but disableBypassPermissions is set`;
|
|
1282
1478
|
const fallbackMode = p.defaults?.permissionMode;
|
|
1283
|
-
if (fallbackMode && !supportsPermissionMode(p.engine, fallbackMode)) return `profile '${p.name}' defaults to permission mode '${fallbackMode}', which engine '${p
|
|
1479
|
+
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(", ")})`;
|
|
1284
1480
|
return null;
|
|
1285
1481
|
};
|
|
1286
1482
|
for (const p of options.profiles ?? []) {
|
|
@@ -1304,6 +1500,31 @@ function createWorkerServer(options = {}) {
|
|
|
1304
1500
|
...p,
|
|
1305
1501
|
managed: true
|
|
1306
1502
|
};
|
|
1503
|
+
/**
|
|
1504
|
+
* Response shape for a profile: the managed marker, the engine's capability
|
|
1505
|
+
* record, its static model catalog (correct from the first request — no
|
|
1506
|
+
* warm-up session, no process spawned), the availability verdict when one
|
|
1507
|
+
* has been probed, and the learned default model (the one thing a static
|
|
1508
|
+
* catalog cannot know: a claude profile's default is the operator's CLI
|
|
1509
|
+
* config, so it stays absent until a session on the profile reports it).
|
|
1510
|
+
* Read-only decoration — never persisted.
|
|
1511
|
+
*/
|
|
1512
|
+
const forResponse = (p) => {
|
|
1513
|
+
const adapter = adapterFor(p.engine);
|
|
1514
|
+
const base = {
|
|
1515
|
+
...withManagedFlag(p),
|
|
1516
|
+
capabilities: adapter.capabilities
|
|
1517
|
+
};
|
|
1518
|
+
if (adapter.catalog.models.length > 0) base.models = adapter.catalog.models;
|
|
1519
|
+
const defaultModel = profileDefaultModels.get(p.name);
|
|
1520
|
+
if (defaultModel) base.defaultModel = defaultModel;
|
|
1521
|
+
const probed = availability.get(p.name)?.verdict;
|
|
1522
|
+
if (probed && probed.available !== "unknown") {
|
|
1523
|
+
base.available = probed.available;
|
|
1524
|
+
if (probed.available === false) base.unavailableReason = probed.reason;
|
|
1525
|
+
}
|
|
1526
|
+
return base;
|
|
1527
|
+
};
|
|
1307
1528
|
/** Declared profiles first: a name collision means the code wins, and the stored
|
|
1308
1529
|
* one is unreachable rather than silently overriding server options. */
|
|
1309
1530
|
const allProfiles = () => [...declared, ...[...stored.values()].filter((p) => !declaredByName.has(p.name))];
|
|
@@ -1377,28 +1598,42 @@ function createWorkerServer(options = {}) {
|
|
|
1377
1598
|
* into 'default' by whatever assembles its runner. Returns an error message. */
|
|
1378
1599
|
const checkPermissionMode = (mode, profile) => {
|
|
1379
1600
|
if (mode === void 0 || supportsPermissionMode(profile?.engine, mode)) return null;
|
|
1380
|
-
return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${profile
|
|
1601
|
+
return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${engineOf(profile)}') — supported: ` + adapterFor(profile?.engine).capabilities.permissionModes.join(", ");
|
|
1381
1602
|
};
|
|
1382
1603
|
/**
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
1385
|
-
*
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
1604
|
+
* Refuse the request fields the resolved profile's engine cannot honor —
|
|
1605
|
+
* read off its capability record, so the create form's filtering and the
|
|
1606
|
+
* API boundary can never disagree. Refusing beats coercing: a caller who
|
|
1607
|
+
* asked for something the engine has no meaning for should be told, not
|
|
1608
|
+
* left wondering where the option went. Also enforces the provider grant
|
|
1609
|
+
* rules (capabilities narrow, never widen; MCP servers are the profile's to
|
|
1610
|
+
* declare — MCP tools are authoritative, server-side, with server
|
|
1611
|
+
* credentials, so honoring a client-supplied server would let a caller
|
|
1612
|
+
* point an authoritative tool anywhere it liked).
|
|
1392
1613
|
*/
|
|
1393
1614
|
const checkEngineGrants = (req, profile) => {
|
|
1615
|
+
const engine = engineOf(profile);
|
|
1616
|
+
const caps = adapterFor(profile?.engine).capabilities;
|
|
1617
|
+
const name = profile?.name ?? "default";
|
|
1618
|
+
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`;
|
|
1619
|
+
if (!caps.budgets && (req.maxTurns !== void 0 || req.maxBudgetUsd !== void 0)) return `the ${engine} engine does not honor maxTurns/maxBudgetUsd`;
|
|
1620
|
+
if (!caps.settingSources && req.settingSources !== void 0) return `the ${engine} engine does not load settingSources`;
|
|
1621
|
+
if (!caps.resume && req.resume !== void 0) return `the ${engine} engine cannot resume a session`;
|
|
1622
|
+
if (req.forkSession && engine !== "claude") return `the ${engine} engine cannot fork a resumed session`;
|
|
1623
|
+
if (req.reasoningEffort !== void 0 && (!caps.reasoningEfforts || caps.reasoningEfforts.length === 0)) return `the ${engine} engine does not take a reasoningEffort`;
|
|
1394
1624
|
if (!profile || !isProviderProfile(profile)) return null;
|
|
1395
|
-
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`;
|
|
1396
1625
|
const granted = profile.session?.capabilities;
|
|
1397
1626
|
if (!req.capabilities || !granted) return null;
|
|
1398
1627
|
const ungranted = req.capabilities.filter((c) => !granted.includes(c));
|
|
1399
1628
|
if (ungranted.length === 0) return null;
|
|
1400
1629
|
return `profile '${profile.name}' does not grant: ${ungranted.join(", ")} (granted: ${granted.join(", ") || "none"}) — a request may narrow capabilities, not widen them`;
|
|
1401
1630
|
};
|
|
1631
|
+
/** Drop request fields that are meaningless (not wrong) for the engine —
|
|
1632
|
+
* today just `questionBehavior` where no approval channel exists, so job
|
|
1633
|
+
* webhooks never grow phantom permission_requested expectations. */
|
|
1634
|
+
const stripInertFields = (req, profile) => {
|
|
1635
|
+
if (!adapterFor(profile?.engine).capabilities.interactiveApprovals) delete req.questionBehavior;
|
|
1636
|
+
};
|
|
1402
1637
|
/** Profile-aware config hook: fill the profile's defaults into unset request fields,
|
|
1403
1638
|
* run the host hook, then pin CLAUDE_CONFIG_DIR — the profile wins even when the
|
|
1404
1639
|
* host hook set its own env (see `claudeSessionEnv` for the one case the pin is
|
|
@@ -1411,7 +1646,7 @@ function createWorkerServer(options = {}) {
|
|
|
1411
1646
|
model: req.model ?? profile.defaults?.model ?? profile.provider?.model,
|
|
1412
1647
|
permissionMode: req.permissionMode ?? profile.defaults?.permissionMode
|
|
1413
1648
|
});
|
|
1414
|
-
if (
|
|
1649
|
+
if (engineOf(profile) !== "claude") return config;
|
|
1415
1650
|
const base = config.env ?? process.env;
|
|
1416
1651
|
const env = claudeSessionEnv(profile, base);
|
|
1417
1652
|
return env === base ? config : {
|
|
@@ -1435,8 +1670,11 @@ function createWorkerServer(options = {}) {
|
|
|
1435
1670
|
bridge,
|
|
1436
1671
|
restore
|
|
1437
1672
|
});
|
|
1438
|
-
|
|
1439
|
-
|
|
1673
|
+
return adapterFor(profile?.engine).createRunner({
|
|
1674
|
+
config,
|
|
1675
|
+
profile,
|
|
1676
|
+
restore
|
|
1677
|
+
});
|
|
1440
1678
|
};
|
|
1441
1679
|
const createRunner = async (config) => {
|
|
1442
1680
|
const runner = registry.register(await buildRunner(config));
|
|
@@ -1483,7 +1721,26 @@ function createWorkerServer(options = {}) {
|
|
|
1483
1721
|
};
|
|
1484
1722
|
};
|
|
1485
1723
|
const notifier = new SessionNotifier(options.notifications ?? {});
|
|
1486
|
-
|
|
1724
|
+
/**
|
|
1725
|
+
* What each claude profile's *default* model resolves to, learned from the
|
|
1726
|
+
* `capabilities` events of sessions that ran on it. The model *list* is the
|
|
1727
|
+
* adapter's static catalog now; the default is the one thing a catalog
|
|
1728
|
+
* cannot know (it is the operator's CLI config), so it alone is still
|
|
1729
|
+
* learned — and still absent on a cold server, the accepted regression.
|
|
1730
|
+
*/
|
|
1731
|
+
const profileDefaultModels = /* @__PURE__ */ new Map();
|
|
1732
|
+
const producedFiles = new ProducedFileStore();
|
|
1733
|
+
const registry = new SessionRegistry({ onRegister: (runner) => {
|
|
1734
|
+
notifier.watch(runner);
|
|
1735
|
+
producedFiles.watch(runner);
|
|
1736
|
+
const profile = runner.info().profile;
|
|
1737
|
+
if (!profile) return;
|
|
1738
|
+
runner.subscribe((event) => {
|
|
1739
|
+
if (event.type !== "capabilities" || !event.defaultModel) return;
|
|
1740
|
+
profileDefaultModels.set(profile, event.defaultModel);
|
|
1741
|
+
});
|
|
1742
|
+
} });
|
|
1743
|
+
const attachmentStore = new AttachmentStore(options.attachments);
|
|
1487
1744
|
const bridge = new BridgeHub({
|
|
1488
1745
|
...options.bridge,
|
|
1489
1746
|
onResult: (sessionId, executionId, result) => {
|
|
@@ -1556,31 +1813,70 @@ function createWorkerServer(options = {}) {
|
|
|
1556
1813
|
});
|
|
1557
1814
|
};
|
|
1558
1815
|
/**
|
|
1559
|
-
*
|
|
1560
|
-
*
|
|
1561
|
-
*
|
|
1562
|
-
*
|
|
1563
|
-
*
|
|
1816
|
+
* Availability, per profile: the adapter's probe run over the env the real
|
|
1817
|
+
* assembly path produces (so anything the host hook injects — a
|
|
1818
|
+
* CLAUDE_CODE_OAUTH_TOKEN, say — counts as logged in). Cached, and served on
|
|
1819
|
+
* `GET /profiles` as `available`/`unavailableReason`.
|
|
1820
|
+
*
|
|
1821
|
+
* Gated on `checkCredentials` like the old claude-only preflight (this is a
|
|
1822
|
+
* library; `pnpm test` must spawn nothing unless a test injects fake
|
|
1823
|
+
* adapters or probes). 'unknown' stays out of the cache's answers: a probe
|
|
1824
|
+
* that couldn't run is not evidence of a missing login. **Display-only**
|
|
1825
|
+
* downstream — session create against an unavailable profile still proceeds
|
|
1826
|
+
* and fails with the engine's own error, because the probe can be stale in
|
|
1827
|
+
* both directions and refusing on it would turn a probe bug into an outage.
|
|
1564
1828
|
*/
|
|
1565
|
-
const
|
|
1829
|
+
const availability = /* @__PURE__ */ new Map();
|
|
1830
|
+
const AVAILABILITY_TTL_MS = 6e4;
|
|
1831
|
+
/** Profiles already warned about on the console, so re-probes don't spam. */
|
|
1832
|
+
const availabilityWarned = /* @__PURE__ */ new Set();
|
|
1833
|
+
const sessionEnvFor = (profile) => {
|
|
1834
|
+
try {
|
|
1835
|
+
return buildRunnerConfig({
|
|
1836
|
+
cwd: process.cwd(),
|
|
1837
|
+
profile: profile.name
|
|
1838
|
+
}).env ?? process.env;
|
|
1839
|
+
} catch {
|
|
1840
|
+
return engineOf(profile) === "claude" ? claudeSessionEnv(profile, process.env) : process.env;
|
|
1841
|
+
}
|
|
1842
|
+
};
|
|
1843
|
+
const probeProfile = (profile) => {
|
|
1566
1844
|
if (!options.checkCredentials) return;
|
|
1567
1845
|
const conf = options.checkCredentials === true ? {} : options.checkCredentials;
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1846
|
+
availability.set(profile.name, {
|
|
1847
|
+
verdict: availability.get(profile.name)?.verdict ?? { available: "unknown" },
|
|
1848
|
+
at: Date.now()
|
|
1849
|
+
});
|
|
1850
|
+
const adapter = adapterFor(profile.engine);
|
|
1851
|
+
const claudeProbe = engineOf(profile) !== "claude" ? void 0 : conf.probe ?? (conf.timeoutMs !== void 0 ? (env) => checkClaudeAuth(env, { timeoutMs: conf.timeoutMs }) : void 0);
|
|
1852
|
+
(claudeProbe ? claudeProbe(sessionEnvFor(profile)).then((status) => status === "logged_in" ? { available: true } : status === "logged_out" ? {
|
|
1853
|
+
available: false,
|
|
1854
|
+
reason: "no usable Claude credentials for this profile"
|
|
1855
|
+
} : { available: "unknown" }) : adapter.checkAvailability(profile, sessionEnvFor(profile))).then((verdict) => {
|
|
1856
|
+
availability.set(profile.name, {
|
|
1857
|
+
verdict,
|
|
1858
|
+
at: Date.now()
|
|
1859
|
+
});
|
|
1860
|
+
if (verdict.available === false && !availabilityWarned.has(profile.name)) {
|
|
1861
|
+
availabilityWarned.add(profile.name);
|
|
1862
|
+
console.warn(`[workerdeck] Profile '${profile.name}' is unavailable: ${verdict.reason} (\`checkCredentials: false\` disables this check)`);
|
|
1579
1863
|
}
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1864
|
+
if (verdict.available === true) availabilityWarned.delete(profile.name);
|
|
1865
|
+
}).catch(() => {});
|
|
1866
|
+
};
|
|
1867
|
+
/** Launch-time sweep, concurrent and fire-and-forget. */
|
|
1868
|
+
const preflightCredentials = () => {
|
|
1869
|
+
for (const profile of allProfiles()) probeProfile(profile);
|
|
1870
|
+
};
|
|
1871
|
+
/** Lazy re-probe on reads, so an operator who just ran `codex login` (or
|
|
1872
|
+
* exported a key) sees the profile go green without a restart. Serves the
|
|
1873
|
+
* cached verdict now; the refreshed one lands on the next request. */
|
|
1874
|
+
const refreshAvailability = (profiles) => {
|
|
1875
|
+
if (!options.checkCredentials) return;
|
|
1876
|
+
const now = Date.now();
|
|
1877
|
+
for (const profile of profiles) {
|
|
1878
|
+
const cached = availability.get(profile.name);
|
|
1879
|
+
if (!cached || now - cached.at > AVAILABILITY_TTL_MS) probeProfile(profile);
|
|
1584
1880
|
}
|
|
1585
1881
|
};
|
|
1586
1882
|
const authenticate = async (req) => {
|
|
@@ -1609,6 +1905,21 @@ function createWorkerServer(options = {}) {
|
|
|
1609
1905
|
id: decodeURIComponent(parts[0]),
|
|
1610
1906
|
permissionId: decodeURIComponent(parts[2])
|
|
1611
1907
|
};
|
|
1908
|
+
if (parts.length <= 3 && parts[1] === "attachments") return {
|
|
1909
|
+
id: decodeURIComponent(parts[0]),
|
|
1910
|
+
attachments: true,
|
|
1911
|
+
attachmentId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
|
|
1912
|
+
};
|
|
1913
|
+
if (parts.length <= 3 && parts[1] === "produced") return {
|
|
1914
|
+
id: decodeURIComponent(parts[0]),
|
|
1915
|
+
produced: true,
|
|
1916
|
+
producedFileId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
|
|
1917
|
+
};
|
|
1918
|
+
if (parts.length <= 3 && parts[1] === "mcp") return {
|
|
1919
|
+
id: decodeURIComponent(parts[0]),
|
|
1920
|
+
mcp: true,
|
|
1921
|
+
mcpServer: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
|
|
1922
|
+
};
|
|
1612
1923
|
if (parts.length >= 2 && parts[1] === "files") {
|
|
1613
1924
|
const filePath = parts.slice(2).map(decodeURIComponent).join("/");
|
|
1614
1925
|
return {
|
|
@@ -1625,6 +1936,172 @@ function createWorkerServer(options = {}) {
|
|
|
1625
1936
|
const maxHostFileBytes = options.hostFiles?.maxFileBytes ?? 1024 * 1024;
|
|
1626
1937
|
const maxHostDirEntries = options.hostFiles?.maxEntries ?? 5e3;
|
|
1627
1938
|
/**
|
|
1939
|
+
* `{basePath}/sessions/:id/attachments` — the files a client sends with a message.
|
|
1940
|
+
*
|
|
1941
|
+
* `POST ?name=<name>` takes the raw bytes as the body and the media type from
|
|
1942
|
+
* the `content-type` header; there is no multipart parsing here on purpose, so
|
|
1943
|
+
* a phone and a browser both upload with one plain request and this file stays
|
|
1944
|
+
* dependency-free. `GET /:attachmentId` hands the bytes back for thumbnails.
|
|
1945
|
+
*
|
|
1946
|
+
* The download always answers `content-disposition: attachment` and `nosniff`,
|
|
1947
|
+
* the same as `/files`: an upload is client-supplied content served from the
|
|
1948
|
+
* gateway's own origin, and it must never render as a document there. (An
|
|
1949
|
+
* `<img src>` is unaffected — disposition does not apply to subresources.)
|
|
1950
|
+
*/
|
|
1951
|
+
const handleAttachments = async (req, res, sessionId, session, attachmentId) => {
|
|
1952
|
+
if (req.method === "POST" && attachmentId === void 0) {
|
|
1953
|
+
const url = new URL(req.url ?? "/", "http://internal");
|
|
1954
|
+
const mediaType = req.headers["content-type"];
|
|
1955
|
+
if (!mediaType) {
|
|
1956
|
+
json(res, 400, { error: "content-type header is required" });
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
const accepted = (session.capabilities ?? ENGINE_CAPABILITIES[session.engine ?? "claude"]).attachments;
|
|
1960
|
+
const kind = attachmentKind(mediaType);
|
|
1961
|
+
if (kind && !accepted.includes(kind === "document" ? "pdf" : kind)) {
|
|
1962
|
+
json(res, 415, { error: `the ${session.engine ?? "claude"} engine does not accept ${kind} attachments` });
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
let body;
|
|
1966
|
+
try {
|
|
1967
|
+
body = await readRawBody(req, attachmentStore.maxFileBytes);
|
|
1968
|
+
} catch {
|
|
1969
|
+
json(res, 413, { error: "attachment is larger than the limit" });
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
const result = attachmentStore.put(sessionId, url.searchParams.get("name") ?? "attachment", mediaType, body);
|
|
1973
|
+
if (!result.ok) {
|
|
1974
|
+
json(res, result.error.code === "unsupported_type" ? 415 : result.error.code === "empty" ? 400 : 413, { error: result.error.message });
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
json(res, 201, { attachment: result.attachment });
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
if (req.method === "GET" && attachmentId !== void 0) {
|
|
1981
|
+
const found = attachmentStore.get(sessionId, attachmentId);
|
|
1982
|
+
if (!found) {
|
|
1983
|
+
json(res, 404, { error: "attachment not found" });
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
const bytes = Buffer.from(found.data, "base64");
|
|
1987
|
+
res.writeHead(200, {
|
|
1988
|
+
"content-type": found.mediaType,
|
|
1989
|
+
"content-length": bytes.length,
|
|
1990
|
+
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(found.name)}`,
|
|
1991
|
+
"x-content-type-options": "nosniff"
|
|
1992
|
+
});
|
|
1993
|
+
res.end(bytes);
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
json(res, 405, { error: "method not allowed" });
|
|
1997
|
+
};
|
|
1998
|
+
/**
|
|
1999
|
+
* `{basePath}/sessions/:id/mcp` — the session's MCP servers, and the three
|
|
2000
|
+
* things the CLI's own `/mcp` screen can do to one (reconnect, enable, disable).
|
|
2001
|
+
*
|
|
2002
|
+
* Every answer goes through `mcpStatusInfo`, which is where the servers' `env`
|
|
2003
|
+
* and `headers` are dropped: reading this route must not be a way to read the
|
|
2004
|
+
* operator's API tokens.
|
|
2005
|
+
*/
|
|
2006
|
+
const handleMcp = async (req, res, runner, serverName) => {
|
|
2007
|
+
const listServers = async () => {
|
|
2008
|
+
const servers = await runner.mcpServers?.();
|
|
2009
|
+
if (!servers) {
|
|
2010
|
+
json(res, 501, { error: "this session does not report MCP servers" });
|
|
2011
|
+
return false;
|
|
2012
|
+
}
|
|
2013
|
+
json(res, 200, { servers });
|
|
2014
|
+
return true;
|
|
2015
|
+
};
|
|
2016
|
+
if (req.method === "GET" && serverName === void 0) {
|
|
2017
|
+
await listServers();
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
if (req.method === "POST" && serverName !== void 0) {
|
|
2021
|
+
const body = await readJsonBody(req, maxBodyBytes);
|
|
2022
|
+
if (body?.action !== "reconnect" && body?.action !== "enable" && body?.action !== "disable") {
|
|
2023
|
+
json(res, 400, { error: "action must be 'reconnect', 'enable' or 'disable'" });
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if (!(body.action === "reconnect" ? typeof runner.reconnectMcpServer === "function" : typeof runner.setMcpServerEnabled === "function")) {
|
|
2027
|
+
json(res, 501, { error: `this session's engine cannot ${body.action} an MCP server` });
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
try {
|
|
2031
|
+
if (body.action === "reconnect") await runner.reconnectMcpServer?.(serverName);
|
|
2032
|
+
else await runner.setMcpServerEnabled?.(serverName, body.action === "enable");
|
|
2033
|
+
} catch (error) {
|
|
2034
|
+
json(res, 400, { error: error instanceof Error ? error.message : "MCP action failed" });
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
await listServers();
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
json(res, 405, { error: "method not allowed" });
|
|
2041
|
+
};
|
|
2042
|
+
/**
|
|
2043
|
+
* `{basePath}/sessions/:id/produced[/:fileId]` — files this session's ENGINE
|
|
2044
|
+
* wrote on the host (codex's generated images), listed and served.
|
|
2045
|
+
*
|
|
2046
|
+
* The one route here with no root allowlist and no byte cap, and the comment
|
|
2047
|
+
* on {@link ProducedFileStore} is the argument for why that is right rather
|
|
2048
|
+
* than lax: the allowlist is the exact set of paths this session's own runner
|
|
2049
|
+
* announced producing. It is emphatically NOT a hole in `/fs/*` — a path the
|
|
2050
|
+
* *agent* named is not a produced file and never enters this store.
|
|
2051
|
+
*
|
|
2052
|
+
* Everything else matches the attachment download: `nosniff` and an attachment
|
|
2053
|
+
* disposition, because these bytes are model-authored and must not render as a
|
|
2054
|
+
* document on the gateway's origin. (`<img src>` is unaffected — disposition
|
|
2055
|
+
* does not apply to subresources, which is the whole point.)
|
|
2056
|
+
*/
|
|
2057
|
+
const handleProducedFiles = async (req, res, sessionId, fileId) => {
|
|
2058
|
+
if (req.method !== "GET") {
|
|
2059
|
+
json(res, 405, { error: "method not allowed" });
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
if (fileId === void 0) {
|
|
2063
|
+
json(res, 200, { files: producedFiles.list(sessionId).map(({ fileId: id, path, mediaType, bytes }) => ({
|
|
2064
|
+
fileId: id,
|
|
2065
|
+
path,
|
|
2066
|
+
...mediaType ? { mediaType } : {},
|
|
2067
|
+
...bytes !== void 0 ? { bytes } : {}
|
|
2068
|
+
})) });
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
const found = producedFiles.get(sessionId, fileId);
|
|
2072
|
+
if (!found) {
|
|
2073
|
+
json(res, 404, { error: "no such produced file" });
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
let stat;
|
|
2077
|
+
try {
|
|
2078
|
+
stat = statSync(found.path);
|
|
2079
|
+
} catch {
|
|
2080
|
+
json(res, 404, { error: "produced file is no longer on disk" });
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
if (!stat.isFile()) {
|
|
2084
|
+
json(res, 404, { error: "produced file is not a regular file" });
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
const filename = basename(found.path) || "file";
|
|
2088
|
+
res.writeHead(200, {
|
|
2089
|
+
"content-type": found.mediaType ?? contentTypeFor(filename),
|
|
2090
|
+
"content-length": stat.size,
|
|
2091
|
+
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
2092
|
+
"x-content-type-options": "nosniff"
|
|
2093
|
+
});
|
|
2094
|
+
await new Promise((done) => {
|
|
2095
|
+
const stream = createReadStream(found.path);
|
|
2096
|
+
stream.on("error", () => {
|
|
2097
|
+
res.destroy();
|
|
2098
|
+
done();
|
|
2099
|
+
});
|
|
2100
|
+
stream.on("close", () => done());
|
|
2101
|
+
stream.pipe(res);
|
|
2102
|
+
});
|
|
2103
|
+
};
|
|
2104
|
+
/**
|
|
1628
2105
|
* `{basePath}/fs/*` — the operator's real tree. Authorized by the auth key alone
|
|
1629
2106
|
* and deliberately outside the agent permission flow: the caller is the operator.
|
|
1630
2107
|
*
|
|
@@ -1847,8 +2324,19 @@ function createWorkerServer(options = {}) {
|
|
|
1847
2324
|
}
|
|
1848
2325
|
json(res, 404, { error: "not found" });
|
|
1849
2326
|
};
|
|
1850
|
-
|
|
1851
|
-
|
|
2327
|
+
/**
|
|
2328
|
+
* `GET /sdk-sessions`, engine-aware: `?profile=` names whose on-disk store to
|
|
2329
|
+
* list, and the profile's engine adapter answers (for codex, over a
|
|
2330
|
+
* short-lived `thread/list` child — no live session involved). Absent
|
|
2331
|
+
* `profile`, the choice is implicit when the server declares exactly one
|
|
2332
|
+
* profile (the resolveProfile rule); with several, the Claude engine's
|
|
2333
|
+
* global store is listed — the pre-engine-aware behavior every existing
|
|
2334
|
+
* caller already gets, kept because old clients cannot answer a new 400.
|
|
2335
|
+
* The injectable `listSdkSessions` option predates the adapter layer and is
|
|
2336
|
+
* honored for the claude engine only (existing tests and hosts wire it),
|
|
2337
|
+
* exactly like the injectable claude auth probe.
|
|
2338
|
+
*/
|
|
2339
|
+
const handleSdkSessions = async (req, res, auth) => {
|
|
1852
2340
|
if (req.method !== "GET") {
|
|
1853
2341
|
json(res, 405, { error: "method not allowed" });
|
|
1854
2342
|
return;
|
|
@@ -1856,21 +2344,58 @@ function createWorkerServer(options = {}) {
|
|
|
1856
2344
|
const url = new URL(req.url ?? "/", "http://internal");
|
|
1857
2345
|
const dir = url.searchParams.get("dir") ?? void 0;
|
|
1858
2346
|
const roots = options.allowedCwdRoots;
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
2347
|
+
const limit = Number(url.searchParams.get("limit") ?? "") || void 0;
|
|
2348
|
+
const offset = Number(url.searchParams.get("offset") ?? "") || void 0;
|
|
2349
|
+
const requested = url.searchParams.get("profile") ?? void 0;
|
|
2350
|
+
let profile;
|
|
2351
|
+
if (requested !== void 0) {
|
|
2352
|
+
const resolved = resolveProfile(requested, auth.allowedProfiles);
|
|
2353
|
+
if (!resolved.ok) {
|
|
2354
|
+
json(res, resolved.status, { error: resolved.error });
|
|
1862
2355
|
return;
|
|
1863
2356
|
}
|
|
1864
|
-
|
|
1865
|
-
|
|
2357
|
+
profile = resolved.profile;
|
|
2358
|
+
} else {
|
|
2359
|
+
const all = allProfiles();
|
|
2360
|
+
if (all.length === 1 && (!auth.allowedProfiles || auth.allowedProfiles.includes(all[0].name))) profile = all[0];
|
|
2361
|
+
}
|
|
2362
|
+
const adapter = adapterFor(profile?.engine);
|
|
2363
|
+
if (!adapter.capabilities.listSessions) {
|
|
2364
|
+
json(res, 400, { error: `profile '${profile?.name ?? "default"}' runs the ${engineOf(profile)} engine, which has no browsable session store` });
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
const lister = engineOf(profile) === "claude" && options.listSdkSessions ? options.listSdkSessions : (params) => {
|
|
2368
|
+
if (!adapter.listSessions) throw new Error(`the ${engineOf(profile)} engine does not implement session listing`);
|
|
2369
|
+
return adapter.listSessions({
|
|
2370
|
+
...params,
|
|
2371
|
+
profile,
|
|
2372
|
+
env: profile ? sessionEnvFor(profile) : process.env
|
|
2373
|
+
});
|
|
2374
|
+
};
|
|
2375
|
+
try {
|
|
2376
|
+
if (roots && roots.length > 0) if (dir) {
|
|
2377
|
+
if (!cwdAllowed(dir, roots)) {
|
|
2378
|
+
json(res, 403, { error: "dir is outside the allowed roots" });
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
} else {
|
|
2382
|
+
json(res, 200, { sdkSessions: withinRoots(await lister({}), roots, limit, offset) });
|
|
1866
2383
|
return;
|
|
1867
2384
|
}
|
|
2385
|
+
json(res, 200, { sdkSessions: await lister({
|
|
2386
|
+
dir,
|
|
2387
|
+
limit,
|
|
2388
|
+
offset
|
|
2389
|
+
}) });
|
|
2390
|
+
} catch (error) {
|
|
2391
|
+
json(res, 500, { error: error instanceof Error ? error.message : "failed to list sessions" });
|
|
1868
2392
|
}
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
2393
|
+
};
|
|
2394
|
+
/** The sessions whose `cwd` is inside the roots, newest first, then paged. A
|
|
2395
|
+
* summary with no `cwd` cannot be shown to be inside them, so it is dropped. */
|
|
2396
|
+
const withinRoots = (sessions, roots, limit, offset = 0) => {
|
|
2397
|
+
const allowed = sessions.filter((s) => s.cwd !== void 0 && cwdAllowed(s.cwd, roots)).sort((a, b) => b.lastModified - a.lastModified);
|
|
2398
|
+
return limit === void 0 ? allowed.slice(offset) : allowed.slice(offset, offset + limit);
|
|
1874
2399
|
};
|
|
1875
2400
|
const handleJobs = async (req, res, pathname, auth) => {
|
|
1876
2401
|
if (!queue) {
|
|
@@ -1924,6 +2449,7 @@ function createWorkerServer(options = {}) {
|
|
|
1924
2449
|
json(res, 400, { error: badRequest });
|
|
1925
2450
|
return;
|
|
1926
2451
|
}
|
|
2452
|
+
stripInertFields(body.session, resolved.profile);
|
|
1927
2453
|
body.session.profile = resolved.profile?.name;
|
|
1928
2454
|
try {
|
|
1929
2455
|
json(res, 201, { job: await queue.submit(body) });
|
|
@@ -2041,8 +2567,10 @@ function createWorkerServer(options = {}) {
|
|
|
2041
2567
|
const rest = pathname.slice((basePath + "/profiles").length).replace(/^\//, "");
|
|
2042
2568
|
if (rest === "") {
|
|
2043
2569
|
if (req.method === "GET") {
|
|
2570
|
+
const visible = auth.allowedProfiles ? allProfiles().filter((p) => auth.allowedProfiles.includes(p.name)) : allProfiles();
|
|
2571
|
+
refreshAvailability(visible);
|
|
2044
2572
|
json(res, 200, {
|
|
2045
|
-
profiles:
|
|
2573
|
+
profiles: visible.map(forResponse),
|
|
2046
2574
|
canManage: manageGuard(auth) === null
|
|
2047
2575
|
});
|
|
2048
2576
|
return;
|
|
@@ -2118,11 +2646,12 @@ function createWorkerServer(options = {}) {
|
|
|
2118
2646
|
return;
|
|
2119
2647
|
}
|
|
2120
2648
|
if (pathname === basePath + "/sdk-sessions") {
|
|
2121
|
-
|
|
2649
|
+
const auth = await authenticate(req);
|
|
2650
|
+
if (!auth.ok) {
|
|
2122
2651
|
json(res, 401, { error: "unauthorized" });
|
|
2123
2652
|
return;
|
|
2124
2653
|
}
|
|
2125
|
-
await handleSdkSessions(req, res);
|
|
2654
|
+
await handleSdkSessions(req, res, auth);
|
|
2126
2655
|
return;
|
|
2127
2656
|
}
|
|
2128
2657
|
if (pathname.startsWith(basePath + "/fs/")) {
|
|
@@ -2173,6 +2702,7 @@ function createWorkerServer(options = {}) {
|
|
|
2173
2702
|
json(res, 400, { error: badRequest });
|
|
2174
2703
|
return;
|
|
2175
2704
|
}
|
|
2705
|
+
stripInertFields(body, resolved.profile);
|
|
2176
2706
|
body.profile = resolved.profile?.name;
|
|
2177
2707
|
const runner = await createRunner(buildRunnerConfig(body));
|
|
2178
2708
|
watchAuthSource(runner);
|
|
@@ -2188,6 +2718,18 @@ function createWorkerServer(options = {}) {
|
|
|
2188
2718
|
json(res, 404, { error: "session not found" });
|
|
2189
2719
|
return;
|
|
2190
2720
|
}
|
|
2721
|
+
if (route.attachments) {
|
|
2722
|
+
await handleAttachments(req, res, route.id, runner?.info() ?? parked.info, route.attachmentId);
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
if (route.mcp) {
|
|
2726
|
+
if (!runner) {
|
|
2727
|
+
json(res, 409, { error: "session is parked (wake it before asking about MCP)" });
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
await handleMcp(req, res, runner, route.mcpServer);
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2191
2733
|
if (route.files) {
|
|
2192
2734
|
if (req.method !== "GET") {
|
|
2193
2735
|
json(res, 405, { error: "method not allowed" });
|
|
@@ -2224,6 +2766,10 @@ function createWorkerServer(options = {}) {
|
|
|
2224
2766
|
res.end(content);
|
|
2225
2767
|
return;
|
|
2226
2768
|
}
|
|
2769
|
+
if (route.produced) {
|
|
2770
|
+
await handleProducedFiles(req, res, route.id, route.producedFileId);
|
|
2771
|
+
return;
|
|
2772
|
+
}
|
|
2227
2773
|
if (route.permissionId) {
|
|
2228
2774
|
if (req.method !== "POST") {
|
|
2229
2775
|
json(res, 405, { error: "method not allowed" });
|
|
@@ -2253,6 +2799,8 @@ function createWorkerServer(options = {}) {
|
|
|
2253
2799
|
registry.remove(route.id);
|
|
2254
2800
|
bridge.remove(route.id);
|
|
2255
2801
|
await parking.discard(route.id);
|
|
2802
|
+
attachmentStore.drop(route.id);
|
|
2803
|
+
producedFiles.drop(route.id);
|
|
2256
2804
|
json(res, 200, { session: runner?.info() ?? {
|
|
2257
2805
|
...parked.info,
|
|
2258
2806
|
status: "closed"
|
|
@@ -2356,9 +2904,16 @@ function createWorkerServer(options = {}) {
|
|
|
2356
2904
|
};
|
|
2357
2905
|
const handleCommand = async (frame, runner) => {
|
|
2358
2906
|
switch (frame.type) {
|
|
2359
|
-
case "user_message":
|
|
2360
|
-
|
|
2907
|
+
case "user_message": {
|
|
2908
|
+
if (!frame.attachmentIds?.length) {
|
|
2909
|
+
runner.sendMessage(frame.text);
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
const resolved = attachmentStore.resolve(runner.id, frame.attachmentIds);
|
|
2913
|
+
if (!resolved.ok) throw new Error(`unknown attachment(s): ${resolved.missing.join(", ")}`);
|
|
2914
|
+
runner.sendMessage(frame.text, resolved.attachments);
|
|
2361
2915
|
return;
|
|
2916
|
+
}
|
|
2362
2917
|
case "permission_decision":
|
|
2363
2918
|
if (frame.behavior === "allow") runner.resolvePermission(frame.requestId, {
|
|
2364
2919
|
behavior: "allow",
|
|
@@ -2478,6 +3033,6 @@ function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profi
|
|
|
2478
3033
|
};
|
|
2479
3034
|
}
|
|
2480
3035
|
//#endregion
|
|
2481
|
-
export { BridgeHub, MemorySessionStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
|
|
3036
|
+
export { AttachmentStore, BridgeHub, MemorySessionStore, ProducedFileStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
|
|
2482
3037
|
|
|
2483
3038
|
//# sourceMappingURL=index.mjs.map
|