@workerdeck/server 0.22.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.d.mts CHANGED
@@ -1,216 +1,83 @@
1
1
  import { IncomingMessage, Server, ServerResponse } from "node:http";
2
- import { AttachmentInput, BridgeAnswer, BrowserBridgeExecutor, ClaudeAuthProbe, EngineAdapter, EngineSessionOptions, HostToolDefinition, LanguageModel, McpConnection, ParkedExecution, Runner, RunnerSnapshot, SessionRunnerConfig, ToolExecutionResult, ToolExecutor, ToolSet } from "@workerdeck/core";
2
+ import { AttachmentInput, BridgeAnswer, BrowserBridgeExecutor, ClaudeAuthProbe, EngineAdapter, EngineSessionOptions, HostToolDefinition, LanguageModel, McpConnection, ParkedExecution, Runner, RunnerSnapshot, SessionRunnerConfig, ToolExecutionCall, ToolExecutionResult, ToolExecutor, ToolSet } from "@workerdeck/core";
3
3
  import { JobQueue, QueueAdapter } from "@workerdeck/queue";
4
4
  import { CreateSessionRequest, JobEvent, MessageAttachment, ProfileEngine, ProfileInfo, ProfileUsage, ProviderConfig, SdkSessionSummary, ServerFrame, SessionCapability, SessionInfo, SessionNotification, SessionWebhookConfig } from "@workerdeck/protocol";
5
5
 
6
6
  //#region src/services/bridge.d.ts
7
7
  type BridgeHubOptions = {
8
- /** How long a bridged call may stay unanswered before it fails. Default 60000. */timeoutMs?: number;
9
- /** Called when a bridged execution reaches a terminal result — the host feeds
10
- * it back into the runner's loop. */
8
+ timeoutMs?: number;
11
9
  onResult?: (sessionId: string, executionId: string, result: ToolExecutionResult) => void;
12
10
  };
13
- /**
14
- * Routes tool executions between a session and the browser tabs attached to it.
15
- *
16
- * A session may have several clients attached (dashboard plus embedded panel);
17
- * the bridge asks the **first attached** one, which is the closest thing to "the
18
- * client driving this session". If none is attached, dispatch fails fast rather
19
- * than hanging — an autonomous job simply never bridges, it uses the server
20
- * executor instead.
21
- */
22
11
  declare class BridgeHub {
23
12
  #private;
24
13
  constructor(options?: BridgeHubOptions);
25
- /** The executor to hand a runner for this session. Created on first use and
26
- * reused, so results routed back always reach the same pending table. */
27
14
  executorFor(sessionId: string): BrowserBridgeExecutor;
28
- /** How many clients are watching this session. Parking consults it: a session
29
- * someone is watching stays live. */
30
15
  attachedCount(sessionId: string): number;
31
- /** Register an attached client. Returns a detach function. */
32
16
  attach(sessionId: string, send: (frame: ServerFrame) => void): () => void;
33
- /**
34
- * Deliver a client's answer to a bridged call. Returns false when the id is
35
- * unknown or already settled — late and duplicate answers are ignored.
36
- */
37
17
  resolve(sessionId: string, executionId: string, answer: BridgeAnswer): boolean;
38
- /** Drop a session's bridge, failing anything still in flight. */
39
18
  remove(sessionId: string): void;
40
19
  }
41
20
  //#endregion
42
21
  //#region src/services/notifications.d.ts
43
22
  type SessionNotificationOptions = {
44
- /** POST target for every notification. */webhook?: SessionWebhookConfig;
45
- /** Local observer, invoked for every notification whether or not a webhook is
46
- * configured — the in-process seam a host (or the CLI's APNs forwarder) hooks.
47
- * Unfiltered: `webhook.events` narrows POST deliveries, not this. */
48
- onNotification?: (notification: SessionNotification) => void; /** Delivery attempts per notification (exponential backoff). Default 3. */
49
- attempts?: number; /** Initial backoff between attempts. Default 500ms. */
23
+ webhook?: SessionWebhookConfig;
24
+ onNotification?: (notification: SessionNotification) => void;
25
+ attempts?: number;
50
26
  retryDelayMs?: number;
51
- /** Gateway wiring, not a host option: the serve-time `SessionInfo` decoration
52
- * (project identity today), so a webhook or push consumer reads the same
53
- * record every REST caller does. The assembly supplies it; identity when
54
- * absent. */
55
27
  decorateInfo?: (info: SessionInfo) => SessionInfo;
56
28
  };
57
- /**
58
- * Turns session events into the handful of notifications a human away from the
59
- * screen cares about, and delivers them to a webhook and/or a local observer.
60
- *
61
- * This is the *primitive*, deliberately transport-agnostic: the server stays
62
- * credential-free and knows nothing about APNs, Slack or email. Turning a
63
- * notification into a push is a forwarder's job (the turnkey CLI's), and one that
64
- * needs credentials, so it does not live here.
65
- *
66
- * Delivery is best-effort and ordered per session, mirroring the job queue's
67
- * webhook behaviour — a consumer that missed one can always attach to the session
68
- * WS with `afterSeq` and see the truth.
69
- */
70
29
  declare class SessionNotifier {
71
30
  #private;
72
31
  constructor(options: SessionNotificationOptions);
73
- /** True when nothing is listening — lets the caller skip subscribing at all. */
74
32
  get idle(): boolean;
75
- /**
76
- * Subscribe to a runner for its lifetime.
77
- *
78
- * `afterSeq` defaults to whatever the runner has already emitted, which is what
79
- * makes this safe on a *rehydrated* session: `subscribe` replays the log from
80
- * `afterSeq`, so subscribing at 0 to a session rebuilt from a park would
81
- * re-announce every permission request it ever made.
82
- */
83
33
  watch(runner: Runner, afterSeq?: number): void;
84
34
  }
85
35
  //#endregion
86
36
  //#region src/services/registry.d.ts
87
37
  type SessionRegistryOptions = {
88
- /**
89
- * Called once per runner as it enters the table, before it starts — the one
90
- * seam every path goes through (create, prepare, adopt, and the rebuild of a
91
- * parked session), which is what a watcher that must not miss a session needs.
92
- */
93
38
  onRegister?: (runner: Runner) => void;
94
39
  };
95
- /** In-memory session table. Terminal sessions stay listed until removed or the process exits. */
96
40
  declare class SessionRegistry {
97
41
  #private;
98
42
  constructor(options?: SessionRegistryOptions);
99
43
  create(config: SessionRunnerConfig): Runner;
100
- /** Build and list a Claude-engine runner without starting it, so watchers can
101
- * subscribe first. Call `start()` once they have. */
102
44
  prepare(config: SessionRunnerConfig): Runner;
103
- /** Register an already-built runner (a non-Claude engine) and start it. */
104
45
  adopt(runner: Runner): Runner;
105
- /** List a runner without starting it — for a rehydrated session, whose watchers
106
- * must be subscribed before it comes back up. */
107
46
  register(runner: Runner): Runner;
108
47
  get(id: string): Runner | undefined;
109
48
  list(): SessionInfo[];
110
49
  remove(id: string): boolean;
111
- /** Drop a runner WITHOUT closing it: the session isn't ending, it parked and
112
- * lives on in its snapshot. Closing here would tell every client it was over. */
113
50
  evict(id: string): boolean;
114
51
  closeAll(): void;
115
52
  }
116
53
  //#endregion
117
54
  //#region src/services/session-store.d.ts
