@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/README.md +26 -0
- package/build/index.d.mts +314 -26
- package/build/index.mjs +685 -227
- package/build/index.mjs.map +1 -1
- package/package.json +5 -5
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
|
-
*
|
|
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
|
-
/**
|
|
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())
|
|
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
|
|
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
|
|
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
|
-
|
|
1867
|
+
const runner = profile && isProviderProfile(profile) ? await options.createEngineRunner({
|
|
1668
1868
|
config,
|
|
1669
1869
|
profile,
|
|
1670
1870
|
bridge,
|
|
1671
|
-
restore
|
|
1672
|
-
|
|
1673
|
-
|
|
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) =>
|
|
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
|
-
|
|
2434
|
-
|
|
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
|
|
2534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|