@workerdeck/server 0.9.0 → 0.11.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 CHANGED
@@ -54,6 +54,7 @@ Routes (default `basePath: '/v1'`):
54
54
  | --- | --- |
55
55
  | `GET/POST /v1/sessions` | List sessions / create one (`CreateSessionRequest`, `cwd` required) |
56
56
  | `GET/DELETE /v1/sessions/:id` | Session info / close and remove |
57
+ | `PATCH /v1/sessions/:id` | Rename (`{ title }`; `null` restores the derived title) |
57
58
  | `WS /v1/sessions/:id/ws?afterSeq=n` | Attach: `attached` frame, replay past `n`, then live events |
58
59
  | `POST /v1/sessions/:id/permissions/:requestId` | Resolve a pending approval over REST |
59
60
  | `GET /v1/sdk-sessions?dir=…` | List the Agent SDK's on-disk sessions to offer resume |
package/build/index.d.mts CHANGED
@@ -658,5 +658,55 @@ declare class AttachmentStore {
658
658
  drop(sessionId: string): void;
659
659
  }
660
660
  //#endregion
661
- export { AttachmentStore, type AttachmentStoreOptions, type Authenticator, BridgeHub, type BridgeHubOptions, type EngineRunnerContext, type FileSessionStoreOptions, MemorySessionStore, type ParkedSessionRecord, type ProfileStore, type QueueServerOptions, type SdkSessionLister, type SessionNotificationOptions, SessionNotifier, SessionParkManager, type SessionParkOptions, SessionRegistry, type SessionRegistryOptions, type SessionStore, type WorkerServer, type WorkerServerOptions, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
661
+ //#region src/produced-files.d.ts
662
+ /** One host file an engine reported writing, as the store holds it. */
663
+ type ProducedFile = {
664
+ fileId: string; /** Absolute host path, exactly as the runner reported it. */
665
+ path: string;
666
+ mediaType?: string; /** Size when the runner announced it — advisory, and re-read at serve time. */
667
+ bytes?: number;
668
+ sessionId: string;
669
+ };
670
+ /**
671
+ * The paths this gateway will serve from `GET /sessions/:id/produced/:fileId`.
672
+ *
673
+ * **This is the whole access-control model, so it is worth being precise about
674
+ * what it is.** The store is an allowlist built from one source and one only:
675
+ * `file_produced` events, which a runner emits about a file its own engine just
676
+ * wrote. It is not a directory grant. Nothing else can add to it — not a
677
+ * request, not a config, and in particular not the agent, whose own path claims
678
+ * go through `/fs/*` and that route's root allowlist.
679
+ *
680
+ * That is why the route needs neither `hostFiles.roots` nor `maxFileBytes`:
681
+ * "somewhere under a root the operator declared" is a guess about which paths
682
+ * are safe, while "the exact path this session's runner reported producing" is
683
+ * a fact about one file. A 2 MB generated PNG is the common case, and making
684
+ * the operator raise a byte cap to see their own picture was the bug this
685
+ * replaces.
686
+ *
687
+ * Lifetime is the session's, like `AttachmentStore`'s: in memory, dropped when
688
+ * the session is removed. The bytes are never held here — only the path, so a
689
+ * gateway serving a long session accumulates a few hundred bytes per picture
690
+ * rather than the pictures.
691
+ */
692
+ declare class ProducedFileStore {
693
+ #private;
694
+ /**
695
+ * Register a runner's produced files for its lifetime.
696
+ *
697
+ * Subscribes from seq 0, which is the opposite of what `SessionNotifier` wants
698
+ * and correct for the same reason: registration is idempotent (a `fileId` is
699
+ * derived from its path, so re-registering overwrites with itself), and a
700
+ * session rebuilt from a park must re-learn every file it produced before the
701
+ * park — otherwise a client's transcript keeps rendering image cards whose
702
+ * bytes have quietly become unreachable.
703
+ */
704
+ watch(runner: Runner): void;
705
+ get(sessionId: string, fileId: string): ProducedFile | undefined;
706
+ /** Everything one session has produced, newest registration last. */
707
+ list(sessionId: string): ProducedFile[];
708
+ drop(sessionId: string): void;
709
+ }
710
+ //#endregion
711
+ export { AttachmentStore, type AttachmentStoreOptions, type Authenticator, BridgeHub, type BridgeHubOptions, type EngineRunnerContext, type FileSessionStoreOptions, MemorySessionStore, type ParkedSessionRecord, type ProducedFile, ProducedFileStore, type ProfileStore, type QueueServerOptions, type SdkSessionLister, type SessionNotificationOptions, SessionNotifier, SessionParkManager, type SessionParkOptions, SessionRegistry, type SessionRegistryOptions, type SessionStore, type WorkerServer, type WorkerServerOptions, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
662
712
  //# sourceMappingURL=index.d.mts.map
package/build/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
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
+ 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";
@@ -266,7 +266,8 @@ const DEFAULT_IGNORED_DIRS = [
266
266
  ".pytest_cache",
267
267
  ".gradle",
268
268
  "Pods",
269
- "DerivedData"
269
+ "DerivedData",
270
+ ".build"
270
271
  ];
271
272
  /**
272
273
  * Breadth-first so shallow files rank first before scoring even runs — for a bare
@@ -489,6 +490,67 @@ function safeName(name) {
489
490
  return cleaned.length > 120 ? cleaned.slice(0, 120) : cleaned;
490
491
  }
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
492
554
  //#region src/registry.ts
493
555
  /** In-memory session table. Terminal sessions stay listed until removed or the process exits. */
494
556
  var SessionRegistry = class {
@@ -1667,8 +1729,10 @@ function createWorkerServer(options = {}) {
1667
1729
  * learned — and still absent on a cold server, the accepted regression.
1668
1730
  */
1669
1731
  const profileDefaultModels = /* @__PURE__ */ new Map();
1732
+ const producedFiles = new ProducedFileStore();
1670
1733
  const registry = new SessionRegistry({ onRegister: (runner) => {
1671
1734
  notifier.watch(runner);
1735
+ producedFiles.watch(runner);
1672
1736
  const profile = runner.info().profile;
1673
1737
  if (!profile) return;
1674
1738
  runner.subscribe((event) => {
@@ -1846,6 +1910,11 @@ function createWorkerServer(options = {}) {
1846
1910
  attachments: true,
1847
1911
  attachmentId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
1848
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
+ };
1849
1918
  if (parts.length <= 3 && parts[1] === "mcp") return {
1850
1919
  id: decodeURIComponent(parts[0]),
1851
1920
  mcp: true,
@@ -1954,6 +2023,10 @@ function createWorkerServer(options = {}) {
1954
2023
  json(res, 400, { error: "action must be 'reconnect', 'enable' or 'disable'" });
1955
2024
  return;
1956
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
+ }
1957
2030
  try {
1958
2031
  if (body.action === "reconnect") await runner.reconnectMcpServer?.(serverName);
1959
2032
  else await runner.setMcpServerEnabled?.(serverName, body.action === "enable");
@@ -1967,6 +2040,68 @@ function createWorkerServer(options = {}) {
1967
2040
  json(res, 405, { error: "method not allowed" });
1968
2041
  };
1969
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
+ /**
1970
2105
  * `{basePath}/fs/*` — the operator's real tree. Authorized by the auth key alone
1971
2106
  * and deliberately outside the agent permission flow: the caller is the operator.
1972
2107
  *
@@ -2631,6 +2766,10 @@ function createWorkerServer(options = {}) {
2631
2766
  res.end(content);
2632
2767
  return;
2633
2768
  }
2769
+ if (route.produced) {
2770
+ await handleProducedFiles(req, res, route.id, route.producedFileId);
2771
+ return;
2772
+ }
2634
2773
  if (route.permissionId) {
2635
2774
  if (req.method !== "POST") {
2636
2775
  json(res, 405, { error: "method not allowed" });
@@ -2656,11 +2795,29 @@ function createWorkerServer(options = {}) {
2656
2795
  json(res, 200, { session: runner?.info() ?? parked.info });
2657
2796
  return;
2658
2797
  }
2798
+ if (req.method === "PATCH") {
2799
+ if (!runner) {
2800
+ json(res, 409, { error: "session is parked (wake it before renaming)" });
2801
+ return;
2802
+ }
2803
+ const body = await readJsonBody(req, maxBodyBytes);
2804
+ if (body?.title !== void 0) {
2805
+ if (body.title !== null && typeof body.title !== "string") {
2806
+ json(res, 400, { error: "title must be a string or null" });
2807
+ return;
2808
+ }
2809
+ const title = typeof body.title === "string" ? body.title.trim() : "";
2810
+ runner.setTitle(title || void 0);
2811
+ }
2812
+ json(res, 200, { session: runner.info() });
2813
+ return;
2814
+ }
2659
2815
  if (req.method === "DELETE") {
2660
2816
  registry.remove(route.id);
2661
2817
  bridge.remove(route.id);
2662
2818
  await parking.discard(route.id);
2663
2819
  attachmentStore.drop(route.id);
2820
+ producedFiles.drop(route.id);
2664
2821
  json(res, 200, { session: runner?.info() ?? {
2665
2822
  ...parked.info,
2666
2823
  status: "closed"
@@ -2893,6 +3050,6 @@ function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profi
2893
3050
  };
2894
3051
  }
2895
3052
  //#endregion
2896
- export { AttachmentStore, BridgeHub, MemorySessionStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
3053
+ export { AttachmentStore, BridgeHub, MemorySessionStore, ProducedFileStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
2897
3054
 
2898
3055
  //# sourceMappingURL=index.mjs.map