@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/README.md +26 -0
- package/build/index.d.mts +293 -25
- package/build/index.mjs +664 -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,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
|
-
|
|
1867
|
+
const runner = profile && isProviderProfile(profile) ? await options.createEngineRunner({
|
|
1669
1868
|
config,
|
|
1670
1869
|
profile,
|
|
1671
1870
|
bridge,
|
|
1672
|
-
restore
|
|
1673
|
-
|
|
1674
|
-
|
|
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) =>
|
|
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
|
-
|
|
2435
|
-
|
|
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
|
|
2535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|