@boardwalk-labs/runner 0.1.11 → 0.1.13

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.
Files changed (43) hide show
  1. package/README.md +18 -0
  2. package/binding.gyp +12 -0
  3. package/dist/runtime/agent/budget.d.ts +5 -5
  4. package/dist/runtime/agent/budget.js +8 -8
  5. package/dist/runtime/agent/model_rates.js +4 -5
  6. package/dist/runtime/agent/secret_redactor.js +1 -1
  7. package/dist/runtime/browser_session.d.ts +59 -0
  8. package/dist/runtime/browser_session.js +188 -0
  9. package/dist/runtime/browser_session_backend.d.ts +44 -0
  10. package/dist/runtime/browser_session_backend.js +221 -0
  11. package/dist/runtime/cancel_watcher.js +1 -1
  12. package/dist/runtime/credit_watcher.d.ts +1 -1
  13. package/dist/runtime/credit_watcher.js +5 -5
  14. package/dist/runtime/freeze_coordinator.d.ts +6 -1
  15. package/dist/runtime/freeze_coordinator.js +18 -4
  16. package/dist/runtime/index.d.ts +11 -6
  17. package/dist/runtime/index.js +96 -41
  18. package/dist/runtime/leaf_executor.d.ts +2 -2
  19. package/dist/runtime/program_runner.d.ts +1 -1
  20. package/dist/runtime/program_runner.js +1 -1
  21. package/dist/runtime/program_worker.d.ts +9 -3
  22. package/dist/runtime/program_worker.js +18 -7
  23. package/dist/runtime/recording_secret_resolver.js +1 -1
  24. package/dist/runtime/run_abort.js +3 -3
  25. package/dist/runtime/runner_control_client.d.ts +29 -4
  26. package/dist/runtime/runner_control_client.js +76 -32
  27. package/dist/runtime/runtime_flusher.d.ts +1 -1
  28. package/dist/runtime/runtime_flusher.js +3 -3
  29. package/dist/runtime/support/index.js +5 -6
  30. package/dist/runtime/tools/artifacts.js +1 -1
  31. package/dist/runtime/tools/types.d.ts +5 -5
  32. package/dist/runtime/tools/types.js +7 -7
  33. package/dist/runtime/tools/web_search.js +5 -6
  34. package/dist/runtime/uniqueness_reseed.d.ts +7 -0
  35. package/dist/runtime/uniqueness_reseed.js +65 -0
  36. package/dist/runtime/wire/artifact_storage.js +2 -2
  37. package/dist/runtime/wire/inference_proxy.d.ts +1 -1
  38. package/dist/runtime/wire/inference_proxy.js +2 -2
  39. package/dist/runtime/workflow_host.d.ts +47 -1
  40. package/dist/runtime/workflow_host.js +100 -10
  41. package/native/reseed.c +52 -0
  42. package/package.json +15 -7
  43. package/prebuilds/linux-x64/@boardwalk-labs+runner.node +0 -0
@@ -1,5 +1,5 @@
1
- // Worker composition root (the workflow runtime design). The Boardwalk-hosted
2
- // worker container runs `node dist/fargate/worker/index.js`; the dispatcher launches it per run with
1
+ // Worker composition root (the workflow runtime design). The hosted worker container runs the
2
+ // compiled worker entrypoint; the dispatcher launches it per run with
3
3
  // RUN_ID + the per-run control-plane handle. This file assembles real implementations behind every
4
4
  // seam `runProgramWorker(runId, deps)` injects, then runs the one workflow program to terminal.
5
5
  //
@@ -11,21 +11,21 @@
11
11
  // - secrets.get() → broker /secrets/resolve (allowlist enforced server-side).
12
12
  // - workflows.call()→ broker /children (durable child run, hold + poll).
13
13
  // - events.emit() → broker /events (fan-out server-side).
14
- // - artifacts / web_search → broker /artifacts + /tools/web_search (no S3 / Tavily creds here).
14
+ // - artifacts / web_search → broker /artifacts + /tools/web_search (no storage or search-provider creds here).
15
15
  // - lifecycle / usage / telemetry → broker /claim,/version,/finalize,/usage,/telemetry.
16
- // There is no DB / Redis / Stripe / Bedrock client on the runner — so its task role is near-zero and
17
- // the metadata-endpoint escape has nothing to steal. The legacy direct (pre-broker) path is removed.
16
+ // There is no database, cache, billing, or model-provider client on the runner — so its task role is
17
+ // near-zero and the metadata-endpoint escape has nothing to steal. The legacy direct (pre-broker) path is removed.
18
18
  //
19
19
  // Split: `assembleWorkerDeps(runtime)` is pure wiring (unit-tested with a fake fetch); `main()` is the
20
20
  // bootstrap shell (read the control-plane env, install signal handlers).
21
21
  //
22
- // Per-session loops wired here (all brokered): a UsageFlusher meters token deltas (§10.7
23
- // /usage/tokens), a CreditWatcher polls funding (§15 → /credit, aborting the run on exhaustion), and a
24
- // CancelWatcher polls for a user cancel (§6 → /cancel, aborting the run when the user cancels — the
25
- // brokered worker holds no Redis, so it can't receive the api-server's cancel publish directly).
22
+ // Per-session loops wired here (all brokered): a UsageFlusher meters token deltas ( /usage/tokens), a
23
+ // CreditWatcher polls funding (→ /credit, aborting the run on exhaustion), and a CancelWatcher polls
24
+ // for a user cancel (→ /cancel, aborting the run when the user cancels — the brokered worker holds no
25
+ // cache client, so it can't receive the platform's cancel publish directly).
26
26
  //
