@workerdeck/server 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.mjs CHANGED
@@ -1,13 +1,12 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { closeSync, constants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, 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 { listSessions } from "@anthropic-ai/claude-agent-sdk";
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 { PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, supportsPermissionMode } from "@workerdeck/protocol";
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
  /**
@@ -364,6 +363,132 @@ function subsequenceScore(haystack, needle) {
364
363
  return score - haystack.length / 100;
365
364
  }
366
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
367
492
  //#region src/registry.ts
368
493
  /** In-memory session table. Terminal sessions stay listed until removed or the process exits. */
369
494
  var SessionRegistry = class {
@@ -1092,18 +1217,6 @@ function parseRecord(value) {
1092
1217
  }
1093
1218
  //#endregion
1094
1219
  //#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
1220
  function json(res, status, body) {
1108
1221
  const payload = JSON.stringify(body);
1109
1222
  res.writeHead(status, {
@@ -1123,6 +1236,18 @@ async function readJsonBody(req, maxBytes) {
1123
1236
  if (size === 0) return {};
1124
1237
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
1125
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
+ }
1126
1251
  /**
1127
1252
  * Curated, view-only snapshot of a profile's config dir for GET /profiles/:name.
1128
1253
  * Best-effort: a missing or unparseable settings.json just omits the settings block.
@@ -1210,6 +1335,10 @@ function contentTypeFor(filename) {
1210
1335
  function isProviderProfile(profile) {
1211
1336
  return profile.engine === "provider";
1212
1337
  }
1338
+ /** The engine a profile runs, absent meaning 'claude' (pre-provider profiles). */
1339
+ function engineOf(profile) {
1340
+ return profile?.engine ?? "claude";
1341
+ }
1213
1342
  /** Where the CLI's own resolution lands for a given environment: an explicit
1214
1343
  * CLAUDE_CONFIG_DIR, else ~/.claude. */
1215
1344
  function cliConfigDir(env) {
@@ -1264,6 +1393,8 @@ function createWorkerServer(options = {}) {
1264
1393
  const basePath = options.basePath ?? "/v1";
1265
1394
  const fallback = options.fallback;
1266
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);
1267
1398
  const hostBuildRunnerConfig = options.buildRunnerConfig ?? ((req) => req);
1268
1399
  const declared = options.profiles ?? detectDefaultProfiles();
1269
1400
  const declaredByName = new Map(declared.map((p) => [p.name, p]));
@@ -1277,10 +1408,13 @@ function createWorkerServer(options = {}) {
1277
1408
  if (isProviderProfile(p)) {
1278
1409
  if (!p.provider?.id) return `provider profile '${p.name}' is missing provider.id`;
1279
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`;
1280
1414
  } else if (!p.configDir || !existsSync(p.configDir)) return `profile '${p.name}' configDir does not exist: ${p.configDir}`;
1281
1415
  if (options.disableBypassPermissions && p.defaults?.permissionMode === "bypassPermissions") return `profile '${p.name}' defaults to bypassPermissions but disableBypassPermissions is set`;
1282
1416
  const fallbackMode = p.defaults?.permissionMode;
1283
- if (fallbackMode && !supportsPermissionMode(p.engine, fallbackMode)) return `profile '${p.name}' defaults to permission mode '${fallbackMode}', which engine '${p.engine}' does not support (supported: ${PROVIDER_PERMISSION_MODES.join(", ")})`;
1417
+ if (fallbackMode && !supportsPermissionMode(p.engine, fallbackMode)) return `profile '${p.name}' defaults to permission mode '${fallbackMode}', which engine '${engineOf(p)}' does not support (supported: ${adapterFor(engineOf(p)).capabilities.permissionModes.join(", ")})`;
1284
1418
  return null;
1285
1419
  };
1286
1420
  for (const p of options.profiles ?? []) {
@@ -1304,6 +1438,31 @@ function createWorkerServer(options = {}) {
1304
1438
  ...p,
1305
1439
  managed: true
1306
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
+ };
1307
1466
  /** Declared profiles first: a name collision means the code wins, and the stored
1308
1467
  * one is unreachable rather than silently overriding server options. */
1309
1468
  const allProfiles = () => [...declared, ...[...stored.values()].filter((p) => !declaredByName.has(p.name))];
@@ -1377,28 +1536,42 @@ function createWorkerServer(options = {}) {
1377
1536
  * into 'default' by whatever assembles its runner. Returns an error message. */
1378
1537
  const checkPermissionMode = (mode, profile) => {
1379
1538
  if (mode === void 0 || supportsPermissionMode(profile?.engine, mode)) return null;
1380
- return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${profile.engine}') — supported: ${PROVIDER_PERMISSION_MODES.join(", ")}`;
1539
+ return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${engineOf(profile)}') — supported: ` + adapterFor(profile?.engine).capabilities.permissionModes.join(", ");
1381
1540
  };
1382
1541
  /**
1383
- * Enforce the provider engine's grant rules on a create request. Two of them:
1384
- *
1385
- * - Capabilities narrow, never widen. A request may run with fewer than the
1386
- * profile grants; naming one it doesn't is refused rather than quietly
1387
- * downgraded, so a caller learns instead of wondering where the tool went.
1388
- * - MCP servers are the profile's to declare. MCP tools are authoritative —
1389
- * server-side, with server credentials, never bridged so honoring a
1390
- * client-supplied server would let a caller point an authoritative tool
1391
- * anywhere it liked. The profile names servers; the host holds their configs.
1542
+ * Refuse the request fields the resolved profile's engine cannot honor
1543
+ * read off its capability record, so the create form's filtering and the
1544
+ * API boundary can never disagree. Refusing beats coercing: a caller who
1545
+ * asked for something the engine has no meaning for should be told, not
1546
+ * left wondering where the option went. Also enforces the provider grant
1547
+ * rules (capabilities narrow, never widen; MCP servers are the profile's to
1548
+ * declare MCP tools are authoritative, server-side, with server
1549
+ * credentials, so honoring a client-supplied server would let a caller
1550
+ * point an authoritative tool anywhere it liked).
1392
1551
  */
1393
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`;
1394
1562
  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
1563
  const granted = profile.session?.capabilities;
1397
1564
  if (!req.capabilities || !granted) return null;
1398
1565
  const ungranted = req.capabilities.filter((c) => !granted.includes(c));
1399
1566
  if (ungranted.length === 0) return null;
1400
1567
  return `profile '${profile.name}' does not grant: ${ungranted.join(", ")} (granted: ${granted.join(", ") || "none"}) — a request may narrow capabilities, not widen them`;
1401
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
+ };
1402
1575
  /** Profile-aware config hook: fill the profile's defaults into unset request fields,
1403
1576
  * run the host hook, then pin CLAUDE_CONFIG_DIR — the profile wins even when the
1404
1577
  * host hook set its own env (see `claudeSessionEnv` for the one case the pin is
@@ -1411,7 +1584,7 @@ function createWorkerServer(options = {}) {
1411
1584
  model: req.model ?? profile.defaults?.model ?? profile.provider?.model,
1412
1585
  permissionMode: req.permissionMode ?? profile.defaults?.permissionMode
1413
1586
  });
1414
- if (isProviderProfile(profile)) return config;
1587
+ if (engineOf(profile) !== "claude") return config;
1415
1588
  const base = config.env ?? process.env;
1416
1589
  const env = claudeSessionEnv(profile, base);
1417
1590
  return env === base ? config : {
@@ -1435,8 +1608,11 @@ function createWorkerServer(options = {}) {
1435
1608
  bridge,
1436
1609
  restore
1437
1610
  });
1438
- if (restore) throw new Error("the Claude engine cannot rebuild a parked session");
1439
- return new Promise((resolve) => resolve(registry.prepare(config)));
1611
+ return adapterFor(profile?.engine).createRunner({
1612
+ config,
1613
+ profile,
1614
+ restore
1615
+ });
1440
1616
  };
1441
1617
  const createRunner = async (config) => {
1442
1618
  const runner = registry.register(await buildRunner(config));
@@ -1483,7 +1659,24 @@ function createWorkerServer(options = {}) {
1483
1659
  };
1484
1660
  };
1485
1661
  const notifier = new SessionNotifier(options.notifications ?? {});
1486
- const registry = new SessionRegistry({ onRegister: (runner) => notifier.watch(runner) });
1662
+ /**
1663
+ * What each claude profile's *default* model resolves to, learned from the
1664
+ * `capabilities` events of sessions that ran on it. The model *list* is the
1665
+ * adapter's static catalog now; the default is the one thing a catalog
1666
+ * cannot know (it is the operator's CLI config), so it alone is still
1667
+ * learned — and still absent on a cold server, the accepted regression.
1668
+ */
1669
+ const profileDefaultModels = /* @__PURE__ */ new Map();
1670
+ const registry = new SessionRegistry({ onRegister: (runner) => {
1671
+ notifier.watch(runner);
1672
+ const profile = runner.info().profile;
1673
+ if (!profile) return;
1674
+ runner.subscribe((event) => {
1675
+ if (event.type !== "capabilities" || !event.defaultModel) return;
1676
+ profileDefaultModels.set(profile, event.defaultModel);
1677
+ });
1678
+ } });
1679
+ const attachmentStore = new AttachmentStore(options.attachments);
1487
1680
  const bridge = new BridgeHub({
1488
1681
  ...options.bridge,
1489
1682
  onResult: (sessionId, executionId, result) => {
@@ -1556,31 +1749,70 @@ function createWorkerServer(options = {}) {
1556
1749
  });
1557
1750
  };
1558
1751
  /**
1559
- * Probe each Claude profile's credentials the way its sessions will actually
1560
- * experience them: the env the real assembly path produces, so anything the
1561
- * host hook injects (a CLAUDE_CODE_OAUTH_TOKEN, say) counts as logged in.
1562
- * Provider profiles resolve credentials in the engine factory and are not
1563
- * probed. Fire-and-forget by design — see the `checkCredentials` option doc.
1752
+ * Availability, per profile: the adapter's probe run over the env the real
1753
+ * assembly path produces (so anything the host hook injects — a
1754
+ * CLAUDE_CODE_OAUTH_TOKEN, say counts as logged in). Cached, and served on
1755
+ * `GET /profiles` as `available`/`unavailableReason`.
1756
+ *
1757
+ * Gated on `checkCredentials` like the old claude-only preflight (this is a
1758
+ * library; `pnpm test` must spawn nothing unless a test injects fake
1759
+ * adapters or probes). 'unknown' stays out of the cache's answers: a probe
1760
+ * that couldn't run is not evidence of a missing login. **Display-only**
1761
+ * downstream — session create against an unavailable profile still proceeds
1762
+ * and fails with the engine's own error, because the probe can be stale in
1763
+ * both directions and refusing on it would turn a probe bug into an outage.
1564
1764
  */
1565
- const preflightCredentials = () => {
1765
+ const availability = /* @__PURE__ */ new Map();
1766
+ const AVAILABILITY_TTL_MS = 6e4;
1767
+ /** Profiles already warned about on the console, so re-probes don't spam. */
1768
+ const availabilityWarned = /* @__PURE__ */ new Set();
1769
+ const sessionEnvFor = (profile) => {
1770
+ try {
1771
+ return buildRunnerConfig({
1772
+ cwd: process.cwd(),
1773
+ profile: profile.name
1774
+ }).env ?? process.env;
1775
+ } catch {
1776
+ return engineOf(profile) === "claude" ? claudeSessionEnv(profile, process.env) : process.env;
1777
+ }
1778
+ };
1779
+ const probeProfile = (profile) => {
1566
1780
  if (!options.checkCredentials) return;
1567
1781
  const conf = options.checkCredentials === true ? {} : options.checkCredentials;
1568
- const probe = conf.probe ?? ((env) => checkClaudeAuth(env, { timeoutMs: conf.timeoutMs }));
1569
- for (const profile of allProfiles()) {
1570
- if (isProviderProfile(profile)) continue;
1571
- let env;
1572
- try {
1573
- env = buildRunnerConfig({
1574
- cwd: process.cwd(),
1575
- profile: profile.name
1576
- }).env ?? process.env;
1577
- } catch {
1578
- env = claudeSessionEnv(profile, process.env);
1782
+ availability.set(profile.name, {
1783
+ verdict: availability.get(profile.name)?.verdict ?? { available: "unknown" },
1784
+ at: Date.now()
1785
+ });
1786
+ const adapter = adapterFor(profile.engine);
1787
+ const claudeProbe = engineOf(profile) !== "claude" ? void 0 : conf.probe ?? (conf.timeoutMs !== void 0 ? (env) => checkClaudeAuth(env, { timeoutMs: conf.timeoutMs }) : void 0);
1788
+ (claudeProbe ? claudeProbe(sessionEnvFor(profile)).then((status) => status === "logged_in" ? { available: true } : status === "logged_out" ? {
1789
+ available: false,
1790
+ reason: "no usable Claude credentials for this profile"
1791
+ } : { available: "unknown" }) : adapter.checkAvailability(profile, sessionEnvFor(profile))).then((verdict) => {
1792
+ availability.set(profile.name, {
1793
+ verdict,
1794
+ at: Date.now()
1795
+ });
1796
+ if (verdict.available === false && !availabilityWarned.has(profile.name)) {
1797
+ availabilityWarned.add(profile.name);
1798
+ console.warn(`[workerdeck] Profile '${profile.name}' is unavailable: ${verdict.reason} (\`checkCredentials: false\` disables this check)`);
1579
1799
  }
1580
- probe(env).then((status) => {
1581
- if (status !== "logged_out") return;
1582
- console.warn(`[workerdeck] Profile '${profile.name}' (${profile.configDir}) has no usable Claude credentials: \`claude auth status\` reports logged out for the environment its sessions run with, so they will fail with "Not logged in". Log in under that dir (CLAUDE_CONFIG_DIR=${profile.configDir} claude auth login), inject a long-lived token via buildRunnerConfig (CLAUDE_CODE_OAUTH_TOKEN), or set ANTHROPIC_API_KEY. \`checkCredentials: false\` disables this check.`);
1583
- }).catch(() => {});
1800
+ if (verdict.available === true) availabilityWarned.delete(profile.name);
1801
+ }).catch(() => {});
1802
+ };
1803
+ /** Launch-time sweep, concurrent and fire-and-forget. */
1804
+ const preflightCredentials = () => {
1805
+ for (const profile of allProfiles()) probeProfile(profile);
1806
+ };
1807
+ /** Lazy re-probe on reads, so an operator who just ran `codex login` (or
1808
+ * exported a key) sees the profile go green without a restart. Serves the
1809
+ * cached verdict now; the refreshed one lands on the next request. */
1810
+ const refreshAvailability = (profiles) => {
1811
+ if (!options.checkCredentials) return;
1812
+ const now = Date.now();
1813
+ for (const profile of profiles) {
1814
+ const cached = availability.get(profile.name);
1815
+ if (!cached || now - cached.at > AVAILABILITY_TTL_MS) probeProfile(profile);
1584
1816
  }
1585
1817
  };
1586
1818
  const authenticate = async (req) => {
@@ -1609,6 +1841,16 @@ function createWorkerServer(options = {}) {
1609
1841
  id: decodeURIComponent(parts[0]),
1610
1842
  permissionId: decodeURIComponent(parts[2])
1611
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
+ };
1612
1854
  if (parts.length >= 2 && parts[1] === "files") {
1613
1855
  const filePath = parts.slice(2).map(decodeURIComponent).join("/");
1614
1856
  return {
@@ -1625,6 +1867,106 @@ function createWorkerServer(options = {}) {
1625
1867
  const maxHostFileBytes = options.hostFiles?.maxFileBytes ?? 1024 * 1024;
1626
1868
  const maxHostDirEntries = options.hostFiles?.maxEntries ?? 5e3;
1627
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
+ /**
1628
1970
  * `{basePath}/fs/*` — the operator's real tree. Authorized by the auth key alone
1629
1971
  * and deliberately outside the agent permission flow: the caller is the operator.
1630
1972
  *
@@ -1847,8 +2189,19 @@ function createWorkerServer(options = {}) {
1847
2189
  }
1848
2190
  json(res, 404, { error: "not found" });
1849
2191
  };
1850
- const listSdkSessions = options.listSdkSessions ?? defaultSdkSessionLister;
1851
- const handleSdkSessions = async (req, res) => {
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) => {
1852
2205
  if (req.method !== "GET") {
1853
2206
  json(res, 405, { error: "method not allowed" });
1854
2207
  return;
@@ -1856,21 +2209,58 @@ function createWorkerServer(options = {}) {
1856
2209
  const url = new URL(req.url ?? "/", "http://internal");
1857
2210
  const dir = url.searchParams.get("dir") ?? void 0;
1858
2211
  const roots = options.allowedCwdRoots;
1859
- if (roots && roots.length > 0) {
1860
- if (!dir) {
1861
- json(res, 400, { error: "dir is required when allowedCwdRoots is set" });
2212
+ const limit = Number(url.searchParams.get("limit") ?? "") || void 0;
2213
+ const offset = Number(url.searchParams.get("offset") ?? "") || void 0;
2214
+ const requested = url.searchParams.get("profile") ?? void 0;
2215
+ let profile;
2216
+ if (requested !== void 0) {
2217
+ const resolved = resolveProfile(requested, auth.allowedProfiles);
2218
+ if (!resolved.ok) {
2219
+ json(res, resolved.status, { error: resolved.error });
1862
2220
  return;
1863
2221
  }
1864
- if (!cwdAllowed(dir, roots)) {
1865
- json(res, 403, { error: "dir is outside the allowed roots" });
2222
+ profile = resolved.profile;
2223
+ } else {
2224
+ const all = allProfiles();
2225
+ if (all.length === 1 && (!auth.allowedProfiles || auth.allowedProfiles.includes(all[0].name))) profile = all[0];
2226
+ }
2227
+ const adapter = adapterFor(profile?.engine);
2228
+ if (!adapter.capabilities.listSessions) {
2229
+ json(res, 400, { error: `profile '${profile?.name ?? "default"}' runs the ${engineOf(profile)} engine, which has no browsable session store` });
2230
+ return;
2231
+ }
2232
+ const lister = engineOf(profile) === "claude" && options.listSdkSessions ? options.listSdkSessions : (params) => {
2233
+ if (!adapter.listSessions) throw new Error(`the ${engineOf(profile)} engine does not implement session listing`);
2234
+ return adapter.listSessions({
2235
+ ...params,
2236
+ profile,
2237
+ env: profile ? sessionEnvFor(profile) : process.env
2238
+ });
2239
+ };
2240
+ try {
2241
+ if (roots && roots.length > 0) if (dir) {
2242
+ if (!cwdAllowed(dir, roots)) {
2243
+ json(res, 403, { error: "dir is outside the allowed roots" });
2244
+ return;
2245
+ }
2246
+ } else {
2247
+ json(res, 200, { sdkSessions: withinRoots(await lister({}), roots, limit, offset) });
1866
2248
  return;
1867
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" });
1868
2257
  }
1869
- json(res, 200, { sdkSessions: await listSdkSessions({
1870
- dir,
1871
- limit: Number(url.searchParams.get("limit") ?? "") || void 0,
1872
- offset: Number(url.searchParams.get("offset") ?? "") || void 0
1873
- }) });
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);
1874
2264
  };
1875
2265
  const handleJobs = async (req, res, pathname, auth) => {
1876
2266
  if (!queue) {
@@ -1924,6 +2314,7 @@ function createWorkerServer(options = {}) {
1924
2314
  json(res, 400, { error: badRequest });
1925
2315
  return;
1926
2316
  }
2317
+ stripInertFields(body.session, resolved.profile);
1927
2318
  body.session.profile = resolved.profile?.name;
1928
2319
  try {
1929
2320
  json(res, 201, { job: await queue.submit(body) });
@@ -2041,8 +2432,10 @@ function createWorkerServer(options = {}) {
2041
2432
  const rest = pathname.slice((basePath + "/profiles").length).replace(/^\//, "");
2042
2433
  if (rest === "") {
2043
2434
  if (req.method === "GET") {
2435
+ const visible = auth.allowedProfiles ? allProfiles().filter((p) => auth.allowedProfiles.includes(p.name)) : allProfiles();
2436
+ refreshAvailability(visible);
2044
2437
  json(res, 200, {
2045
- profiles: (auth.allowedProfiles ? allProfiles().filter((p) => auth.allowedProfiles.includes(p.name)) : allProfiles()).map(withManagedFlag),
2438
+ profiles: visible.map(forResponse),
2046
2439
  canManage: manageGuard(auth) === null
2047
2440
  });
2048
2441
  return;
@@ -2118,11 +2511,12 @@ function createWorkerServer(options = {}) {
2118
2511
  return;
2119
2512
  }
2120
2513
  if (pathname === basePath + "/sdk-sessions") {
2121
- if (!(await authenticate(req)).ok) {
2514
+ const auth = await authenticate(req);
2515
+ if (!auth.ok) {
2122
2516
  json(res, 401, { error: "unauthorized" });
2123
2517
  return;
2124
2518
  }
2125
- await handleSdkSessions(req, res);
2519
+ await handleSdkSessions(req, res, auth);
2126
2520
  return;
2127
2521
  }
2128
2522
  if (pathname.startsWith(basePath + "/fs/")) {
@@ -2173,6 +2567,7 @@ function createWorkerServer(options = {}) {
2173
2567
  json(res, 400, { error: badRequest });
2174
2568
  return;
2175
2569
  }
2570
+ stripInertFields(body, resolved.profile);
2176
2571
  body.profile = resolved.profile?.name;
2177
2572
  const runner = await createRunner(buildRunnerConfig(body));
2178
2573
  watchAuthSource(runner);
@@ -2188,6 +2583,18 @@ function createWorkerServer(options = {}) {
2188
2583
  json(res, 404, { error: "session not found" });
2189
2584
  return;
2190
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
+ }
2191
2598
  if (route.files) {
2192
2599
  if (req.method !== "GET") {
2193
2600
  json(res, 405, { error: "method not allowed" });
@@ -2253,6 +2660,7 @@ function createWorkerServer(options = {}) {
2253
2660
  registry.remove(route.id);
2254
2661
  bridge.remove(route.id);
2255
2662
  await parking.discard(route.id);
2663
+ attachmentStore.drop(route.id);
2256
2664
  json(res, 200, { session: runner?.info() ?? {
2257
2665
  ...parked.info,
2258
2666
  status: "closed"
@@ -2356,9 +2764,16 @@ function createWorkerServer(options = {}) {
2356
2764
  };
2357
2765
  const handleCommand = async (frame, runner) => {
2358
2766
  switch (frame.type) {
2359
- case "user_message":
2360
- runner.sendMessage(frame.text);
2767
+ case "user_message": {
2768
+ if (!frame.attachmentIds?.length) {
2769
+ runner.sendMessage(frame.text);
2770
+ return;
2771
+ }
2772
+ const resolved = attachmentStore.resolve(runner.id, frame.attachmentIds);
2773
+ if (!resolved.ok) throw new Error(`unknown attachment(s): ${resolved.missing.join(", ")}`);
2774
+ runner.sendMessage(frame.text, resolved.attachments);
2361
2775
  return;
2776
+ }
2362
2777
  case "permission_decision":
2363
2778
  if (frame.behavior === "allow") runner.resolvePermission(frame.requestId, {
2364
2779
  behavior: "allow",
@@ -2478,6 +2893,6 @@ function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profi
2478
2893
  };
2479
2894
  }
2480
2895
  //#endregion
2481
- export { BridgeHub, MemorySessionStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
2896
+ export { AttachmentStore, BridgeHub, MemorySessionStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
2482
2897
 
2483
2898
  //# sourceMappingURL=index.mjs.map