@workerdeck/server 0.13.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,6 +1531,54 @@ 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";
@@ -1635,18 +1764,88 @@ function createWorkerServer(options = {}) {
1635
1764
  const stripInertFields = (req, profile) => {
1636
1765
  if (!adapterFor(profile?.engine).capabilities.interactiveApprovals) delete req.questionBehavior;
1637
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
+ };
1638
1837
  /** Profile-aware config hook: fill the profile's defaults into unset request fields,
1639
1838
  * run the host hook, then pin CLAUDE_CONFIG_DIR — the profile wins even when the
1640
1839
  * host hook set its own env (see `claudeSessionEnv` for the one case the pin is
1641
1840
  * skipped, and why). Handed to the queue too, so jobs inherit profiles. */
1642
1841
  const buildRunnerConfig = (req) => {
1643
1842
  const profile = req.profile !== void 0 ? profileFor(req.profile) : void 0;
1644
- if (!profile) return hostBuildRunnerConfig(req);
1645
- const config = hostBuildRunnerConfig({
1843
+ if (!profile) return withScope(hostBuildRunnerConfig(req), req.scope);
1844
+ const config = withScope(hostBuildRunnerConfig({
1646
1845
  ...req,
1647
1846
  model: req.model ?? profile.defaults?.model ?? profile.provider?.model,
1648
1847
  permissionMode: req.permissionMode ?? profile.defaults?.permissionMode
1649
- });
1848
+ }), req.scope);
1650
1849
  if (engineOf(profile) !== "claude") return config;
1651
1850
  const base = config.env ?? process.env;
1652
1851
  const env = claudeSessionEnv(profile, base);
@@ -1661,21 +1860,25 @@ function createWorkerServer(options = {}) {
1661
1860
  *
1662
1861
  * `restore` rebuilds a parked session rather than creating a new one — same id,
1663
1862
  * same log, mid-task. */
1664
- const buildRunner = async (config, restore) => {
1863
+ const buildRunner = async (config, restore, id) => {
1665
1864
  const name = config.profile;
1666
1865
  const profile = name !== void 0 ? profileFor(name) : void 0;
1667
1866
  if (name !== void 0 && !profile) throw new Error(`unknown profile: ${name}`);
1668
- if (profile && isProviderProfile(profile)) return options.createEngineRunner({
1867
+ const runner = profile && isProviderProfile(profile) ? await options.createEngineRunner({
1669
1868
  config,
1670
1869
  profile,
1671
1870
  bridge,
1672
- restore
1673
- });
1674
- return adapterFor(profile?.engine).createRunner({
1871
+ restore,
1872
+ id
1873
+ }) : await adapterFor(profile?.engine).createRunner({
1675
1874
  config,
1676
1875
  profile,
1677
- restore
1876
+ restore,
1877
+ id
1678
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;
1679
1882
  };
1680
1883
  const createRunner = async (config) => {
1681
1884
  const runner = registry.register(await buildRunner(config));
@@ -1755,7 +1958,10 @@ function createWorkerServer(options = {}) {
1755
1958
  parkDelayMs: options.parking?.parkDelayMs,
1756
1959
  expiredGraceMs: options.parking?.expiredGraceMs,
1757
1960
  onError: options.parking?.onError,
1758
- 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),
1759
1965
  attachedCount: (sessionId) => bridge.attachedCount(sessionId),
1760
1966
  onParking: (sessionId, executionId) => queue?.onSessionParking(sessionId, executionId) ?? true,
1761
1967
  onResumed: (sessionId, runner) => queue?.onSessionResumed(sessionId, runner)
@@ -1865,6 +2071,20 @@ function createWorkerServer(options = {}) {
1865
2071
  if (verdict.available === true) availabilityWarned.delete(profile.name);
1866
2072
  }).catch(() => {});
1867
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
+ };
1868
2088
  /** Launch-time sweep, concurrent and fire-and-forget. */
1869
2089
  const preflightCredentials = () => {
1870
2090
  for (const profile of allProfiles()) probeProfile(profile);
@@ -1885,12 +2105,79 @@ function createWorkerServer(options = {}) {
1885
2105
  const principal = await options.authenticate(req);
1886
2106
  if (principal === null || principal === void 0 || principal === false) return { ok: false };
1887
2107
  const allowed = principal.allowedProfiles;
2108
+ const scope = readScope(principal.scope);
1888
2109
  return {
1889
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,
1890
2114
  allowedProfiles: Array.isArray(allowed) && allowed.every((p) => typeof p === "string") ? allowed : void 0,
1891
2115
  canManageProfiles: principal.canManageProfiles === true
1892
2116
  };
1893
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);
1894
2181
  const parseRoute = (url) => {
1895
2182
  const pathname = new URL(url, "http://internal").pathname;
1896
2183
  if (!pathname.startsWith(basePath + "/sessions")) return null;
@@ -2408,13 +2695,17 @@ function createWorkerServer(options = {}) {
2408
2695
  json(res, 405, { error: "method not allowed" });
2409
2696
  return;
2410
2697
  }
2698
+ if (!isOperator(auth)) {
2699
+ json(res, 404, { error: "not found" });
2700
+ return;
2701
+ }
2411
2702
  json(res, 200, { stats: await queue.stats() });
2412
2703
  return;
2413
2704
  }
2414
2705
  const rest = pathname.slice((basePath + "/jobs").length).replace(/^\//, "");
2415
2706
  if (rest === "") {
2416
2707
  if (req.method === "GET") {
2417
- json(res, 200, { jobs: await queue.list() });
2708
+ json(res, 200, { jobs: (await queue.list()).filter((job) => canSeeJob(auth, job)) });
2418
2709
  return;
2419
2710
  }
2420
2711
  if (req.method === "POST") {
@@ -2423,16 +2714,13 @@ function createWorkerServer(options = {}) {
2423
2714
  json(res, 400, { error: "session is required" });
2424
2715
  return;
2425
2716
  }
2426
- if (!body.session.cwd || typeof body.session.cwd !== "string") {
2427
- json(res, 400, { error: "session.cwd is required" });
2428
- return;
2429
- }
2430
2717
  if (!body.session.prompt || typeof body.session.prompt !== "string") {
2431
2718
  json(res, 400, { error: "session.prompt is required" });
2432
2719
  return;
2433
2720
  }
2434
- if (!cwdAllowed(body.session.cwd, options.allowedCwdRoots)) {
2435
- 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 });
2436
2724
  return;
2437
2725
  }
2438
2726
  const refused = applyBypassPolicy(body.session);
@@ -2445,6 +2733,16 @@ function createWorkerServer(options = {}) {
2445
2733
  json(res, resolved.status, { error: resolved.error });
2446
2734
  return;
2447
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
+ }
2448
2746
  const badRequest = checkPermissionMode(body.session.permissionMode, resolved.profile) ?? checkEngineGrants(body.session, resolved.profile);
2449
2747
  if (badRequest) {
2450
2748
  json(res, 400, { error: badRequest });
@@ -2469,11 +2767,16 @@ function createWorkerServer(options = {}) {
2469
2767
  }
2470
2768
  if (req.method === "GET") {
2471
2769
  const job = await queue.get(id);
2472
- if (job) json(res, 200, { job });
2770
+ if (job && canSeeJob(auth, job)) json(res, 200, { job });
2473
2771
  else json(res, 404, { error: "job not found" });
2474
2772
  return;
2475
2773
  }
2476
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
+ }
2477
2780
  const job = await queue.cancel(id);
2478
2781
  if (job) json(res, 200, { job });
2479
2782
  else json(res, 404, { error: "job not found" });
@@ -2529,10 +2832,11 @@ function createWorkerServer(options = {}) {
2529
2832
  json(res, 400, { error: "status must be 'ok' or 'failed'" });
2530
2833
  return;
2531
2834
  }
2532
- if (auth.allowedProfiles) {
2835
+ if (auth.allowedProfiles || auth.scope || options.authorizeSession) {
2533
2836
  const owner = parking.sessionFor(executionId);
2534
- const profile = owner === void 0 ? void 0 : registry.get(owner)?.info().profile ?? (await parking.get(owner))?.profile;
2535
- 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)) {
2536
2840
  json(res, 404, { error: "execution not found" });
2537
2841
  return;
2538
2842
  }
@@ -2630,7 +2934,7 @@ function createWorkerServer(options = {}) {
2630
2934
  if (req.method === "GET") {
2631
2935
  json(res, 200, {
2632
2936
  profile: withManagedFlag(profile),
2633
- config: readProfileConfig(profile)
2937
+ config: isOperator(auth) ? readProfileConfig(profile) : void 0
2634
2938
  });
2635
2939
  return;
2636
2940
  }
@@ -2672,14 +2976,23 @@ function createWorkerServer(options = {}) {
2672
2976
  json(res, 401, { error: "unauthorized" });
2673
2977
  return;
2674
2978
  }
2979
+ if (!isOperator(auth)) {
2980
+ json(res, 404, { error: "not found" });
2981
+ return;
2982
+ }
2675
2983
  await handleSdkSessions(req, res, auth);
2676
2984
  return;
2677
2985
  }
2678
2986
  if (pathname.startsWith(basePath + "/fs/")) {
2679
- if (!(await authenticate(req)).ok) {
2987
+ const auth = await authenticate(req);
2988
+ if (!auth.ok) {
2680
2989
  json(res, 401, { error: "unauthorized" });
2681
2990
  return;
2682
2991
  }
2992
+ if (!isOperator(auth)) {
2993
+ json(res, 404, { error: "not found" });
2994
+ return;
2995
+ }
2683
2996
  await handleHostFiles(req, res, pathname);
2684
2997
  return;
2685
2998
  }
@@ -2695,17 +3008,14 @@ function createWorkerServer(options = {}) {
2695
3008
  }
2696
3009
  if (!route.id) {
2697
3010
  if (req.method === "GET") {
2698
- json(res, 200, { sessions: [...registry.list(), ...await parking.listInfo()] });
3011
+ json(res, 200, { sessions: [...registry.list(), ...await parking.listInfo()].filter((session) => canSee(auth, session)) });
2699
3012
  return;
2700
3013
  }
2701
3014
  if (req.method === "POST") {
2702
3015
  const body = await readJsonBody(req, maxBodyBytes);
2703
- if (!body.cwd || typeof body.cwd !== "string") {
2704
- json(res, 400, { error: "cwd is required" });
2705
- return;
2706
- }
2707
- if (!cwdAllowed(body.cwd, options.allowedCwdRoots)) {
2708
- 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 });
2709
3019
  return;
2710
3020
  }
2711
3021
  const refused = applyBypassPolicy(body);
@@ -2718,6 +3028,16 @@ function createWorkerServer(options = {}) {
2718
3028
  json(res, resolved.status, { error: resolved.error });
2719
3029
  return;
2720
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
+ }
2721
3041
  const badRequest = checkPermissionMode(body.permissionMode, resolved.profile) ?? checkEngineGrants(body, resolved.profile);
2722
3042
  if (badRequest) {
2723
3043
  json(res, 400, { error: badRequest });
@@ -2739,6 +3059,10 @@ function createWorkerServer(options = {}) {
2739
3059
  json(res, 404, { error: "session not found" });
2740
3060
  return;
2741
3061
  }
3062
+ if (!canSee(auth, runner?.info() ?? parked.info)) {
3063
+ json(res, 404, { error: "session not found" });
3064
+ return;
3065
+ }
2742
3066
  if (route.attachments) {
2743
3067
  await handleAttachments(req, res, route.id, runner?.info() ?? parked.info, route.attachmentId);
2744
3068
  return;
@@ -2756,7 +3080,7 @@ function createWorkerServer(options = {}) {
2756
3080
  json(res, 405, { error: "method not allowed" });
2757
3081
  return;
2758
3082
  }
2759
- const snapshotFiles = parked?.snapshot.vfs;
3083
+ const snapshotFiles = parked && !isDormant(parked) ? parked.snapshot.vfs : void 0;
2760
3084
  const vfs = runner?.vfs ?? (snapshotFiles && {
2761
3085
  list: () => Object.keys(snapshotFiles).sort(),
2762
3086
  read: (path) => snapshotFiles[path]
@@ -2862,11 +3186,17 @@ function createWorkerServer(options = {}) {
2862
3186
  socket.destroy();
2863
3187
  return;
2864
3188
  }
2865
- if (!(await authenticate(req)).ok) {
3189
+ const queueAuth = await authenticate(req);
3190
+ if (!queueAuth.ok) {
2866
3191
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
2867
3192
  socket.destroy();
2868
3193
  return;
2869
3194
  }
3195
+ if (!isOperator(queueAuth)) {
3196
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
3197
+ socket.destroy();
3198
+ return;
3199
+ }
2870
3200
  wss.handleUpgrade(req, socket, head, (ws) => {
2871
3201
  queueSockets.add(ws);
2872
3202
  ws.on("close", () => queueSockets.delete(ws));
@@ -2883,13 +3213,20 @@ function createWorkerServer(options = {}) {
2883
3213
  socket.destroy();
2884
3214
  return;
2885
3215
  }
2886
- if (!(await authenticate(req)).ok) {
3216
+ const auth = await authenticate(req);
3217
+ if (!auth.ok) {
2887
3218
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
2888
3219
  socket.destroy();
2889
3220
  return;
2890
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
+ }
2891
3228
  const runner = await parking.ensureLive(route.id).catch(() => void 0);
2892
- if (!runner) {
3229
+ if (!runner || !canSee(auth, runner.info())) {
2893
3230
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
2894
3231
  socket.destroy();
2895
3232
  return;
@@ -3023,6 +3360,106 @@ function createWorkerServer(options = {}) {
3023
3360
  };
3024
3361
  }
3025
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
3026
3463
  //#region src/profile-store.ts
3027
3464
  /** Non-durable store for tests and ephemeral deployments. */
3028
3465
  function createMemoryProfileStore(seed = []) {
@@ -3071,6 +3508,6 @@ function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profi
3071
3508
  };
3072
3509
  }
3073
3510
  //#endregion
3074
- 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 };
3075
3512
 
3076
3513
  //# sourceMappingURL=index.mjs.map