27
- // Documented v0 deferral (a clear seam): durable events — a no-op AgentEventStore (live Redis fan-out
28
- // via the broker only) until the run_step_events table lands.
27
+ // Documented v0 deferral (a clear seam): durable events — a no-op AgentEventStore (live fan-out
28
+ // via the broker only) until durable event storage lands.
29
29
  import { createLogger, newId, AppError, ErrorCode, DEFAULT_LEASE_MS } from "./support/index.js";
30
30
  import { LspService } from "@boardwalk-labs/engine/core";
31
31
  import { BudgetMeter } from "./agent/budget.js";
@@ -37,7 +37,10 @@ import { EngineLeafExecutor } from "./leaf_executor.js";
37
37
  import { parseByoProviders } from "./direct_inference.js";
38
38
  import { applyIdentityToEnv, connectIdentityRelayFd, relayFdFromEnv, workerDiagnostics, } from "./identity_relay.js";
39
39
  import { FreezeCoordinator } from "./freeze_coordinator.js";
40
+ import { reseedUserspaceCsprng } from "./uniqueness_reseed.js";
40
41
  import { WorkerWorkflowHost } from "./workflow_host.js";
42
+ import { BrowserSessionManager } from "./browser_session.js";
43
+ import { loadGuestBrowserConfig, makeGuestBrowserBackend, connectSessionMcp, } from "./browser_session_backend.js";
41
44
  import { runProgramWorker, } from "./program_worker.js";
42
45
  import { RunnerControlClient } from "./runner_control_client.js";
43
46
  import { BrokerChildDispatcher } from "./broker_child_dispatcher.js";
@@ -54,13 +57,13 @@ import { PhaseTracker } from "./phase_tracker.js";
54
57
  import { mkdir } from "node:fs/promises";
55
58
  import { join } from "node:path";
56
59
  const log = createLogger("worker_entrypoint");
57
- /** Durable events are deferred (run_step_events table) — live fan-out via the broker only. */
60
+ /** Durable events are deferred (durable event storage) — live fan-out via the broker only. */
58
61
  /** Synthesize the run's AuthContext. The run was already authorized at trigger time; the
59
62
  * worker acts on the org's behalf, so it carries the org + an owner role. Tool-level
60
63
  * boundaries (the broker's server-side manifest allowlist) are the real guard.
61
64
  *
62
65
  * source='workflow' (NOT 'session_jwt'): the program must never perform SESSION_JWT_ONLY
63
- * credential mutations (§12.11), so a tool that ever exposed such a service is denied by
66
+ * credential mutations, so a tool that ever exposed such a service is denied by
64
67
  * construction regardless of the owner role. */