118
- /**
119
- * A session captured whole: its wire-visible info, the config to rebuild the
120
- * runner, the engine's snapshot, and what it is waiting for.
121
- *
122
- * Two things are stored in this one shape, and the discriminator is `kind`:
123
- *
124
- * - **`parked`** — the live runner is torn down and the session is waiting on
125
- * deferred executions. The record *is* the session, so waking consumes it.
126
- * - **`live`** — the runner is up and this is a copy taken after a turn, so the
127
- * session survives a restart (`persistLive`). It is a way back that the
128
- * session still needs the *next* time the process dies, so waking **refreshes
129
- * it in place** and only `session_closed` removes it.
130
- *
131
- * That difference is the whole reason these are two kinds rather than one, and
132
- * it is a correctness difference, not bookkeeping: consuming a live record on
133
- * wake opens a window from the attach to the next turn in which the session
134
- * exists nowhere durable. A user who opens a session, reads it and types nothing
135
- * would lose it to a redeploy — silently, which is the failure class worth
136
- * spending a discriminator on.
137
- *
138
- * They share the shape so a store, and an older server, need no new code: every
139
- * other branch (rebuild from `config` + `snapshot`, serve `snapshot.vfs`, arm
140
- * `executions`, subscribe past `snapshot.seq`) is already right for both.
141
- */
142
55
  type ParkedSessionRecord = {
143
- /** Absent on records written before dormant sessions existed — those are all parked. */kind?: 'parked' | 'live';
56
+ kind?: 'parked' | 'live';
144
57
  id: string;
145
- /** Session info as of the write: `parked` for a park, `idle` for a live copy —
146
- * never `running`, which would come back as a spinner over no process. */
147
58
  info: SessionInfo;
148
- profile?: string; /** The config the session was created with (profile defaults already applied). */
59
+ profile?: string;
149
60
  config: SessionRunnerConfig;
150
61
  snapshot: RunnerSnapshot;
151
- /** Empty for a live record: an idle session is waiting on nothing, so there is
152
- * nothing for `hydrate` to arm a watchdog for. */
153
- executions: ParkedExecution[]; /** When it was written. Named for the park that came first. */
62
+ executions: ParkedExecution[];
154
63
  parkedAt: number;
155
64
  };
156
- /**
157
- * A live session, remembered so it can be brought back after a gateway restart.
158
- *
159
- * The counterpart to a park, and deliberately not the same mechanism. A park
160
- * preserves *mid-task* state, which means a `RunnerSnapshot` — and only the
161
- * provider engine can produce one, because the claude and codex engines run
162
- * behind a binary that owns its own process state. What those two have instead
163
- * is a session store of their own: the transcript is already on disk under an
164
- * engine session id, and `CreateSessionRequest.resume` is how you get it back.
165
- *
166
- * So this record holds no transcript at all. It holds the id (every client keys
167
- * its watermarks and routes on it), the engine session id to resume from, and
168
- * the config to rebuild with — and rehydration is an ordinary create with
169
- * `resume` set, done **lazily on first attach**, because eagerly respawning
170
- * every session at boot is a fork bomb wearing a feature's clothes.
171
- *
172
- * Written only for engines whose capability record says `resume`, and only once
173
- * an `sdkSessionId` exists: a record that would come back with an empty
174
- * transcript is worse than no record.
175
- */
176
65
  type DormantSessionRecord = {
177
66
  kind: 'dormant';
178
67
  id: string;
179
- /** Session info as of the last save, with `status: 'idle'` — whatever it was
180
- * doing, it is not doing it now. */
181
68
  info: SessionInfo;
182
69
  profile?: string;
183
- /**
184
- * The config the session was built from, minus the ephemeral keys. Fed back
185
- * through the server's `buildRunnerConfig` on wake rather than used as-is, so
186
- * the profile's env pin and the host hook's injections are **re-derived**
187
- * instead of persisted (see {@link EPHEMERAL_CONFIG_KEYS}).
188
- */
189
- config: SessionRunnerConfig; /** Where the transcript actually lives. Without one there is nothing to resume. */
70
+ config: SessionRunnerConfig;
190
71
  sdkSessionId: string;
191
72
  savedAt: number;
192
73
  };
193
- /** What a {@link SessionStore} holds: a session waiting on deferred work, or one
194
- * waiting to be asked for again. */
195
74
  type StoredSessionRecord = ParkedSessionRecord | DormantSessionRecord;
196
- /**
197
- * Where parked sessions live. Two implementations ship: {@link MemorySessionStore}
198
- * (a park survives a disconnect, not a restart) and {@link createFileSessionStore}
199
- * (it survives both, on one host); a redis/sqlite/table store implements the same
200
- * four operations.
201
- *
202
- * Two things to know before writing one: the record holds the session's whole
203
- * transcript and tool I/O, and `config` may carry host-injected values (env, hooks,
204
- * injected functions) that a JSON round-trip silently drops or, worse, persists.
205
- * {@link toDurableRecord} is the filter the bundled file store applies — reuse it.
206
- */
207
75
  interface SessionStore {
208
76
  save(record: StoredSessionRecord): Promise<void>;
209
77
  get(id: string): Promise<StoredSessionRecord | null>;
210
78
  list(): Promise<StoredSessionRecord[]>;
211
79
  delete(id: string): Promise<boolean>;
212
80
  }
