@workerdeck/server 0.6.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/LICENSE +21 -0
- package/README.md +187 -0
- package/build/index.d.mts +530 -0
- package/build/index.mjs +1876 -0
- package/build/index.mjs.map +1 -0
- package/package.json +62 -0
package/build/index.mjs
ADDED
|
@@ -0,0 +1,1876 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
5
|
+
import { WebSocketServer } from "ws";
|
|
6
|
+
import { listSessions } from "@anthropic-ai/claude-agent-sdk";
|
|
7
|
+
import { BrowserBridgeExecutor, SessionRunner, checkClaudeAuth } from "@workerdeck/core";
|
|
8
|
+
import { JobQueue } from "@workerdeck/queue";
|
|
9
|
+
import { PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, supportsPermissionMode } from "@workerdeck/protocol";
|
|
10
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
//#region src/registry.ts
|
|
12
|
+
/** In-memory session table. Terminal sessions stay listed until removed or the process exits. */
|
|
13
|
+
var SessionRegistry = class {
|
|
14
|
+
#sessions = /* @__PURE__ */ new Map();
|
|
15
|
+
#options;
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.#options = options;
|
|
18
|
+
}
|
|
19
|
+
create(config) {
|
|
20
|
+
return this.adopt(new SessionRunner(config));
|
|
21
|
+
}
|
|
22
|
+
/** Build and list a Claude-engine runner without starting it, so watchers can
|
|
23
|
+
* subscribe first. Call `start()` once they have. */
|
|
24
|
+
prepare(config) {
|
|
25
|
+
return this.register(new SessionRunner(config));
|
|
26
|
+
}
|
|
27
|
+
/** Register an already-built runner (a non-Claude engine) and start it. */
|
|
28
|
+
adopt(runner) {
|
|
29
|
+
this.register(runner);
|
|
30
|
+
runner.start();
|
|
31
|
+
return runner;
|
|
32
|
+
}
|
|
33
|
+
/** List a runner without starting it — for a rehydrated session, whose watchers
|
|
34
|
+
* must be subscribed before it comes back up. */
|
|
35
|
+
register(runner) {
|
|
36
|
+
const existing = this.#sessions.get(runner.id);
|
|
37
|
+
this.#sessions.set(runner.id, runner);
|
|
38
|
+
if (existing !== runner) this.#options.onRegister?.(runner);
|
|
39
|
+
return runner;
|
|
40
|
+
}
|
|
41
|
+
get(id) {
|
|
42
|
+
return this.#sessions.get(id);
|
|
43
|
+
}
|
|
44
|
+
list() {
|
|
45
|
+
return [...this.#sessions.values()].map((r) => r.info());
|
|
46
|
+
}
|
|
47
|
+
remove(id) {
|
|
48
|
+
const runner = this.#sessions.get(id);
|
|
49
|
+
if (!runner) return false;
|
|
50
|
+
runner.close("server");
|
|
51
|
+
return this.#sessions.delete(id);
|
|
52
|
+
}
|
|
53
|
+
/** Drop a runner WITHOUT closing it: the session isn't ending, it parked and
|
|
54
|
+
* lives on in its snapshot. Closing here would tell every client it was over. */
|
|
55
|
+
evict(id) {
|
|
56
|
+
return this.#sessions.delete(id);
|
|
57
|
+
}
|
|
58
|
+
closeAll() {
|
|
59
|
+
for (const runner of this.#sessions.values()) runner.close("server");
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/notifications.ts
|
|
64
|
+
/**
|
|
65
|
+
* Turns session events into the handful of notifications a human away from the
|
|
66
|
+
* screen cares about, and delivers them to a webhook and/or a local observer.
|
|
67
|
+
*
|
|
68
|
+
* This is the *primitive*, deliberately transport-agnostic: the server stays
|
|
69
|
+
* credential-free and knows nothing about APNs, Slack or email. Turning a
|
|
70
|
+
* notification into a push is a forwarder's job (the turnkey CLI's), and one that
|
|
71
|
+
* needs credentials, so it does not live here.
|
|
72
|
+
*
|
|
73
|
+
* Delivery is best-effort and ordered per session, mirroring the job queue's
|
|
74
|
+
* webhook behaviour — a consumer that missed one can always attach to the session
|
|
75
|
+
* WS with `afterSeq` and see the truth.
|
|
76
|
+
*/
|
|
77
|
+
var SessionNotifier = class {
|
|
78
|
+
#options;
|
|
79
|
+
/** Per-session delivery chain, so a session's notifications arrive in order. */
|
|
80
|
+
#chains = /* @__PURE__ */ new Map();
|
|
81
|
+
constructor(options) {
|
|
82
|
+
this.#options = options;
|
|
83
|
+
}
|
|
84
|
+
/** True when nothing is listening — lets the caller skip subscribing at all. */
|
|
85
|
+
get idle() {
|
|
86
|
+
return !this.#options.webhook && !this.#options.onNotification;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Subscribe to a runner for its lifetime.
|
|
90
|
+
*
|
|
91
|
+
* `afterSeq` defaults to whatever the runner has already emitted, which is what
|
|
92
|
+
* makes this safe on a *rehydrated* session: `subscribe` replays the log from
|
|
93
|
+
* `afterSeq`, so subscribing at 0 to a session rebuilt from a park would
|
|
94
|
+
* re-announce every permission request it ever made.
|
|
95
|
+
*/
|
|
96
|
+
watch(runner, afterSeq = runner.info().lastSeq) {
|
|
97
|
+
if (this.idle) return;
|
|
98
|
+
runner.subscribe((event) => {
|
|
99
|
+
switch (event.type) {
|
|
100
|
+
case "permission_requested":
|
|
101
|
+
this.#emit(runner, event.seq, event.ts, {
|
|
102
|
+
type: "permission_requested",
|
|
103
|
+
preview: event.request.title ?? event.request.toolName,
|
|
104
|
+
request: event.request
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
case "turn_result":
|
|
108
|
+
this.#emit(runner, event.seq, event.ts, {
|
|
109
|
+
type: "turn_completed",
|
|
110
|
+
preview: event.isError ? event.errors?.join("\n") : event.result,
|
|
111
|
+
result: {
|
|
112
|
+
isError: event.isError,
|
|
113
|
+
durationMs: event.durationMs,
|
|
114
|
+
numTurns: event.numTurns,
|
|
115
|
+
totalCostUsd: event.totalCostUsd
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
case "session_error":
|
|
120
|
+
this.#emit(runner, event.seq, event.ts, {
|
|
121
|
+
type: "session_error",
|
|
122
|
+
preview: event.message
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
case "session_closed":
|
|
126
|
+
this.#emit(runner, event.seq, event.ts, {
|
|
127
|
+
type: "session_closed",
|
|
128
|
+
reason: event.reason
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
default: return;
|
|
132
|
+
}
|
|
133
|
+
}, afterSeq);
|
|
134
|
+
}
|
|
135
|
+
#emit(runner, seq, ts, body) {
|
|
136
|
+
queueMicrotask(() => this.#send(runner, seq, ts, body));
|
|
137
|
+
}
|
|
138
|
+
#send(runner, seq, ts, body) {
|
|
139
|
+
const webhook = this.#options.webhook;
|
|
140
|
+
const wanted = !webhook?.events || webhook.events.includes(body.type);
|
|
141
|
+
if (!webhook && !this.#options.onNotification) return;
|
|
142
|
+
const notification = {
|
|
143
|
+
...body,
|
|
144
|
+
sessionId: runner.id,
|
|
145
|
+
session: runner.info(),
|
|
146
|
+
seq,
|
|
147
|
+
ts
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
this.#options.onNotification?.(notification);
|
|
151
|
+
} catch {}
|
|
152
|
+
if (!webhook || !wanted) return;
|
|
153
|
+
const next = (this.#chains.get(runner.id) ?? Promise.resolve()).then(() => this.#deliver(webhook, notification));
|
|
154
|
+
this.#chains.set(runner.id, next);
|
|
155
|
+
next.then(() => {
|
|
156
|
+
if (this.#chains.get(runner.id) === next) this.#chains.delete(runner.id);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Best-effort POST with exponential backoff. Deliberately a near-copy of the
|
|
161
|
+
* queue's job-webhook delivery rather than a shared helper: the two channels
|
|
162
|
+
* have different payloads and different consumers, and coupling them would mean
|
|
163
|
+
* a change to job deliveries silently changing session deliveries.
|
|
164
|
+
*/
|
|
165
|
+
async #deliver(webhook, notification) {
|
|
166
|
+
const attempts = this.#options.attempts ?? 3;
|
|
167
|
+
const baseDelay = this.#options.retryDelayMs ?? 500;
|
|
168
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
169
|
+
try {
|
|
170
|
+
if ((await fetch(webhook.url, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: {
|
|
173
|
+
"content-type": "application/json",
|
|
174
|
+
...webhook.headers
|
|
175
|
+
},
|
|
176
|
+
body: JSON.stringify(notification)
|
|
177
|
+
})).ok) return;
|
|
178
|
+
} catch {}
|
|
179
|
+
if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, baseDelay * 2 ** attempt));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/bridge.ts
|
|
185
|
+
/**
|
|
186
|
+
* Routes tool executions between a session and the browser tabs attached to it.
|
|
187
|
+
*
|
|
188
|
+
* A session may have several clients attached (dashboard plus embedded panel);
|
|
189
|
+
* the bridge asks the **first attached** one, which is the closest thing to "the
|
|
190
|
+
* client driving this session". If none is attached, dispatch fails fast rather
|
|
191
|
+
* than hanging — an autonomous job simply never bridges, it uses the server
|
|
192
|
+
* executor instead.
|
|
193
|
+
*/
|
|
194
|
+
var BridgeHub = class {
|
|
195
|
+
#sessions = /* @__PURE__ */ new Map();
|
|
196
|
+
#options;
|
|
197
|
+
constructor(options = {}) {
|
|
198
|
+
this.#options = options;
|
|
199
|
+
}
|
|
200
|
+
/** The executor to hand a runner for this session. Created on first use and
|
|
201
|
+
* reused, so results routed back always reach the same pending table. */
|
|
202
|
+
executorFor(sessionId) {
|
|
203
|
+
return this.#bridge(sessionId).executor;
|
|
204
|
+
}
|
|
205
|
+
/** How many clients are watching this session. Parking consults it: a session
|
|
206
|
+
* someone is watching stays live. */
|
|
207
|
+
attachedCount(sessionId) {
|
|
208
|
+
return this.#sessions.get(sessionId)?.sockets.length ?? 0;
|
|
209
|
+
}
|
|
210
|
+
/** Register an attached client. Returns a detach function. */
|
|
211
|
+
attach(sessionId, send) {
|
|
212
|
+
const bridge = this.#bridge(sessionId);
|
|
213
|
+
bridge.sockets.push(send);
|
|
214
|
+
return () => {
|
|
215
|
+
const index = bridge.sockets.indexOf(send);
|
|
216
|
+
if (index >= 0) bridge.sockets.splice(index, 1);
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Deliver a client's answer to a bridged call. Returns false when the id is
|
|
221
|
+
* unknown or already settled — late and duplicate answers are ignored.
|
|
222
|
+
*/
|
|
223
|
+
resolve(sessionId, executionId, answer) {
|
|
224
|
+
return this.#sessions.get(sessionId)?.executor.resolve(executionId, answer) ?? false;
|
|
225
|
+
}
|
|
226
|
+
/** Drop a session's bridge, failing anything still in flight. */
|
|
227
|
+
remove(sessionId) {
|
|
228
|
+
const bridge = this.#sessions.get(sessionId);
|
|
229
|
+
if (!bridge) return;
|
|
230
|
+
bridge.executor.registry.cancelAll("session_closed", "the session was closed");
|
|
231
|
+
this.#sessions.delete(sessionId);
|
|
232
|
+
}
|
|
233
|
+
#bridge(sessionId) {
|
|
234
|
+
const existing = this.#sessions.get(sessionId);
|
|
235
|
+
if (existing) return existing;
|
|
236
|
+
const bridge = {
|
|
237
|
+
sockets: [],
|
|
238
|
+
executor: new BrowserBridgeExecutor({
|
|
239
|
+
timeoutMs: this.#options.timeoutMs,
|
|
240
|
+
send: (frame) => {
|
|
241
|
+
const target = bridge.sockets[0];
|
|
242
|
+
if (!target) return false;
|
|
243
|
+
target(frame);
|
|
244
|
+
return true;
|
|
245
|
+
},
|
|
246
|
+
cancel: (executionId, reason) => {
|
|
247
|
+
for (const send of bridge.sockets) send({
|
|
248
|
+
type: "tool_call_canceled",
|
|
249
|
+
executionId,
|
|
250
|
+
reason
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
onResult: (executionId, result) => {
|
|
254
|
+
this.#options.onResult?.(sessionId, executionId, result);
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
};
|
|
258
|
+
this.#sessions.set(sessionId, bridge);
|
|
259
|
+
return bridge;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/parking.ts
|
|
264
|
+
/**
|
|
265
|
+
* Deferred execution's other half: parking a session that is waiting on work no
|
|
266
|
+
* process in this server is doing.
|
|
267
|
+
*
|
|
268
|
+
* The runner announces the moment with `status_changed: 'parked'` — emitted only
|
|
269
|
+
* once every dispatch of the batch has been handed over, so the snapshot can never
|
|
270
|
+
* miss a call that was still being dispatched. From there this class snapshots,
|
|
271
|
+
* evicts, and persists; delivering a result rebuilds the runner under the same id
|
|
272
|
+
* and hands the result to it. The session's identity, event log, and seq numbering
|
|
273
|
+
* survive intact, so a client reattaching with `afterSeq` sees one unbroken stream.
|
|
274
|
+
*/
|
|
275
|
+
var SessionParkManager = class {
|
|
276
|
+
#options;
|
|
277
|
+
/** executionId → sessionId, for routing a result to its session. Kept in memory
|
|
278
|
+
* across the park; rebuilt from the store by {@link hydrate}. */
|
|
279
|
+
#owners = /* @__PURE__ */ new Map();
|
|
280
|
+
/** Executions already settled, kept until their session ends so a late or
|
|
281
|
+
* duplicate delivery answers "already settled" instead of "never heard of it". */
|
|
282
|
+
#settled = /* @__PURE__ */ new Map();
|
|
283
|
+
#timers = /* @__PURE__ */ new Map();
|
|
284
|
+
/** One resume per session, ever: two results arriving together must not build
|
|
285
|
+
* two runners under the same id (the second would orphan the first, leaking the
|
|
286
|
+
* MCP connection the park existed to release). */
|
|
287
|
+
#resuming = /* @__PURE__ */ new Map();
|
|
288
|
+
#detachTimers = /* @__PURE__ */ new Map();
|
|
289
|
+
/** The config each live session was built from — what a rebuild needs, and the
|
|
290
|
+
* one thing a runner doesn't carry on its public surface. */
|
|
291
|
+
#configs = /* @__PURE__ */ new Map();
|
|
292
|
+
/**
|
|
293
|
+
* In-flight store work per session, so operations on one record run in order.
|
|
294
|
+
*
|
|
295
|
+
* Load-bearing with any store whose writes are real I/O. `#park` must evict the
|
|
296
|
+
* runner *before* the save completes (an attach between `park()` and `evict()`
|
|
297
|
+
* would bind a client to an inert runner), which leaves a window where the
|
|
298
|
+
* session is in neither the registry nor the store. A delivery arriving inside it
|
|
299
|
+
* would read past the write: `store.get` misses, the result is answered 404, the
|
|
300
|
+
* execution is filed as settled with its watchdog cleared — and then the record
|
|
301
|
+
* lands on disk with nothing left alive that could ever wake it. A `discard`
|
|
302
|
+
* inside the same window would delete nothing and leave the save to resurrect a
|
|
303
|
+
* session the caller was told was closed.
|
|
304
|
+
*/
|
|
305
|
+
#storeOps = /* @__PURE__ */ new Map();
|
|
306
|
+
#closed = false;
|
|
307
|
+
constructor(options) {
|
|
308
|
+
this.#options = options;
|
|
309
|
+
}
|
|
310
|
+
/** Record the config a session was created with. Only sessions the host
|
|
311
|
+
* remembers can be parked — there is no way to rebuild the others. */
|
|
312
|
+
remember(sessionId, config) {
|
|
313
|
+
this.#configs.set(sessionId, config);
|
|
314
|
+
}
|
|
315
|
+
/** Adopt the store's contents (a durable store after a restart): re-index the
|
|
316
|
+
* executions and re-arm their watchdogs, no deadline sooner than the grace
|
|
317
|
+
* window — nothing could have been delivered while the process was down. */
|
|
318
|
+
async hydrate() {
|
|
319
|
+
const floor = Date.now() + (this.#options.expiredGraceMs ?? 6e4);
|
|
320
|
+
for (const record of await this.#options.store.list()) for (const execution of record.executions) this.#track(record.id, execution, floor);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Follow a session's lifecycle: index its deferred executions, park it when the
|
|
324
|
+
* engine says the turn has come to rest on them, and clean up when it ends.
|
|
325
|
+
* `afterSeq` skips a rehydrated runner's replayed history (re-arming a watchdog
|
|
326
|
+
* from an event whose deadline already passed would fail the execution instantly).
|
|
327
|
+
*/
|
|
328
|
+
watch(runner, afterSeq = 0) {
|
|
329
|
+
return runner.subscribe((event) => {
|
|
330
|
+
switch (event.type) {
|
|
331
|
+
case "execution_dispatched":
|
|
332
|
+
if (!event.deferred) return;
|
|
333
|
+
this.#track(runner.id, {
|
|
334
|
+
executionId: event.executionId,
|
|
335
|
+
toolName: event.toolName,
|
|
336
|
+
expiresAt: event.expiresAt
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
case "execution_result":
|
|
340
|
+
case "execution_failed":
|
|
341
|
+
this.#forget(event.executionId);
|
|
342
|
+
if (runner.info().status === "parked") this.#park(runner);
|
|
343
|
+
return;
|
|
344
|
+
case "status_changed":
|
|
345
|
+
if (event.status === "parked") this.#park(runner);
|
|
346
|
+
return;
|
|
347
|
+
case "session_closed":
|
|
348
|
+
this.discard(runner.id);
|
|
349
|
+
return;
|
|
350
|
+
default: return;
|
|
351
|
+
}
|
|
352
|
+
}, afterSeq);
|
|
353
|
+
}
|
|
354
|
+
/** A client detached: park the session if that was the last one watching. */
|
|
355
|
+
onDetach(sessionId) {
|
|
356
|
+
if (this.#closed) return;
|
|
357
|
+
const runner = this.#options.registry.get(sessionId);
|
|
358
|
+
if (!runner || runner.info().status !== "parked") return;
|
|
359
|
+
clearTimeout(this.#detachTimers.get(sessionId));
|
|
360
|
+
const timer = setTimeout(() => {
|
|
361
|
+
this.#detachTimers.delete(sessionId);
|
|
362
|
+
this.#park(runner);
|
|
363
|
+
}, this.#options.parkDelayMs ?? 2e3);
|
|
364
|
+
timer.unref?.();
|
|
365
|
+
this.#detachTimers.set(sessionId, timer);
|
|
366
|
+
}
|
|
367
|
+
/** Which session this execution belongs to — still waiting, or already settled. */
|
|
368
|
+
sessionFor(executionId) {
|
|
369
|
+
return this.#owners.get(executionId) ?? this.#settled.get(executionId);
|
|
370
|
+
}
|
|
371
|
+
/** The parked session's record, for the read paths (GET, list, attach). */
|
|
372
|
+
get(id) {
|
|
373
|
+
return this.#queue(id, () => this.#options.store.get(id));
|
|
374
|
+
}
|
|
375
|
+
/** Every parked session's info, to merge into `GET {basePath}/sessions`. */
|
|
376
|
+
async listInfo() {
|
|
377
|
+
await Promise.all(this.#storeOps.values());
|
|
378
|
+
return (await this.#options.store.list()).map((record) => record.info);
|
|
379
|
+
}
|
|
380
|
+
/** The live runner for a session, rehydrating a parked one on demand. Undefined
|
|
381
|
+
* when the session is neither live nor parked. */
|
|
382
|
+
async ensureLive(id) {
|
|
383
|
+
const live = this.#options.registry.get(id);
|
|
384
|
+
if (live) return live;
|
|
385
|
+
return this.#resume(id);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Deliver a deferred execution's result. Rehydrates the session if needed and
|
|
389
|
+
* folds the result into its agent loop.
|
|
390
|
+
*
|
|
391
|
+
* Undefined = no session is waiting on that id. `applied: false` = it was already
|
|
392
|
+
* settled: a duplicate delivery, or one racing the watchdog. Both are expected,
|
|
393
|
+
* neither is an error.
|
|
394
|
+
*/
|
|
395
|
+
async submitResult(executionId, result) {
|
|
396
|
+
const sessionId = this.#owners.get(executionId);
|
|
397
|
+
if (sessionId === void 0) {
|
|
398
|
+
const settled = this.#settled.get(executionId);
|
|
399
|
+
return settled === void 0 ? void 0 : {
|
|
400
|
+
applied: false,
|
|
401
|
+
sessionId: settled
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const runner = await this.ensureLive(sessionId);
|
|
405
|
+
if (!runner) {
|
|
406
|
+
this.#forget(executionId);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
this.#clearTimer(executionId);
|
|
410
|
+
const applied = runner.settleExecution?.(executionId, result) ?? false;
|
|
411
|
+
if (applied) this.#forget(executionId);
|
|
412
|
+
return {
|
|
413
|
+
applied,
|
|
414
|
+
sessionId
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/** Drop a parked session for good: the run is over (closed, canceled, killed). */
|
|
418
|
+
async discard(sessionId) {
|
|
419
|
+
clearTimeout(this.#detachTimers.get(sessionId));
|
|
420
|
+
this.#detachTimers.delete(sessionId);
|
|
421
|
+
this.#configs.delete(sessionId);
|
|
422
|
+
for (const [executionId, owner] of this.#owners) if (owner === sessionId) this.#forget(executionId);
|
|
423
|
+
for (const [executionId, owner] of this.#settled) if (owner === sessionId) this.#settled.delete(executionId);
|
|
424
|
+
await this.#queue(sessionId, () => this.#options.store.delete(sessionId));
|
|
425
|
+
}
|
|
426
|
+
close() {
|
|
427
|
+
this.#closed = true;
|
|
428
|
+
for (const timer of this.#timers.values()) clearTimeout(timer);
|
|
429
|
+
for (const timer of this.#detachTimers.values()) clearTimeout(timer);
|
|
430
|
+
this.#timers.clear();
|
|
431
|
+
this.#detachTimers.clear();
|
|
432
|
+
}
|
|
433
|
+
async #park(runner) {
|
|
434
|
+
if (this.#closed || !runner.park) return;
|
|
435
|
+
const id = runner.id;
|
|
436
|
+
if (this.#options.registry.get(id) !== runner) return;
|
|
437
|
+
if (runner.info().status !== "parked") return;
|
|
438
|
+
if (this.#options.attachedCount(id) > 0) return;
|
|
439
|
+
const executions = [...this.#owners].filter(([, owner]) => owner === id).map(([e]) => e);
|
|
440
|
+
if (executions.length === 0) return;
|
|
441
|
+
const config = this.#configs.get(id);
|
|
442
|
+
if (!config) return;
|
|
443
|
+
if (this.#options.onParking && !this.#options.onParking(id, executions[0])) return;
|
|
444
|
+
const snapshot = runner.park();
|
|
445
|
+
if (!snapshot) return;
|
|
446
|
+
const info = {
|
|
447
|
+
...runner.info(),
|
|
448
|
+
status: "parked"
|
|
449
|
+
};
|
|
450
|
+
this.#options.registry.evict(id);
|
|
451
|
+
const record = {
|
|
452
|
+
id,
|
|
453
|
+
info,
|
|
454
|
+
profile: info.profile,
|
|
455
|
+
config,
|
|
456
|
+
snapshot,
|
|
457
|
+
executions: snapshot.parked,
|
|
458
|
+
parkedAt: Date.now()
|
|
459
|
+
};
|
|
460
|
+
for (const execution of snapshot.parked) this.#track(id, execution);
|
|
461
|
+
try {
|
|
462
|
+
await this.#queue(id, () => this.#options.store.save(record));
|
|
463
|
+
} catch (error) {
|
|
464
|
+
this.#options.onError?.(error, {
|
|
465
|
+
sessionId: id,
|
|
466
|
+
phase: "park"
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async #resume(id) {
|
|
471
|
+
const inFlight = this.#resuming.get(id);
|
|
472
|
+
if (inFlight) return inFlight;
|
|
473
|
+
const attempt = this.#rebuild(id);
|
|
474
|
+
this.#resuming.set(id, attempt);
|
|
475
|
+
try {
|
|
476
|
+
return await attempt;
|
|
477
|
+
} finally {
|
|
478
|
+
this.#resuming.delete(id);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async #rebuild(id) {
|
|
482
|
+
const record = await this.#queue(id, () => this.#options.store.get(id));
|
|
483
|
+
if (!record) return void 0;
|
|
484
|
+
let runner;
|
|
485
|
+
try {
|
|
486
|
+
runner = await this.#options.rebuild(record);
|
|
487
|
+
} catch (error) {
|
|
488
|
+
this.#options.onError?.(error, {
|
|
489
|
+
sessionId: id,
|
|
490
|
+
phase: "resume"
|
|
491
|
+
});
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
if (runner.id !== id) {
|
|
495
|
+
runner.close("error");
|
|
496
|
+
const error = /* @__PURE__ */ new Error(`rebuilt session has id '${runner.id}', expected '${id}' — the engine factory must forward EngineRunnerContext.restore to the runner config`);
|
|
497
|
+
this.#options.onError?.(error, {
|
|
498
|
+
sessionId: id,
|
|
499
|
+
phase: "resume"
|
|
500
|
+
});
|
|
501
|
+
throw error;
|
|
502
|
+
}
|
|
503
|
+
this.#options.registry.register(runner);
|
|
504
|
+
this.remember(id, record.config);
|
|
505
|
+
this.watch(runner, record.snapshot.seq);
|
|
506
|
+
this.#options.onResumed?.(id, runner);
|
|
507
|
+
await this.#queue(id, () => this.#options.store.delete(id));
|
|
508
|
+
runner.start();
|
|
509
|
+
return runner;
|
|
510
|
+
}
|
|
511
|
+
/** Run a store operation after whatever is already in flight for this session.
|
|
512
|
+
* The chain is per session and drops itself once idle; a failed operation never
|
|
513
|
+
* poisons the ones behind it (each caller handles its own). */
|
|
514
|
+
#queue(sessionId, op) {
|
|
515
|
+
const result = (this.#storeOps.get(sessionId) ?? Promise.resolve()).then(op);
|
|
516
|
+
const settled = result.then(() => {}, () => {});
|
|
517
|
+
this.#storeOps.set(sessionId, settled);
|
|
518
|
+
settled.then(() => {
|
|
519
|
+
if (this.#storeOps.get(sessionId) === settled) this.#storeOps.delete(sessionId);
|
|
520
|
+
});
|
|
521
|
+
return result;
|
|
522
|
+
}
|
|
523
|
+
#track(sessionId, execution, notBefore = 0) {
|
|
524
|
+
this.#owners.set(execution.executionId, sessionId);
|
|
525
|
+
if (execution.expiresAt === void 0 || this.#timers.has(execution.executionId)) return;
|
|
526
|
+
const expiresAt = Math.max(execution.expiresAt, notBefore);
|
|
527
|
+
const timer = setTimeout(() => {
|
|
528
|
+
this.#timers.delete(execution.executionId);
|
|
529
|
+
this.submitResult(execution.executionId, {
|
|
530
|
+
status: "failed",
|
|
531
|
+
reason: "timeout",
|
|
532
|
+
error: `deferred execution '${execution.toolName}' produced no result before its deadline`
|
|
533
|
+
}).catch((error) => {
|
|
534
|
+
this.#options.onError?.(error, {
|
|
535
|
+
sessionId,
|
|
536
|
+
phase: "resume"
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
}, Math.max(0, expiresAt - Date.now()));
|
|
540
|
+
timer.unref?.();
|
|
541
|
+
this.#timers.set(execution.executionId, timer);
|
|
542
|
+
}
|
|
543
|
+
#forget(executionId) {
|
|
544
|
+
this.#clearTimer(executionId);
|
|
545
|
+
const owner = this.#owners.get(executionId);
|
|
546
|
+
if (owner !== void 0) this.#settled.set(executionId, owner);
|
|
547
|
+
this.#owners.delete(executionId);
|
|
548
|
+
}
|
|
549
|
+
#clearTimer(executionId) {
|
|
550
|
+
const timer = this.#timers.get(executionId);
|
|
551
|
+
if (timer === void 0) return;
|
|
552
|
+
clearTimeout(timer);
|
|
553
|
+
this.#timers.delete(executionId);
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
//#endregion
|
|
557
|
+
//#region src/session-store.ts
|
|
558
|
+
/** Single-process, no persistence: parks survive a client disconnect, not a restart. */
|
|
559
|
+
var MemorySessionStore = class {
|
|
560
|
+
#records = /* @__PURE__ */ new Map();
|
|
561
|
+
save(record) {
|
|
562
|
+
this.#records.set(record.id, record);
|
|
563
|
+
return Promise.resolve();
|
|
564
|
+
}
|
|
565
|
+
get(id) {
|
|
566
|
+
return Promise.resolve(this.#records.get(id) ?? null);
|
|
567
|
+
}
|
|
568
|
+
list() {
|
|
569
|
+
return Promise.resolve([...this.#records.values()]);
|
|
570
|
+
}
|
|
571
|
+
delete(id) {
|
|
572
|
+
return Promise.resolve(this.#records.delete(id));
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
/**
|
|
576
|
+
* Config fields that must not be written to durable storage: two are functions
|
|
577
|
+
* (JSON drops them silently), `extraOptions` is SDK `Options` and may hold hooks
|
|
578
|
+
* and callbacks, and `env` is a credential-bearing map — the same rule
|
|
579
|
+
* `profile-store.ts` follows, for the same reason.
|
|
580
|
+
*
|
|
581
|
+
* Dropping them costs a rehydrated session nothing: all four are consumed by the
|
|
582
|
+
* Claude engine alone, and the Claude engine cannot park (the CLI owns its process
|
|
583
|
+
* state — `buildRunner` refuses a `restore` for it). A provider session's
|
|
584
|
+
* credentials are resolved by `createEngineRunner` from the operator's environment
|
|
585
|
+
* on every build, wake included.
|
|
586
|
+
*/
|
|
587
|
+
const EPHEMERAL_CONFIG_KEYS = [
|
|
588
|
+
"queryFn",
|
|
589
|
+
"historyFn",
|
|
590
|
+
"extraOptions",
|
|
591
|
+
"env"
|
|
592
|
+
];
|
|
593
|
+
/** The record as it may be persisted: same session, config narrowed to what is
|
|
594
|
+
* safe and meaningful to keep (see {@link EPHEMERAL_CONFIG_KEYS}). */
|
|
595
|
+
function toDurableRecord(record) {
|
|
596
|
+
const config = { ...record.config };
|
|
597
|
+
for (const key of EPHEMERAL_CONFIG_KEYS) delete config[key];
|
|
598
|
+
return {
|
|
599
|
+
...record,
|
|
600
|
+
config
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
/** Bump when the on-disk shape changes incompatibly; records written by another
|
|
604
|
+
* version are ignored rather than half-read into a broken session. */
|
|
605
|
+
const FORMAT_VERSION = 1;
|
|
606
|
+
/**
|
|
607
|
+
* Durable single-host store: one JSON file per parked session under `dir`, written
|
|
608
|
+
* through a temp file and a rename so a crash mid-write cannot truncate a session.
|
|
609
|
+
* `hydrate()` at `listen()` picks them up, re-indexes their executions, and re-arms
|
|
610
|
+
* the watchdogs, so a restart no longer loses parked work.
|
|
611
|
+
*
|
|
612
|
+
* Know what is on that disk: **the record holds the session's entire transcript** —
|
|
613
|
+
* prompts, model output, and tool I/O — in plaintext. Put it somewhere with the same
|
|
614
|
+
* protection as the SDK's own transcripts (`~/.claude/projects`), not in a directory
|
|
615
|
+
* that gets served, synced, or backed up somewhere looser.
|
|
616
|
+
*
|
|
617
|
+
* Single-process by design, exactly like the bundled queue adapter and profile
|
|
618
|
+
* store: two servers sharing one directory would both hydrate the same records and
|
|
619
|
+
* race to rebuild them. That is what the seam is for.
|
|
620
|
+
*
|
|
621
|
+
* Nothing here reaps: a record leaves only when its session wakes or is deleted.
|
|
622
|
+
* An execution dispatched without a deadline (a `DeferredExecutor` with no
|
|
623
|
+
* `timeoutMs`) has no watchdog to end the wait, so its record — and its transcript
|
|
624
|
+
* — stays until `DELETE /sessions/:id`. Give deferred calls a deadline, or sweep.
|
|
625
|
+
*/
|
|
626
|
+
function createFileSessionStore(options = {}) {
|
|
627
|
+
const dir = options.dir ?? join(process.cwd(), ".workerdeck", "parked");
|
|
628
|
+
const fileFor = (id) => join(dir, `${encodeURIComponent(id)}.json`);
|
|
629
|
+
const read = async (path) => {
|
|
630
|
+
let raw;
|
|
631
|
+
try {
|
|
632
|
+
raw = await readFile(path, "utf8");
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (isMissing(error)) return null;
|
|
635
|
+
options.onError?.(error, {
|
|
636
|
+
path,
|
|
637
|
+
op: "read"
|
|
638
|
+
});
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
return parseRecord(JSON.parse(raw));
|
|
643
|
+
} catch (error) {
|
|
644
|
+
options.onError?.(error, {
|
|
645
|
+
path,
|
|
646
|
+
op: "read"
|
|
647
|
+
});
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
return {
|
|
652
|
+
save: async (record) => {
|
|
653
|
+
const path = fileFor(record.id);
|
|
654
|
+
let payload;
|
|
655
|
+
try {
|
|
656
|
+
payload = JSON.stringify({
|
|
657
|
+
version: FORMAT_VERSION,
|
|
658
|
+
record: toDurableRecord(record)
|
|
659
|
+
});
|
|
660
|
+
} catch (error) {
|
|
661
|
+
options.onError?.(error, {
|
|
662
|
+
path,
|
|
663
|
+
op: "save"
|
|
664
|
+
});
|
|
665
|
+
throw new Error(`parked session '${record.id}' is not JSON-serializable — a host-injected value reached its config or snapshot: ${String(error)}`);
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
await mkdir(dir, {
|
|
669
|
+
recursive: true,
|
|
670
|
+
mode: 448
|
|
671
|
+
});
|
|
672
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
673
|
+
await writeFile(temp, payload, { mode: 384 });
|
|
674
|
+
await rename(temp, path);
|
|
675
|
+
} catch (error) {
|
|
676
|
+
options.onError?.(error, {
|
|
677
|
+
path,
|
|
678
|
+
op: "save"
|
|
679
|
+
});
|
|
680
|
+
throw error;
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
get: (id) => read(fileFor(id)),
|
|
684
|
+
list: async () => {
|
|
685
|
+
let names;
|
|
686
|
+
try {
|
|
687
|
+
names = await readdir(dir);
|
|
688
|
+
} catch (error) {
|
|
689
|
+
if (isMissing(error)) return [];
|
|
690
|
+
options.onError?.(error, {
|
|
691
|
+
path: dir,
|
|
692
|
+
op: "read"
|
|
693
|
+
});
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
return (await Promise.all(names.filter((name) => name.endsWith(".json")).map(async (name) => {
|
|
697
|
+
const record = await read(join(dir, name));
|
|
698
|
+
if (!record) return null;
|
|
699
|
+
if (`${encodeURIComponent(record.id)}.json` === name) return record;
|
|
700
|
+
options.onError?.(/* @__PURE__ */ new Error(`parked record '${record.id}' is stored as '${name}' and cannot be read back by id`), {
|
|
701
|
+
path: join(dir, name),
|
|
702
|
+
op: "read"
|
|
703
|
+
});
|
|
704
|
+
return null;
|
|
705
|
+
}))).filter((record) => record !== null);
|
|
706
|
+
},
|
|
707
|
+
delete: async (id) => {
|
|
708
|
+
const path = fileFor(id);
|
|
709
|
+
try {
|
|
710
|
+
await rm(path);
|
|
711
|
+
return true;
|
|
712
|
+
} catch (error) {
|
|
713
|
+
if (isMissing(error)) return false;
|
|
714
|
+
options.onError?.(error, {
|
|
715
|
+
path,
|
|
716
|
+
op: "delete"
|
|
717
|
+
});
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
const isMissing = (error) => error.code === "ENOENT";
|
|
724
|
+
/** Shape-check a parsed file. A record missing any of these could not be rebuilt,
|
|
725
|
+
* and half-restoring one is worse than skipping it. */
|
|
726
|
+
function parseRecord(value) {
|
|
727
|
+
if (!value || typeof value !== "object") return null;
|
|
728
|
+
const envelope = value;
|
|
729
|
+
if (envelope.version !== FORMAT_VERSION) return null;
|
|
730
|
+
const record = envelope.record;
|
|
731
|
+
if (!record || typeof record !== "object") return null;
|
|
732
|
+
if (typeof record.id !== "string" || typeof record.parkedAt !== "number") return null;
|
|
733
|
+
if (!record.info || !record.config || !record.snapshot) return null;
|
|
734
|
+
if (!Array.isArray(record.executions)) return null;
|
|
735
|
+
return record;
|
|
736
|
+
}
|
|
737
|
+
//#endregion
|
|
738
|
+
//#region src/server.ts
|
|
739
|
+
const defaultSdkSessionLister = async (options) => {
|
|
740
|
+
return (await listSessions(options)).map((s) => ({
|
|
741
|
+
sessionId: s.sessionId,
|
|
742
|
+
summary: s.summary,
|
|
743
|
+
lastModified: s.lastModified,
|
|
744
|
+
createdAt: s.createdAt,
|
|
745
|
+
customTitle: s.customTitle,
|
|
746
|
+
firstPrompt: s.firstPrompt,
|
|
747
|
+
gitBranch: s.gitBranch,
|
|
748
|
+
cwd: s.cwd
|
|
749
|
+
}));
|
|
750
|
+
};
|
|
751
|
+
function json(res, status, body) {
|
|
752
|
+
const payload = JSON.stringify(body);
|
|
753
|
+
res.writeHead(status, {
|
|
754
|
+
"content-type": "application/json",
|
|
755
|
+
"content-length": Buffer.byteLength(payload)
|
|
756
|
+
});
|
|
757
|
+
res.end(payload);
|
|
758
|
+
}
|
|
759
|
+
async function readJsonBody(req, maxBytes) {
|
|
760
|
+
const chunks = [];
|
|
761
|
+
let size = 0;
|
|
762
|
+
for await (const chunk of req) {
|
|
763
|
+
size += chunk.length;
|
|
764
|
+
if (size > maxBytes) throw new Error("request body too large");
|
|
765
|
+
chunks.push(chunk);
|
|
766
|
+
}
|
|
767
|
+
if (size === 0) return {};
|
|
768
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Curated, view-only snapshot of a profile's config dir for GET /profiles/:name.
|
|
772
|
+
* Best-effort: a missing or unparseable settings.json just omits the settings block.
|
|
773
|
+
* Env var VALUES are never read into the response — names only.
|
|
774
|
+
*
|
|
775
|
+
* Provider profiles have no config dir, so the snapshot is empty for them: their
|
|
776
|
+
* configuration is the `provider` block already on ProfileInfo.
|
|
777
|
+
*/
|
|
778
|
+
function readProfileConfig(profile) {
|
|
779
|
+
const dir = profile.configDir;
|
|
780
|
+
if (!dir) return {
|
|
781
|
+
hasUserMemory: false,
|
|
782
|
+
skills: [],
|
|
783
|
+
agents: [],
|
|
784
|
+
commands: []
|
|
785
|
+
};
|
|
786
|
+
const listDirs = (path) => {
|
|
787
|
+
try {
|
|
788
|
+
return readdirSync(path, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
789
|
+
} catch {
|
|
790
|
+
return [];
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
const listMd = (path) => {
|
|
794
|
+
try {
|
|
795
|
+
return readdirSync(path).filter((file) => file.endsWith(".md")).map((file) => file.slice(0, -3)).sort();
|
|
796
|
+
} catch {
|
|
797
|
+
return [];
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
const snapshot = {
|
|
801
|
+
hasUserMemory: existsSync(join(dir, "CLAUDE.md")),
|
|
802
|
+
skills: listDirs(join(dir, "skills")),
|
|
803
|
+
agents: listMd(join(dir, "agents")),
|
|
804
|
+
commands: listMd(join(dir, "commands"))
|
|
805
|
+
};
|
|
806
|
+
try {
|
|
807
|
+
const raw = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8"));
|
|
808
|
+
const permissions = raw.permissions ?? {};
|
|
809
|
+
const count = (rules) => Array.isArray(rules) ? rules.length : 0;
|
|
810
|
+
snapshot.settings = {
|
|
811
|
+
model: typeof raw.model === "string" ? raw.model : void 0,
|
|
812
|
+
defaultPermissionMode: typeof permissions.defaultMode === "string" ? permissions.defaultMode : void 0,
|
|
813
|
+
permissionRules: {
|
|
814
|
+
allow: count(permissions.allow),
|
|
815
|
+
ask: count(permissions.ask),
|
|
816
|
+
deny: count(permissions.deny)
|
|
817
|
+
},
|
|
818
|
+
envKeys: raw.env && typeof raw.env === "object" ? Object.keys(raw.env).sort() : void 0,
|
|
819
|
+
hooks: raw.hooks && typeof raw.hooks === "object" ? Object.keys(raw.hooks).sort() : void 0
|
|
820
|
+
};
|
|
821
|
+
} catch {}
|
|
822
|
+
return snapshot;
|
|
823
|
+
}
|
|
824
|
+
/** Conservative content types for VFS downloads: text formats the agent actually
|
|
825
|
+
* produces; anything unrecognized ships as plain text (the VFS is string-backed). */
|
|
826
|
+
const CONTENT_TYPES = {
|
|
827
|
+
json: "application/json; charset=utf-8",
|
|
828
|
+
md: "text/markdown; charset=utf-8",
|
|
829
|
+
html: "text/html; charset=utf-8",
|
|
830
|
+
csv: "text/csv; charset=utf-8",
|
|
831
|
+
xml: "application/xml; charset=utf-8",
|
|
832
|
+
svg: "image/svg+xml; charset=utf-8"
|
|
833
|
+
};
|
|
834
|
+
function contentTypeFor(filename) {
|
|
835
|
+
return CONTENT_TYPES[filename.includes(".") ? filename.split(".").pop().toLowerCase() : ""] ?? "text/plain; charset=utf-8";
|
|
836
|
+
}
|
|
837
|
+
/** A profile runs the model-agnostic engine rather than Claude Code. `engine` is
|
|
838
|
+
* optional so profiles written before provider support keep meaning 'claude'. */
|
|
839
|
+
function isProviderProfile(profile) {
|
|
840
|
+
return profile.engine === "provider";
|
|
841
|
+
}
|
|
842
|
+
/** Where the CLI's own resolution lands for a given environment: an explicit
|
|
843
|
+
* CLAUDE_CONFIG_DIR, else ~/.claude. */
|
|
844
|
+
function cliConfigDir(env) {
|
|
845
|
+
return env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
846
|
+
}
|
|
847
|
+
/** Auto-created profile when none are declared: the operator's own config dir. */
|
|
848
|
+
function detectDefaultProfiles() {
|
|
849
|
+
const dir = cliConfigDir(process.env);
|
|
850
|
+
return existsSync(dir) ? [{
|
|
851
|
+
name: "default",
|
|
852
|
+
configDir: dir
|
|
853
|
+
}] : [];
|
|
854
|
+
}
|
|
855
|
+
/** Compare config dirs by what they name on disk: declared paths arrive with
|
|
856
|
+
* trailing slashes or symlinked prefixes (`/var` vs `/private/var` on macOS); a
|
|
857
|
+
* path that doesn't exist falls back to plain normalization. */
|
|
858
|
+
function canonicalDir(path) {
|
|
859
|
+
try {
|
|
860
|
+
return realpathSync(path);
|
|
861
|
+
} catch {
|
|
862
|
+
return resolve(path);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* The env a Claude session under `profile` is spawned with, starting from
|
|
867
|
+
* `base` (the host hook's env, else the server's own). The pin is skipped when
|
|
868
|
+
* `base` would already land the CLI in the profile's dir, and that skip is
|
|
869
|
+
* load-bearing, not an optimisation: CLAUDE_CONFIG_DIR *set at all* switches
|
|
870
|
+
* the CLI's credential source to `<dir>/.credentials.json` — on macOS a
|
|
871
|
+
* claude.ai login lives in the login Keychain, consulted only while the
|
|
872
|
+
* variable is UNSET, so pinning even the CLI's own default `~/.claude` turns a
|
|
873
|
+
* working login into "Not logged in". When `base` names a *different* dir than
|
|
874
|
+
* the profile, the pin stands: the profile must win over hook- or operator-set
|
|
875
|
+
* env, or sessions under two profiles quietly collapse into one identity.
|
|
876
|
+
*/
|
|
877
|
+
function claudeSessionEnv(profile, base) {
|
|
878
|
+
return canonicalDir(profile.configDir) === canonicalDir(cliConfigDir(base)) ? base : {
|
|
879
|
+
...base,
|
|
880
|
+
CLAUDE_CONFIG_DIR: profile.configDir
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
function cwdAllowed(cwd, roots) {
|
|
884
|
+
if (!roots || roots.length === 0) return true;
|
|
885
|
+
const resolved = resolve(cwd);
|
|
886
|
+
return roots.some((root) => {
|
|
887
|
+
const r = resolve(root);
|
|
888
|
+
return resolved === r || resolved.startsWith(r + sep);
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
function createWorkerServer(options = {}) {
|
|
892
|
+
if (!options.authenticate && !options.allowUnauthenticated) throw new Error("createWorkerServer: provide `authenticate` or explicitly set `allowUnauthenticated: true`");
|
|
893
|
+
const basePath = options.basePath ?? "/v1";
|
|
894
|
+
const fallback = options.fallback;
|
|
895
|
+
const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
|
|
896
|
+
const hostBuildRunnerConfig = options.buildRunnerConfig ?? ((req) => req);
|
|
897
|
+
const declared = options.profiles ?? detectDefaultProfiles();
|
|
898
|
+
const declaredByName = new Map(declared.map((p) => [p.name, p]));
|
|
899
|
+
if (declaredByName.size !== declared.length) throw new Error("createWorkerServer: duplicate profile names in `profiles`");
|
|
900
|
+
/**
|
|
901
|
+
* Everything wrong with a profile that the server can tell without running it.
|
|
902
|
+
* Shared by startup (where it throws) and the management routes (where it 400s),
|
|
903
|
+
* so a profile created over HTTP can never be one startup would have refused.
|
|
904
|
+
*/
|
|
905
|
+
const validateProfile = (p) => {
|
|
906
|
+
if (isProviderProfile(p)) {
|
|
907
|
+
if (!p.provider?.id) return `provider profile '${p.name}' is missing provider.id`;
|
|
908
|
+
if (!options.createEngineRunner) return `profile '${p.name}' uses engine 'provider' but no \`createEngineRunner\` was provided to build one`;
|
|
909
|
+
} else if (!p.configDir || !existsSync(p.configDir)) return `profile '${p.name}' configDir does not exist: ${p.configDir}`;
|
|
910
|
+
if (options.disableBypassPermissions && p.defaults?.permissionMode === "bypassPermissions") return `profile '${p.name}' defaults to bypassPermissions but disableBypassPermissions is set`;
|
|
911
|
+
const fallbackMode = p.defaults?.permissionMode;
|
|
912
|
+
if (fallbackMode && !supportsPermissionMode(p.engine, fallbackMode)) return `profile '${p.name}' defaults to permission mode '${fallbackMode}', which engine '${p.engine}' does not support (supported: ${PROVIDER_PERMISSION_MODES.join(", ")})`;
|
|
913
|
+
return null;
|
|
914
|
+
};
|
|
915
|
+
for (const p of options.profiles ?? []) {
|
|
916
|
+
const invalid = validateProfile(p);
|
|
917
|
+
if (invalid) throw new Error(`createWorkerServer: ${invalid}`);
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Store-managed profiles, mirrored in memory so every lookup on the request path
|
|
921
|
+
* stays synchronous. Loaded once at `listen()` and refreshed after each mutation
|
|
922
|
+
* — single-process, exactly like the bundled queue adapter.
|
|
923
|
+
*/
|
|
924
|
+
const stored = /* @__PURE__ */ new Map();
|
|
925
|
+
const refreshStored = async () => {
|
|
926
|
+
if (!options.profileStore) return;
|
|
927
|
+
stored.clear();
|
|
928
|
+
for (const p of await options.profileStore.list()) stored.set(p.name, p);
|
|
929
|
+
};
|
|
930
|
+
/** Response-only marker so a UI knows which rows it may edit. Declared profiles
|
|
931
|
+
* are code; only store-backed ones can be changed over the API. */
|
|
932
|
+
const withManagedFlag = (p) => declaredByName.has(p.name) ? p : {
|
|
933
|
+
...p,
|
|
934
|
+
managed: true
|
|
935
|
+
};
|
|
936
|
+
/** Declared profiles first: a name collision means the code wins, and the stored
|
|
937
|
+
* one is unreachable rather than silently overriding server options. */
|
|
938
|
+
const allProfiles = () => [...declared, ...[...stored.values()].filter((p) => !declaredByName.has(p.name))];
|
|
939
|
+
const profileFor = (name) => declaredByName.get(name) ?? stored.get(name);
|
|
940
|
+
/** Profile management is doubly opt-in: the operator wires a store, and the host
|
|
941
|
+
* marks the principal. Neither on its own is enough. */
|
|
942
|
+
const manageGuard = (auth) => {
|
|
943
|
+
if (!options.profileStore) return {
|
|
944
|
+
status: 404,
|
|
945
|
+
error: "profile management is not enabled on this server"
|
|
946
|
+
};
|
|
947
|
+
if (!auth.canManageProfiles) return {
|
|
948
|
+
status: 403,
|
|
949
|
+
error: "not allowed to manage profiles"
|
|
950
|
+
};
|
|
951
|
+
return null;
|
|
952
|
+
};
|
|
953
|
+
/** Startup-declared profiles are code. Editing one over HTTP would make the
|
|
954
|
+
* server options lie about what is actually running. */
|
|
955
|
+
const declaredGuard = (profile) => declaredByName.has(profile.name) ? {
|
|
956
|
+
status: 403,
|
|
957
|
+
error: `profile '${profile.name}' is declared in server options and cannot be changed over the API — edit the \`profiles\` option instead`
|
|
958
|
+
} : null;
|
|
959
|
+
/**
|
|
960
|
+
* A managed Claude profile names a config directory, and that directory is a
|
|
961
|
+
* credential store. Bound it to operator-declared roots; unset roots means the
|
|
962
|
+
* management routes create provider profiles only.
|
|
963
|
+
*/
|
|
964
|
+
const configDirGuard = (profile) => {
|
|
965
|
+
if (isProviderProfile(profile)) return null;
|
|
966
|
+
const roots = options.allowedConfigDirRoots;
|
|
967
|
+
if (!roots || roots.length === 0) return {
|
|
968
|
+
status: 403,
|
|
969
|
+
error: "managed Claude profiles are disabled: set `allowedConfigDirRoots` to the directories they may point at"
|
|
970
|
+
};
|
|
971
|
+
return profile.configDir && cwdAllowed(profile.configDir, roots) ? null : {
|
|
972
|
+
status: 403,
|
|
973
|
+
error: "configDir is outside the allowed roots"
|
|
974
|
+
};
|
|
975
|
+
};
|
|
976
|
+
/** Validate, persist, and re-read: shared by create and update so a PATCH can
|
|
977
|
+
* never leave behind a profile a POST would have refused. */
|
|
978
|
+
const saveManagedProfile = async (res, incoming) => {
|
|
979
|
+
const { managed: _clientClaim, ...profile } = incoming;
|
|
980
|
+
const refused = configDirGuard(profile);
|
|
981
|
+
if (refused) {
|
|
982
|
+
json(res, refused.status, { error: refused.error });
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
const invalid = validateProfile(profile);
|
|
986
|
+
if (invalid) {
|
|
987
|
+
json(res, 400, { error: invalid });
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
await options.profileStore.save(profile);
|
|
991
|
+
await refreshStored();
|
|
992
|
+
json(res, 200, { profile: withManagedFlag(profile) });
|
|
993
|
+
};
|
|
994
|
+
/** Enforce the server's bypass policy on a create request. Returns a 403 message
|
|
995
|
+
* for an explicit bypass-mode request; strips the pre-authorization capability
|
|
996
|
+
* silently (see the option's doc for why). */
|
|
997
|
+
const applyBypassPolicy = (req) => {
|
|
998
|
+
if (!options.disableBypassPermissions) return null;
|
|
999
|
+
if (req.permissionMode === "bypassPermissions") return "bypassPermissions is disabled on this server (disableBypassPermissions)";
|
|
1000
|
+
delete req.allowDangerouslySkipPermissions;
|
|
1001
|
+
return null;
|
|
1002
|
+
};
|
|
1003
|
+
/** Reject a permission mode the resolved profile's engine has no meaning for.
|
|
1004
|
+
* The create form already filters what it offers, but the API is the boundary:
|
|
1005
|
+
* a provider session asked for 'plan' should be told so, not silently coerced
|
|
1006
|
+
* into 'default' by whatever assembles its runner. Returns an error message. */
|
|
1007
|
+
const checkPermissionMode = (mode, profile) => {
|
|
1008
|
+
if (mode === void 0 || supportsPermissionMode(profile?.engine, mode)) return null;
|
|
1009
|
+
return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${profile.engine}') — supported: ${PROVIDER_PERMISSION_MODES.join(", ")}`;
|
|
1010
|
+
};
|
|
1011
|
+
/**
|
|
1012
|
+
* Enforce the provider engine's grant rules on a create request. Two of them:
|
|
1013
|
+
*
|
|
1014
|
+
* - Capabilities narrow, never widen. A request may run with fewer than the
|
|
1015
|
+
* profile grants; naming one it doesn't is refused rather than quietly
|
|
1016
|
+
* downgraded, so a caller learns instead of wondering where the tool went.
|
|
1017
|
+
* - MCP servers are the profile's to declare. MCP tools are authoritative —
|
|
1018
|
+
* server-side, with server credentials, never bridged — so honoring a
|
|
1019
|
+
* client-supplied server would let a caller point an authoritative tool
|
|
1020
|
+
* anywhere it liked. The profile names servers; the host holds their configs.
|
|
1021
|
+
*/
|
|
1022
|
+
const checkEngineGrants = (req, profile) => {
|
|
1023
|
+
if (!profile || !isProviderProfile(profile)) return null;
|
|
1024
|
+
if (req.mcpServers && Object.keys(req.mcpServers).length > 0) return `profile '${profile.name}' runs the provider engine, whose MCP servers are declared on the profile (session.mcpServers) — a session request cannot add its own`;
|
|
1025
|
+
const granted = profile.session?.capabilities;
|
|
1026
|
+
if (!req.capabilities || !granted) return null;
|
|
1027
|
+
const ungranted = req.capabilities.filter((c) => !granted.includes(c));
|
|
1028
|
+
if (ungranted.length === 0) return null;
|
|
1029
|
+
return `profile '${profile.name}' does not grant: ${ungranted.join(", ")} (granted: ${granted.join(", ") || "none"}) — a request may narrow capabilities, not widen them`;
|
|
1030
|
+
};
|
|
1031
|
+
/** Profile-aware config hook: fill the profile's defaults into unset request fields,
|
|
1032
|
+
* run the host hook, then pin CLAUDE_CONFIG_DIR — the profile wins even when the
|
|
1033
|
+
* host hook set its own env (see `claudeSessionEnv` for the one case the pin is
|
|
1034
|
+
* skipped, and why). Handed to the queue too, so jobs inherit profiles. */
|
|
1035
|
+
const buildRunnerConfig = (req) => {
|
|
1036
|
+
const profile = req.profile !== void 0 ? profileFor(req.profile) : void 0;
|
|
1037
|
+
if (!profile) return hostBuildRunnerConfig(req);
|
|
1038
|
+
const config = hostBuildRunnerConfig({
|
|
1039
|
+
...req,
|
|
1040
|
+
model: req.model ?? profile.defaults?.model ?? profile.provider?.model,
|
|
1041
|
+
permissionMode: req.permissionMode ?? profile.defaults?.permissionMode
|
|
1042
|
+
});
|
|
1043
|
+
if (isProviderProfile(profile)) return config;
|
|
1044
|
+
const base = config.env ?? process.env;
|
|
1045
|
+
const env = claudeSessionEnv(profile, base);
|
|
1046
|
+
return env === base ? config : {
|
|
1047
|
+
...config,
|
|
1048
|
+
env
|
|
1049
|
+
};
|
|
1050
|
+
};
|
|
1051
|
+
/** Build a runner for a session, choosing the engine from its profile. Async
|
|
1052
|
+
* because the engine factory may be: a provider session can need an awaited
|
|
1053
|
+
* assembly step (per-session MCP connect) before it has a runner at all.
|
|
1054
|
+
*
|
|
1055
|
+
* `restore` rebuilds a parked session rather than creating a new one — same id,
|
|
1056
|
+
* same log, mid-task. */
|
|
1057
|
+
const buildRunner = async (config, restore) => {
|
|
1058
|
+
const name = config.profile;
|
|
1059
|
+
const profile = name !== void 0 ? profileFor(name) : void 0;
|
|
1060
|
+
if (name !== void 0 && !profile) throw new Error(`unknown profile: ${name}`);
|
|
1061
|
+
if (profile && isProviderProfile(profile)) return options.createEngineRunner({
|
|
1062
|
+
config,
|
|
1063
|
+
profile,
|
|
1064
|
+
bridge,
|
|
1065
|
+
restore
|
|
1066
|
+
});
|
|
1067
|
+
if (restore) throw new Error("the Claude engine cannot rebuild a parked session");
|
|
1068
|
+
return new Promise((resolve) => resolve(registry.prepare(config)));
|
|
1069
|
+
};
|
|
1070
|
+
const createRunner = async (config) => {
|
|
1071
|
+
const runner = registry.register(await buildRunner(config));
|
|
1072
|
+
parking.remember(runner.id, config);
|
|
1073
|
+
parking.watch(runner);
|
|
1074
|
+
runner.start();
|
|
1075
|
+
return runner;
|
|
1076
|
+
};
|
|
1077
|
+
/** Resolve a request's profile: required when several are declared, implicit with
|
|
1078
|
+
* exactly one, scoped by the principal's allowedProfiles. Returns the resolved
|
|
1079
|
+
* profile (undefined when the server declares none) or a response-ready error. */
|
|
1080
|
+
const resolveProfile = (name, allowedProfiles) => {
|
|
1081
|
+
if (name !== void 0 && typeof name !== "string") return {
|
|
1082
|
+
ok: false,
|
|
1083
|
+
status: 400,
|
|
1084
|
+
error: "profile must be a string"
|
|
1085
|
+
};
|
|
1086
|
+
const profiles = allProfiles();
|
|
1087
|
+
if (profiles.length === 0) return name !== void 0 ? {
|
|
1088
|
+
ok: false,
|
|
1089
|
+
status: 400,
|
|
1090
|
+
error: "no profiles are configured on this server"
|
|
1091
|
+
} : { ok: true };
|
|
1092
|
+
const effective = name ?? (profiles.length === 1 ? profiles[0].name : void 0);
|
|
1093
|
+
if (effective === void 0) return {
|
|
1094
|
+
ok: false,
|
|
1095
|
+
status: 400,
|
|
1096
|
+
error: `profile is required (available: ${profiles.map((p) => p.name).join(", ")})`
|
|
1097
|
+
};
|
|
1098
|
+
const profile = profileFor(effective);
|
|
1099
|
+
if (!profile) return {
|
|
1100
|
+
ok: false,
|
|
1101
|
+
status: 400,
|
|
1102
|
+
error: `unknown profile: ${effective}`
|
|
1103
|
+
};
|
|
1104
|
+
if (allowedProfiles && !allowedProfiles.includes(profile.name)) return {
|
|
1105
|
+
ok: false,
|
|
1106
|
+
status: 403,
|
|
1107
|
+
error: `profile not allowed: ${profile.name}`
|
|
1108
|
+
};
|
|
1109
|
+
return {
|
|
1110
|
+
ok: true,
|
|
1111
|
+
profile
|
|
1112
|
+
};
|
|
1113
|
+
};
|
|
1114
|
+
const notifier = new SessionNotifier(options.notifications ?? {});
|
|
1115
|
+
const registry = new SessionRegistry({ onRegister: (runner) => notifier.watch(runner) });
|
|
1116
|
+
const bridge = new BridgeHub({
|
|
1117
|
+
...options.bridge,
|
|
1118
|
+
onResult: (sessionId, executionId, result) => {
|
|
1119
|
+
registry.get(sessionId)?.settleExecution?.(executionId, result);
|
|
1120
|
+
options.bridge?.onResult?.(sessionId, executionId, result);
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
const parking = new SessionParkManager({
|
|
1124
|
+
registry,
|
|
1125
|
+
store: options.parking?.store ?? new MemorySessionStore(),
|
|
1126
|
+
parkDelayMs: options.parking?.parkDelayMs,
|
|
1127
|
+
expiredGraceMs: options.parking?.expiredGraceMs,
|
|
1128
|
+
onError: options.parking?.onError,
|
|
1129
|
+
rebuild: (record) => buildRunner(record.config, record.snapshot),
|
|
1130
|
+
attachedCount: (sessionId) => bridge.attachedCount(sessionId),
|
|
1131
|
+
onParking: (sessionId, executionId) => queue?.onSessionParking(sessionId, executionId) ?? true,
|
|
1132
|
+
onResumed: (sessionId, runner) => queue?.onSessionResumed(sessionId, runner)
|
|
1133
|
+
});
|
|
1134
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1135
|
+
/** Profiles (by name; '' = none) whose oauth notice has been logged. */
|
|
1136
|
+
const subscriptionNoticeShown = /* @__PURE__ */ new Set();
|
|
1137
|
+
const queueSockets = /* @__PURE__ */ new Set();
|
|
1138
|
+
const sendQueueFrame = (ws, frame) => {
|
|
1139
|
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame));
|
|
1140
|
+
};
|
|
1141
|
+
const broadcastJobEvent = (event) => {
|
|
1142
|
+
if (queueSockets.size === 0) return;
|
|
1143
|
+
for (const ws of queueSockets) sendQueueFrame(ws, {
|
|
1144
|
+
type: "job_event",
|
|
1145
|
+
event
|
|
1146
|
+
});
|
|
1147
|
+
if (event.type !== "job_progress") queue?.stats().then((stats) => {
|
|
1148
|
+
for (const ws of queueSockets) sendQueueFrame(ws, {
|
|
1149
|
+
type: "queue_stats",
|
|
1150
|
+
stats
|
|
1151
|
+
});
|
|
1152
|
+
}).catch(() => {});
|
|
1153
|
+
};
|
|
1154
|
+
const queue = options.queue ? new JobQueue({
|
|
1155
|
+
...options.queue,
|
|
1156
|
+
onEvent: (event) => {
|
|
1157
|
+
try {
|
|
1158
|
+
options.queue?.onEvent?.(event);
|
|
1159
|
+
} finally {
|
|
1160
|
+
broadcastJobEvent(event);
|
|
1161
|
+
}
|
|
1162
|
+
},
|
|
1163
|
+
createRunner: async (config) => {
|
|
1164
|
+
const runner = await createRunner(config);
|
|
1165
|
+
watchAuthSource(runner);
|
|
1166
|
+
return runner;
|
|
1167
|
+
},
|
|
1168
|
+
buildRunnerConfig,
|
|
1169
|
+
discardSession: (sessionId) => parking.discard(sessionId)
|
|
1170
|
+
}) : void 0;
|
|
1171
|
+
const watchAuthSource = (runner) => {
|
|
1172
|
+
let seen = false;
|
|
1173
|
+
runner.subscribe((event) => {
|
|
1174
|
+
if (seen || event.type !== "system_init") return;
|
|
1175
|
+
seen = true;
|
|
1176
|
+
if (event.apiKeySource !== "oauth") return;
|
|
1177
|
+
if (options.requireApiKey) runner.fail("This server requires API-key auth (requireApiKey), but the session initialized with claude.ai subscription credentials (apiKeySource 'oauth'). Set ANTHROPIC_API_KEY (or Bedrock/Vertex auth) in the server environment.");
|
|
1178
|
+
else {
|
|
1179
|
+
const profileName = runner.info().profile ?? "";
|
|
1180
|
+
if (subscriptionNoticeShown.has(profileName)) return;
|
|
1181
|
+
subscriptionNoticeShown.add(profileName);
|
|
1182
|
+
const scope = profileName ? `Sessions under profile '${profileName}'` : "Sessions";
|
|
1183
|
+
console.warn(`[workerdeck] ${scope} are using claude.ai subscription credentials (apiKeySource 'oauth'), not an API key. That is only appropriate for personal, single-user use of your own account. Unattended/scheduled or multi-user use requires an API key under Anthropic's terms — set ANTHROPIC_API_KEY in the server environment, or set requireApiKey: true to fail closed.`);
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
};
|
|
1187
|
+
/**
|
|
1188
|
+
* Probe each Claude profile's credentials the way its sessions will actually
|
|
1189
|
+
* experience them: the env the real assembly path produces, so anything the
|
|
1190
|
+
* host hook injects (a CLAUDE_CODE_OAUTH_TOKEN, say) counts as logged in.
|
|
1191
|
+
* Provider profiles resolve credentials in the engine factory and are not
|
|
1192
|
+
* probed. Fire-and-forget by design — see the `checkCredentials` option doc.
|
|
1193
|
+
*/
|
|
1194
|
+
const preflightCredentials = () => {
|
|
1195
|
+
if (!options.checkCredentials) return;
|
|
1196
|
+
const conf = options.checkCredentials === true ? {} : options.checkCredentials;
|
|
1197
|
+
const probe = conf.probe ?? ((env) => checkClaudeAuth(env, { timeoutMs: conf.timeoutMs }));
|
|
1198
|
+
for (const profile of allProfiles()) {
|
|
1199
|
+
if (isProviderProfile(profile)) continue;
|
|
1200
|
+
let env;
|
|
1201
|
+
try {
|
|
1202
|
+
env = buildRunnerConfig({
|
|
1203
|
+
cwd: process.cwd(),
|
|
1204
|
+
profile: profile.name
|
|
1205
|
+
}).env ?? process.env;
|
|
1206
|
+
} catch {
|
|
1207
|
+
env = claudeSessionEnv(profile, process.env);
|
|
1208
|
+
}
|
|
1209
|
+
probe(env).then((status) => {
|
|
1210
|
+
if (status !== "logged_out") return;
|
|
1211
|
+
console.warn(`[workerdeck] Profile '${profile.name}' (${profile.configDir}) has no usable Claude credentials: \`claude auth status\` reports logged out for the environment its sessions run with, so they will fail with "Not logged in". Log in under that dir (CLAUDE_CONFIG_DIR=${profile.configDir} claude auth login), inject a long-lived token via buildRunnerConfig (CLAUDE_CODE_OAUTH_TOKEN), or set ANTHROPIC_API_KEY. \`checkCredentials: false\` disables this check.`);
|
|
1212
|
+
}).catch(() => {});
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
const authenticate = async (req) => {
|
|
1216
|
+
if (!options.authenticate) return { ok: true };
|
|
1217
|
+
const principal = await options.authenticate(req);
|
|
1218
|
+
if (principal === null || principal === void 0 || principal === false) return { ok: false };
|
|
1219
|
+
const allowed = principal.allowedProfiles;
|
|
1220
|
+
return {
|
|
1221
|
+
ok: true,
|
|
1222
|
+
allowedProfiles: Array.isArray(allowed) && allowed.every((p) => typeof p === "string") ? allowed : void 0,
|
|
1223
|
+
canManageProfiles: principal.canManageProfiles === true
|
|
1224
|
+
};
|
|
1225
|
+
};
|
|
1226
|
+
const parseRoute = (url) => {
|
|
1227
|
+
const pathname = new URL(url, "http://internal").pathname;
|
|
1228
|
+
if (!pathname.startsWith(basePath + "/sessions")) return null;
|
|
1229
|
+
const rest = pathname.slice((basePath + "/sessions").length);
|
|
1230
|
+
if (rest === "" || rest === "/") return {};
|
|
1231
|
+
const parts = rest.replace(/^\//, "").split("/");
|
|
1232
|
+
if (parts.length === 1) return { id: decodeURIComponent(parts[0]) };
|
|
1233
|
+
if (parts.length === 2 && parts[1] === "ws") return {
|
|
1234
|
+
id: decodeURIComponent(parts[0]),
|
|
1235
|
+
ws: true
|
|
1236
|
+
};
|
|
1237
|
+
if (parts.length === 3 && parts[1] === "permissions") return {
|
|
1238
|
+
id: decodeURIComponent(parts[0]),
|
|
1239
|
+
permissionId: decodeURIComponent(parts[2])
|
|
1240
|
+
};
|
|
1241
|
+
if (parts.length >= 2 && parts[1] === "files") {
|
|
1242
|
+
const filePath = parts.slice(2).map(decodeURIComponent).join("/");
|
|
1243
|
+
return {
|
|
1244
|
+
id: decodeURIComponent(parts[0]),
|
|
1245
|
+
files: true,
|
|
1246
|
+
filePath: filePath === "" ? void 0 : "/" + filePath
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
return null;
|
|
1250
|
+
};
|
|
1251
|
+
const listSdkSessions = options.listSdkSessions ?? defaultSdkSessionLister;
|
|
1252
|
+
const handleSdkSessions = async (req, res) => {
|
|
1253
|
+
if (req.method !== "GET") {
|
|
1254
|
+
json(res, 405, { error: "method not allowed" });
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const url = new URL(req.url ?? "/", "http://internal");
|
|
1258
|
+
const dir = url.searchParams.get("dir") ?? void 0;
|
|
1259
|
+
const roots = options.allowedCwdRoots;
|
|
1260
|
+
if (roots && roots.length > 0) {
|
|
1261
|
+
if (!dir) {
|
|
1262
|
+
json(res, 400, { error: "dir is required when allowedCwdRoots is set" });
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
if (!cwdAllowed(dir, roots)) {
|
|
1266
|
+
json(res, 403, { error: "dir is outside the allowed roots" });
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
json(res, 200, { sdkSessions: await listSdkSessions({
|
|
1271
|
+
dir,
|
|
1272
|
+
limit: Number(url.searchParams.get("limit") ?? "") || void 0,
|
|
1273
|
+
offset: Number(url.searchParams.get("offset") ?? "") || void 0
|
|
1274
|
+
}) });
|
|
1275
|
+
};
|
|
1276
|
+
const handleJobs = async (req, res, pathname, auth) => {
|
|
1277
|
+
if (!queue) {
|
|
1278
|
+
json(res, 404, { error: "job queue not configured" });
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
if (pathname === basePath + "/queue") {
|
|
1282
|
+
if (req.method !== "GET") {
|
|
1283
|
+
json(res, 405, { error: "method not allowed" });
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
json(res, 200, { stats: await queue.stats() });
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
const rest = pathname.slice((basePath + "/jobs").length).replace(/^\//, "");
|
|
1290
|
+
if (rest === "") {
|
|
1291
|
+
if (req.method === "GET") {
|
|
1292
|
+
json(res, 200, { jobs: await queue.list() });
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (req.method === "POST") {
|
|
1296
|
+
const body = await readJsonBody(req, maxBodyBytes);
|
|
1297
|
+
if (!body.session || typeof body.session !== "object") {
|
|
1298
|
+
json(res, 400, { error: "session is required" });
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
if (!body.session.cwd || typeof body.session.cwd !== "string") {
|
|
1302
|
+
json(res, 400, { error: "session.cwd is required" });
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
if (!body.session.prompt || typeof body.session.prompt !== "string") {
|
|
1306
|
+
json(res, 400, { error: "session.prompt is required" });
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
if (!cwdAllowed(body.session.cwd, options.allowedCwdRoots)) {
|
|
1310
|
+
json(res, 403, { error: "cwd is outside the allowed roots" });
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
const refused = applyBypassPolicy(body.session);
|
|
1314
|
+
if (refused) {
|
|
1315
|
+
json(res, 403, { error: refused });
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
const resolved = resolveProfile(body.session.profile, auth.allowedProfiles);
|
|
1319
|
+
if (!resolved.ok) {
|
|
1320
|
+
json(res, resolved.status, { error: resolved.error });
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
const badRequest = checkPermissionMode(body.session.permissionMode, resolved.profile) ?? checkEngineGrants(body.session, resolved.profile);
|
|
1324
|
+
if (badRequest) {
|
|
1325
|
+
json(res, 400, { error: badRequest });
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
body.session.profile = resolved.profile?.name;
|
|
1329
|
+
try {
|
|
1330
|
+
json(res, 201, { job: await queue.submit(body) });
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
json(res, 400, { error: error instanceof Error ? error.message : "invalid job" });
|
|
1333
|
+
}
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
json(res, 405, { error: "method not allowed" });
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
const id = decodeURIComponent(rest);
|
|
1340
|
+
if (id.includes("/")) {
|
|
1341
|
+
json(res, 404, { error: "not found" });
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
if (req.method === "GET") {
|
|
1345
|
+
const job = await queue.get(id);
|
|
1346
|
+
if (job) json(res, 200, { job });
|
|
1347
|
+
else json(res, 404, { error: "job not found" });
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (req.method === "DELETE") {
|
|
1351
|
+
const job = await queue.cancel(id);
|
|
1352
|
+
if (job) json(res, 200, { job });
|
|
1353
|
+
else json(res, 404, { error: "job not found" });
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
json(res, 405, { error: "method not allowed" });
|
|
1357
|
+
};
|
|
1358
|
+
/**
|
|
1359
|
+
* `POST {basePath}/executions/:executionId/result` — a deferred executor
|
|
1360
|
+
* delivering its outcome. Wakes the parked session, applies the result to its
|
|
1361
|
+
* agent loop, and lets the run continue.
|
|
1362
|
+
*
|
|
1363
|
+
* Scoped like every other session route: a principal restricted to certain
|
|
1364
|
+
* profiles cannot settle an execution belonging to a session outside them —
|
|
1365
|
+
* a result is trusted tool input, and injecting one into another tenant's loop
|
|
1366
|
+
* would be a way to steer it.
|
|
1367
|
+
*/
|
|
1368
|
+
const handleExecutionResult = async (req, res, pathname, auth) => {
|
|
1369
|
+
const rest = pathname.slice((basePath + "/executions/").length).split("/");
|
|
1370
|
+
if (rest.length !== 2 || rest[1] !== "result" || !rest[0]) {
|
|
1371
|
+
json(res, 404, { error: "not found" });
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (req.method !== "POST") {
|
|
1375
|
+
json(res, 405, { error: "method not allowed" });
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
const executionId = decodeURIComponent(rest[0]);
|
|
1379
|
+
const body = await readJsonBody(req, maxBodyBytes);
|
|
1380
|
+
let result;
|
|
1381
|
+
if (body?.status === "ok") {
|
|
1382
|
+
if (!body.output || typeof body.output !== "object") {
|
|
1383
|
+
json(res, 400, { error: "output is required for status 'ok'" });
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
result = {
|
|
1387
|
+
status: "ok",
|
|
1388
|
+
output: body.output.value,
|
|
1389
|
+
logs: body.logs
|
|
1390
|
+
};
|
|
1391
|
+
} else if (body?.status === "failed") {
|
|
1392
|
+
if (typeof body.reason !== "string" || typeof body.error !== "string") {
|
|
1393
|
+
json(res, 400, { error: "reason and error are required for status 'failed'" });
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
result = {
|
|
1397
|
+
status: "failed",
|
|
1398
|
+
reason: body.reason,
|
|
1399
|
+
error: body.error,
|
|
1400
|
+
logs: body.logs
|
|
1401
|
+
};
|
|
1402
|
+
} else {
|
|
1403
|
+
json(res, 400, { error: "status must be 'ok' or 'failed'" });
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
if (auth.allowedProfiles) {
|
|
1407
|
+
const owner = parking.sessionFor(executionId);
|
|
1408
|
+
const profile = owner === void 0 ? void 0 : registry.get(owner)?.info().profile ?? (await parking.get(owner))?.profile;
|
|
1409
|
+
if (owner === void 0 || profile !== void 0 && !auth.allowedProfiles.includes(profile)) {
|
|
1410
|
+
json(res, 404, { error: "execution not found" });
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
const applied = await parking.submitResult(executionId, result);
|
|
1415
|
+
if (!applied) {
|
|
1416
|
+
json(res, 404, { error: "execution not found (unknown id, or its session has ended)" });
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
json(res, 200, applied);
|
|
1420
|
+
};
|
|
1421
|
+
const handleRequest = async (req, res) => {
|
|
1422
|
+
const pathname = new URL(req.url ?? "/", "http://internal").pathname;
|
|
1423
|
+
if (fallback && pathname !== basePath && !pathname.startsWith(basePath + "/")) {
|
|
1424
|
+
await fallback(req, res);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (pathname === basePath + "/jobs" || pathname.startsWith(basePath + "/jobs/") || pathname === basePath + "/queue") {
|
|
1428
|
+
const auth = await authenticate(req);
|
|
1429
|
+
if (!auth.ok) {
|
|
1430
|
+
json(res, 401, { error: "unauthorized" });
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
await handleJobs(req, res, pathname, auth);
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
if (pathname === basePath + "/profiles" || pathname.startsWith(basePath + "/profiles/")) {
|
|
1437
|
+
const auth = await authenticate(req);
|
|
1438
|
+
if (!auth.ok) {
|
|
1439
|
+
json(res, 401, { error: "unauthorized" });
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
const rest = pathname.slice((basePath + "/profiles").length).replace(/^\//, "");
|
|
1443
|
+
if (rest === "") {
|
|
1444
|
+
if (req.method === "GET") {
|
|
1445
|
+
json(res, 200, {
|
|
1446
|
+
profiles: (auth.allowedProfiles ? allProfiles().filter((p) => auth.allowedProfiles.includes(p.name)) : allProfiles()).map(withManagedFlag),
|
|
1447
|
+
canManage: manageGuard(auth) === null
|
|
1448
|
+
});
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
if (req.method === "POST") {
|
|
1452
|
+
const refused = manageGuard(auth);
|
|
1453
|
+
if (refused) {
|
|
1454
|
+
json(res, refused.status, { error: refused.error });
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
const body = await readJsonBody(req, maxBodyBytes);
|
|
1458
|
+
if (!body.name || typeof body.name !== "string") {
|
|
1459
|
+
json(res, 400, { error: "name is required" });
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
if (profileFor(body.name)) {
|
|
1463
|
+
json(res, 409, { error: `profile already exists: ${body.name}` });
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
await saveManagedProfile(res, body);
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
json(res, 405, { error: "method not allowed" });
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
const name = decodeURIComponent(rest);
|
|
1473
|
+
const profile = name.includes("/") ? void 0 : profileFor(name);
|
|
1474
|
+
if (!profile) {
|
|
1475
|
+
json(res, 404, { error: "profile not found" });
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
if (auth.allowedProfiles && !auth.allowedProfiles.includes(profile.name)) {
|
|
1479
|
+
json(res, 403, { error: `profile not allowed: ${profile.name}` });
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
if (req.method === "GET") {
|
|
1483
|
+
json(res, 200, {
|
|
1484
|
+
profile: withManagedFlag(profile),
|
|
1485
|
+
config: readProfileConfig(profile)
|
|
1486
|
+
});
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
if (req.method === "PATCH" || req.method === "DELETE") {
|
|
1490
|
+
const refused = manageGuard(auth) ?? declaredGuard(profile);
|
|
1491
|
+
if (refused) {
|
|
1492
|
+
json(res, refused.status, { error: refused.error });
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
if (req.method === "DELETE") {
|
|
1496
|
+
await options.profileStore.delete(profile.name);
|
|
1497
|
+
await refreshStored();
|
|
1498
|
+
res.writeHead(204).end();
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
const patch = await readJsonBody(req, maxBodyBytes);
|
|
1502
|
+
await saveManagedProfile(res, {
|
|
1503
|
+
...profile,
|
|
1504
|
+
...patch,
|
|
1505
|
+
name: profile.name
|
|
1506
|
+
});
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
json(res, 405, { error: "method not allowed" });
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
if (pathname.startsWith(basePath + "/executions/")) {
|
|
1513
|
+
const auth = await authenticate(req);
|
|
1514
|
+
if (!auth.ok) {
|
|
1515
|
+
json(res, 401, { error: "unauthorized" });
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
await handleExecutionResult(req, res, pathname, auth);
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
if (pathname === basePath + "/sdk-sessions") {
|
|
1522
|
+
if (!(await authenticate(req)).ok) {
|
|
1523
|
+
json(res, 401, { error: "unauthorized" });
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
await handleSdkSessions(req, res);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
const route = parseRoute(req.url ?? "/");
|
|
1530
|
+
if (!route || route.ws) {
|
|
1531
|
+
json(res, 404, { error: "not found" });
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
const auth = await authenticate(req);
|
|
1535
|
+
if (!auth.ok) {
|
|
1536
|
+
json(res, 401, { error: "unauthorized" });
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
if (!route.id) {
|
|
1540
|
+
if (req.method === "GET") {
|
|
1541
|
+
json(res, 200, { sessions: [...registry.list(), ...await parking.listInfo()] });
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
if (req.method === "POST") {
|
|
1545
|
+
const body = await readJsonBody(req, maxBodyBytes);
|
|
1546
|
+
if (!body.cwd || typeof body.cwd !== "string") {
|
|
1547
|
+
json(res, 400, { error: "cwd is required" });
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
if (!cwdAllowed(body.cwd, options.allowedCwdRoots)) {
|
|
1551
|
+
json(res, 403, { error: "cwd is outside the allowed roots" });
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
const refused = applyBypassPolicy(body);
|
|
1555
|
+
if (refused) {
|
|
1556
|
+
json(res, 403, { error: refused });
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
const resolved = resolveProfile(body.profile, auth.allowedProfiles);
|
|
1560
|
+
if (!resolved.ok) {
|
|
1561
|
+
json(res, resolved.status, { error: resolved.error });
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
const badRequest = checkPermissionMode(body.permissionMode, resolved.profile) ?? checkEngineGrants(body, resolved.profile);
|
|
1565
|
+
if (badRequest) {
|
|
1566
|
+
json(res, 400, { error: badRequest });
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
body.profile = resolved.profile?.name;
|
|
1570
|
+
const runner = await createRunner(buildRunnerConfig(body));
|
|
1571
|
+
watchAuthSource(runner);
|
|
1572
|
+
json(res, 201, { session: runner.info() });
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
json(res, 405, { error: "method not allowed" });
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
const runner = registry.get(route.id);
|
|
1579
|
+
const parked = runner ? null : await parking.get(route.id);
|
|
1580
|
+
if (!runner && !parked) {
|
|
1581
|
+
json(res, 404, { error: "session not found" });
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
if (route.files) {
|
|
1585
|
+
if (req.method !== "GET") {
|
|
1586
|
+
json(res, 405, { error: "method not allowed" });
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
const snapshotFiles = parked?.snapshot.vfs;
|
|
1590
|
+
const vfs = runner?.vfs ?? (snapshotFiles && {
|
|
1591
|
+
list: () => Object.keys(snapshotFiles).sort(),
|
|
1592
|
+
read: (path) => snapshotFiles[path]
|
|
1593
|
+
});
|
|
1594
|
+
if (!vfs) {
|
|
1595
|
+
json(res, 404, { error: "session has no file store" });
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
if (route.filePath === void 0) {
|
|
1599
|
+
json(res, 200, { files: vfs.list().map((path) => ({
|
|
1600
|
+
path,
|
|
1601
|
+
bytes: vfs.read(path)?.length ?? 0
|
|
1602
|
+
})) });
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
const content = vfs.read(route.filePath);
|
|
1606
|
+
if (content === void 0) {
|
|
1607
|
+
json(res, 404, { error: `no such file: ${route.filePath}` });
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
const filename = route.filePath.split("/").pop() || "file";
|
|
1611
|
+
res.writeHead(200, {
|
|
1612
|
+
"content-type": contentTypeFor(filename),
|
|
1613
|
+
"content-length": Buffer.byteLength(content),
|
|
1614
|
+
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
1615
|
+
"x-content-type-options": "nosniff"
|
|
1616
|
+
});
|
|
1617
|
+
res.end(content);
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
if (route.permissionId) {
|
|
1621
|
+
if (req.method !== "POST") {
|
|
1622
|
+
json(res, 405, { error: "method not allowed" });
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
const body = await readJsonBody(req, maxBodyBytes);
|
|
1626
|
+
if (body?.behavior !== "allow" && body?.behavior !== "deny") {
|
|
1627
|
+
json(res, 400, { error: "behavior must be 'allow' or 'deny'" });
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (!runner) {
|
|
1631
|
+
json(res, 409, { error: "session is parked (it has no pending permission requests)" });
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
if (!runner.resolvePermission(route.permissionId, body)) {
|
|
1635
|
+
json(res, 404, { error: "permission request not found (already resolved or expired)" });
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
json(res, 200, { resolved: true });
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
if (req.method === "GET") {
|
|
1642
|
+
json(res, 200, { session: runner?.info() ?? parked.info });
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
if (req.method === "DELETE") {
|
|
1646
|
+
registry.remove(route.id);
|
|
1647
|
+
bridge.remove(route.id);
|
|
1648
|
+
await parking.discard(route.id);
|
|
1649
|
+
json(res, 200, { session: runner?.info() ?? {
|
|
1650
|
+
...parked.info,
|
|
1651
|
+
status: "closed"
|
|
1652
|
+
} });
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
json(res, 405, { error: "method not allowed" });
|
|
1656
|
+
};
|
|
1657
|
+
const server = createServer((req, res) => {
|
|
1658
|
+
handleRequest(req, res).catch((error) => {
|
|
1659
|
+
const message = error instanceof Error ? error.message : "internal error";
|
|
1660
|
+
if (!res.headersSent) json(res, error instanceof SyntaxError ? 400 : 500, { error: message });
|
|
1661
|
+
else res.end();
|
|
1662
|
+
});
|
|
1663
|
+
});
|
|
1664
|
+
server.on("upgrade", (req, socket, head) => {
|
|
1665
|
+
(async () => {
|
|
1666
|
+
if (new URL(req.url ?? "/", "http://internal").pathname === basePath + "/queue/ws") {
|
|
1667
|
+
if (!queue) {
|
|
1668
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
1669
|
+
socket.destroy();
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
if (!(await authenticate(req)).ok) {
|
|
1673
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
1674
|
+
socket.destroy();
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1678
|
+
queueSockets.add(ws);
|
|
1679
|
+
ws.on("close", () => queueSockets.delete(ws));
|
|
1680
|
+
queue.stats().then((stats) => sendQueueFrame(ws, {
|
|
1681
|
+
type: "queue_attached",
|
|
1682
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1683
|
+
stats
|
|
1684
|
+
})).catch(() => {});
|
|
1685
|
+
});
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
const route = parseRoute(req.url ?? "/");
|
|
1689
|
+
if (!route?.ws || !route.id) {
|
|
1690
|
+
socket.destroy();
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
if (!(await authenticate(req)).ok) {
|
|
1694
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
1695
|
+
socket.destroy();
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
const runner = await parking.ensureLive(route.id).catch(() => void 0);
|
|
1699
|
+
if (!runner) {
|
|
1700
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
1701
|
+
socket.destroy();
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1705
|
+
attachClient(ws, runner, req);
|
|
1706
|
+
});
|
|
1707
|
+
})().catch(() => socket.destroy());
|
|
1708
|
+
});
|
|
1709
|
+
const attachClient = (ws, runner, req) => {
|
|
1710
|
+
const url = new URL(req.url ?? "/", "http://internal");
|
|
1711
|
+
const afterSeq = Number(url.searchParams.get("afterSeq") ?? "0") || 0;
|
|
1712
|
+
const send = (frame) => {
|
|
1713
|
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame));
|
|
1714
|
+
};
|
|
1715
|
+
send({
|
|
1716
|
+
type: "attached",
|
|
1717
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1718
|
+
session: runner.info(),
|
|
1719
|
+
replayingFrom: afterSeq
|
|
1720
|
+
});
|
|
1721
|
+
const unsubscribe = runner.subscribe((event) => send({
|
|
1722
|
+
type: "event",
|
|
1723
|
+
event
|
|
1724
|
+
}), afterSeq);
|
|
1725
|
+
const detachBridge = bridge.attach(runner.id, send);
|
|
1726
|
+
ws.on("message", (data) => {
|
|
1727
|
+
let frame;
|
|
1728
|
+
try {
|
|
1729
|
+
frame = JSON.parse(data.toString("utf8"));
|
|
1730
|
+
} catch {
|
|
1731
|
+
send({
|
|
1732
|
+
type: "protocol_error",
|
|
1733
|
+
message: "invalid JSON frame"
|
|
1734
|
+
});
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
handleCommand(frame, runner).catch((error) => {
|
|
1738
|
+
send({
|
|
1739
|
+
type: "protocol_error",
|
|
1740
|
+
message: error instanceof Error ? error.message : "command failed"
|
|
1741
|
+
});
|
|
1742
|
+
});
|
|
1743
|
+
});
|
|
1744
|
+
ws.on("close", () => {
|
|
1745
|
+
unsubscribe();
|
|
1746
|
+
detachBridge();
|
|
1747
|
+
parking.onDetach(runner.id);
|
|
1748
|
+
});
|
|
1749
|
+
};
|
|
1750
|
+
const handleCommand = async (frame, runner) => {
|
|
1751
|
+
switch (frame.type) {
|
|
1752
|
+
case "user_message":
|
|
1753
|
+
runner.sendMessage(frame.text);
|
|
1754
|
+
return;
|
|
1755
|
+
case "permission_decision":
|
|
1756
|
+
if (frame.behavior === "allow") runner.resolvePermission(frame.requestId, {
|
|
1757
|
+
behavior: "allow",
|
|
1758
|
+
updatedInput: frame.updatedInput
|
|
1759
|
+
});
|
|
1760
|
+
else runner.resolvePermission(frame.requestId, {
|
|
1761
|
+
behavior: "deny",
|
|
1762
|
+
message: frame.message,
|
|
1763
|
+
interrupt: frame.interrupt
|
|
1764
|
+
});
|
|
1765
|
+
return;
|
|
1766
|
+
case "interrupt":
|
|
1767
|
+
await runner.interrupt();
|
|
1768
|
+
return;
|
|
1769
|
+
case "set_permission_mode":
|
|
1770
|
+
if (frame.mode === "bypassPermissions" && options.disableBypassPermissions) throw new Error("bypassPermissions is disabled on this server (disableBypassPermissions)");
|
|
1771
|
+
await runner.setPermissionMode(frame.mode);
|
|
1772
|
+
return;
|
|
1773
|
+
case "set_model":
|
|
1774
|
+
await runner.setModel(frame.model);
|
|
1775
|
+
return;
|
|
1776
|
+
case "tool_call_result":
|
|
1777
|
+
bridge.resolve(runner.id, frame.executionId, {
|
|
1778
|
+
output: frame.output,
|
|
1779
|
+
logs: frame.logs
|
|
1780
|
+
});
|
|
1781
|
+
return;
|
|
1782
|
+
case "tool_call_error":
|
|
1783
|
+
bridge.resolve(runner.id, frame.executionId, {
|
|
1784
|
+
reason: frame.reason,
|
|
1785
|
+
error: frame.error,
|
|
1786
|
+
logs: frame.logs
|
|
1787
|
+
});
|
|
1788
|
+
return;
|
|
1789
|
+
case "close":
|
|
1790
|
+
runner.close("client");
|
|
1791
|
+
return;
|
|
1792
|
+
default: throw new Error(`unknown command: ${frame.type}`);
|
|
1793
|
+
}
|
|
1794
|
+
};
|
|
1795
|
+
return {
|
|
1796
|
+
server,
|
|
1797
|
+
registry,
|
|
1798
|
+
queue,
|
|
1799
|
+
bridge,
|
|
1800
|
+
parking,
|
|
1801
|
+
listen: async (port, host) => {
|
|
1802
|
+
await refreshStored();
|
|
1803
|
+
await parking.hydrate();
|
|
1804
|
+
return new Promise((resolve, reject) => {
|
|
1805
|
+
server.once("error", reject);
|
|
1806
|
+
server.listen(port, host, () => {
|
|
1807
|
+
preflightCredentials();
|
|
1808
|
+
const address = server.address();
|
|
1809
|
+
resolve({ port: typeof address === "object" && address ? address.port : port });
|
|
1810
|
+
});
|
|
1811
|
+
});
|
|
1812
|
+
},
|
|
1813
|
+
close: () => new Promise((resolve) => {
|
|
1814
|
+
queue?.close();
|
|
1815
|
+
parking.close();
|
|
1816
|
+
registry.closeAll();
|
|
1817
|
+
for (const ws of queueSockets) ws.close();
|
|
1818
|
+
queueSockets.clear();
|
|
1819
|
+
wss.close();
|
|
1820
|
+
server.close(() => resolve());
|
|
1821
|
+
server.closeAllConnections();
|
|
1822
|
+
})
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
//#endregion
|
|
1826
|
+
//#region src/profile-store.ts
|
|
1827
|
+
/** Non-durable store for tests and ephemeral deployments. */
|
|
1828
|
+
function createMemoryProfileStore(seed = []) {
|
|
1829
|
+
const profiles = new Map(seed.map((p) => [p.name, p]));
|
|
1830
|
+
return {
|
|
1831
|
+
list: () => [...profiles.values()],
|
|
1832
|
+
save: (profile) => void profiles.set(profile.name, profile),
|
|
1833
|
+
delete: (name) => void profiles.delete(name)
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* JSON-file store: one array of profiles at `path` (default
|
|
1838
|
+
* `<cwd>/.workerdeck/profiles.json`). Writes go through a temp file and a
|
|
1839
|
+
* rename so a crash mid-write cannot truncate the operator's profile list.
|
|
1840
|
+
*
|
|
1841
|
+
* Single-process by design, exactly like the bundled queue adapter — two servers
|
|
1842
|
+
* sharing one file would race. That is what the seam is for.
|
|
1843
|
+
*/
|
|
1844
|
+
function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profiles.json")) {
|
|
1845
|
+
const read = () => {
|
|
1846
|
+
try {
|
|
1847
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1848
|
+
if (!Array.isArray(parsed)) return /* @__PURE__ */ new Map();
|
|
1849
|
+
return new Map(parsed.filter((p) => p && typeof p.name === "string").map((p) => [p.name, p]));
|
|
1850
|
+
} catch {
|
|
1851
|
+
return /* @__PURE__ */ new Map();
|
|
1852
|
+
}
|
|
1853
|
+
};
|
|
1854
|
+
const write = (profiles) => {
|
|
1855
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1856
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
1857
|
+
writeFileSync(temp, JSON.stringify([...profiles.values()], null, 2));
|
|
1858
|
+
renameSync(temp, path);
|
|
1859
|
+
};
|
|
1860
|
+
return {
|
|
1861
|
+
list: () => [...read().values()],
|
|
1862
|
+
save: (profile) => {
|
|
1863
|
+
const profiles = read();
|
|
1864
|
+
profiles.set(profile.name, profile);
|
|
1865
|
+
write(profiles);
|
|
1866
|
+
},
|
|
1867
|
+
delete: (name) => {
|
|
1868
|
+
const profiles = read();
|
|
1869
|
+
if (profiles.delete(name)) write(profiles);
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
//#endregion
|
|
1874
|
+
export { BridgeHub, MemorySessionStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
|
|
1875
|
+
|
|
1876
|
+
//# sourceMappingURL=index.mjs.map
|