65
68
  export function workerAuthContext(run) {
66
69
  const userId = run.actor.type === "user" ? run.actor.user_id : `workflow:${run.workflowId}`;
@@ -84,7 +87,7 @@ export function tokenMeterIdentifiers(runId, sessionId) {
84
87
  return (leafIndex) => `${runId}:${sessionId}:${String(leafIndex)}:${String(seq++)}`;
85
88
  }
86
89
  /** Pure wiring: build the full ProgramWorkerDeps from the control-plane handle. The runner reaches
87
- * every privileged seam through the broker over its run token — no DB / Redis / Stripe / model creds. */
90
+ * every privileged seam through the broker over its run token — no database, cache, billing, or model creds. */
88
91
  export function assembleWorkerDeps(runtime) {
89
92
  const broker = new RunnerControlClient({
90
93
  baseUrl: runtime.controlPlane.baseUrl,
@@ -114,8 +117,9 @@ export function assembleWorkerDeps(runtime) {
114
117
  freeze = new FreezeCoordinator({ channel });
115
118
  log.info("freeze_mode_enabled", { runId: runtime.runId });
116
119
  }
117
- // Live agent-event stream → /telemetry (broker publishes to Redis server-side). Inference,
118
- // web_search, artifacts, and the model call are all brokered — no Redis / model creds / Tavily / S3.
120
+ // Live agent-event stream → /telemetry (broker fans out server-side). Inference,
121
+ // web_search, artifacts, and the model call are all brokered — no cache client, model creds,
122
+ // search-provider creds, or object-storage creds here.
119
123
  const eventPublisher = new BrokerEventPublisher({
120
124
  send: (frames) => broker.publishTelemetry(frames),
121
125
  });
@@ -129,11 +133,11 @@ export function assembleWorkerDeps(runtime) {
129
133
  // bootstrap captured + scrubbed it (capturePlatformContext) so the agent's bash / subprocesses
130
134
  // can't read it. The program reaches it ONLY via `runtime.apiToken()` (built below); we still
131
135
  // record it in each run's redactor so a value the program threads into a tool can't echo back out
132
- // of a tool result. Absent in the dev no-signing-key path. (the run env/credential rules)
136
+ // of a tool result. Absent in the dev no-signing-key path.
133
137
  // MUTABLE: on the snapshot substrate a wake carries a fresh token (the frozen one expired).
134
138
  let runApiKey = runtime.controlPlane.apiToken;
135
- // The broker (Runner Control API) is hosted on the api-server, so the public API shares its
136
- // origin; `runtime.apiUrl` exposes it and the program appends `/v1` or `/mcp/v1`.
139
+ // The broker (Runner Control API) shares an origin with the public API;
140
+ // `runtime.apiUrl` exposes it and the program appends `/v1` or `/mcp/v1`.
137
141
  const apiUrl = publicApiOrigin(runtime.controlPlane.baseUrl);
138
142
  // Per-run host: agent() leaf + sleep-hold + secrets + children + events, all brokered. `signal`
139
143
  // carries cooperative cancellation (credit exhaustion) into every host hook.
@@ -143,7 +147,7 @@ export function assembleWorkerDeps(runtime) {
143
147
  // Fallback budget-cap rate. A managed turn caps on the broker's EXACT upstream cost (forwarded on
144
148
  // the inference result frame → BudgetMeter `realCostUsd`); this representative sonnet-class rate
145
149
  // applies only to a turn with no upstream cost (BYO / unavailable). `max_usd` is a guardrail, not
146
- // the bill — that's per-leaf cost pass-through at the broker.
150
+ // the bill — the platform meters the actual per-leaf usage.
147
151
  rate: BUDGET_GUARDRAIL_RATE,
148
152
  startedAt: Date.now(),
149
153
  // deadline_seconds is WALL-CLOCK from the run's ORIGINAL start (incl. suspended idle), so a run
@@ -160,7 +164,7 @@ export function assembleWorkerDeps(runtime) {
160
164
  const nextMeterIdentifier = tokenMeterIdentifiers(run.id, meteringSessionId);
161
165
  // Per-run secret boundary: every value resolved (program `secrets.get` OR a tool's
162
166
  // ctx.secrets.resolve) is recorded into one shared redactor; the leaf seeds a fresh engine
163
- // Redactor from it so the loop scrubs those values out of all model-bound content. the platform spec
167
+ // Redactor from it so the loop scrubs those values out of all model-bound content.
164
168
  const redactor = new SecretRedactor();
165
169
  // Record the run's own API key so a prompt-injected agent can't echo it back to the model.
166
170
  if (runApiKey !== undefined && runApiKey !== "")
@@ -172,6 +176,26 @@ export function assembleWorkerDeps(runtime) {
172
176
  // Broker-backed artifact store (shared by the program's artifacts.write hook AND the engine's
173
177
  // host-backed `artifacts` tool). Presigned for large bodies; the runner holds no S3 creds.
174
178
  const artifactStore = new BrokerArtifactStore(broker);
179
+ // Browser tier (browser-session computer use): a per-run manager over the image's process backend.
180
+ // Only present when the runner image ships the browser stack (runtime.browserBackend set); else
181
+ // `computer.openBrowser()` throws "not available". A captured screenshot is stored as a run artifact
182
+ // through the SAME broker store — decoded from the MCP image block's base64 as binary bytes.
183
+ const browserSessions = runtime.browserBackend !== undefined
184
+ ? new BrowserSessionManager({
185
+ backend: runtime.browserBackend,
186
+ connect: connectSessionMcp,
187
+ writeArtifact: (name, contentType, base64, metadata) => artifactStore
188
+ .write({
189
+ name,
190
+ contentType,
191
+ body: base64,
192
+ encoding: "base64",
193
+ ...(metadata !== undefined ? { metadata } : {}),
194
+ })
195
+ .then((res) => ({ id: res.id, name: res.name, url: res.signedUrl })),
196
+ nextId: () => newId(),
197
+ })
198
+ : undefined;
175
199
  // Backend for the engine's host-backed built-in tools (webfetch/web_search/artifacts): web_search
176
200
  // + artifact read-back go through the broker (no Tavily/S3 creds here); webfetch is an in-process
177
201
  // fetch already gated by the worker's egress proxy.
@@ -210,9 +234,9 @@ export function assembleWorkerDeps(runtime) {
210
234
  // until the artifact extracts. The empty `/workspace` and this dir are SEPARATE dirs on hosted,
211
235
  // exactly the two-tier case the engine's AGENTS.md loader is built for.
212
236
  programDir: () => programDir,
213
- // Per-leaf, per-model token metering — reported THROUGH the broker (the worker holds no Stripe
214
- // credential); the broker decides `billed_by_boardwalk` per model + meters to Stripe (BYO models
215
- // no-op there). Fire-and-forget: a metering hiccup must never fail the run.
237
+ // Per-leaf, per-model token metering — reported THROUGH the broker (the worker holds no billing
238
+ // credential); the broker decides `billed_by_boardwalk` per model + meters usage to the platform
239
+ // (BYO models no-op there). Fire-and-forget: a metering hiccup must never fail the run.
216
240
  meterUsage: ({ model, inputTokens, outputTokens, cachedReadTokens, cachedWriteTokens, leafIndex, }) => {
217
241
  void broker
218
242
  .meterTokens({
@@ -237,7 +261,7 @@ export function assembleWorkerDeps(runtime) {
237
261
  // comes from the org's connection vault via the Runner Control API — never stored on the worker.
238
262
  brokerMcpToken: (serverUrl, invalidateToken) => broker.mcpToken(serverUrl, invalidateToken),
239
263
  });
240
- // Per-workflow persistent /workspace (§5): only when the manifest opts in. The BROKER additionally
264
+ // Per-workflow persistent /workspace: only when the manifest opts in. The BROKER additionally
241
265
  // gates on hosted (self-hosted runs get null URLs), and snapshots are keyed per-workflow + scoped
242
266
  // by the run token — so even the untrusted in-process program can't reach another tenant's data.
243
267
  const workspaceStore = manifest.workspace?.persist === true
@@ -311,6 +335,19 @@ export function assembleWorkerDeps(runtime) {
311
335
  // Snapshot-substrate suspension: seams freeze in place under the quiescence gate instead of
312
336
  // raising onSuspend (which stays wired as the transitional/local path's fallback).
313
337
  ...(freeze !== undefined ? { freeze } : {}),
338
+ // Register-without-release for held HITL gates — only on the
339
+ // freeze substrate, backed by the broker's inputs endpoints.
340
+ ...(freeze !== undefined
341
+ ? {
342
+ heldInput: {
343
+ register: (seq, gate) => broker.registerInput(seq, gate),
344
+ poll: (seq) => broker.pollInputAnswers(seq),
345
+ },
346
+ }
347
+ : {}),
348
+ // Browser tier: `computer.openBrowser()` + `agent({ session })` resolve through this manager
349
+ // (absent ⇒ the host throws "not available on this runner image").
350
+ ...(browserSessions !== undefined ? { browserSessions } : {}),
314
351
  phases: phaseTracker,
315
352
  });
316
353
  // Late-bind the coordinator's per-run hooks now that the run-scoped objects exist.
@@ -319,18 +356,21 @@ export function assembleWorkerDeps(runtime) {
319
356
  let freezeWallMs = 0;
320
357
  coordinator.setHooks({
321
358
  // At quiescence, immediately before the freeze: book the runtime tail (suspended time must
322
- // never appear billed — SUSPEND_POLICY) and persist the workspace (crash-during-suspension
359
+ // never appear billed) and persist the workspace (crash-during-suspension
323
360
  // recovery parity with the sleep-hold path). The wall stamp anchors the idle rebase below.
324
361
  onBeforeFreeze: async () => {
325
362
  freezeWallMs = Date.now();
326
363
  await activeFlusher?.flushNow();
327
364
  await workspaceStore?.persist();
328
365
  },
329
- // On wake: swap the fresh tokens onto the broker client + the program-facing apiToken()
330
- // (recording the new values in the run's redactor, same discipline as boot), and exclude
331
- // the frozen window from billed runtime using the wake's authoritative wall clock (the
332
- // guest's own clock was stopped).
366
+ // On wake: reseed the userspace CSPRNG (clause 3 a suspend snapshot restored more than
367
+ // once, e.g. a re-dispatch retry, would otherwise repeat its post-wake `crypto.*` draws),
368
+ // swap the fresh tokens onto the broker client + the program-facing apiToken() (recording
369
+ // the new values in the run's redactor, same discipline as boot), and exclude the frozen
370
+ // window from billed runtime using the wake's authoritative wall clock. The reseed runs
371
+ // BEFORE the seam resolves, so no woken author code draws from the stale (pre-suspend) DRBG.
333
372
  onAfterWake: (wake) => {
373
+ reseedUserspaceCsprng();
334
374
  broker.swapRunToken(wake.run_token);
335
375
  redactor.record(wake.run_token);
336
376
  if (wake.api_token !== undefined && wake.api_token !== "") {
@@ -364,6 +404,9 @@ export function assembleWorkerDeps(runtime) {
364
404
  // Resolves when a host seam suspends the run; the program runner races it against the body.
365
405
  suspendSignal,
366
406
  ...(workspaceStore !== undefined ? { workspace: workspaceStore } : {}),
407
+ // The orchestrator reaps every still-open browser session on terminal (kill Chromium + its
408
+ // Playwright MCP) so no browser process leaks past the run.
409
+ ...(browserSessions !== undefined ? { browserSessions } : {}),
367
410
  });
368
411
  };
369
412
  return {
@@ -412,7 +455,7 @@ export function assembleWorkerDeps(runtime) {
412
455
  suspender: {
413
456
  suspend: (signal, workerId) => broker.suspend(signal, workerId),
414
457
  },
415
- // Runtime metering (§15): flush runtime as periodic deltas (+ a terminal tail) through the broker,
458
+ // Runtime metering: flush runtime as periodic deltas (+ a terminal tail) through the broker,
416
459
  // idempotent per flush, so a long/perpetual run bills as it burns and the credit watcher sees it —
417
460
  // not a single charge at terminal (which a never-terminating run never reached). A fresh per-session
418
461
  // id keeps a restarted run's sessions distinct in the idempotency key (distinct ids sum).
@@ -434,7 +477,7 @@ export function assembleWorkerDeps(runtime) {
434
477
  return { stop: () => flusher.stop(), flushFinal: () => flusher.flushFinal() };
435
478
  },
436
479
  buildHost,
437
- // Mid-run credit watching (§15): a CreditWatcher polls the broker's GET /credit on a timer; when
480
+ // Mid-run credit watching: a CreditWatcher polls the broker's GET /credit on a timer; when
438
481
  // the org runs out, onExhausted aborts the run (the orchestrator wires it to AbortController).
439
482
  startCreditWatch: ({ run, onExhausted }) => {
440
483
  const watcher = new CreditWatcher({
@@ -445,10 +488,10 @@ export function assembleWorkerDeps(runtime) {
445
488
  watcher.start();
446
489
  return { stop: () => watcher.stop() };
447
490
  },
448
- // Mid-run user-cancel watching (§6): a CancelWatcher polls the broker's GET /cancel on a timer;
491
+ // Mid-run user-cancel watching: a CancelWatcher polls the broker's GET /cancel on a timer;
449
492
  // when the user cancels (the run is flipped to `cancelling`), onCancelled aborts the run. This is
450
- // how the brokered (Redis-less) worker learns of a cancel — the api-server's Redis publish can't
451
- // reach it.
493
+ // how the brokered (cache-less) worker learns of a cancel — the platform's cancel publish can't
494
+ // reach it directly.
452
495
  startCancelWatch: ({ run, onCancelled }) => {
453
496
  const watcher = new CancelWatcher({
454
497
  runId: run.id,
@@ -458,7 +501,7 @@ export function assembleWorkerDeps(runtime) {
458
501
  watcher.start();
459
502
  return { stop: () => watcher.stop() };
460
503
  },
461
- // Lease renewal (§6): a LeaseRenewer extends the lease through the broker's POST /renew on a timer
504
+ // Lease renewal: a LeaseRenewer extends the lease through the broker's POST /renew on a timer
462
505
  // (well under the lease), so a run longer than the 5-min lease isn't reclaimed mid-flight by the
463
506
  // recovery sweep. `renew` re-extends with the SAME workerId that claimed it; a null result (the
464
507
  // run is no longer ours) fires onLost → the run aborts `lease_lost` without finalizing.
@@ -481,7 +524,7 @@ export function assembleWorkerDeps(runtime) {
481
524
  }
482
525
  // ---- Bootstrap (real container only) -------------------------------------------------
483
526
  /**
484
- * The per-run platform values the dispatcher injects as container env (ecs_run_task_client.ts). They
527
+ * The per-run platform values the dispatcher injects as container env. They
485
528
  * are captured into private worker state at bootstrap and DELETED from `process.env` before any user
486
529
  * program / agent leaf / subprocess can run — the run token + API token are credentials, and nothing
487
530
  * untrusted run code touches should inherit them (the run env/credential rules). The user owns the rest
@@ -495,7 +538,7 @@ export const PLATFORM_ENV_KEYS = [
495
538
  "BOARDWALK_TASK_CPU_UNITS",
496
539
  ];
497
540
  /** The public-API origin a run uses for raw API / MCP / CLI calls. The broker (Runner Control API)
498
- * is hosted on the api-server, so the public API shares its origin; the program appends `/v1` or
541
+ * shares an origin with the public API, so the program appends `/v1` or
499
542
  * `/mcp/v1`. Falls back to the broker URL unchanged if it can't be parsed. */
500
543
  export function publicApiOrigin(controlPlaneBaseUrl) {
501
544
  try {
@@ -519,7 +562,7 @@ export function capturePlatformContext(env) {
519
562
  return value.trim();
520
563
  };
521
564
  // The dispatcher always injects the control-plane handle (the Runner Credential Broker model). The runner is
522
- // brokered-only — there is no DB/Redis/Stripe fallback — so a missing handle is a hard config error.
565
+ // brokered-only — there is no database, cache, or billing fallback — so a missing handle is a hard config error.
523
566
  const runId = read("RUN_ID");
524
567
  const apiToken = env.BOARDWALK_API_KEY?.trim();
525
568
  const controlPlane = {
@@ -552,6 +595,12 @@ export async function main() {
552
595
  relay.announceReady(workerDiagnostics());
553
596
  const identity = await relay.awaitIdentity();
554
597
  applyIdentityToEnv(identity, process.env);
598
+ // Clause 3 (SNAPSHOT_UNIQUENESS_CONTRACT): this run was restored from the SHARED base
599
+ // snapshot, so its OpenSSL DRBG is identical to every other run's. Reseed it from the
600
+ // (VMGenID-diverged) OS entropy BEFORE `acceptIdentity` releases the worker into the brokered
601
+ // lifecycle — so no `crypto.*` draw by the SDK, the agent, or author code can collide across
602
+ // clones. Every wake reseeds again (the after-wake hook).
603
+ reseedUserspaceCsprng();
555
604
  relay.acceptIdentity();
556
605
  // The relay now becomes the suspend/wake channel: assembleWorkerDeps opens it into the
557
606
  // FreezeCoordinator, and the host's suspending seams freeze in place instead of exiting.
@@ -564,6 +613,11 @@ export async function main() {
564
613
  const runId = platform.runId;
565
614
  const byoProviders = parseByoProviders(process.env.BOARDWALK_BYO_PROVIDERS);
566
615
  Reflect.deleteProperty(process.env, "BOARDWALK_BYO_PROVIDERS");
616
+ // Browser tier: only when the runner IMAGE declares the browser stack (BOARDWALK_BROWSER_TIER=1 +
617
+ // a Chrome path). The backend is run-independent (buildHost builds the per-run manager over it);
618
+ // absent ⇒ `computer.openBrowser()` fails clearly. Read here so env-reading stays out of the pure wiring.
619
+ const guestBrowserConfig = loadGuestBrowserConfig(process.env);
620
+ const browserBackend = guestBrowserConfig !== null ? makeGuestBrowserBackend(guestBrowserConfig) : undefined;
567
621
  const deps = assembleWorkerDeps({
568
622
  workerId: process.env.WORKER_ID ?? `worker-${runId}`,
569
623
  workspaceRoot: process.env.WORKSPACE_ROOT ?? "/workspace",
@@ -572,12 +626,13 @@ export async function main() {
572
626
  vcpus: platform.vcpus,
573
627
  ...(byoProviders.length > 0 ? { byoProviders } : {}),
574
628
  ...(freezeRelay !== undefined ? { freezeRelay } : {}),
629
+ ...(browserBackend !== undefined ? { browserBackend } : {}),
575
630
  });
576
- // The only thing to drain is the batched telemetry buffer — the runner opens no DB/Redis/SQS.
631
+ // The only thing to drain is the batched telemetry buffer — the runner opens no database, cache, or queue.
577
632
  const cleanup = async () => {
578
633
  await deps.flushTelemetry?.().catch(() => undefined);
579
634
  };
580
- // SIGTERM: ECS is stopping the task. Hold-and-pay has no mid-run checkpoint; we exit and let the
635
+ // SIGTERM: the orchestrator is stopping the task. Hold-and-pay has no mid-run checkpoint; we exit and let the
581
636
  // lease expire → the scheduler-sweep reclaims it and a fresh worker RESTARTS the run from the
582
637
  // top (Lambda/GHA semantics; durable children re-attach via idempotency). Crash-safe by design.
583
638
  let shuttingDown = false;
@@ -32,7 +32,7 @@ export interface EngineLeafExecutorDeps {
32
32
  budget: BudgetMeter;
33
33
  /** RUN-level redactor (shared with the secret resolver): every resolved secret value is recorded
34
34
  * here. We seed a fresh engine `Redactor` from it per leaf so the loop scrubs known values out of
35
- * the prompt, tool args/results, and transcript before they reach the model (the platform spec). */
35
+ * the prompt, tool args/results, and transcript before they reach the model. */
36
36
  redactor: SecretRedactor;
37
37
  /** Builds this leaf's event sink; `leafIndex` (1-based) is only a metering identifier — the sink
38
38
  * owns the run-global cursor. `identity` names the leaf on its turn frames. */
@@ -51,7 +51,7 @@ export interface EngineLeafExecutorDeps {
51
51
  * omitted ⇒ no bundled tier (only the workspace AGENTS.md applies). */
52
52
  programDir?: () => string | null;
53
53
  /** Per-leaf token metering seam (fire-and-forget). Reports THIS leaf's tokens + its model to the
54
- * broker, which decides `billed_by_boardwalk` per model + meters to Stripe. Omitted in tests. */
54
+ * broker, which decides `billed_by_boardwalk` per model + meters usage to the platform. Omitted in tests. */
55
55
  meterUsage?: (input: MeterUsageInput) => void;
56
56
  /** Backend for the engine's host-backed built-in tools (`webfetch` / `web_search` / `artifacts`):
57
57
  * set as the leaf's `capabilities.host` so the engine registers them. Broker-backed on hosted runs
@@ -53,7 +53,7 @@ export interface ProgramRunnerDeps {
53
53
  * Scrubs known secret values out of a string (the run's `SecretRedactor.redactText`). Applied to a
54
54
  * top-level throw's message before it is logged AND before it is returned to the worker — a program
55
55
  * that resolves a secret and then throws it in an error message must NOT land that secret raw in the
56
- * logs or the finalized run output (the platform spec). Defaults to identity (tests/local).
56
+ * logs or the finalized run output. Defaults to identity (tests/local).
57
57
  */
58
58
  redactText?: (text: string) => string;
59
59
  /**
@@ -1,6 +1,6 @@
1
1
  // WorkflowProgramRunner — executes a workflow program (the JS-body model, the workflow runtime design).
2
2
  //
3
- // A run is the execution of a built program ARTIFACT (§3.9): the worker is handed the VERIFIED tarball
3
+ // A run is the execution of a built program ARTIFACT: the worker is handed the VERIFIED tarball
4
4
  // (its sha256 already checked against the pinned digest by the orchestrator) plus the entry module
5
5
  // name. This module is the mechanism: it installs the host adapter + trigger payload onto the
6
6
  // `@boardwalk-labs/workflow` singleton, extracts the artifact into a unique temp dir under a
@@ -24,7 +24,7 @@ export interface ProgramVersionReader {
24
24
  program: ProgramRef;
25
25
  } | null>;
26
26
  }
27
- /** Books the run's RUNTIME usage as periodic deltas (the worker's RuntimeFlusher; §10.7 + §15). The
27
+ /** Books the run's RUNTIME usage as periodic deltas (the worker's RuntimeFlusher). The
28
28
  * orchestrator drives the lifecycle: the timer flushes mid-run, `stop()` halts it at the body's end,
29
29
  * and `flushFinal()` books the tail on a clean terminal (skipped on a `lease_lost` handoff — the new
30
30
  * owner books its own runtime). Replaces the old single terminal runtime charge. */
@@ -51,7 +51,7 @@ export interface RunFinalizer {
51
51
  export interface RunSuspender {
52
52
  suspend(signal: SuspendSignal, workerId: string): Promise<void>;
53
53
  }
54
- /** Restores/snapshots the workflow's persistent `/workspace` (§5). Best-effort — both no-op when the
54
+ /** Restores/snapshots the workflow's persistent `/workspace`. Best-effort — both no-op when the
55
55
  * run isn't eligible (not opted-in / self-hosted), and neither throws. */
56
56
  export interface WorkspaceHandle {
57
57
  hydrate(): Promise<void>;
@@ -97,6 +97,12 @@ export type ProgramHostBuilder = (run: Run, manifest: WorkflowManifest, signal:
97
97
  * program runner so a suspend short-circuits the body out of band (the durable-suspension design). Absent ⇒
98
98
  * no durable suspension on this path (a suspend then surfaces as a thrown SuspendError). */
99
99
  suspendSignal?: Promise<SuspendSignal>;
100
+ /** The run's browser-session manager (browser tier). The orchestrator reaps every still-open session
101
+ * on EVERY terminal path so no Chromium / Playwright MCP process leaks past the run. `closeAll` is
102
+ * best-effort + never throws. Absent on images without the browser stack. */
103
+ browserSessions?: {
104
+ closeAll(): Promise<void>;
105
+ };
100
106
  }>;
101
107
  /** Handle to a running per-session loop (metering or credit watch); `stop()` ends + drains it. */
102
108
  export interface RunSessionHandle {
@@ -155,7 +161,7 @@ export interface ProgramWorkerDeps {
155
161
  /** Emit the program's `console.*` output as `log` run-events while the body runs (optional —
156
162
  * absent disables capture). Wired by the entrypoint to the batched telemetry publisher. */
157
163
  onProgramLog?: (stream: LogStream, text: string) => void;
158
- /** ECS task ARN (or any stable worker identity). */
164
+ /** Task ARN (or any stable worker identity). */
159
165
  workerId: string;
160
166
  /** Drain any buffered telemetry before the worker exits (brokered path's BrokerEventPublisher).
161
167
  * Called by the worker entrypoint's cleanup; the orchestrator itself never invokes it. */
@@ -11,12 +11,12 @@
11
11
  // aborted mid-flight — credit exhaustion — finalize failed with the abort reason).
12
12
  //
13
13
  // While the program runs, two per-session loops watch it (both brokered): a UsageFlusher meters token
14
- // deltas (§10.7) and a CreditWatcher polls the org's funding (§15). When credit hits zero the watcher
14
+ // deltas and a CreditWatcher polls the org's funding. When credit hits zero the watcher
15
15
  // aborts the run's AbortSignal, which the WorkflowHost honors at every hook (cooperative
16
16
  // cancellation, run_abort.ts) — `signal.aborted` is authoritative for the terminal status.
17
17
  //
18
18
  // What's gone vs. the old worker: no checkpoint load/pause, no resume, no sleep/wait_for_child
19
- // pause outcomes (the program holds), no per-turn transcript. The Strands loop now lives one
19
+ // pause outcomes (the program holds), no per-turn transcript. The agent loop now lives one
20
20
  // level down, behind the host's agent() leaf.
21
21
  //
22
22
  // v0 deferral (a clear seam): run-level outcome validation (needs program output capture).
@@ -111,6 +111,7 @@ export async function runProgramWorker(runId, deps) {
111
111
  return { kind: "failed", reason: "host_build_failed" };
112
112
  }
113
113
  const { host, redactor, workspace, phases, activity, setProgramDir, lsp, suspendSignal } = built;
114
+ const browserSessions = built.browserSessions;
114
115
  // Guarantee the /workspace sandbox dir exists for EVERY run (persist or not) so a program can write
115
116
  // to /workspace without a defensive mkdir. Runs before hydrate (whose extract targets the dir).
116
117
  // Best-effort — the image pre-creates the dir; a failure here would resurface at the program's write.
@@ -130,16 +131,16 @@ export async function runProgramWorker(runId, deps) {
130
131
  if (workspace !== undefined)
131
132
  await workspace.hydrate();
132
133
  // Token metering is PER-LEAF: each agent() leaf reports its own tokens + model to the broker, which
133
- // decides `billed_by_boardwalk` per model + meters to Stripe (see leaf_executor `meterUsage`). A
134
+ // decides `billed_by_boardwalk` per model + meters usage to the platform (see leaf_executor `meterUsage`). A
134
135
  // workflow has no run-level model, so there is no run-level token metering here.
135
- // Mid-run credit watching (§15): when the org runs out of credit, abort the run cooperatively.
136
+ // Mid-run credit watching: when the org runs out of credit, abort the run cooperatively.
136
137
  const credit = deps.startCreditWatch?.({
137
138
  run: claimed,
138
139
  onExhausted: () => {
139
140
  controller.abort(new RunAbortedError("credit_exhausted"));
140
141
  },
141
142
  });
142
- // Mid-run user-cancel watching (§6): when the user cancels, abort the run cooperatively. The host
143
+ // Mid-run user-cancel watching: when the user cancels, abort the run cooperatively. The host
143
144
  // honors the abort at the next hook boundary (a `sleep` hold wakes immediately); the broker then
144
145
  // upgrades the terminal write to `cancelled` because the run was flipped to `cancelling`.
145
146
  const cancel = deps.startCancelWatch?.({
@@ -148,7 +149,7 @@ export async function runProgramWorker(runId, deps) {
148
149
  controller.abort(new RunAbortedError("cancelled"));
149
150
  },
150
151
  });
151
- // Lease renewal (§6): heartbeat the lease so a long run isn't reclaimed mid-flight. If the lease is
152
+ // Lease renewal: heartbeat the lease so a long run isn't reclaimed mid-flight. If the lease is
152
153
  // definitively lost (another worker reclaimed it), abort `lease_lost` — the run stops without
153
154
  // finalizing (the new owner owns the terminal write; see the lease_lost guard after the body).
154
155
  const lease = deps.startLeaseRenew?.({
@@ -157,7 +158,7 @@ export async function runProgramWorker(runId, deps) {
157
158
  controller.abort(new RunAbortedError("lease_lost"));
158
159
  },
159
160
  });
160
- // Runtime metering (§15): flush runtime as periodic deltas from the claim, so a long/perpetual run
161
+ // Runtime metering: flush runtime as periodic deltas from the claim, so a long/perpetual run
161
162
  // bills as it burns (and the credit watcher sees it) instead of only at terminal. The tail is booked
162
163
  // by `flushFinal()` after the body (on every path except a lease_lost handoff).
163
164
  const runtimeFlush = deps.startRuntimeFlush?.({ run: claimed, startedAtMs: sessionStartMs });
@@ -208,6 +209,16 @@ export async function runProgramWorker(runId, deps) {
208
209
  // is safe (it runs before the workspace snapshot, which is the slowest step).
209
210
  if (lsp !== undefined)
210
211
  await lsp.close();
212
+ // Reap every still-open browser session (kill Chromium + its Playwright MCP) on EVERY terminal
213
+ // path, before the workspace snapshot. Best-effort — a dead session must not mask the run's outcome.
214
+ if (browserSessions !== undefined) {
215
+ await browserSessions.closeAll().catch((err) => {
216
+ log.warn("browser_sessions_close_failed", {
217
+ runId,
218
+ error: err instanceof Error ? err.message : String(err),
219
+ });
220
+ });
221
+ }
211
222
  // Snapshot the final /workspace so the workflow's NEXT run hydrates it. Best-effort.
212
223
  if (workspace !== undefined) {
213
224
  await workspace.persist();
@@ -1,4 +1,4 @@
1
- // RecordingSecretResolver — the redaction feeder (the platform spec).
1
+ // RecordingSecretResolver — the redaction feeder.
2
2
  //
3
3
  // Decorates any SecretResolver so EVERY value a run resolves — whether the workflow program asked
4
4
  // via `secrets.get(name)` or a tool asked via `ctx.secrets.resolve(ref)` — is recorded into the
@@ -1,11 +1,11 @@
1
1
  // run_abort — the provider-agnostic cooperative-cancellation substrate for a run (the Runner Credential Broker model).
2
2
  //
3
- // The cancellation primitive is a Web-standard `AbortSignal` — NOT a Strands/model concept. The worker
3
+ // The cancellation primitive is a Web-standard `AbortSignal` — NOT a framework/model concept. The worker
4
4
  // owns one `AbortController` per run session; a watcher (credit, later user-cancel) calls
5
5
  // `controller.abort(new RunAbortedError(reason))`. The WorkflowHost honors that signal at every hook
6
6
  // boundary (`agent`/`sleep`/`workflows.call`/…), unwinding the program. The ONLY place that translates
7
- // the signal into a model-specific stop is the Strands leaf (signal → `agent.cancel()`); a future
8
- // non-Strands leaf honors the SAME `AbortSignal` its own way. So nothing here, in the host, or in the
7
+ // the signal into a model-specific stop is the agent leaf (signal → the leaf's cancel path); a different
8
+ // leaf implementation honors the SAME `AbortSignal` its own way. So nothing here, in the host, or in the
9
9
  // program-facing SDK depends on the model backend.
10
10
  //
11
11
  // `signal.aborted` is AUTHORITATIVE at the orchestrator: a program that catches RunAbortedError and
@@ -13,6 +13,10 @@ export interface RunnerControlClientConfig {
13
13
  runId: string;
14
14
  /** Injected fetch (defaults to global fetch). */
15
15
  fetchImpl?: typeof fetch;
16
+ /** Per-call ceiling for short control calls (default 30s) — bounds a poll frozen mid-flight. */
17
+ controlTimeoutMs?: number;
18
+ /** Per-call ceiling for bulk artifact/workspace transfers (default 5 min). */
19
+ bulkTimeoutMs?: number;
16
20
  }
17
21
  /** The pinned program's download reference (the worker fetches + verifies + extracts it). */
18
22
  export interface BrokerProgram {
@@ -47,9 +51,24 @@ export declare class RunnerControlClient {
47
51
  /** The live bearer. Mutable: on the snapshot substrate a wake carries a FRESH run token (the
48
52
  * frozen one expired while suspended) and the worker swaps it at runtime. */
49
53
  private runToken;
54
+ private readonly controlTimeoutMs;
55
+ private readonly bulkTimeoutMs;
50
56
  constructor(cfg: RunnerControlClientConfig);
51
57
  /** Swap the bearer for a fresh run token (the wake path). Every subsequent call uses it. */
52
58
  swapRunToken(token: string): void;
59
+ /**
60
+ * Every SHORT control call (claim / renew / cancel / credit / journal / …) goes through here so
61
+ * it carries a hard timeout. Without one, a poll frozen mid-flight on the snapshot substrate
62
+ * hangs FOREVER on restore (the socket is dead but never reset), and since a watcher serializes
63
+ * its ticks, one hung tick wedges that watcher — the dead-connections gotcha for the
64
+ * background pollers (lease/cancel/credit), which run on untracked timers the quiescence gate
65
+ * doesn't cover. Also plain robustness: no broker call should hang on a network blip. The
66
+ * streaming inference call is the ONE exception (long-lived NDJSON) and bypasses this.
67
+ */
68
+ private controlFetch;
69
+ /** Bulk transfers (artifact + workspace up/download over presigned S3) — a much larger ceiling
70
+ * than a control call, but still bounded so a dead socket can't hang the run. */
71
+ private bulkFetch;
53
72
  /** Claim the run's lease. Returns the run on success, or null when it isn't claimable (409 —
54
73
  * another worker has it, or it isn't pending), which the worker treats as "claim lost". */
55
74
  claim(workerId: string, leaseSeconds: number): Promise<{
@@ -89,8 +108,8 @@ export declare class RunnerControlClient {
89
108
  * retried/duplicate flush idempotent; distinct per-flush ids sum into the run's runtime total. */
90
109
  reportUsage(runtimeSeconds: number, identifier: string): Promise<void>;
91
110
  /** Report a token-usage delta for incremental in-run metering (the usage flusher → broker). The
92
- * broker gates on the run's per-connection `billed_by_boardwalk` server-side + meters to Stripe;
93
- * `identifier` makes a retried/duplicate flush idempotent. Satisfies {@link TokenUsageReporter}. */
111
+ * broker gates on the run's per-connection `billed_by_boardwalk` server-side + meters usage to the
112
+ * platform; `identifier` makes a retried/duplicate flush idempotent. Satisfies {@link TokenUsageReporter}. */
94
113
  meterTokens(input: {
95
114
  inputTokens: number;
96
115
  outputTokens: number;
@@ -101,14 +120,20 @@ export declare class RunnerControlClient {
101
120
  cachedWriteTokens?: number;
102
121
  }): Promise<void>;
103
122
  /** Check whether the run's org is still funded (the CreditWatcher → broker). The broker reads the
104
- * live Stripe balance server-side; `false` means out of credit (the watcher then aborts the run). */
123
+ * live billing balance server-side; `false` means out of credit (the watcher then aborts the run). */
105
124
  checkCredit(): Promise<boolean>;
106
125
  /** Check whether the run has been asked to cancel (the CancelWatcher → broker). `true` once the user
107
126
  * cancelled the run (the broker flipped it to `cancelling`/`cancelled`); the watcher then aborts the
108
127
  * run. Brokered because the runner holds no DB/Redis — this replaces the unreachable Redis channel. */
109
128
  checkCancelled(): Promise<boolean>;
129
+ /** Register-without-release: register a HELD HITL gate's request row
130
+ * so it is answerable while the run keeps running — no suspend. Idempotent. Returns whether a new
131
+ * gate was registered. */
132
+ registerInput(seq: number, gate: unknown): Promise<boolean>;
133
+ /** Poll the resolved answers for a held gate at `seq` (empty until a human responds). */
134
+ pollInputAnswers(seq: number): Promise<Record<string, unknown>>;
110
135
  /** Mint a presigned GET URL to restore this workflow's last `/workspace` snapshot (workspace
111
- * persistence, §5). `null` when the run isn't eligible (not opted-in, or self-hosted). */
136
+ * persistence). `null` when the run isn't eligible (not opted-in, or self-hosted). */
112
137
  workspaceHydrateUrl(): Promise<string | null>;
113
138
  /** Mint a presigned PUT URL to snapshot this workflow's `/workspace` (the worker uploads the tarball
114
139
  * straight to S3). `null` when the run isn't eligible. `sizeBytes` is the archive's on-disk size