213
- /** Single-process, no persistence: parks survive a client disconnect, not a restart. */
214
81
  declare class MemorySessionStore implements SessionStore {
215
82
  #private;
216
83
  save(record: StoredSessionRecord): Promise<void>;
@@ -218,209 +85,60 @@ declare class MemorySessionStore implements SessionStore {
218
85
  list(): Promise<StoredSessionRecord[]>;
219
86
  delete(id: string): Promise<boolean>;
220
87
  }
221
- /** The record as it may be persisted: same session, config narrowed to what is
222
- * safe and meaningful to keep (see {@link EPHEMERAL_CONFIG_KEYS}). */
223
88
  declare function toDurableRecord<T extends StoredSessionRecord>(record: T): T;
224
89
  type FileSessionStoreOptions = {
225
- /** Directory holding one JSON file per parked session.
226
- * Default `<cwd>/.workerdeck/parked`. */
227
90
  dir?: string;
228
- /** A record that could not be read or written. Losing one is losing a session's
229
- * way back, so this is worth logging — the store itself stays quiet and skips it. */
230
91
  onError?: (error: unknown, context: {
231
92
  path: string;
232
93
  op: 'save' | 'read' | 'delete';
233
94
  }) => void;
234
95
  };
235
- /**
236
- * Durable single-host store: one JSON file per parked session under `dir`, written
237
- * through a temp file and a rename so a crash mid-write cannot truncate a session.
238
- * `hydrate()` at `listen()` picks them up, re-indexes their executions, and re-arms
239
- * the watchdogs, so a restart no longer loses parked work.
240
- *
241
- * Know what is on that disk: **the record holds the session's entire transcript** —
242
- * prompts, model output, and tool I/O — in plaintext. Put it somewhere with the same
243
- * protection as the SDK's own transcripts (`~/.claude/projects`), not in a directory
244
- * that gets served, synced, or backed up somewhere looser.
245
- *
246
- * Single-process by design, exactly like the bundled queue adapter and profile
247
- * store: two servers sharing one directory would both hydrate the same records and
248
- * race to rebuild them. That is what the seam is for.
249
- *
250
- * Nothing here reaps: a record leaves only when its session wakes or is deleted.
251
- * An execution dispatched without a deadline (a `DeferredExecutor` with no
252
- * `timeoutMs`) has no watchdog to end the wait, so its record — and its transcript
253
- * — stays until `DELETE /sessions/:id`. Give deferred calls a deadline, or sweep.
254
- */
255
96
  declare function createFileSessionStore(options?: FileSessionStoreOptions): SessionStore;
256
97
  //#endregion
257
98
  //#region src/services/parking.d.ts
99
+ type ParkErrorContext = {
100
+ sessionId: string;
101
+ phase: 'park' | 'remember' | 'resume';
102
+ };
258
103
  type SessionParkOptions = {
259
104
  registry: SessionRegistry;
260
105
  store: SessionStore;
261
- /**
262
- * Rebuild a stored session's runner. For a park the snapshot rides in on
263
- * `config.restore`, so the engine adopts the id, event log, and history; for a
264
- * dormant record there is no snapshot and the engine is asked to resume its
265
- * own session under the stored id. Either way the runner it returns must carry
266
- * `record.id` — {@link SessionParkManager} refuses one that does not.
267
- */
268
106
  rebuild: (record: StoredSessionRecord) => Promise<Runner>;
269
- /** How many clients are attached to this session. A watched session stays live:
270
- * parking would pull the runner out from under the socket. */
271
107
  attachedCount: (sessionId: string) => number;
272
- /** Wait this long after the last client detaches before parking, so a reconnect
273
- * (a wifi blip, a page reload) doesn't cost a teardown. Default 2000. */
274
108
  parkDelayMs?: number;
275
- /**
276
- * Keep a live session's snapshot written through to the store, so it survives
277
- * a gateway restart. **Off by default**: a library must not start writing a
278
- * session's whole transcript to disk because someone upgraded.
279
- *
280
- * This is the restart story for the engine that cannot go dormant. Dormancy
281
- * works by remembering an *engine* session id to resume from, which the
282
- * provider engine does not have — there is no store behind it, the history
283
- * lives in the runner. What it has instead is `snapshot()`, so the record
284
- * carries the state itself and rehydration is the ordinary `restore` path.
285
- * Between the two, every engine survives a restart.
286
- *
287
- * Written at the end of a turn, never on a shutdown hook — a `kill -9`, an OOM
288
- * or a pulled power cable run no hook, and that is precisely the case this
289
- * exists for. Turn-end is also a natural rate limit: one write per turn, not
290
- * one per token.
291
- */
292
109
  persistLive?: boolean;
293
- /** Grace given at {@link SessionParkManager.hydrate} to an execution whose
294
- * deadline passed while the server was down. Its result could not have been
295
- * delivered during the outage, so failing it the instant the process is back
296
- * would throw away an answer that is very likely seconds behind. Extends a
297
- * deadline, never shortens one. Default 60000. */
298
110
  expiredGraceMs?: number;
299
- /** Veto + accounting hook, called before the teardown: the job queue frees the
300
- * run's concurrency slot here, and refuses (false) when the run is finalizing. */
301
111
  onParking?: (sessionId: string, executionId: string) => boolean;
302
- /** The session is live again under a NEW runner object — anything holding the
303
- * old reference must rebind. */
304
112
  onResumed?: (sessionId: string, runner: Runner) => void;
305
- /** Park/remember/resume failures. These are not session errors — the session
306
- * is intact, the host's storage or engine assembly isn't. */
307
- onError?: (error: unknown, context: {
308
- sessionId: string;
309
- phase: 'park' | 'remember' | 'resume';
310
- }) => void;
113
+ onError?: (error: unknown, context: ParkErrorContext) => void;
311
114
  };
312
- /**
313
- * Two ways a session outlives its runner, behind one door.
314
- *
315
- * **Parking** is deferred execution's other half: a session waiting on work no
316
- * process in this server is doing.
317
- *
318
- * **Dormancy** is the restart story for the engines that cannot park. Every live
319
- * claude or codex session leaves a small record naming its engine session id, so
320
- * a gateway that comes back up lists them and resumes one the first time someone
321
- * attaches. Both kinds live in the same store and come back through the same
322
- * `ensureLive`, which is why there is one class here and not two.
323
- *
324
- * The runner announces the moment with `status_changed: 'parked'` — emitted only
325
- * once every dispatch of the batch has been handed over, so the snapshot can never
326
- * miss a call that was still being dispatched. From there this class snapshots,
327
- * evicts, and persists; delivering a result rebuilds the runner under the same id
328
- * and hands the result to it. The session's identity, event log, and seq numbering
329
- * survive intact, so a client reattaching with `afterSeq` sees one unbroken stream.
330
- */
331
115
  declare class SessionParkManager {
332
116
  #private;
333
117
  constructor(options: SessionParkOptions);
334
- /** Record the config a session was created with. Only sessions the host
335
- * remembers can be parked — there is no way to rebuild the others. */
336
118
  remember(sessionId: string, config: SessionRunnerConfig): void;
337
- /**
338
- * Re-save a live session's dormant record because something outside the event
339
- * stream changed it.
340
- *
341
- * `#rememberDormant` is otherwise driven by `status_changed` and `system_init`
342
- * alone, and a rename (`PATCH /sessions/:id`) fires neither — so without this
343
- * a renamed session that is never touched again keeps its old title on disk
344
- * and comes back under it. Safe to call for anything: every gate in
345
- * `#rememberDormant` still applies, so a session that cannot be resumed, has
346
- * no engine session yet, or is no longer the registry's writes nothing.
347
- */
348
119
  touch(runner: Runner): void;
349
- /**
350
- * Adopt the store's contents (a durable store after a restart): re-index the
351
- * executions and re-arm their watchdogs, no deadline sooner than the grace
352
- * window — nothing could have been delivered while the process was down.
353
- *
354
- * Dormant records need nothing here, which is the point of them. They list
355
- * from the store (`listInfo`) and come back on first attach (`ensureLive`), so
356
- * a boot with fifty remembered sessions spawns nothing at all.
357
- */
358
120
  hydrate(): Promise<void>;
359
- /**
360
- * Follow a session's lifecycle: index its deferred executions, park it when the
361
- * engine says the turn has come to rest on them, and clean up when it ends.
362
- * `afterSeq` skips a rehydrated runner's replayed history (re-arming a watchdog
363
- * from an event whose deadline already passed would fail the execution instantly).
364
- */
365
121
  watch(runner: Runner, afterSeq?: number): () => void;
366
- /** A client detached: park the session if that was the last one watching. */
367
122
  onDetach(sessionId: string): void;
368
- /** Which session this execution belongs to — still waiting, or already settled. */
369
123
  sessionFor(executionId: string): string | undefined;
370
- /** The stored session's record, for the read paths (GET, list, attach). */
371
124
  get(id: string): Promise<StoredSessionRecord | null>;
372
- /** Every stored session's info, to merge into `GET {basePath}/sessions`. */
373
125
  listInfo(): Promise<SessionInfo[]>;
374
- /** The live runner for a session, rehydrating a parked one on demand. Undefined
375
- * when the session is neither live nor parked. */
376
126
  ensureLive(id: string): Promise<Runner | undefined>;
377
- /**
378
- * Deliver a deferred execution's result. Rehydrates the session if needed and
379
- * folds the result into its agent loop.
380
- *
381
- * Undefined = no session is waiting on that id. `applied: false` = it was already
382
- * settled: a duplicate delivery, or one racing the watchdog. Both are expected,
383
- * neither is an error.
384
- */
385
127
  submitResult(executionId: string, result: ToolExecutionResult): Promise<{
386
128
  applied: boolean;
387
129
  sessionId: string;
388
130
  } | undefined>;
389
- /** Drop a parked session for good: the run is over (closed, canceled, killed). */
390
131
  discard(sessionId: string): Promise<void>;
391
132
  close(): void;
392
133
  }
393
134
  //#endregion
394
135
  //#region src/services/profile-store.d.ts
395
- /**
396
- * Where dashboard-managed profiles live. The seam exists for the same reason
397
- * `QueueAdapter` does: a single-host deployment wants the bundled file store and
398
- * no configuration, while an operator with a database wants their own.
399
- *
400
- * Profiles declared in `createWorkerServer({ profiles })` never enter a store —
401
- * they are code, and stay immutable. The store holds only what the management
402
- * routes created, and the two sets are unioned by name.
403
- *
404
- * A store holds NO credentials: `ProviderConfig.apiKeyEnv` is a variable name and
405
- * a Claude profile's `configDir` is a path. Both are resolved by the server's own
406
- * environment at session time, which is what keeps a stored profile safe to write
407
- * to disk and safe to serve from `GET /profiles`.
408
- */
409
136
  type ProfileStore = {
410
- /** Every stored profile. Called once at `listen()` and after each mutation. */list(): ProfileInfo[] | Promise<ProfileInfo[]>; /** Create or replace by `profile.name`. */
411
- save(profile: ProfileInfo): void | Promise<void>; /** Remove by name. Removing something absent is not an error. */
137
+ list(): ProfileInfo[] | Promise<ProfileInfo[]>;
138
+ save(profile: ProfileInfo): void | Promise<void>;
412
139
  delete(name: string): void | Promise<void>;
413
140
  };
414
- /** Non-durable store for tests and ephemeral deployments. */
415
141
  declare function createMemoryProfileStore(seed?: ProfileInfo[]): ProfileStore;
416
- /**
417
- * JSON-file store: one array of profiles at `path` (default
418
- * `<cwd>/.workerdeck/profiles.json`). Writes go through a temp file and a
419
- * rename so a crash mid-write cannot truncate the operator's profile list.
420
- *
421
- * Single-process by design, exactly like the bundled queue adapter — two servers
422
- * sharing one file would race. That is what the seam is for.
423
- */
424
142
  declare function createFileProfileStore(path?: string): ProfileStore;
425
143
  //#endregion
426
144
  //#region src/options.d.ts
@@ -429,400 +147,105 @@ type SdkSessionLister = (options: {
429
147
  limit?: number;
430
148
  offset?: number;
431
149
  }) => Promise<SdkSessionSummary[]>;
432
- /**
433
- * Return a principal (any truthy value) to accept the request, or null/undefined to
434
- * reject with 401. The host app supplies this — the worker has no auth story of its
435
- * own. A principal object may carry `allowedProfiles: string[]` to restrict which
436
- * profiles the caller can create sessions/jobs under (and see in GET /profiles) —
437
- * without it the caller may use every declared profile. It may also carry
438
- * `canManageProfiles: true` to allow creating/editing/deleting managed profiles
439
- * (requires the `profileStore` option); anything else means no.
440
- *
441
- * It may also carry `scope: Record<string, string>` — the opaque tags deciding
442
- * which *sessions* this caller may see at all (see
443
- * {@link WorkerServerOptions.authorizeSession}). A principal carrying a scope is
444
- * an embedded end user rather than the operator, and is refused the
445
- * operator-privileged surfaces outright: `/fs/*`, `/sdk-sessions`, `/queue` and
446
- * `/queue/ws`.
447
- *
448
- * **This is the place to be expensive.** It is already async and already runs
449
- * once per request, so a lookup (which spaces is this user in?) belongs here,
450
- * landing its answer on the principal. The visibility check itself is
451
- * synchronous by design: it runs on every route and every row of every list.
452
- */
453
150
  type Authenticator = (req: IncomingMessage) => unknown | Promise<unknown>;
454
151
  type WorkerServerOptions = {
455
- /** Required unless `allowUnauthenticated: true` — the worker must never be exposed bare. */authenticate?: Authenticator; /** Explicit opt-in to run without auth (local dev only). */
152
+ authenticate?: Authenticator;
456
153
  allowUnauthenticated?: boolean;
457
- /**
458
- * Whether a principal may see one session — the policy half of
459
- * {@link CreateSessionRequest.scope}. WorkerDeck stores the opaque tags and
460
- * enforces the answer at every door; what the tags *mean* is the host's, and
461
- * has to be, because "space" and "user" are one app's vocabulary and the next
462
- * embedder has tenants or projects or nothing.
463
- *
464
- * **Synchronous, deliberately.** It runs per route and per row of every list,
465
- * so resolving it against a database per request is the failure mode this
466
- * signature designs out: do the lookup in {@link Authenticator} and put the
467
- * answer on the principal.
468
- *
469
- * Unset, the default rule applies: every key the principal's `scope` pins must
470
- * equal the session's, and a principal with no scope (`undefined` or `{}`) is
471
- * unrestricted — the same "unset means all" rule `allowedProfiles` uses, so an
472
- * operator's dashboard is unaffected. A consequence worth stating: a session
473
- * carrying *no* scope is invisible to a scoped principal, which is the right
474
- * fail direction — sessions predating this feature never leak into an
475
- * end user's list.
476
- *
477
- * **False means the session does not exist**: every refusal answers 404, never
478
- * 403, matching `host-files.ts`' uniform-disclosure discipline. A predicate
479
- * that *throws* has not said yes — it is caught and read as false, so one
480
- * surprising row cannot turn a hundred-row list into a page-wide error.
481
- *
482
- * **Declaring this withdraws the unscoped-means-operator default.** The
483
- * gateway-wide surfaces (`/fs/*`, `/sdk-sessions`, `/queue`, `/queue/ws`) key
484
- * on {@link Authenticator}'s principal carrying no `scope` — but a host may
485
- * well write this predicate over its own principal shape and never set one,
486
- * and reading that as "everyone is the operator" would serve the host
487
- * filesystem to every end user whose sessions this correctly walls off. So
488
- * with a policy declared, operator principals must say `operator: true`.
489
- *
490
- * **True means full control, not read access.** An attach can send
491
- * `user_message`, `permission_decision`, `interrupt` and `close`, and a
492
- * bridged client can settle a tool call — so this is one boolean over "may
493
- * drive this session", not a visibility level. A read-only-for-my-team policy
494
- * is not expressible here yet; do not approximate it with `readOnly`, which is
495
- * affordance removal in a client and not an authorization boundary.
496
- */
497
- authorizeSession?: (principal: unknown, session: SessionInfo) => boolean; /** If set, session cwd must resolve inside one of these roots. Strongly recommended. */
154
+ authorizeSession?: (principal: unknown, session: SessionInfo) => boolean;
498
155
  allowedCwdRoots?: string[];
499
- /**
500
- * The host filesystem routes (`{basePath}/fs/*`) — browse and read the
501
- * operator's real project tree, and optionally write to it.
502
- *
503
- * **Reading follows {@link allowedCwdRoots} and needs no grant of its own.**
504
- * A caller holding the auth key can already start a session in any allowed root
505
- * and have the agent read whatever is in it, so serving those same trees over
506
- * `/fs` adds no authority — it only removes the absurdity of going through a
507
- * language model to `cat` a file. Set `roots` here only to *narrow* that (or to
508
- * expose a tree sessions may not run in).
509
- *
510
- * With neither set the routes 404. That is not the same as inheriting
511
- * `allowedCwdRoots`' permissive "unset means anywhere": no cwd policy means
512
- * there is nothing to inherit, and "anywhere" is a statement about paths the
513
- * operator types at a keyboard, not one about what a phone may read.
514
- *
515
- * **Writing is a separate opt-in**, because it is the one part that is not
516
- * already implied. An agent's writes go through the permission flow; a `PUT` to
517
- * `/fs/write` does not. These routes are operator-privileged by design — the
518
- * caller is the operator — but that is a reason to make the bypass deliberate,
519
- * not a reason to skip the switch.
520
- *
521
- * Containment is *not* `cwdAllowed`, whichever roots are in play: these routes
522
- * walk paths the agent may have authored, so a symlink can escape a lexical
523
- * prefix check. See `host-files.ts` — canonicalize, then re-check.
524
- */
525
156
  hostFiles?: {
526
- /** Absolute paths. Unset inherits {@link allowedCwdRoots}; an explicit empty
527
- * array disables the routes (a policy, not an absence). */
528
- roots?: string[]; /** Enable `PUT {basePath}/fs/write`. Default false — read-only. */
157
+ roots?: string[];
529
158
  write?: boolean;
530
- /** Refuse reads above this (413) rather than streaming a gigabyte to a phone.
531
- * Default 1 MiB. Writes are bounded by {@link maxBodyBytes} instead. */
532
159
  maxFileBytes?: number;
533
- /** Cap on entries returned per directory (the response says `truncated`).
534
- * Default 5000. */
535
160
  maxEntries?: number;
536
- /** Directory names `GET /fs/find` will not descend into. Defaults to
537
- * `DEFAULT_IGNORED_DIRS` (`.git`, `node_modules`, build output…) — the thing
538
- * that keeps a per-keystroke search cheap on a real source tree. */
539
161
  ignore?: string[];
540
162
  };
541
- /**
542
- * Message attachments (`{basePath}/sessions/:id/attachments`) — the photos and
543
- * files a client sends alongside a message. Always on; these knobs only size it.
544
- *
545
- * There is no grant to make here the way `hostFiles.write` is one: an upload
546
- * lands in the session's own in-memory hold and reaches the model as message
547
- * content, which is exactly what typing does. What it *can* do is cost memory,
548
- * so both caps default low enough that a phone camera roll cannot fill the
549
- * gateway.
550
- */
551
163
  attachments?: {
552
- /** Largest single upload; over it is a 413. Default 10 MiB. */maxFileBytes?: number; /** Ceiling on what one session holds at once. Default 64 MiB. */
164
+ maxFileBytes?: number;
553
165
  maxSessionBytes?: number;
554
166
  };
555
- /**
556
- * Named Claude Code config directories sessions can run under (each becomes the
557
- * session's CLAUDE_CONFIG_DIR — settings, memory, skills, and the credentials the
558
- * SDK resolves from it). Declared here at startup; the API only reads them
559
- * (GET {basePath}/profiles). With more than one declared, every session/job create
560
- * must name its profile; with exactly one it is implicit. Unset: a 'default'
561
- * profile is auto-created from $CLAUDE_CONFIG_DIR or ~/.claude when that directory
562
- * exists. Pass [] to run without profiles (no env pinning at all).
563
- */
564
167
  profiles?: ProfileInfo[];
565
- /**
566
- * Persistence for dashboard-managed profiles, which mounts the profile
567
- * management routes (`POST /profiles`, `PATCH`/`DELETE /profiles/:name`).
568
- * Without it the profile set is startup config and the API stays read-only.
569
- *
570
- * Profiles declared in `profiles` are never stored and never editable over
571
- * HTTP — they are code. The two sets are unioned by name, declared winning.
572
- * Callers still need `canManageProfiles` on their principal.
573
- */
574
168
  profileStore?: ProfileStore;
575
- /**
576
- * Config-dir roots a *managed* Claude profile's `configDir` must resolve inside
577
- * (mirrors {@link allowedCwdRoots}). Unset — the default — means the management
578
- * routes create provider profiles only: naming a config directory is choosing
579
- * which credential store a session runs on, so it stays operator-bounded.
580
- * Declared profiles are unaffected.
581
- */
582
169
  allowedConfigDirRoots?: string[];
583
- /** Map/patch the incoming CreateSessionRequest into the runner config (inject queryFn,
584
- * env, tool policy, per-skill constraints...). Defaults to identity.
585
- *
586
- * What this hook injects is **not** durable: a session rebuilt from a parked
587
- * record is built from the stored config, and a durable store persists neither
588
- * `env` nor injected functions (see `toDurableRecord` in session-store.ts). That costs the
589
- * Claude engine nothing, since it cannot park — but a provider host that
590
- * resolves credentials into `config.env` here for its `createEngineRunner` to
591
- * read back loses them on the wake. Resolve them in the factory instead. */
592
- buildRunnerConfig?: (req: CreateSessionRequest) => SessionRunnerConfig; /** URL prefix for all routes. Default '/v1'. */
170
+ buildRunnerConfig?: (req: CreateSessionRequest) => SessionRunnerConfig;
593
171
  basePath?: string;
594
- /**
595
- * Handle requests that fall outside `basePath` instead of 404ing them. The
596
- * turnkey CLI serves the dashboard through this, which is the whole reason it
597
- * exists: a browser cannot put a header on a WebSocket handshake, so the only
598
- * credential a tab can present on a session attach is a cookie — and a cookie
599
- * only rides requests to the origin that set it. Serving the app and the API
600
- * from one origin is therefore not a convenience, it is what makes an
601
- * authenticated dashboard possible without a stamping proxy in front.
602
- *
603
- * Upgrades are not routed here: anything outside `basePath` is still refused.
604
- */
605
172
  fallback?: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
606
- /**
607
- * Browser origins allowed to call this API cross-origin — for a dashboard
608
- * served somewhere other than this gateway.
609
- *
610
- * Off unless configured, and even then it is *sharing policy, not a
611
- * credential*: preflights are answered before auth (browsers strip
612
- * credentials from them, so they would otherwise 401), but every real request
613
- * still goes through `authenticate`, and an allowlisted page that does not
614
- * hold the key gets nothing.
615
- *
616
- * Two rules the implementation must keep: **exact origins only**, no
617
- * wildcards or suffix matching; and `Access-Control-Allow-Credentials` is
618
- * **never** sent, which is what keeps an ambient cookie from becoming
619
- * cross-origin authority. WebSocket upgrades are exempt from CORS entirely
620
- * and are unaffected by this — their credential is whatever the host's
621
- * `authenticate` accepts on the handshake.
622
- */
623
173
  cors?: {
624
174
  origins: string[];
625
- }; /** Max JSON body size in bytes. Default 1 MiB. */
175
+ };
626
176
  maxBodyBytes?: number;
627
- /**
628
- * Server-wide bypass policy: refuse `permissionMode: 'bypassPermissions'` on
629
- * session/job creation (403), and strip the `allowDangerouslySkipPermissions`
630
- * pre-authorization from requests (so clients that ask for the capability by
631
- * default keep working — their later switch attempt fails with the CLI's own
632
- * visible error instead). Mirrors Claude Code's
633
- * `permissions.disableBypassPermissionsMode` setting, enforced at the gateway.
634
- */
635
177
  disableBypassPermissions?: boolean;
636
- /**
637
- * Fail closed on subscription credentials: if a session initializes with
638
- * `apiKeySource: 'oauth'` (a claude.ai login rather than an API key / Bedrock / Vertex),
639
- * it is terminated with a session_error. Recommended for services and any
640
- * unattended/scheduled use — Anthropic's terms require API-key auth for those.
641
- * Off by default: single-user personal deployments may legitimately run on the
642
- * operator's own subscription; the server then logs a one-time notice instead.
643
- */
644
178
  requireApiKey?: boolean;
645
- /**
646
- * Launch-time credential sanity check: once `listen()` binds, each Claude
647
- * profile's session environment — exactly what `buildRunnerConfig` would hand
648
- * a session, host hook included — is probed with the SDK-bundled CLI's
649
- * `claude auth status`, concurrently and fire-and-forget, and a profile that
650
- * reports logged-out gets one console warning. Warn, never fail: the operator
651
- * may be about to log in, and a probe that cannot run at all (missing binary,
652
- * a CLI without `auth status`, unparseable output) stays silent — "couldn't
653
- * check" is not "not logged in". No credential material is read or logged.
654
- * Off by default (this is a library; tests must spawn nothing) — the turnkey
655
- * CLI turns it on. Pass an object to inject the probe (tests) or a timeout.
656
- */
657
179
  checkCredentials?: boolean | {
658
180
  probe?: ClaudeAuthProbe;
659
181
  timeoutMs?: number;
660
182
  };
661
- /**
662
- * Refuse to create a session or submit a job on a profile the credential
663
- * probe has reported **unavailable** — 503 with the probe's own reason —
664
- * rather than letting the run start and die mid-turn on a raw provider error.
665
- *
666
- * Off by default, and that default is right for an operator's own gateway:
667
- * the verdict can be stale in both directions, the operator may be three
668
- * seconds from finishing a login, and turning a probe bug into an outage is
669
- * worse than one confusing failure. It is wrong in front of an **end user**,
670
- * who cannot read a provider stack trace and did not choose the deployment's
671
- * credentials — which is why every embedder otherwise grows its own
672
- * `available` flag in front of the create button.
673
- *
674
- * Requires `checkCredentials`; without probes nothing is ever unavailable.
675
- * A profile whose verdict is 'unknown' (never probed, probe couldn't run) is
676
- * always allowed through — "couldn't check" is not "not available".
677
- */
678
183
  requireAvailableProfile?: boolean;
679
- /** Injectable lister for GET /sdk-sessions (tests) — honored for the CLAUDE
680
- * engine only, like the injectable claude auth probe (it predates the adapter
681
- * layer). Defaults to the claude adapter's lister (the SDK's on-disk session
682
- * store); other engines always answer through their adapter's
683
- * `listSessions`. */
684
184
  listSdkSessions?: SdkSessionLister;
685
- /** Enable the job queue (`/jobs` + `/queue` routes). Jobs run as ordinary registry
686
- * sessions — attachable over the sessions WS — governed by these limits. */
687
185
  queue?: QueueServerOptions;
688
- /**
689
- * Out-of-band notification for interactive sessions: the four moments a person
690
- * away from the screen needs (permission requested, turn done, error, closed)
691
- * POSTed to a webhook and/or handed to a local observer. Off unless configured.
692
- *
693
- * Server-wide, unlike the queue's per-job webhook — the point is to hear about
694
- * sessions you neither created nor are attached to, which is the situation a
695
- * mobile client is in permanently (iOS will not hold a WebSocket open in the
696
- * background). Every registry session qualifies, job runs included, so a job
697
- * carrying its own webhook is reported on both channels.
698
- *
699
- * This is the primitive, and it stays transport-agnostic on purpose: the OSS
700
- * server holds no push credentials. Turning a notification into an APNs push is
701
- * a forwarder's job — see the turnkey CLI.
702
- */
703
186
  notifications?: SessionNotificationOptions;
704
- /** Browser-bridged tool execution: how long a bridged call may go unanswered
705
- * before it fails (default 60000), and where terminal results are delivered.
706
- * The hub is always available on the returned server as `bridge`. */
707
187
  bridge?: BridgeHubOptions;
708
- /**
709
- * Deferred execution: a session that parks on an execution nothing here is
710
- * running has its state persisted and its runner torn down, and comes back when
711
- * the result is POSTed to `{basePath}/executions/:executionId/result`.
712
- *
713
- * On by default with an in-memory store, so a park survives a disconnect but not
714
- * a restart; pass `store: createFileSessionStore()` (or your own) to change that
715
- * — read its doc first, the record holds the whole transcript.
716
- */
717
188
  parking?: {
718
- store?: SessionStore; /** Grace after the last client detaches before parking. Default 2000. */
189
+ store?: SessionStore;
719
190
  parkDelayMs?: number;
720
- /** Grace given on boot to an execution whose deadline passed while the server
721
- * was down (durable stores only — nothing else survives a restart). Default 60000. */
722
191
  expiredGraceMs?: number;
723
- /**
724
- * Keep live `provider` sessions written through to the store after each
725
- * turn, so they survive a gateway restart. **Off by default** — this writes
726
- * a session's whole transcript to `store` and a library must not start doing
727
- * that because someone upgraded.
728
- *
729
- * It is the restart story for the one engine dormancy cannot cover: claude
730
- * and codex are remembered by *engine session id* and resumed from their own
731
- * on-disk store, which a provider session does not have. Pair it with a
732
- * durable `store` — with the default in-memory one it does nothing a park
733
- * did not already do.
734
- *
735
- * The record is rebuilt lazily, on first attach, exactly like a dormant one;
736
- * a boot with fifty remembered sessions spawns nothing.
737
- */
738
192
  persistLive?: boolean;
739
- /** Park/remember/resume failures storage or engine-assembly problems, not
740
- * session errors. 'remember' is the write that lets a live session survive a
741
- * restart; losing one costs that session its way back and nothing else. */
742
- onError?: (error: unknown, context: {
743
- sessionId: string;
744
- phase: 'park' | 'remember' | 'resume';
745
- }) => void;
193
+ onError?: (error: unknown, context: ParkErrorContext) => void;
746
194
  };
747
- /**
748
- * Build a runner for a `provider` profile (the model-agnostic engine).
749
- * Required if any such profile is declared — the server refuses to start
750
- * otherwise, rather than failing at create time.
751
- *
752
- * Kept as a host hook so the server package neither imports a model SDK nor
753
- * decides how provider credentials are resolved: the factory reads them from
754
- * the operator's environment, exactly like the Claude credential chain.
755
- * `claude` and `codex` profiles never come through here — those engines ship
756
- * as in-repo adapters (`@workerdeck/core`'s `getEngineAdapter`).
757
- *
758
- * May be async: assembly that has to await — a per-session MCP connect, a
759
- * credential lookup — belongs here, with `AiSdkRunnerConfig.onClose` as the
760
- * disposer. A rejection fails the create — the session POST answers 500 with
761
- * the message, a job goes straight to `failed`.
762
- */
763
195
  createEngineRunner?: (context: EngineRunnerContext) => Runner | Promise<Runner>;
764
- /**
765
- * Adapter overrides, keyed by engine — **for tests only** (the server
766
- * integration suite injects a fake codex engine so `pnpm test` spawns no
767
- * binary). Not a public extension point: third engines belong in core as
768
- * adapters, or behind `createEngineRunner` as provider profiles.
769
- */
770
196
  engines?: Partial<Record<ProfileEngine, EngineAdapter>>;
771
197
  };
