@boardwalk-labs/runner 0.1.3 → 0.1.5

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.
@@ -92,6 +92,10 @@ export function startDaemon(deps) {
92
92
  }
93
93
  const done = (async () => {
94
94
  log.info("daemon_started", { runnerId: deps.runnerId, workDir: deps.workDir });
95
+ // Reclaim run dirs a previous daemon left behind (crash / SIGKILL / force-quit): a completed
96
+ // run always cleans its own dir, so anything here is orphaned and may hold a workspace the
97
+ // crashed run wrote. Best-effort; a failure must not stop the daemon.
98
+ await rm(path.join(deps.workDir, "runs"), { recursive: true, force: true }).catch(() => undefined);
95
99
  while (!draining) {
96
100
  deps.onIdle?.();
97
101
  try {
@@ -6,7 +6,7 @@
6
6
  // no upstream cost: a BYO-provider turn (the org pays its own key), or a managed turn whose cost the
7
7
  // broker couldn't read. It bounds runaway loops; it is NOT the bill. Actual billing is per-request
8
8
  // COST PASS-THROUGH (OpenRouter's reported cost × margin) via Stripe meters (domain/billing +
9
- // run_usage_service).
9
+ // run_usage_service). the platform spec
10
10
  //
11
11
  // Deliberately NOT a per-model table with a silent fallback: a lookup that returns a Sonnet-class
12
12
  // default for any unknown id only created the ILLUSION of precision. The real per-request cost (above)
@@ -1,5 +1,4 @@
1
- // SecretRedactor — scrubs known secret values from everything bound for an LLM (the platform spec,
2
- // the workflow runtime design, plan #9).
1
+ // SecretRedactor — scrubs known secret values from everything bound for an LLM (the platform spec).
3
2
  //
4
3
  // The architectural guarantee is structural: secrets live ONLY in the workflow PROGRAM (the trusted
5
4
  // deterministic tool layer), which holds them via `secrets.get` / `ctx.secrets.resolve`. The
@@ -1,5 +1,5 @@
1
1
  // BrokerArtifactStore — the ArtifactStore the `artifacts` tool uses under the Runner Credential
2
- // Broker (the Runner Credential Broker model — Artifacts). It forwards every op to the Runner Control API:
2
+ // Broker (the Runner Credential Broker model). It forwards every op to the Runner Control API:
3
3
  // the broker computes the S3 key, neutralizes the served content type, PUTs with its
4
4
  // own creds, and records the catalog row — so the untrusted runner holds no S3 credential and can't
5
5
  // bypass those server-side rules. A thin adapter over the run-bound RunnerControlClient.
@@ -1,5 +1,5 @@
1
1
  // BrokerEventPublisher — a `RedisPublisher` that ships the agent event-stream through the broker
2
- // instead of publishing to Redis directly (the Runner Credential Broker model — Telemetry).
2
+ // instead of publishing to Redis directly (the Runner Credential Broker model).
3
3
  //
4
4
  // The run-event live channel is a per-event `redis.publish(run:<id>, frame)`. Under the broker the
5
5
  // runner holds no Redis credential, so this stand-in BUFFERS frames and POSTs them in batches to the
@@ -1,4 +1,4 @@
1
- // CancelWatcher — stops a run when the user cancels it mid-flight (the platform spec + the Runner Credential Broker model).
1
+ // CancelWatcher — stops a run when the user cancels it mid-flight (the platform spec).
2
2
  //
3
3
  // The user-cancel counterpart to CreditWatcher. One per run session. On a timer it asks — through the
4
4
  // broker (`GET /cancel`, since the runner holds no DB/Redis credential) — whether the run has been
@@ -1,5 +1,4 @@
1
- // CreditWatcher — stops a run when its org runs out of prepaid credit mid-flight (the platform spec +
2
- // the coding-agent design §6/§10).
1
+ // CreditWatcher — stops a run when its org runs out of prepaid credit mid-flight (the platform spec).
3
2
  //
4
3
  // One per run session. On a timer it asks — through the broker (`GET /credit`, since the runner holds
5
4
  // no Stripe credential) — whether the org is still funded; the FIRST time it isn't, it fires
@@ -23,7 +23,13 @@ export interface DirectInferenceDeps {
23
23
  }
24
24
  /** One model turn, straight to the org's endpoint. Returns the ChatTurn + canonical model ref;
25
25
  * BYO carries no platform cost (costMicros stays 0 — BYO is never metered). */
26
- export declare function streamDirectTurn(deps: DirectInferenceDeps, entry: ByoInferenceProvider, req: DirectTurnRequest, onDelta: ((text: string) => void) | undefined): Promise<{
26
+ export declare function streamDirectTurn(deps: DirectInferenceDeps, entry: ByoInferenceProvider, req: DirectTurnRequest, onDelta: ((text: string) => void) | undefined,
27
+ /** Register the resolved key with the CURRENT leaf's engine redactor, before the model call.
28
+ * The key resolves mid-leaf (after the leaf's redactor snapshot is seeded), so without this a
29
+ * provider that echoes the Authorization header in an error body would leak it into that
30
+ * leaf's error run event. The run-level redactor already has it via resolveSecret; this closes
31
+ * the per-leaf gap. */
32
+ registerSecret?: (value: string) => void): Promise<{
27
33
  turn: ChatTurn;
28
34
  modelRef: string;
29
35
  }>;
@@ -53,8 +53,16 @@ export function directProviderFor(registry, provider) {
53
53
  }
54
54
  /** One model turn, straight to the org's endpoint. Returns the ChatTurn + canonical model ref;
55
55
  * BYO carries no platform cost (costMicros stays 0 — BYO is never metered). */
56
- export async function streamDirectTurn(deps, entry, req, onDelta) {
56
+ export async function streamDirectTurn(deps, entry, req, onDelta,
57
+ /** Register the resolved key with the CURRENT leaf's engine redactor, before the model call.
58
+ * The key resolves mid-leaf (after the leaf's redactor snapshot is seeded), so without this a
59
+ * provider that echoes the Authorization header in an error body would leak it into that
60
+ * leaf's error run event. The run-level redactor already has it via resolveSecret; this closes
61
+ * the per-leaf gap. */
62
+ registerSecret) {
57
63
  const apiKey = entry.auth_secret_name === null ? null : await deps.resolveSecret(entry.auth_secret_name);
64
+ if (apiKey !== null)
65
+ registerSecret?.(apiKey);
58
66
  if (entry.base_url === null) {
59
67
  throw new Error(`BYO provider '${entry.name}' has no base_url`);
60
68
  }
@@ -1,4 +1,4 @@
1
- // Worker composition root (the workflow runtime design + the Runner Credential Broker model). The Boardwalk-hosted
1
+ // Worker composition root (the workflow runtime design). The Boardwalk-hosted
2
2
  // worker container runs `node dist/fargate/worker/index.js`; 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.
@@ -139,7 +139,7 @@ export function assembleWorkerDeps(runtime) {
139
139
  const nextMeterIdentifier = tokenMeterIdentifiers(run.id, meteringSessionId);
140
140
  // Per-run secret boundary: every value resolved (program `secrets.get` OR a tool's
141
141
  // ctx.secrets.resolve) is recorded into one shared redactor; the leaf seeds a fresh engine
142
- // Redactor from it so the loop scrubs those values out of all model-bound content (see the platform spec).
142
+ // Redactor from it so the loop scrubs those values out of all model-bound content. the platform spec
143
143
  const redactor = new SecretRedactor();
144
144
  // Record the run's own API key so a prompt-injected agent can't echo it back to the model.
145
145
  if (runApiKey !== undefined && runApiKey !== "")
@@ -1,5 +1,5 @@
1
1
  // The broker inference transport the engine-backed leaf streams a model turn through
2
- // (the Runner Credential Broker model — "Inference (class 2)").
2
+ // (the Runner Credential Broker model)").
3
3
  //
4
4
  // Under the Runner Credential Broker the runner invokes NO model directly: it holds neither the
5
5
  // managed-inference key nor any BYO provider key. So the `agent()` leaf's `LeafIo.streamModel`
@@ -105,7 +105,7 @@ export class EngineLeafExecutor {
105
105
  // through providerIo.onDelta and return the terminal turn. An aborted run rejects in-flight.
106
106
  streamModel: async (req, providerIo) => {
107
107
  throwIfAborted(signal);
108
- return this.streamModel(req, providerIo, signal, (costMicros) => {
108
+ return this.streamModel(req, providerIo, signal, redactor, (costMicros) => {
109
109
  pendingCostMicros = (pendingCostMicros ?? 0) + costMicros;
110
110
  });
111
111
  },
@@ -193,7 +193,7 @@ export class EngineLeafExecutor {
193
193
  /** POST one model turn to the broker's `/inference` and adapt its NDJSON stream into the engine's
194
194
  * ModelTurnResult: each `delta` frame drives providerIo.onDelta; the terminal `result` frame is
195
195
  * the turn. An `error` frame throws (the broker already classified it). An abort mid-stream throws. */
196
- async streamModel(req, providerIo, signal, onCost) {
196
+ async streamModel(req, providerIo, signal, redactor, onCost) {
197
197
  // Runner-direct BYO (D7): the org's own endpoint + key, called with the same engine adapters
198
198
  // the broker uses. No platform cost (BYO is never metered) — onCost stays untouched.
199
199
  // A direct turn needs an explicit model (an omitted model means the managed auto lane,
@@ -209,7 +209,10 @@ export class EngineLeafExecutor {
209
209
  messages: req.messages,
210
210
  tools: req.tools,
211
211
  ...(req.reasoning !== undefined ? { reasoning: req.reasoning } : {}),
212
- }, providerIo.onDelta);
212
+ }, providerIo.onDelta,
213
+ // Register the org's key with THIS leaf's redactor before the model call, so an error
214
+ // body echoing it is scrubbed from the leaf's run events (not just the terminal error).
215
+ (value) => redactor.add("byo-key", value));
213
216
  throwIfAborted(signal);
214
217
  return { turn: out.turn, modelRef: out.modelRef };
215
218
  }
@@ -1,5 +1,5 @@
1
1
  // LeaseRenewer — keeps a long run's lease fresh so the recovery sweep doesn't reclaim a STILL-ALIVE
2
- // worker (the Runner Credential Broker model; the platform spec).
2
+ // worker (the Runner Credential Broker model).
3
3
  //
4
4
  // A run is claimed with a fixed lease (DEFAULT_LEASE_MS, 5 min). The worker never used to renew it,
5
5
  // so any run longer than the lease (an Opus agentic loop is routinely 7+ min) had its lease expire
@@ -1,5 +1,15 @@
1
1
  import type { WorkflowHost } from "@boardwalk-labs/workflow/runtime";
2
2
  import { type SuspendSignal } from "./suspension.js";
3
+ /**
4
+ * Link THIS runtime's `@boardwalk-labs/workflow` into the exec dir's `node_modules` so the
5
+ * program's bare import resolves anywhere (a self-hosted daemon workspace has no ancestor
6
+ * `node_modules`; hosted images do, making the link a harmless no-op there). A symlink — not a
7
+ * copy — is load-bearing: Node resolves it to the REAL path, so the program gets the same
8
+ * module instance the host adapter was installed on (the singleton contract). `junction` covers
9
+ * Windows without elevation; a failed link is only logged, since a resolvable ancestor may
10
+ * still exist.
11
+ */
12
+ export declare function ensureSdkLink(execDir: string): Promise<void>;
3
13
  export interface RunProgramArgs {
4
14
  /** Run id — used for the temp dir path + correlation. */
5
15
  runId: string;
@@ -38,7 +48,7 @@ export interface ProgramRunnerDeps {
38
48
  * Scrubs known secret values out of a string (the run's `SecretRedactor.redactText`). Applied to a
39
49
  * top-level throw's message before it is logged AND before it is returned to the worker — a program
40
50
  * that resolves a secret and then throws it in an error message must NOT land that secret raw in the
41
- * logs or the finalized run output (the platform spec,). Defaults to identity (tests/local).
51
+ * logs or the finalized run output (the platform spec). Defaults to identity (tests/local).
42
52
  */
43
53
  redactText?: (text: string) => string;
44
54
  /**
@@ -20,16 +20,55 @@
20
20
  // Durability: the body runs once, in-process. `sleep`/`workflows.call` hold in-process via the host
21
21
  // (no checkpoint, no exit). A crash mid-run restarts the run from the top (handled by the
22
22
  // worker/scheduler-sweep, not here). Output capture is deferred (v0 returns null).
23
- import { mkdir, writeFile, rm } from "node:fs/promises";
24
- import { join } from "node:path";
23
+ import { mkdir, writeFile, rm, symlink, lstat } from "node:fs/promises";
24
+ import { dirname, join } from "node:path";
25
+ import { createRequire } from "node:module";
25
26
  import { pathToFileURL } from "node:url";
26
27
  import { randomUUID } from "node:crypto";
27
28
  import { installHost, installInput, installConfig, takeDeclaredOutput, resetRuntime, } from "@boardwalk-labs/workflow/runtime";
28
- import { createLogger } from "./support/index.js";
29
+ import { AppError, ErrorCode, createLogger } from "./support/index.js";
29
30
  import { SuspendError } from "./suspension.js";
30
31
  const log = createLogger("ProgramRunner");
31
32
  /** Subdirectory (under the work root) that holds transient extracted program trees. */
32
33
  const RUN_DIR = ".bw-runs";
34
+ /**
35
+ * Link THIS runtime's `@boardwalk-labs/workflow` into the exec dir's `node_modules` so the
36
+ * program's bare import resolves anywhere (a self-hosted daemon workspace has no ancestor
37
+ * `node_modules`; hosted images do, making the link a harmless no-op there). A symlink — not a
38
+ * copy — is load-bearing: Node resolves it to the REAL path, so the program gets the same
39
+ * module instance the host adapter was installed on (the singleton contract). `junction` covers
40
+ * Windows without elevation; a failed link is only logged, since a resolvable ancestor may
41
+ * still exist.
42
+ */
43
+ export async function ensureSdkLink(execDir) {
44
+ // A program tarball that ships its own `node_modules/@boardwalk-labs/workflow` would shadow the
45
+ // runtime's copy — the host adapter is installed on OUR instance, so the program's hooks would
46
+ // silently throw "no host installed". Fail loudly instead of link-then-EEXIST-swallow. Only a
47
+ // REAL entry is a vendored copy; a symlink is our own link (idempotent re-invocation), left be.
48
+ const linkPath = join(execDir, "node_modules", "@boardwalk-labs", "workflow");
49
+ const existing = await lstat(linkPath).catch(() => null);
50
+ if (existing !== null) {
51
+ if (existing.isSymbolicLink())
52
+ return;
53
+ throw new AppError(ErrorCode.VALIDATION_FAILED, "Program bundles its own @boardwalk-labs/workflow; the runtime provides it (do not vendor it).");
54
+ }
55
+ try {
56
+ // Resolve via the MAIN entry (the SDK's export map exposes no "./package.json") and cut the
57
+ // path back to the package root.
58
+ const entry = createRequire(import.meta.url).resolve("@boardwalk-labs/workflow");
59
+ const marker = join("node_modules", "@boardwalk-labs", "workflow");
60
+ const idx = entry.lastIndexOf(marker);
61
+ const sdkDir = idx === -1 ? dirname(dirname(entry)) : entry.slice(0, idx + marker.length);
62
+ const scopeDir = join(execDir, "node_modules", "@boardwalk-labs");
63
+ await mkdir(scopeDir, { recursive: true });
64
+ await symlink(sdkDir, join(scopeDir, "workflow"), process.platform === "win32" ? "junction" : "dir");
65
+ }
66
+ catch (err) {
67
+ if (err.code !== "EEXIST") {
68
+ log.warn("sdk_link_failed", { error: err instanceof Error ? err.message : String(err) });
69
+ }
70
+ }
71
+ }
33
72
  /** Scratch filename for the in-flight artifact tarball inside a run's dir. */
34
73
  const ARTIFACT_FILE = "__program.tgz";
35
74
  /**
@@ -54,6 +93,9 @@ export async function runWorkflowProgram(args, deps) {
54
93
  await rm(tgzPath, { force: true });
55
94
  // The bundled tree is now on disk — let the worker point the agent() leaf at `<dir>/skills/*.md`.
56
95
  deps.onExtracted?.(dir);
96
+ // Make the bare `@boardwalk-labs/workflow` import resolve from ANY work root — hosted images
97
+ // provide it via an ancestor node_modules, but a self-hosted daemon's workspace has none.
98
+ await ensureSdkLink(dir);
57
99
  const entryPath = join(dir, ...args.entry.split("/"));
58
100
  // Run the program body. A SUSPEND is surfaced two ways and both land here as a `suspended`
59
101
  // result: (a) out of band via `suspendSignal` — racing the body, immune to a program's own
@@ -1,4 +1,4 @@
1
- // RecordingSecretResolver — the redaction feeder (the platform spec, plan #9).
1
+ // RecordingSecretResolver — the redaction feeder (the platform spec).
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,5 +1,4 @@
1
- // run_abort — the provider-agnostic cooperative-cancellation substrate for a run (the Runner Credential Broker model
2
- // §15 credit watching; the foundation user-initiated cancel will reuse).
1
+ // run_abort — the provider-agnostic cooperative-cancellation substrate for a run (the Runner Credential Broker model).
3
2
  //
4
3
  // The cancellation primitive is a Web-standard `AbortSignal` — NOT a Strands/model concept. The worker
5
4
  // owns one `AbortController` per run session; a watcher (credit, later user-cancel) calls
@@ -168,7 +168,7 @@ export declare class RunnerControlClient {
168
168
  output: unknown;
169
169
  } | null>;
170
170
  /**
171
- * Proxy one model turn through the broker (the Runner Credential Broker model — Inference). POSTs the
171
+ * Proxy one model turn through the broker (the Runner Credential Broker model). POSTs the
172
172
  * neutral conversation; the broker resolves the REAL model server-side (the runner holds no model
173
173
  * creds), invokes the matching engine adapter, and relays the model's stream back as NDJSON
174
174
  * `InferenceFrame`s (delta / result / error). Yields each frame; the engine-backed leaf surfaces
@@ -381,7 +381,7 @@ export class RunnerControlClient {
381
381
  return (await res.json());
382
382
  }
383
383
  /**
384
- * Proxy one model turn through the broker (the Runner Credential Broker model — Inference). POSTs the
384
+ * Proxy one model turn through the broker (the Runner Credential Broker model). POSTs the
385
385
  * neutral conversation; the broker resolves the REAL model server-side (the runner holds no model
386
386
  * creds), invokes the matching engine adapter, and relays the model's stream back as NDJSON
387
387
  * `InferenceFrame`s (delta / result / error). Yields each frame; the engine-backed leaf surfaces
@@ -1,5 +1,5 @@
1
1
  // RuntimeFlusher — meters a run's held-task runtime as periodic DELTAS, instead of one charge at
2
- // terminal (the platform spec; the Runner Credential Broker model). The heartbeat counterpart to the credit / cancel
2
+ // terminal (the platform spec). The heartbeat counterpart to the credit / cancel
3
3
  // / lease watchers.
4
4
  //
5
5
  // Why: runtime used to be booked once, at terminal (`now - sessionStart`). That left two holes — a run
@@ -4,7 +4,8 @@
4
4
  // (AppError taxonomy) and minimally where it doesn't (the logger: structured JSON to
5
5
  // stdout, same call surface as the platform's Powertools child logger).
6
6
  import { randomUUID } from "node:crypto";
7
- // ---- errors (ported from the Boardwalk platform's error taxonomy) ----
7
+ // ---- errors (ported from boardwalk-backend src/common/errors.ts; keep in sync until the
8
+ // backend consumes this package and the copy there is deleted) ----
8
9
  export var ErrorCode;
9
10
  (function (ErrorCode) {
10
11
  ErrorCode["VALIDATION_FAILED"] = "VALIDATION_FAILED";
@@ -1,4 +1,4 @@
1
- // artifacts — persist + reference run outputs (the platform spec + §13.6). Available to any agent
1
+ // artifacts — persist + reference run outputs (the platform spec). Available to any agent
2
2
  // (no sandbox required). Three operations:
3
3
  // * write(name, content_type, body) → store a file under the run's prefix; returns id + a signed URL.
4
4
  // * list() → artifacts produced in THIS run.
@@ -1,5 +1,5 @@
1
1
  // Artifact storage policy — the server-side rules for turning an agent's artifact-write request into
2
- // a stored S3 object (the platform spec,). These used to live in the `artifacts` TOOL,
2
+ // a stored S3 object (the platform spec). These used to live in the `artifacts` TOOL,
3
3
  // which runs on the UNTRUSTED runner; under the Runner Credential Broker (the Runner Credential Broker model) the
4
4
  // broker owns them so a malicious runner can't bypass content-type neutralization or escape its
5
5
  // run's key prefix. Pure functions — unit-tested exhaustively.
@@ -1,4 +1,4 @@
1
- // Inference proxy protocol (the Runner Credential Broker model — "Inference (class 2)").
1
+ // Inference proxy protocol (the Runner Credential Broker model)").
2
2
  //
3
3
  // The runner never invokes a model directly: under the Runner Credential Broker it holds no
4
4
  // managed-inference creds and no BYO provider key. Instead the `agent()` leaf's model turn (the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boardwalk-labs/runner",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Boardwalk self-hosted runner: execute cloud-scheduled runs on your own machines. Currently: the canonical runner contract types.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {