@workerdeck/server 0.12.0 → 0.15.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
@@ -4,7 +4,7 @@ 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 { BrowserBridgeExecutor, SessionRunner, attachmentKind, checkClaudeAuth, getEngineAdapter, normalizeMediaType } from "@workerdeck/core";
7
+ import { BrowserBridgeExecutor, SessionRunner, attachmentKind, checkClaudeAuth, createEngineSession, getEngineAdapter, normalizeMediaType } from "@workerdeck/core";
8
8
  import { JobQueue } from "@workerdeck/queue";
9
9
  import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, supportsPermissionMode } from "@workerdeck/protocol";
10
10
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
@@ -803,11 +803,213 @@ var BridgeHub = class {
803
803
  }
804
804
  };
805
805
  //#endregion
806
+ //#region src/session-store.ts
807
+ const isDormant = (record) => record.kind === "dormant";
808
+ /** Single-process, no persistence: parks survive a client disconnect, not a restart. */
809
+ var MemorySessionStore = class {
810
+ #records = /* @__PURE__ */ new Map();
811
+ save(record) {
812
+ this.#records.set(record.id, record);
813
+ return Promise.resolve();
814
+ }
815
+ get(id) {
816
+ return Promise.resolve(this.#records.get(id) ?? null);
817
+ }
818
+ list() {
819
+ return Promise.resolve([...this.#records.values()]);
820
+ }
821
+ delete(id) {
822
+ return Promise.resolve(this.#records.delete(id));
823
+ }
824
+ };
825
+ /**
826
+ * Config fields that must not be written to durable storage: two are functions
827
+ * (JSON drops them silently), `extraOptions` is SDK `Options` and may hold hooks
828
+ * and callbacks, and `env` is a credential-bearing map — the same rule
829
+ * `profile-store.ts` follows, for the same reason.
830
+ *
831
+ * Dropping them costs a rehydrated session nothing, but the reason differs by
832
+ * record kind and both halves matter. A provider session's credentials are
833
+ * resolved by `createEngineRunner` from the operator's environment on every
834
+ * build, wake included — so a parked record never needed them. A **dormant**
835
+ * record is a claude or codex session, which does consume `env` (the profile's
836
+ * `CLAUDE_CONFIG_DIR` pin lives there), and that is precisely why waking one
837
+ * feeds its config back through the server's `buildRunnerConfig` instead of
838
+ * handing it to the engine as-is: the pin and the host hook's injections are
839
+ * re-derived from the profile, never read back off disk. Persisting them would
840
+ * be a credential map in a file *and* a stale one.
841
+ */
842
+ const EPHEMERAL_CONFIG_KEYS = [
843
+ "queryFn",
844
+ "historyFn",
845
+ "extraOptions",
846
+ "env"
847
+ ];
848
+ /** The record as it may be persisted: same session, config narrowed to what is
849
+ * safe and meaningful to keep (see {@link EPHEMERAL_CONFIG_KEYS}). */
850
+ function toDurableRecord(record) {
851
+ const config = { ...record.config };
852
+ for (const key of EPHEMERAL_CONFIG_KEYS) delete config[key];
853
+ return {
854
+ ...record,
855
+ config
856
+ };
857
+ }
858
+ /** Bump when the on-disk shape changes incompatibly; records written by another
859
+ * version are ignored rather than half-read into a broken session. */
860
+ const FORMAT_VERSION = 1;
861
+ /**
862
+ * Durable single-host store: one JSON file per parked session under `dir`, written
863
+ * through a temp file and a rename so a crash mid-write cannot truncate a session.
864
+ * `hydrate()` at `listen()` picks them up, re-indexes their executions, and re-arms
865
+ * the watchdogs, so a restart no longer loses parked work.
866
+ *
867
+ * Know what is on that disk: **the record holds the session's entire transcript** —
868
+ * prompts, model output, and tool I/O — in plaintext. Put it somewhere with the same
869
+ * protection as the SDK's own transcripts (`~/.claude/projects`), not in a directory
870
+ * that gets served, synced, or backed up somewhere looser.
871
+ *
872
+ * Single-process by design, exactly like the bundled queue adapter and profile
873
+ * store: two servers sharing one directory would both hydrate the same records and
874
+ * race to rebuild them. That is what the seam is for.
875
+ *
876
+ * Nothing here reaps: a record leaves only when its session wakes or is deleted.
877
+ * An execution dispatched without a deadline (a `DeferredExecutor` with no
878
+ * `timeoutMs`) has no watchdog to end the wait, so its record — and its transcript
879
+ * — stays until `DELETE /sessions/:id`. Give deferred calls a deadline, or sweep.
880
+ */
881
+ function createFileSessionStore(options = {}) {
882
+ const dir = options.dir ?? join(process.cwd(), ".workerdeck", "parked");
883
+ const fileFor = (id) => join(dir, `${encodeURIComponent(id)}.json`);
884
+ const read = async (path) => {
885
+ let raw;
886
+ try {
887
+ raw = await readFile(path, "utf8");
888
+ } catch (error) {
889
+ if (isMissing(error)) return null;
890
+ options.onError?.(error, {
891
+ path,
892
+ op: "read"
893
+ });
894
+ return null;
895
+ }
896
+ try {
897
+ return parseRecord(JSON.parse(raw));
898
+ } catch (error) {
899
+ options.onError?.(error, {
900
+ path,
901
+ op: "read"
902
+ });
903
+ return null;
904
+ }
905
+ };
906
+ return {
907
+ save: async (record) => {
908
+ const path = fileFor(record.id);
909
+ let payload;
910
+ try {
911
+ payload = JSON.stringify({
912
+ version: FORMAT_VERSION,
913
+ record: toDurableRecord(record)
914
+ });
915
+ } catch (error) {
916
+ options.onError?.(error, {
917
+ path,
918
+ op: "save"
919
+ });
920
+ throw new Error(`parked session '${record.id}' is not JSON-serializable — a host-injected value reached its config or snapshot: ${String(error)}`);
921
+ }
922
+ try {
923
+ await mkdir(dir, {
924
+ recursive: true,
925
+ mode: 448
926
+ });
927
+ const temp = `${path}.${process.pid}.tmp`;
928
+ await writeFile(temp, payload, { mode: 384 });
929
+ await rename(temp, path);
930
+ } catch (error) {
931
+ options.onError?.(error, {
932
+ path,
933
+ op: "save"
934
+ });
935
+ throw error;
936
+ }
937
+ },
938
+ get: (id) => read(fileFor(id)),
939
+ list: async () => {
940
+ let names;
941
+ try {
942
+ names = await readdir(dir);
943
+ } catch (error) {
944
+ if (isMissing(error)) return [];
945
+ options.onError?.(error, {
946
+ path: dir,
947
+ op: "read"
948
+ });
949
+ throw error;
950
+ }
951
+ return (await Promise.all(names.filter((name) => name.endsWith(".json")).map(async (name) => {
952
+ const record = await read(join(dir, name));
953
+ if (!record) return null;
954
+ if (`${encodeURIComponent(record.id)}.json` === name) return record;
955
+ options.onError?.(/* @__PURE__ */ new Error(`parked record '${record.id}' is stored as '${name}' and cannot be read back by id`), {
956
+ path: join(dir, name),
957
+ op: "read"
958
+ });
959
+ return null;
960
+ }))).filter((record) => record !== null);
961
+ },
962
+ delete: async (id) => {
963
+ const path = fileFor(id);
964
+ try {
965
+ await rm(path);
966
+ return true;
967
+ } catch (error) {
968
+ if (isMissing(error)) return false;
969
+ options.onError?.(error, {
970
+ path,
971
+ op: "delete"
972
+ });
973
+ return false;
974
+ }
975
+ }
976
+ };
977
+ }
978
+ const isMissing = (error) => error.code === "ENOENT";
979
+ /** Shape-check a parsed file. A record missing any of these could not be rebuilt,
980
+ * and half-restoring one is worse than skipping it. */
981
+ function parseRecord(value) {
982
+ if (!value || typeof value !== "object") return null;
983
+ const envelope = value;
984
+ if (envelope.version !== FORMAT_VERSION) return null;
985
+ const record = envelope.record;
986
+ if (!record || typeof record !== "object") return null;
987
+ if (typeof record.id !== "string") return null;
988
+ if (!record.info || !record.config) return null;
989
+ if (record.kind === "dormant") {
990
+ const dormant = record;
991
+ if (typeof dormant.sdkSessionId !== "string" || typeof dormant.savedAt !== "number") return null;
992
+ return dormant;
993
+ }
994
+ const parked = record;
995
+ if (typeof parked.parkedAt !== "number" || !parked.snapshot) return null;
996
+ if (!Array.isArray(parked.executions)) return null;
997
+ return parked;
998
+ }
999
+ //#endregion
806
1000
  //#region src/parking.ts
807
1001
  /**
808
- * Deferred execution's other half: parking a session that is waiting on work no
1002
+ * Two ways a session outlives its runner, behind one door.
1003
+ *
1004
+ * **Parking** is deferred execution's other half: a session waiting on work no
809
1005
  * process in this server is doing.
810
1006
  *
1007
+ * **Dormancy** is the restart story for the engines that cannot park. Every live
1008
+ * claude or codex session leaves a small record naming its engine session id, so
1009
+ * a gateway that comes back up lists them and resumes one the first time someone
1010
+ * attaches. Both kinds live in the same store and come back through the same
1011
+ * `ensureLive`, which is why there is one class here and not two.
1012
+ *
811
1013
  * The runner announces the moment with `status_changed: 'parked'` — emitted only
812
1014
  * once every dispatch of the batch has been handed over, so the snapshot can never
813
1015
  * miss a call that was still being dispatched. From there this class snapshots,
@@ -855,12 +1057,21 @@ var SessionParkManager = class {
855
1057
  remember(sessionId, config) {
856
1058
  this.#configs.set(sessionId, config);
857
1059
  }
858
- /** Adopt the store's contents (a durable store after a restart): re-index the
1060
+ /**
1061
+ * Adopt the store's contents (a durable store after a restart): re-index the
859
1062
  * executions and re-arm their watchdogs, no deadline sooner than the grace
860
- * window — nothing could have been delivered while the process was down. */
1063
+ * window — nothing could have been delivered while the process was down.
1064
+ *
1065
+ * Dormant records need nothing here, which is the point of them. They list
1066
+ * from the store (`listInfo`) and come back on first attach (`ensureLive`), so
1067
+ * a boot with fifty remembered sessions spawns nothing at all.
1068
+ */
861
1069
  async hydrate() {
862
1070
  const floor = Date.now() + (this.#options.expiredGraceMs ?? 6e4);
863
- for (const record of await this.#options.store.list()) for (const execution of record.executions) this.#track(record.id, execution, floor);
1071
+ for (const record of await this.#options.store.list()) {
1072
+ if (isDormant(record)) continue;
1073
+ for (const execution of record.executions) this.#track(record.id, execution, floor);
1074
+ }
864
1075
  }
865
1076
  /**
866
1077
  * Follow a session's lifecycle: index its deferred executions, park it when the
@@ -886,8 +1097,13 @@ var SessionParkManager = class {
886
1097
  return;
887
1098
  case "status_changed":
888
1099
  if (event.status === "parked") this.#park(runner);
1100
+ else this.#rememberDormant(runner);
1101
+ return;
1102
+ case "system_init":
1103
+ this.#rememberDormant(runner);
889
1104
  return;
890
1105
  case "session_closed":
1106
+ if (this.#closed) return;
891
1107
  this.discard(runner.id);
892
1108
  return;
893
1109
  default: return;
@@ -911,14 +1127,14 @@ var SessionParkManager = class {
911
1127
  sessionFor(executionId) {
912
1128
  return this.#owners.get(executionId) ?? this.#settled.get(executionId);
913
1129
  }
914
- /** The parked session's record, for the read paths (GET, list, attach). */
1130
+ /** The stored session's record, for the read paths (GET, list, attach). */
915
1131
  get(id) {
916
1132
  return this.#queue(id, () => this.#options.store.get(id));
917
1133
  }
918
- /** Every parked session's info, to merge into `GET {basePath}/sessions`. */
1134
+ /** Every stored session's info, to merge into `GET {basePath}/sessions`. */
919
1135
  async listInfo() {
920
1136
  await Promise.all(this.#storeOps.values());
921
- return (await this.#options.store.list()).map((record) => record.info);
1137
+ return (await this.#options.store.list()).filter((record) => this.#options.registry.get(record.id) === void 0).map((record) => record.info);
922
1138
  }
923
1139
  /** The live runner for a session, rehydrating a parked one on demand. Undefined
924
1140
  * when the session is neither live nor parked. */
@@ -973,6 +1189,51 @@ var SessionParkManager = class {
973
1189
  this.#timers.clear();
974
1190
  this.#detachTimers.clear();
975
1191
  }
1192
+ /**
1193
+ * Write (or refresh) the dormant record that lets this session survive a
1194
+ * restart. Cheap and repeated on purpose — driven off `system_init` and every
1195
+ * non-park status change — because the alternative is a shutdown hook, and a
1196
+ * shutdown hook is exactly what a `kill -9`, an OOM or a pulled power cable
1197
+ * do not run.
1198
+ *
1199
+ * Four gates, each of which would otherwise produce a record that is worse
1200
+ * than none: the engine must be able to resume at all (a provider session
1201
+ * would come back with an empty transcript — it has `park()` instead), it must
1202
+ * have named its session, the host must remember the config to rebuild from,
1203
+ * and the runner must still be the registry's. That last one is what keeps a
1204
+ * park from being overwritten: `#park` evicts before it saves, so a late event
1205
+ * from an evicted runner finds itself a stranger here and writes nothing.
1206
+ */
1207
+ async #rememberDormant(runner) {
1208
+ if (this.#closed) return;
1209
+ const info = runner.info();
1210
+ const sdkSessionId = info.sdkSessionId;
1211
+ if (sdkSessionId === void 0) return;
1212
+ if (!(info.capabilities ?? ENGINE_CAPABILITIES[info.engine ?? "claude"]).resume) return;
1213
+ const config = this.#configs.get(runner.id);
1214
+ if (!config) return;
1215
+ if (this.#options.registry.get(runner.id) !== runner) return;
1216
+ const record = {
1217
+ kind: "dormant",
1218
+ id: runner.id,
1219
+ info: {
1220
+ ...info,
1221
+ status: "idle"
1222
+ },
1223
+ profile: info.profile,
1224
+ config,
1225
+ sdkSessionId,
1226
+ savedAt: Date.now()
1227
+ };
1228
+ try {
1229
+ await this.#queue(runner.id, () => this.#options.store.save(record));
1230
+ } catch (error) {
1231
+ this.#options.onError?.(error, {
1232
+ sessionId: runner.id,
1233
+ phase: "remember"
1234
+ });
1235
+ }
1236
+ }
976
1237
  async #park(runner) {
977
1238
  if (this.#closed || !runner.park) return;
978
1239
  const id = runner.id;
@@ -992,6 +1253,7 @@ var SessionParkManager = class {
992
1253
  };
993
1254
  this.#options.registry.evict(id);
994
1255
  const record = {
1256
+ kind: "parked",
995
1257
  id,
996
1258
  info,
997
1259
  profile: info.profile,
@@ -1036,7 +1298,7 @@ var SessionParkManager = class {
1036
1298
  }
1037
1299
  if (runner.id !== id) {
1038
1300
  runner.close("error");
1039
- const error = /* @__PURE__ */ new Error(`rebuilt session has id '${runner.id}', expected '${id}' — the engine factory must forward EngineRunnerContext.restore to the runner config`);
1301
+ const error = /* @__PURE__ */ new Error(`rebuilt session has id '${runner.id}', expected '${id}' — the engine factory must forward EngineRunnerContext.restore (or, without a snapshot, the session id) to the runner config`);
1040
1302
  this.#options.onError?.(error, {
1041
1303
  sessionId: id,
1042
1304
  phase: "resume"
@@ -1045,9 +1307,9 @@ var SessionParkManager = class {
1045
1307
  }
1046
1308
  this.#options.registry.register(runner);
1047
1309
  this.remember(id, record.config);
1048
- this.watch(runner, record.snapshot.seq);
1310
+ this.watch(runner, isDormant(record) ? 0 : record.snapshot.seq);
1049
1311
  this.#options.onResumed?.(id, runner);
1050
- await this.#queue(id, () => this.#options.store.delete(id));
1312
+ if (!isDormant(record)) await this.#queue(id, () => this.#options.store.delete(id));
1051
1313
  runner.start();
1052
1314
  return runner;
1053
1315
  }
@@ -1097,187 +1359,6 @@ var SessionParkManager = class {
1097
1359
  }
1098
1360
  };
1099
1361
  //#endregion
1100
- //#region src/session-store.ts
1101
- /** Single-process, no persistence: parks survive a client disconnect, not a restart. */
1102
- var MemorySessionStore = class {
1103
- #records = /* @__PURE__ */ new Map();
1104
- save(record) {
1105
- this.#records.set(record.id, record);
1106
- return Promise.resolve();
1107
- }
1108
- get(id) {
1109
- return Promise.resolve(this.#records.get(id) ?? null);
1110
- }
1111
- list() {
1112
- return Promise.resolve([...this.#records.values()]);
1113
- }
1114
- delete(id) {
1115
- return Promise.resolve(this.#records.delete(id));
1116
- }
1117
- };
1118
- /**
1119
- * Config fields that must not be written to durable storage: two are functions
1120
- * (JSON drops them silently), `extraOptions` is SDK `Options` and may hold hooks
1121
- * and callbacks, and `env` is a credential-bearing map — the same rule
1122
- * `profile-store.ts` follows, for the same reason.
1123
- *
1124
- * Dropping them costs a rehydrated session nothing: all four are consumed by the
1125
- * Claude engine alone, and the Claude engine cannot park (the CLI owns its process
1126
- * state — `buildRunner` refuses a `restore` for it). A provider session's
1127
- * credentials are resolved by `createEngineRunner` from the operator's environment
1128
- * on every build, wake included.
1129
- */
1130
- const EPHEMERAL_CONFIG_KEYS = [
1131
- "queryFn",
1132
- "historyFn",
1133
- "extraOptions",
1134
- "env"
1135
- ];
1136
- /** The record as it may be persisted: same session, config narrowed to what is
1137
- * safe and meaningful to keep (see {@link EPHEMERAL_CONFIG_KEYS}). */
1138
- function toDurableRecord(record) {
1139
- const config = { ...record.config };
1140
- for (const key of EPHEMERAL_CONFIG_KEYS) delete config[key];
1141
- return {
1142
- ...record,
1143
- config
1144
- };
1145
- }
1146
- /** Bump when the on-disk shape changes incompatibly; records written by another
1147
- * version are ignored rather than half-read into a broken session. */
1148
- const FORMAT_VERSION = 1;
1149
- /**
1150
- * Durable single-host store: one JSON file per parked session under `dir`, written
1151
- * through a temp file and a rename so a crash mid-write cannot truncate a session.
1152
- * `hydrate()` at `listen()` picks them up, re-indexes their executions, and re-arms
1153
- * the watchdogs, so a restart no longer loses parked work.
1154
- *
1155
- * Know what is on that disk: **the record holds the session's entire transcript** —
1156
- * prompts, model output, and tool I/O — in plaintext. Put it somewhere with the same
1157
- * protection as the SDK's own transcripts (`~/.claude/projects`), not in a directory
1158
- * that gets served, synced, or backed up somewhere looser.
1159
- *
1160
- * Single-process by design, exactly like the bundled queue adapter and profile
1161
- * store: two servers sharing one directory would both hydrate the same records and
1162
- * race to rebuild them. That is what the seam is for.
1163
- *
1164
- * Nothing here reaps: a record leaves only when its session wakes or is deleted.
1165
- * An execution dispatched without a deadline (a `DeferredExecutor` with no
1166
- * `timeoutMs`) has no watchdog to end the wait, so its record — and its transcript
1167
- * — stays until `DELETE /sessions/:id`. Give deferred calls a deadline, or sweep.
1168
- */
1169
- function createFileSessionStore(options = {}) {
1170
- const dir = options.dir ?? join(process.cwd(), ".workerdeck", "parked");
1171
- const fileFor = (id) => join(dir, `${encodeURIComponent(id)}.json`);
1172
- const read = async (path) => {
1173
- let raw;
1174
- try {
1175
- raw = await readFile(path, "utf8");
1176
- } catch (error) {
1177
- if (isMissing(error)) return null;
1178
- options.onError?.(error, {
1179
- path,
1180
- op: "read"
1181
- });
1182
- return null;
1183
- }
1184
- try {
1185
- return parseRecord(JSON.parse(raw));
1186
- } catch (error) {
1187
- options.onError?.(error, {
1188
- path,
1189
- op: "read"
1190
- });
1191
- return null;
1192
- }
1193
- };
1194
- return {
1195
- save: async (record) => {
1196
- const path = fileFor(record.id);
1197
- let payload;
1198
- try {
1199
- payload = JSON.stringify({
1200
- version: FORMAT_VERSION,
1201
- record: toDurableRecord(record)
1202
- });
1203
- } catch (error) {
1204
- options.onError?.(error, {
1205
- path,
1206
- op: "save"
1207
- });
1208
- throw new Error(`parked session '${record.id}' is not JSON-serializable — a host-injected value reached its config or snapshot: ${String(error)}`);
1209
- }
1210
- try {
1211
- await mkdir(dir, {
1212
- recursive: true,
1213
- mode: 448
1214
- });
1215
- const temp = `${path}.${process.pid}.tmp`;
1216
- await writeFile(temp, payload, { mode: 384 });
1217
- await rename(temp, path);
1218
- } catch (error) {
1219
- options.onError?.(error, {
1220
- path,
1221
- op: "save"
1222
- });
1223
- throw error;
1224
- }
1225
- },
1226
- get: (id) => read(fileFor(id)),
1227
- list: async () => {
1228
- let names;
1229
- try {
1230
- names = await readdir(dir);
1231
- } catch (error) {
1232
- if (isMissing(error)) return [];
1233
- options.onError?.(error, {
1234
- path: dir,
1235
- op: "read"
1236
- });
1237
- throw error;
1238
- }
1239
- return (await Promise.all(names.filter((name) => name.endsWith(".json")).map(async (name) => {
1240
- const record = await read(join(dir, name));
1241
- if (!record) return null;
1242
- if (`${encodeURIComponent(record.id)}.json` === name) return record;
1243
- options.onError?.(/* @__PURE__ */ new Error(`parked record '${record.id}' is stored as '${name}' and cannot be read back by id`), {
1244
- path: join(dir, name),
1245
- op: "read"
1246
- });
1247
- return null;
1248
- }))).filter((record) => record !== null);
1249
- },
1250
- delete: async (id) => {
1251
- const path = fileFor(id);
1252
- try {
1253
- await rm(path);
1254
- return true;
1255
- } catch (error) {
1256
- if (isMissing(error)) return false;
1257
- options.onError?.(error, {
1258
- path,
1259
- op: "delete"
1260
- });
1261
- return false;
1262
- }
1263
- }
1264
- };
1265
- }
1266
- const isMissing = (error) => error.code === "ENOENT";
1267
- /** Shape-check a parsed file. A record missing any of these could not be rebuilt,
1268
- * and half-restoring one is worse than skipping it. */
1269
- function parseRecord(value) {
1270
- if (!value || typeof value !== "object") return null;
1271
- const envelope = value;
1272
- if (envelope.version !== FORMAT_VERSION) return null;
1273
- const record = envelope.record;
1274
- if (!record || typeof record !== "object") return null;
1275
- if (typeof record.id !== "string" || typeof record.parkedAt !== "number") return null;
1276
- if (!record.info || !record.config || !record.snapshot) return null;
1277
- if (!Array.isArray(record.executions)) return null;
1278
- return record;
1279
- }
1280
- //#endregion
1281
1362
  //#region src/server.ts
1282
1363
  function json(res, status, body) {
1283
1364
  const payload = JSON.stringify(body);
@@ -1450,10 +1531,59 @@ function cwdAllowed(cwd, roots) {
1450
1531
  return resolved === r || resolved.startsWith(r + sep);
1451
1532
  });
1452
1533
  }
1534
+ /** Most tags one session (or one principal) may carry, and the longest a key or
1535
+ * value may be. Not a security property — a bound so an opaque map cannot become
1536
+ * an unbounded store that every list response then carries. */
1537
+ const MAX_SCOPE_KEYS = 16;
1538
+ const MAX_SCOPE_LEN = 200;
1539
+ /** A `Record<string, string>` or nothing. Duck-typed the same way
1540
+ * `allowedProfiles` is: a malformed value is ignored, never half-applied. */
1541
+ function readScope(value) {
1542
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
1543
+ const entries = Object.entries(value);
1544
+ if (entries.some(([, v]) => typeof v !== "string")) return void 0;
1545
+ return Object.fromEntries(entries);
1546
+ }
1547
+ /** Validate a caller-supplied scope. Returns an error string, or null when it is
1548
+ * well-formed (including when it is absent). */
1549
+ function checkScope(value) {
1550
+ if (value === void 0) return null;
1551
+ const scope = readScope(value);
1552
+ if (!scope) return "scope must be an object of string values";
1553
+ const entries = Object.entries(scope);
1554
+ if (entries.length > MAX_SCOPE_KEYS) return `scope may carry at most ${MAX_SCOPE_KEYS} keys`;
1555
+ for (const [key, val] of entries) {
1556
+ if (key.length === 0) return "scope keys must not be empty";
1557
+ if (key.length > MAX_SCOPE_LEN || val.length > MAX_SCOPE_LEN) return `scope keys and values must be at most ${MAX_SCOPE_LEN} characters`;
1558
+ }
1559
+ return null;
1560
+ }
1561
+ /** Key-order-independent equality — a host runner that rebuilt the record
1562
+ * rather than echoing the reference must still pass the build-time check. */
1563
+ function sameScope(a, b) {
1564
+ const left = Object.entries(a ?? {}).sort(([x], [y]) => x < y ? -1 : 1);
1565
+ const right = Object.entries(b ?? {}).sort(([x], [y]) => x < y ? -1 : 1);
1566
+ return left.length === right.length && left.every(([key, value], i) => right[i][0] === key && right[i][1] === value);
1567
+ }
1568
+ /**
1569
+ * The default visibility rule, used whenever the host supplies no
1570
+ * `authorizeSession`: every key the principal pins must match the session's, and
1571
+ * an unset principal scope sees everything.
1572
+ *
1573
+ * The asymmetry is intended — a session may carry tags the principal says
1574
+ * nothing about (an app that tags `{space, user, conversation}` while the
1575
+ * principal only pins `{space, user}` still works), but a session missing a key
1576
+ * the principal pins is not this caller's.
1577
+ */
1578
+ function scopeMatches(principal, session) {
1579
+ if (!principal) return true;
1580
+ return Object.entries(principal).every(([key, value]) => session?.[key] === value);
1581
+ }
1453
1582
  function createWorkerServer(options = {}) {
1454
1583
  if (!options.authenticate && !options.allowUnauthenticated) throw new Error("createWorkerServer: provide `authenticate` or explicitly set `allowUnauthenticated: true`");
1455
1584
  const basePath = options.basePath ?? "/v1";
1456
1585
  const fallback = options.fallback;
1586
+ const corsOrigins = options.cors?.origins.length ? new Set(options.cors.origins) : void 0;
1457
1587
  const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
1458
1588
  /** The engine's adapter, honoring the test-only `engines` override. */
1459
1589
  const adapterFor = (engine) => options.engines?.[engine ?? "claude"] ?? getEngineAdapter(engine);
@@ -1634,18 +1764,88 @@ function createWorkerServer(options = {}) {
1634
1764
  const stripInertFields = (req, profile) => {
1635
1765
  if (!adapterFor(profile?.engine).capabilities.interactiveApprovals) delete req.questionBehavior;
1636
1766
  };
1767
+ /**
1768
+ * Validate the request's scope and merge the principal's into it.
1769
+ *
1770
+ * A scoped principal's keys are *filled in* when the request omits them and
1771
+ * *refused* when the request disagrees: a caller inside a scope may narrow
1772
+ * itself with extra tags, never claim to be somewhere else. That makes an
1773
+ * embedder's stamping proxy defense in depth rather than the only line — a
1774
+ * request that slipped past it still cannot create a session in another
1775
+ * scope. An unscoped principal (the operator) may write any tags.
1776
+ */
1777
+ const applyScope = (req, auth) => {
1778
+ const invalid = checkScope(req.scope);
1779
+ if (invalid) return {
1780
+ status: 400,
1781
+ error: invalid
1782
+ };
1783
+ if (!auth.scope) return null;
1784
+ const merged = { ...req.scope };
1785
+ for (const [key, value] of Object.entries(auth.scope)) {
1786
+ const claimed = merged[key];
1787
+ if (claimed !== void 0 && claimed !== value) return {
1788
+ status: 403,
1789
+ error: `scope '${key}' does not match the caller's`
1790
+ };
1791
+ merged[key] = value;
1792
+ }
1793
+ const tooBig = checkScope(merged);
1794
+ if (tooBig) return {
1795
+ status: 400,
1796
+ error: tooBig
1797
+ };
1798
+ req.scope = merged;
1799
+ return null;
1800
+ };
1801
+ /**
1802
+ * `cwd`, required or not depending on the engine's capability record — the
1803
+ * record rather than the engine name, so a host engine that has no host
1804
+ * filesystem gets the same treatment without this file learning its name.
1805
+ *
1806
+ * When one *is* supplied it is validated even for an engine that will not read
1807
+ * it: a path the caller went out of their way to name should not be quietly
1808
+ * exempt from the operator's roots. And note what this check is not — for a
1809
+ * filesystem-less engine `allowedCwdRoots` guards nothing at all. The
1810
+ * boundary there is the capability wiring, not a path prefix.
1811
+ */
1812
+ const checkCwd = (req, profile) => {
1813
+ if (req.cwd !== void 0 && typeof req.cwd !== "string") return {
1814
+ status: 400,
1815
+ error: "cwd must be a string"
1816
+ };
1817
+ if (!req.cwd) return adapterFor(profile?.engine).capabilities.hostCwd === false ? null : {
1818
+ status: 400,
1819
+ error: "cwd is required"
1820
+ };
1821
+ return cwdAllowed(req.cwd, options.allowedCwdRoots) ? null : {
1822
+ status: 403,
1823
+ error: "cwd is outside the allowed roots"
1824
+ };
1825
+ };
1826
+ /**
1827
+ * Re-stamp the request's scope onto whatever the host's `buildRunnerConfig`
1828
+ * returned. The hook is host code and may rewrite the config wholesale; a
1829
+ * hook that dropped `scope` would silently *widen* a session's visibility,
1830
+ * which is the one direction a bug here must not go. Same posture as the
1831
+ * profile's env pin winning over the hook.
1832
+ */
1833
+ const withScope = (config, scope) => scope === void 0 ? config : {
1834
+ ...config,
1835
+ scope
1836
+ };
1637
1837
  /** Profile-aware config hook: fill the profile's defaults into unset request fields,
1638
1838
  * run the host hook, then pin CLAUDE_CONFIG_DIR — the profile wins even when the
1639
1839
  * host hook set its own env (see `claudeSessionEnv` for the one case the pin is
1640
1840
  * skipped, and why). Handed to the queue too, so jobs inherit profiles. */
1641
1841
  const buildRunnerConfig = (req) => {
1642
1842
  const profile = req.profile !== void 0 ? profileFor(req.profile) : void 0;
1643
- if (!profile) return hostBuildRunnerConfig(req);
1644
- const config = hostBuildRunnerConfig({
1843
+ if (!profile) return withScope(hostBuildRunnerConfig(req), req.scope);
1844
+ const config = withScope(hostBuildRunnerConfig({
1645
1845
  ...req,
1646
1846
  model: req.model ?? profile.defaults?.model ?? profile.provider?.model,
1647
1847
  permissionMode: req.permissionMode ?? profile.defaults?.permissionMode
1648
- });
1848
+ }), req.scope);
1649
1849
  if (engineOf(profile) !== "claude") return config;
1650
1850
  const base = config.env ?? process.env;
1651
1851
  const env = claudeSessionEnv(profile, base);
@@ -1660,21 +1860,25 @@ function createWorkerServer(options = {}) {
1660
1860
  *
1661
1861
  * `restore` rebuilds a parked session rather than creating a new one — same id,
1662
1862
  * same log, mid-task. */
1663
- const buildRunner = async (config, restore) => {
1863
+ const buildRunner = async (config, restore, id) => {
1664
1864
  const name = config.profile;
1665
1865
  const profile = name !== void 0 ? profileFor(name) : void 0;
1666
1866
  if (name !== void 0 && !profile) throw new Error(`unknown profile: ${name}`);
1667
- if (profile && isProviderProfile(profile)) return options.createEngineRunner({
1867
+ const runner = profile && isProviderProfile(profile) ? await options.createEngineRunner({
1668
1868
  config,
1669
1869
  profile,
1670
1870
  bridge,
1671
- restore
1672
- });
1673
- return adapterFor(profile?.engine).createRunner({
1871
+ restore,
1872
+ id
1873
+ }) : await adapterFor(profile?.engine).createRunner({
1674
1874
  config,
1675
1875
  profile,
1676
- restore
1876
+ restore,
1877
+ id
1677
1878
  });
1879
+ const reported = runner.info().scope;
1880
+ if (!sameScope(reported, config.scope)) throw new Error(`runner for session ${runner.id} reports scope ${JSON.stringify(reported)}, expected ${JSON.stringify(config.scope)} — echo config.scope from info()`);
1881
+ return runner;
1678
1882
  };
1679
1883
  const createRunner = async (config) => {
1680
1884
  const runner = registry.register(await buildRunner(config));
@@ -1754,7 +1958,10 @@ function createWorkerServer(options = {}) {
1754
1958
  parkDelayMs: options.parking?.parkDelayMs,
1755
1959
  expiredGraceMs: options.parking?.expiredGraceMs,
1756
1960
  onError: options.parking?.onError,
1757
- rebuild: (record) => buildRunner(record.config, record.snapshot),
1961
+ rebuild: (record) => isDormant(record) ? buildRunner(buildRunnerConfig({
1962
+ ...record.config,
1963
+ resume: record.sdkSessionId
1964
+ }), void 0, record.id) : buildRunner(record.config, record.snapshot),
1758
1965
  attachedCount: (sessionId) => bridge.attachedCount(sessionId),
1759
1966
  onParking: (sessionId, executionId) => queue?.onSessionParking(sessionId, executionId) ?? true,
1760
1967
  onResumed: (sessionId, runner) => queue?.onSessionResumed(sessionId, runner)
@@ -1864,6 +2071,20 @@ function createWorkerServer(options = {}) {
1864
2071
  if (verdict.available === true) availabilityWarned.delete(profile.name);
1865
2072
  }).catch(() => {});
1866
2073
  };
2074
+ /**
2075
+ * The create-time half of `requireAvailableProfile`. Only a definite `false`
2076
+ * refuses: an unprobed profile ('unknown', or probes turned off entirely) is
2077
+ * not evidence of anything and must not become a closed door.
2078
+ */
2079
+ const checkAvailable = (profile) => {
2080
+ if (!options.requireAvailableProfile || !profile) return null;
2081
+ const verdict = availability.get(profile.name)?.verdict;
2082
+ if (!verdict || verdict.available !== false) return null;
2083
+ return {
2084
+ status: 503,
2085
+ error: `profile '${profile.name}' is unavailable: ${verdict.reason ?? "no usable credentials"}`
2086
+ };
2087
+ };
1867
2088
  /** Launch-time sweep, concurrent and fire-and-forget. */
1868
2089
  const preflightCredentials = () => {
1869
2090
  for (const profile of allProfiles()) probeProfile(profile);
@@ -1884,12 +2105,79 @@ function createWorkerServer(options = {}) {
1884
2105
  const principal = await options.authenticate(req);
1885
2106
  if (principal === null || principal === void 0 || principal === false) return { ok: false };
1886
2107
  const allowed = principal.allowedProfiles;
2108
+ const scope = readScope(principal.scope);
1887
2109
  return {
1888
2110
  ok: true,
2111
+ principal,
2112
+ scope: scope && Object.keys(scope).length > 0 ? scope : void 0,
2113
+ operator: typeof principal.operator === "boolean" ? principal.operator : void 0,
1889
2114
  allowedProfiles: Array.isArray(allowed) && allowed.every((p) => typeof p === "string") ? allowed : void 0,
1890
2115
  canManageProfiles: principal.canManageProfiles === true
1891
2116
  };
1892
2117
  };
2118
+ /**
2119
+ * May this caller see — and therefore drive — this session? The one rule
2120
+ * behind the list filter, every `/sessions/:id/*` route, the WS attach, the
2121
+ * execution-result door and the job routes: three callers, one predicate, so
2122
+ * they cannot drift into three subtly different answers.
2123
+ */
2124
+ const canSee = (auth, session) => {
2125
+ if (!options.authorizeSession) return scopeMatches(auth.scope, session.scope);
2126
+ try {
2127
+ return options.authorizeSession(auth.principal, session) === true;
2128
+ } catch {
2129
+ return false;
2130
+ }
2131
+ };
2132
+ /**
2133
+ * The job flavour of {@link canSee}. Once the run has started, the live
2134
+ * session's info is the real subject and the host's rule decides on it. Before
2135
+ * that (queued) and after (finished, session gone) there is no session to
2136
+ * hand over, so the predicate gets a **stub** built from what the job records:
2137
+ * its scope, its profile, its cwd.
2138
+ *
2139
+ * A stub rather than a fallback to the default rule, which is what this did
2140
+ * first and was wrong: a host policy *narrower* than plain tag-match (tags
2141
+ * plus a role, say) would have had queued jobs admitted — and cancelable — by
2142
+ * a peer it rejects. The predicate must be the only rule wherever it exists.
2143
+ * A host reading fields a queued job cannot have (model, status detail) gets
2144
+ * `undefined` and should treat the id and the scope as the load-bearing ones.
2145
+ */
2146
+ const canSeeJob = (auth, job) => {
2147
+ const live = job.sessionId ? registry.get(job.sessionId)?.info() : void 0;
2148
+ if (live) return canSee(auth, live);
2149
+ if (!options.authorizeSession) return scopeMatches(auth.scope, job.scope);
2150
+ return canSee(auth, {
2151
+ id: job.sessionId ?? job.id,
2152
+ status: job.status === "running" ? "running" : job.status === "parked" ? "parked" : job.status === "queued" ? "starting" : "closed",
2153
+ cwd: job.cwd,
2154
+ profile: job.profile,
2155
+ createdAt: job.createdAt,
2156
+ lastSeq: 0,
2157
+ pendingPermissionCount: 0,
2158
+ scope: job.scope
2159
+ });
2160
+ };
2161
+ /**
2162
+ * Is this caller the operator, rather than someone embedded inside a scope?
2163
+ *
2164
+ * It decides the surfaces that answer about the **gateway** instead of about
2165
+ * one session — the host filesystem, the engine's own on-disk session store,
2166
+ * the queue and its firehose. There is nothing to filter on those and no
2167
+ * honest way to narrow them, so a non-operator is refused outright (404, like
2168
+ * every other miss).
2169
+ *
2170
+ * Two ways to be one, and the second exists because the first is not enough.
2171
+ * A principal carrying `scope` is an end user; a principal carrying neither
2172
+ * `scope` nor a policy is the operator — that is the unscoped default every
2173
+ * existing deployment relies on. But a host may write `authorizeSession` over
2174
+ * its *own* principal shape and never set `scope` at all, and reading that as
2175
+ * "everyone is the operator" is how a locked-down gateway ends up serving its
2176
+ * filesystem to end users. So **declaring a policy withdraws the default**,
2177
+ * and such a host marks its operator principals explicitly with
2178
+ * `operator: true` (`operator: false` forces the other way, at any time).
2179
+ */
2180
+ const isOperator = (auth) => auth.operator ?? (auth.scope === void 0 && !options.authorizeSession);
1893
2181
  const parseRoute = (url) => {
1894
2182
  const pathname = new URL(url, "http://internal").pathname;
1895
2183
  if (!pathname.startsWith(basePath + "/sessions")) return null;
@@ -2407,13 +2695,17 @@ function createWorkerServer(options = {}) {
2407
2695
  json(res, 405, { error: "method not allowed" });
2408
2696
  return;
2409
2697
  }
2698
+ if (!isOperator(auth)) {
2699
+ json(res, 404, { error: "not found" });
2700
+ return;
2701
+ }
2410
2702
  json(res, 200, { stats: await queue.stats() });
2411
2703
  return;
2412
2704
  }
2413
2705
  const rest = pathname.slice((basePath + "/jobs").length).replace(/^\//, "");
2414
2706
  if (rest === "") {
2415
2707
  if (req.method === "GET") {
2416
- json(res, 200, { jobs: await queue.list() });
2708
+ json(res, 200, { jobs: (await queue.list()).filter((job) => canSeeJob(auth, job)) });
2417
2709
  return;
2418
2710
  }
2419
2711
  if (req.method === "POST") {
@@ -2422,16 +2714,13 @@ function createWorkerServer(options = {}) {
2422
2714
  json(res, 400, { error: "session is required" });
2423
2715
  return;
2424
2716
  }
2425
- if (!body.session.cwd || typeof body.session.cwd !== "string") {
2426
- json(res, 400, { error: "session.cwd is required" });
2427
- return;
2428
- }
2429
2717
  if (!body.session.prompt || typeof body.session.prompt !== "string") {
2430
2718
  json(res, 400, { error: "session.prompt is required" });
2431
2719
  return;
2432
2720
  }
2433
- if (!cwdAllowed(body.session.cwd, options.allowedCwdRoots)) {
2434
- json(res, 403, { error: "cwd is outside the allowed roots" });
2721
+ const refusedScope = applyScope(body.session, auth);
2722
+ if (refusedScope) {
2723
+ json(res, refusedScope.status, { error: refusedScope.error });
2435
2724
  return;
2436
2725
  }
2437
2726
  const refused = applyBypassPolicy(body.session);
@@ -2444,6 +2733,16 @@ function createWorkerServer(options = {}) {
2444
2733
  json(res, resolved.status, { error: resolved.error });
2445
2734
  return;
2446
2735
  }
2736
+ const unavailable = checkAvailable(resolved.profile);
2737
+ if (unavailable) {
2738
+ json(res, unavailable.status, { error: unavailable.error });
2739
+ return;
2740
+ }
2741
+ const refusedCwd = checkCwd(body.session, resolved.profile);
2742
+ if (refusedCwd) {
2743
+ json(res, refusedCwd.status, { error: refusedCwd.error });
2744
+ return;
2745
+ }
2447
2746
  const badRequest = checkPermissionMode(body.session.permissionMode, resolved.profile) ?? checkEngineGrants(body.session, resolved.profile);
2448
2747
  if (badRequest) {
2449
2748
  json(res, 400, { error: badRequest });
@@ -2468,11 +2767,16 @@ function createWorkerServer(options = {}) {
2468
2767
  }
2469
2768
  if (req.method === "GET") {
2470
2769
  const job = await queue.get(id);
2471
- if (job) json(res, 200, { job });
2770
+ if (job && canSeeJob(auth, job)) json(res, 200, { job });
2472
2771
  else json(res, 404, { error: "job not found" });
2473
2772
  return;
2474
2773
  }
2475
2774
  if (req.method === "DELETE") {
2775
+ const existing = await queue.get(id);
2776
+ if (!existing || !canSeeJob(auth, existing)) {
2777
+ json(res, 404, { error: "job not found" });
2778
+ return;
2779
+ }
2476
2780
  const job = await queue.cancel(id);
2477
2781
  if (job) json(res, 200, { job });
2478
2782
  else json(res, 404, { error: "job not found" });
@@ -2528,10 +2832,11 @@ function createWorkerServer(options = {}) {
2528
2832
  json(res, 400, { error: "status must be 'ok' or 'failed'" });
2529
2833
  return;
2530
2834
  }
2531
- if (auth.allowedProfiles) {
2835
+ if (auth.allowedProfiles || auth.scope || options.authorizeSession) {
2532
2836
  const owner = parking.sessionFor(executionId);
2533
- const profile = owner === void 0 ? void 0 : registry.get(owner)?.info().profile ?? (await parking.get(owner))?.profile;
2534
- if (owner === void 0 || profile !== void 0 && !auth.allowedProfiles.includes(profile)) {
2837
+ const info = owner === void 0 ? void 0 : registry.get(owner)?.info() ?? (await parking.get(owner))?.info;
2838
+ const profile = info?.profile;
2839
+ if (owner === void 0 || auth.allowedProfiles !== void 0 && profile !== void 0 && !auth.allowedProfiles.includes(profile) || info !== void 0 && !canSee(auth, info)) {
2535
2840
  json(res, 404, { error: "execution not found" });
2536
2841
  return;
2537
2842
  }
@@ -2545,6 +2850,26 @@ function createWorkerServer(options = {}) {
2545
2850
  };
2546
2851
  const handleRequest = async (req, res) => {
2547
2852
  const pathname = new URL(req.url ?? "/", "http://internal").pathname;
2853
+ const origin = req.headers.origin;
2854
+ const originAllowed = typeof origin === "string" && corsOrigins !== void 0 && corsOrigins.has(origin);
2855
+ if (originAllowed) {
2856
+ res.setHeader("access-control-allow-origin", origin);
2857
+ res.setHeader("vary", "origin");
2858
+ }
2859
+ if (req.method === "OPTIONS" && req.headers["access-control-request-method"] !== void 0) {
2860
+ if (!originAllowed) {
2861
+ res.writeHead(403);
2862
+ res.end();
2863
+ return;
2864
+ }
2865
+ res.setHeader("access-control-allow-methods", "GET, HEAD, POST, PATCH, PUT, DELETE");
2866
+ res.setHeader("access-control-allow-headers", "authorization, content-type, x-workerdeck-key");
2867
+ res.setHeader("access-control-max-age", "600");
2868
+ if (req.headers["access-control-request-private-network"] === "true") res.setHeader("access-control-allow-private-network", "true");
2869
+ res.writeHead(204);
2870
+ res.end();
2871
+ return;
2872
+ }
2548
2873
  if (fallback && pathname !== basePath && !pathname.startsWith(basePath + "/")) {
2549
2874
  await fallback(req, res);
2550
2875
  return;
@@ -2609,7 +2934,7 @@ function createWorkerServer(options = {}) {
2609
2934
  if (req.method === "GET") {
2610
2935
  json(res, 200, {
2611
2936
  profile: withManagedFlag(profile),
2612
- config: readProfileConfig(profile)
2937
+ config: isOperator(auth) ? readProfileConfig(profile) : void 0
2613
2938
  });
2614
2939
  return;
2615
2940
  }
@@ -2651,14 +2976,23 @@ function createWorkerServer(options = {}) {
2651
2976
  json(res, 401, { error: "unauthorized" });
2652
2977
  return;
2653
2978
  }
2979
+ if (!isOperator(auth)) {
2980
+ json(res, 404, { error: "not found" });
2981
+ return;
2982
+ }
2654
2983
  await handleSdkSessions(req, res, auth);
2655
2984
  return;
2656
2985
  }
2657
2986
  if (pathname.startsWith(basePath + "/fs/")) {
2658
- if (!(await authenticate(req)).ok) {
2987
+ const auth = await authenticate(req);
2988
+ if (!auth.ok) {
2659
2989
  json(res, 401, { error: "unauthorized" });
2660
2990
  return;
2661
2991
  }
2992
+ if (!isOperator(auth)) {
2993
+ json(res, 404, { error: "not found" });
2994
+ return;
2995
+ }
2662
2996
  await handleHostFiles(req, res, pathname);
2663
2997
  return;
2664
2998
  }
@@ -2674,17 +3008,14 @@ function createWorkerServer(options = {}) {
2674
3008
  }
2675
3009
  if (!route.id) {
2676
3010
  if (req.method === "GET") {
2677
- json(res, 200, { sessions: [...registry.list(), ...await parking.listInfo()] });
3011
+ json(res, 200, { sessions: [...registry.list(), ...await parking.listInfo()].filter((session) => canSee(auth, session)) });
2678
3012
  return;
2679
3013
  }
2680
3014
  if (req.method === "POST") {
2681
3015
  const body = await readJsonBody(req, maxBodyBytes);
2682
- if (!body.cwd || typeof body.cwd !== "string") {
2683
- json(res, 400, { error: "cwd is required" });
2684
- return;
2685
- }
2686
- if (!cwdAllowed(body.cwd, options.allowedCwdRoots)) {
2687
- json(res, 403, { error: "cwd is outside the allowed roots" });
3016
+ const refusedScope = applyScope(body, auth);
3017
+ if (refusedScope) {
3018
+ json(res, refusedScope.status, { error: refusedScope.error });
2688
3019
  return;
2689
3020
  }
2690
3021
  const refused = applyBypassPolicy(body);
@@ -2697,6 +3028,16 @@ function createWorkerServer(options = {}) {
2697
3028
  json(res, resolved.status, { error: resolved.error });
2698
3029
  return;
2699
3030
  }
3031
+ const unavailable = checkAvailable(resolved.profile);
3032
+ if (unavailable) {
3033
+ json(res, unavailable.status, { error: unavailable.error });
3034
+ return;
3035
+ }
3036
+ const refusedCwd = checkCwd(body, resolved.profile);
3037
+ if (refusedCwd) {
3038
+ json(res, refusedCwd.status, { error: refusedCwd.error });
3039
+ return;
3040
+ }
2700
3041
  const badRequest = checkPermissionMode(body.permissionMode, resolved.profile) ?? checkEngineGrants(body, resolved.profile);
2701
3042
  if (badRequest) {
2702
3043
  json(res, 400, { error: badRequest });
@@ -2718,6 +3059,10 @@ function createWorkerServer(options = {}) {
2718
3059
  json(res, 404, { error: "session not found" });
2719
3060
  return;
2720
3061
  }
3062
+ if (!canSee(auth, runner?.info() ?? parked.info)) {
3063
+ json(res, 404, { error: "session not found" });
3064
+ return;
3065
+ }
2721
3066
  if (route.attachments) {
2722
3067
  await handleAttachments(req, res, route.id, runner?.info() ?? parked.info, route.attachmentId);
2723
3068
  return;
@@ -2735,7 +3080,7 @@ function createWorkerServer(options = {}) {
2735
3080
  json(res, 405, { error: "method not allowed" });
2736
3081
  return;
2737
3082
  }
2738
- const snapshotFiles = parked?.snapshot.vfs;
3083
+ const snapshotFiles = parked && !isDormant(parked) ? parked.snapshot.vfs : void 0;
2739
3084
  const vfs = runner?.vfs ?? (snapshotFiles && {
2740
3085
  list: () => Object.keys(snapshotFiles).sort(),
2741
3086
  read: (path) => snapshotFiles[path]
@@ -2841,11 +3186,17 @@ function createWorkerServer(options = {}) {
2841
3186
  socket.destroy();
2842
3187
  return;
2843
3188
  }
2844
- if (!(await authenticate(req)).ok) {
3189
+ const queueAuth = await authenticate(req);
3190
+ if (!queueAuth.ok) {
2845
3191
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
2846
3192
  socket.destroy();
2847
3193
  return;
2848
3194
  }
3195
+ if (!isOperator(queueAuth)) {
3196
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
3197
+ socket.destroy();
3198
+ return;
3199
+ }
2849
3200
  wss.handleUpgrade(req, socket, head, (ws) => {
2850
3201
  queueSockets.add(ws);
2851
3202
  ws.on("close", () => queueSockets.delete(ws));
@@ -2862,13 +3213,20 @@ function createWorkerServer(options = {}) {
2862
3213
  socket.destroy();
2863
3214
  return;
2864
3215
  }
2865
- if (!(await authenticate(req)).ok) {
3216
+ const auth = await authenticate(req);
3217
+ if (!auth.ok) {
2866
3218
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
2867
3219
  socket.destroy();
2868
3220
  return;
2869
3221
  }
3222
+ const known = registry.get(route.id)?.info() ?? (await parking.get(route.id))?.info;
3223
+ if (known && !canSee(auth, known)) {
3224
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
3225
+ socket.destroy();
3226
+ return;
3227
+ }
2870
3228
  const runner = await parking.ensureLive(route.id).catch(() => void 0);
2871
- if (!runner) {
3229
+ if (!runner || !canSee(auth, runner.info())) {
2872
3230
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
2873
3231
  socket.destroy();
2874
3232
  return;
@@ -3002,6 +3360,106 @@ function createWorkerServer(options = {}) {
3002
3360
  };
3003
3361
  }
3004
3362
  //#endregion
3363
+ //#region src/sandboxed-profile.ts
3364
+ /**
3365
+ * A `provider` profile that grants a session nothing but the sandbox: the
3366
+ * QuickJS guest, the in-memory VFS, and the model.
3367
+ *
3368
+ * This adds no mechanism. `capabilities: []` and `mcpServers: []` already mean
3369
+ * what they mean, and `createToolContext` already withholds a tool whose backend
3370
+ * the host did not inject. What the helper buys is that the locked-down profile
3371
+ * is one call rather than three fields an operator has to get right together —
3372
+ * the failure mode being a profile that *looks* sandboxed and still grants
3373
+ * `deliver_file` because nobody wrote the empty array.
3374
+ *
3375
+ * What a session under it can do:
3376
+ * - run untrusted JavaScript in the WASM guest, under the interpreter's own
3377
+ * timeout and memory limits (`eval_script`),
3378
+ * - read and write the session's in-memory VFS, which is a map and not a
3379
+ * filesystem — no host path is reachable from it.
3380
+ *
3381
+ * What it cannot do: read or write a host path, spawn a process, reach the
3382
+ * network (`web_fetch`/`download`/`web_search` are capabilities, and none is
3383
+ * granted), deliver a file, or use an MCP server.
3384
+ *
3385
+ * Two things this helper does **not** do, because they are not a profile's to
3386
+ * decide. It does not authorize anyone — visibility is
3387
+ * `CreateSessionRequest.scope` plus the gateway's `authorizeSession`. And it
3388
+ * does not make the model's *input* trustworthy: content the loop reads is
3389
+ * attacker-influenced by default, and a sandbox bounds what a tool can reach,
3390
+ * not what a prompt can talk the model into asking for.
3391
+ *
3392
+ * @param name Profile name clients name in `CreateSessionRequest.profile`.
3393
+ * @param provider Which model to run (credentials stay in the operator's
3394
+ * environment and are resolved by the host's `createEngineRunner` — never
3395
+ * here, and never on the wire).
3396
+ */
3397
+ function sandboxedProviderProfile(name, provider, options = {}) {
3398
+ return {
3399
+ name,
3400
+ engine: "provider",
3401
+ provider,
3402
+ description: options.description ?? "Sandboxed: no host filesystem, no shell, no egress",
3403
+ defaults: options.defaults,
3404
+ session: {
3405
+ capabilities: options.capabilities ?? [],
3406
+ mcpServers: options.mcpServers ?? [],
3407
+ instructions: options.instructions
3408
+ }
3409
+ };
3410
+ }
3411
+ //#endregion
3412
+ //#region src/provider-runner.ts
3413
+ /**
3414
+ * Build a provider-engine runner from the server's `createEngineRunner` context.
3415
+ *
3416
+ * `createEngineRunner` is a blank sheet: it hands you a context and wants a
3417
+ * `Runner`, and four of the five things a correct one must do are invisible in
3418
+ * the types — forward `restore`, adopt `id`, seed the VFS only when *not*
3419
+ * restoring, and dispose per-session resources. Each is a runtime-only failure
3420
+ * (a woken session that starts empty, a refused rebuild, an overwritten
3421
+ * filesystem, a connection leaked per session), and each is handled here.
3422
+ *
3423
+ * ```ts
3424
+ * createEngineRunner: (ctx) =>
3425
+ * createProviderRunner(ctx, {
3426
+ * model: (id) => openai(id ?? 'gpt-5.6-luna'),
3427
+ * executor: quickjs,
3428
+ * capabilities: { webFetch: {} },
3429
+ * mcp,
3430
+ * onClose: () => mcp.close(),
3431
+ * }),
3432
+ * ```
3433
+ *
3434
+ * The hook itself stays open for anything this does not cover — this is the
3435
+ * 80% case, not a replacement for it.
3436
+ */
3437
+ async function createProviderRunner(ctx, options) {
3438
+ const { config, profile, bridge, restore, id } = ctx;
3439
+ const resolveModel = (modelId) => typeof options.model === "function" ? options.model(modelId) : options.model;
3440
+ const executor = options.executor === "browser" ? { dispatch: (call) => bridge.executorFor(call.sessionId).dispatch(call) } : options.executor;
3441
+ return createEngineSession({
3442
+ config: {
3443
+ ...config,
3444
+ languageModel: resolveModel(config.model),
3445
+ restore,
3446
+ onClose: options.onClose
3447
+ },
3448
+ id,
3449
+ profile,
3450
+ resolveModel: (_profile, c) => resolveModel(c.model),
3451
+ selectExecutor: () => executor,
3452
+ backend: options.executor === "browser" ? "browser" : "server",
3453
+ capabilities: options.capabilities,
3454
+ tools: options.tools,
3455
+ mcp: options.mcp,
3456
+ mcpTools: options.mcpTools,
3457
+ instructions: options.instructions,
3458
+ executionLimits: options.executionLimits,
3459
+ seedVfs: options.seedVfs
3460
+ });
3461
+ }
3462
+ //#endregion
3005
3463
  //#region src/profile-store.ts
3006
3464
  /** Non-durable store for tests and ephemeral deployments. */
3007
3465
  function createMemoryProfileStore(seed = []) {
@@ -3050,6 +3508,6 @@ function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profi
3050
3508
  };
3051
3509
  }
3052
3510
  //#endregion
3053
- export { AttachmentStore, BridgeHub, MemorySessionStore, ProducedFileStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
3511
+ export { AttachmentStore, BridgeHub, MemorySessionStore, ProducedFileStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createProviderRunner, createWorkerServer, sandboxedProviderProfile, toDurableRecord };
3054
3512
 
3055
3513
  //# sourceMappingURL=index.mjs.map