772
198
  type EngineRunnerContext = {
773
- /** The session config, with profile defaults already applied. */config: SessionRunnerConfig; /** The profile that selected this engine. */
199
+ config: SessionRunnerConfig;
774
200
  profile: ProfileInfo;
775
- /** Bridge hub, for handing the runner a browser-backed ToolExecutor
776
- * (`bridge.executorFor(sessionId)`). */
777
201
  bridge: BridgeHub;
778
- /**
779
- * Set when rebuilding a session that parked on a deferred execution. Forward it
780
- * as `restore` on the engine config (`createEngineSession({ config: { ...config,
781
- * restore } })`) — the engine then adopts the session's id, event log, seq
782
- * numbering, history, and scratch filesystem instead of starting fresh.
783
- */
784
202
  restore?: RunnerSnapshot;
785
- /**
786
- * Set when rehydrating a session across a gateway restart: build the runner
787
- * under exactly this id rather than a fresh one. Never set together with
788
- * `restore` (a snapshot carries its own id). Ignoring it strands every
789
- * client's watermarks and routes, and the rebuild is refused.
790
- */
791
203
  id?: string;
792
204
  };
793
205
  type QueueServerOptions = {
794
- /** Concurrent job sessions. Default 1. */maxConcurrency?: number; /** Token cap per job session (input+output+cache tokens); exceeding it kills the run. */
795
- sessionTokenLimit?: number; /** Global job-token budget per UTC day; queued jobs are held once exhausted. */
796
- dailyTokenLimit?: number; /** Wall-clock cap per job run — the watchdog against stuck CLIs. */
797
- maxJobDurationMs?: number; /** Grace between interrupting a killed run and force-closing it. Default 5000. */
206
+ maxConcurrency?: number;
207
+ sessionTokenLimit?: number;
208
+ dailyTokenLimit?: number;
209
+ maxJobDurationMs?: number;
798
210
  killGraceMs?: number;
799
- /** Expire terminal jobs after `maxAgeMs` (the in-memory adapter otherwise grows
800
- * unboundedly). */
801
211
  retention?: {
802
212
  maxAgeMs: number;
803
213
  sweepIntervalMs?: number;
804
214
  };
805
- /** Queue backend. Defaults to the bundled in-memory adapter (single process,
806
- * no persistence) — redis/bullmq/pubsub adapters implement the same interface. */
807
- adapter?: QueueAdapter; /** Webhook delivery attempts per event (default 3, exponential backoff). */
215
+ adapter?: QueueAdapter;
808
216
  webhookAttempts?: number;
809
- webhookRetryDelayMs?: number; /** Local observer for job lifecycle events (in addition to per-job webhooks). */
217
+ webhookRetryDelayMs?: number;
810
218
  onEvent?: (event: JobEvent) => void;
811
219
  };
220
+ /** A point-in-time answer to "is it safe to stop yet?", as reported while draining. */
221
+ type DrainReport = {
222
+ /** Sessions mid-turn. These resolve on their own, so the drain waits for them. */working: string[];
223
+ /**
224
+ * Sessions blocked on a human — a pending approval. The drain names these but never waits for them: nothing about
225
+ * shutting down will answer the prompt, so waiting is a hang with better manners.
226
+ */
227
+ awaitingHuman: string[]; /** True when the deadline passed with work still running. */
228
+ timedOut: boolean;
229
+ };
230
+ type DrainOptions = {
231
+ /** Overall budget. The drain gives up and reports rather than blocking shutdown forever. */timeoutMs?: number;
232
+ pollMs?: number;
233
+ onProgress?: (report: DrainReport) => void;
234
+ };
812
235
  type WorkerServer = {
813
236
  server: Server;
814
- registry: SessionRegistry; /** The job queue, when `queue` options were provided. */
237
+ registry: SessionRegistry;
815
238
  queue?: JobQueue;
816
- /** Routes tool executions to attached browser clients. `bridge.executorFor(id)`
817
- * is the `ToolExecutor` to hand a runner that should execute in the tab. */
818
239
  bridge: BridgeHub;
819
- /** Parked sessions: the store, the execution index, and the rehydration path.
820
- * Deliver a deferred result with `parking.submitResult(...)` in-process, or POST
821
- * it to `{basePath}/executions/:executionId/result`. */
822
240
  parking: SessionParkManager;
823
241
  listen: (port: number, host?: string) => Promise<{
824
242
  port: number;
825
243
  }>;
244
+ /**
245
+ * Let running turns finish before `close()`. A courtesy, never a correctness requirement: records are written
246
+ * continuously, so a hard stop already loses nothing. Refuses new sessions for as long as it runs.
247
+ */
248
+ drain: (options?: DrainOptions) => Promise<DrainReport>;
826
249
  close: () => Promise<void>;
827
250
  };
828
251
  //#endregion
@@ -830,135 +253,40 @@ type WorkerServer = {
830
253
  declare function createWorkerServer(options?: WorkerServerOptions): WorkerServer;
831
254
  //#endregion
832
255
  //#region src/lib/sandboxed-profile.d.ts
833
- /**
834
- * A `provider` profile that grants a session nothing but the sandbox: the
835
- * QuickJS guest, the in-memory VFS, and the model.
836
- *
837
- * This adds no mechanism. `capabilities: []` and `mcpServers: []` already mean
838
- * what they mean, and `createToolContext` already withholds a tool whose backend
839
- * the host did not inject. What the helper buys is that the locked-down profile
840
- * is one call rather than three fields an operator has to get right together —
841
- * the failure mode being a profile that *looks* sandboxed and still grants
842
- * `deliver_file` because nobody wrote the empty array.
843
- *
844
- * What a session under it can do:
845
- * - run untrusted JavaScript in the WASM guest, under the interpreter's own
846
- * timeout and memory limits (`eval_script`),
847
- * - read and write the session's in-memory VFS, which is a map and not a
848
- * filesystem — no host path is reachable from it.
849
- *
850
- * What it cannot do: read or write a host path, spawn a process, reach the
851
- * network (`web_fetch`/`download`/`web_search` are capabilities, and none is
852
- * granted), deliver a file, or use an MCP server.
853
- *
854
- * Two things this helper does **not** do, because they are not a profile's to
855
- * decide. It does not authorize anyone — visibility is
856
- * `CreateSessionRequest.scope` plus the gateway's `authorizeSession`. And it
857
- * does not make the model's *input* trustworthy: content the loop reads is
858
- * attacker-influenced by default, and a sandbox bounds what a tool can reach,
859
- * not what a prompt can talk the model into asking for.
860
- *
861
- * @param name Profile name clients name in `CreateSessionRequest.profile`.
862
- * @param provider Which model to run (credentials stay in the operator's
863
- * environment and are resolved by the host's `createEngineRunner` — never
864
- * here, and never on the wire).
865
- */
866
256
  declare function sandboxedProviderProfile(name: string, provider: ProviderConfig, options?: {
867
- description?: string; /** Prepended to the session's system prompt. */
257
+ description?: string;
868
258
  instructions?: string;
869
- /** Profile-level run defaults (model, permission mode) — see
870
- * {@link ProfileInfo.defaults}. */
871
259
  defaults?: ProfileInfo['defaults'];
872
- /**
873
- * Capabilities to grant on top of the floor. Default `[]` — the floor is
874
- * nothing, and every entry here is a deliberate widening you are writing
875
- * down: `web_fetch` gives the loop egress (SSRF-guarded, but egress),
876
- * `download` and `web_search` reach whatever backends you injected, and
877
- * `deliver_file` lets it hand a file to the client.
878
- */
879
260
  capabilities?: SessionCapability[];
880
- /**
881
- * MCP servers, **by name**, whose tools sessions may use. Default `[]`.
882
- * MCP tools are authoritative — they run with the host's credentials and
883
- * are never bridged — so naming one here is a larger grant than any
884
- * capability above it.
885
- */
886
261
  mcpServers?: string[];
887
262
  }): ProfileInfo;
888
263
  //#endregion
889
264
  //#region src/lib/provider-runner.d.ts
890
265
  type ProviderRunnerOptions = {
891
- /**
892
- * The model to run. A function is called per turn with the session's
893
- * requested model id (undefined = the profile's default), which is what makes
894
- * the in-session model switcher work; a bare instance pins one model.
895
- */
896
266
  model: LanguageModel | ((modelId: string | undefined) => LanguageModel);
897
- /**
898
- * Where sandboxed tools (`eval_script` and any `sandboxed` entry in `tools`)
899
- * execute. This is a real architectural choice, not a default worth guessing
900
- * at, so it is required:
901
- *
902
- * - a {@link ToolExecutor} — an in-process guest (`new QuickJsExecutor(...)`
903
- * from `@workerdeck/core`), which is right when the data the loop reasons
904
- * over lives in this process. It is also the only option that works when no
905
- * client is attached, which is every unattended job.
906
- * - `'browser'` — the attached tab, resolved per call from the bridge. Right
907
- * when the data is *there* (a document the user is editing) and it should
908
- * not travel to the gateway at all. Note the trade: it hands an executor to
909
- * the party being sandboxed against, so its results are untrusted input.
910
- */
911
- executor: ToolExecutor | 'browser';
912
- /** Capability backends — the same shape {@link createEngineSession} takes.
913
- * Wiring one only offers it; the profile and request decide the grant. */
914
- capabilities?: EngineSessionOptions['capabilities']; /** Host tools at explicit trust levels (`@workerdeck/core`'s `withHostTools`). */
267
+ executor: ToolExecutor | 'browser' | ((call: ToolExecutionCall) => ToolExecutor | 'browser');
268
+ capabilities?: EngineSessionOptions['capabilities'];
915
269
  tools?: Record<string, HostToolDefinition>;
916
- /** A live MCP connection from `connectMcpTools`. Prefer this over `mcpTools`:
917
- * it is what lets a profile's unhonoured `mcpServers` refuse the build, and
918
- * what makes `GET /sessions/:id/mcp` answer for this session. */
919
- mcp?: McpConnection; /** A bare MCP tool set, for a host assembling one itself. */
920
- mcpTools?: ToolSet; /** System-prompt addition, unless the profile declares its own. */
921
- instructions?: string; /** Sandbox limits per execution. */
270
+ mcp?: McpConnection;
271
+ mcpTools?: ToolSet;
272
+ instructions?: string;
922
273
  executionLimits?: {
923
274
  timeoutMs?: number;
924
275
  memoryLimitBytes?: number;
925
276
  };
926
- /** Scratch-filesystem seed for a new session. Ignored on a rehydration, so a
927
- * parked turn's files are never overwritten. */
928
277
  seedVfs?: Record<string, string>;
929
- /** Release per-session resources: the MCP connection, an issued token, a
930
- * watcher. Runs on close **and on park** — parking releases the same things. */
278
+ shouldApprove?: (call: {
279
+ toolName: string;
280
+ input: unknown;
281
+ }) => boolean;
282
+ approvalTimeoutMs?: number;
931
283
  onClose?: () => void | Promise<void>;
932
284
  };
933
- /**
934
- * Build a provider-engine runner from the server's `createEngineRunner` context.
935
- *
936
- * `createEngineRunner` is a blank sheet: it hands you a context and wants a
937
- * `Runner`, and four of the five things a correct one must do are invisible in
938
- * the types — forward `restore`, adopt `id`, seed the VFS only when *not*
939
- * restoring, and dispose per-session resources. Each is a runtime-only failure
940
- * (a woken session that starts empty, a refused rebuild, an overwritten
941
- * filesystem, a connection leaked per session), and each is handled here.
942
- *
943
- * ```ts
944
- * createEngineRunner: (ctx) =>
945
- * createProviderRunner(ctx, {
946
- * model: (id) => openai(id ?? 'gpt-5.6-luna'),
947
- * executor: quickjs,
948
- * capabilities: { webFetch: {} },
949
- * mcp,
950
- * onClose: () => mcp.close(),
951
- * }),
952
- * ```
953
- *
954
- * The hook itself stays open for anything this does not cover — this is the
955
- * 80% case, not a replacement for it.
956
- */
957
285
  declare function createProviderRunner(ctx: EngineRunnerContext, options: ProviderRunnerOptions): Promise<Runner>;
958
286
  //#endregion
959
287
  //#region src/services/attachments.d.ts
960
288
  type AttachmentStoreOptions = {
961
- /** Largest single upload. Default 10 MiB. */maxFileBytes?: number; /** Ceiling on everything one session is holding. Default 64 MiB. */
289
+ maxFileBytes?: number;
962
290
  maxSessionBytes?: number;
963
291
  };
964
292
  type AttachmentRejection = {
@@ -981,35 +309,12 @@ type PutResult = {
981
309
  ok: false;
982
310
  error: AttachmentRejection;
983
311
  };
984
- /**
985
- * Per-session hold for files the user attached to a message.
986
- *
987
- * In memory, and deliberately so. An attachment is only *needed* for the instant
988
- * between the upload and the message that names it; everything after that is
989
- * convenience (a client re-rendering a thumbnail after a reattach). That is the
990
- * same bargain `GET /sessions/:id/files` makes — the session's lifetime, no
991
- * durability tier — and it keeps the gateway from accumulating a photo library
992
- * on disk that nobody asked it to look after.
993
- *
994
- * Both caps are enforced here rather than at the route, so a host embedding the
995
- * server cannot forget one: a single file that is too big is a 413, and so is a
996
- * session whose total would go over.
997
- */
998
312
  declare class AttachmentStore {
999
313
  #private;
1000
314
  constructor(options?: AttachmentStoreOptions);
1001
315
  get maxFileBytes(): number;
1002
316
  put(sessionId: string, name: string, mediaType: string, body: Buffer): PutResult;
1003
- /** The stored record, bytes included — for the download route and for the send
1004
- * path that turns ids into content blocks. */
1005
317
  get(sessionId: string, id: string): AttachmentInput | undefined;
1006
- /**
1007
- * Resolve the ids a `user_message` named, in the order given.
1008
- *
1009
- * Missing ids are reported rather than skipped: a message that quietly lost its
1010
- * picture reads as the model ignoring it, which is a far worse failure than a
1011
- * command that errors.
1012
- */
1013
318
  resolve(sessionId: string, ids: readonly string[]): {
1014
319
  ok: true;
1015
320
  attachments: AttachmentInput[];
@@ -1021,101 +326,25 @@ declare class AttachmentStore {
1021
326
  }
1022
327
  //#endregion
1023
328
  //#region src/services/produced-files.d.ts
1024
- /** One host file an engine reported writing, as the store holds it. */
1025
329
  type ProducedFile = {
1026
- fileId: string; /** Absolute host path, exactly as the runner reported it. */
330
+ fileId: string;
1027
331
  path: string;
1028
- mediaType?: string; /** Size when the runner announced it — advisory, and re-read at serve time. */
332
+ mediaType?: string;
1029
333
  bytes?: number;
1030
334
  sessionId: string;
1031
335
  };
1032
- /**
1033
- * The paths this gateway will serve from `GET /sessions/:id/produced/:fileId`.
1034
- *
1035
- * **This is the whole access-control model, so it is worth being precise about
1036
- * what it is.** The store is an allowlist built from one source and one only:
1037
- * `file_produced` events, which a runner emits about a file its own engine just
1038
- * wrote. It is not a directory grant. Nothing else can add to it — not a
1039
- * request, not a config, and in particular not the agent, whose own path claims
1040
- * go through `/fs/*` and that route's root allowlist.
1041
- *
1042
- * That is why the route needs neither `hostFiles.roots` nor `maxFileBytes`:
1043
- * "somewhere under a root the operator declared" is a guess about which paths
1044
- * are safe, while "the exact path this session's runner reported producing" is
1045
- * a fact about one file. A 2 MB generated PNG is the common case, and making
1046
- * the operator raise a byte cap to see their own picture was the bug this
1047
- * replaces.
1048
- *
1049
- * Lifetime is the session's, like `AttachmentStore`'s: in memory, dropped when
1050
- * the session is removed. The bytes are never held here — only the path, so a
1051
- * gateway serving a long session accumulates a few hundred bytes per picture
1052
- * rather than the pictures.
1053
- */
1054
336
  declare class ProducedFileStore {
1055
337
  #private;
1056
- /**
1057
- * Register a runner's produced files for its lifetime.
1058
- *
1059
- * Subscribes from seq 0, which is the opposite of what `SessionNotifier` wants
1060
- * and correct for the same reason: registration is idempotent (a `fileId` is
1061
- * derived from its path, so re-registering overwrites with itself), and a
1062
- * session rebuilt from a park must re-learn every file it produced before the
1063
- * park — otherwise a client's transcript keeps rendering image cards whose
1064
- * bytes have quietly become unreachable.
1065
- */
1066
338
  watch(runner: Runner): void;
1067
339
  get(sessionId: string, fileId: string): ProducedFile | undefined;
1068
- /** Everything one session has produced, newest registration last. */
1069
340
  list(sessionId: string): ProducedFile[];
1070
341
  drop(sessionId: string): void;
1071
342
  }
1072
343
  //#endregion
1073
344
  //#region src/services/profile-usage.d.ts
1074
- /**
1075
- * The gateway's single plan-usage state per profile, fed from every session's
1076
- * `rate_limit` events and served on `GET /profiles` (`ProfileInfo.usage`).
1077
- *
1078
- * Why this exists at all: usage had only ever lived in session transcripts, so
1079
- * a client attaching to a session that idled since yesterday replayed
1080
- * yesterday's reading as if current — and a session opened today knew nothing
1081
- * of what a sibling session on the same account spent an hour ago. The profile
1082
- * is the account boundary (one config dir / codex home / provider key = one
1083
- * plan), so the newest reading across all of a profile's sessions is the one
1084
- * usage state that is ever worth showing. No history: last-write-wins per
1085
- * window, exactly the reducer's rule on the client side.
1086
- *
1087
- * Last-write-wins goes by the **event's own clock**, not arrival order:
1088
- * `watch()` subscribes from seq 0 (a replayed log is how a rebuilt session's
1089
- * readings arrive at all), and a replayed yesterday-reading must not clobber
1090
- * the fresher one another session on the same profile reported live. All
1091
- * events are stamped by this gateway's clock at emit time, so the comparison
1092
- * is sound across sessions.
1093
- *
1094
- * In-memory on purpose, like the learned default models and the availability
1095
- * cache: display-only state may start empty after a restart (absent = unknown,
1096
- * never 0%), and the first session to report refills it.
1097
- */
1098
345
  declare class ProfileUsageTracker {
1099
346
  #private;
1100
- /** Follow a runner's `rate_limit` events for its lifetime. Sessions without a
1101
- * profile have no account to attribute usage to and are skipped. */
1102
347
  watch(runner: Runner): void;
1103
- /**
1104
- * The profile's windows as they should be served *now*. Undefined until any
1105
- * session on the profile has reported (unknown, never 0%).
1106
- *
1107
- * The 0%-after-reset inference lives here — at serve time — and nowhere
1108
- * else, because it is a function of the wall clock: a window whose own
1109
- * `resetsAt` has passed with no newer reading has provably rolled, so the
1110
- * pre-reset utilization is no longer merely stale but *wrong*. It cannot be
1111
- * a producer's job (the producers only relay what the engine said, and the
1112
- * whole problem is the engine's silence; a fabricated 0% event would be
1113
- * replayed from transcripts forever as if reported) and must not be every
1114
- * renderer's (N clients would each reimplement the clock math). The held
1115
- * reading stays untouched, so a late fresh report still lands by ts, and the
1116
- * served zero is labeled `inferredReset` — it is a floor, not a report: the
1117
- * account may have been used outside this gateway since the reset.
1118
- */
1119
348
  usage(profile: string, now?: number): ProfileUsage | undefined;
1120
349
  }
1121
350
  //#endregion