@boardwalk-labs/runner 0.2.16 → 0.2.17

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/dist/bin.js CHANGED
@@ -156,6 +156,10 @@ const USAGE = `boardwalk-runner <register|start|deregister> [flags]
156
156
  async function mainCli() {
157
157
  // --verbose: debug-level daemon logs (poll cycles, heartbeats). --debug: the same, PLUS the
158
158
  // spawned run processes log debug too (the env below is forwarded to children by realSpawn).
159
+ // Sanctioned OPERATOR write of the runner log env, BEFORE any worker boots — the worker's
160
+ // `configureLogging` snapshots it from the trusted boot env, so this operator flag wins while a
161
+ // run's author `meta.env` (applied later, over the relay) does not.
162
+ /* eslint-disable no-restricted-syntax -- operator sets the runner log env pre-boot; not author-reachable */
159
163
  if (hasFlag("debug")) {
160
164
  process.env.BOARDWALK_RUNNER_LOG_LEVEL = "debug";
161
165
  process.env.BOARDWALK_RUNNER_DEBUG = "1";
@@ -163,6 +167,7 @@ async function mainCli() {
163
167
  else if (hasFlag("verbose")) {
164
168
  process.env.BOARDWALK_RUNNER_LOG_LEVEL = "debug";
165
169
  }
170
+ /* eslint-enable no-restricted-syntax */
166
171
  const cmd = process.argv[2];
167
172
  if (cmd === "register")
168
173
  return await cmdRegister();
@@ -75,18 +75,28 @@ export function applyIdentityToEnv(identity, env) {
75
75
  for (const [key, value] of Object.entries(identity.env ?? {})) {
76
76
  env[key] = value;
77
77
  }
78
+ // The platform transport keys OWN their names: assert the trusted value, or DELETE the key when the
79
+ // identity omits it — so an author's `meta.env` (applied just above) can NEVER survive on a platform
80
+ // key. RUN_ID / control-plane URL / run token are always present; the api token, task size, and BYO
81
+ // registry are optional, so they set-or-clear (a conditional-only overwrite would leave an
82
+ // author-supplied `BOARDWALK_TASK_CPU_UNITS` / `BOARDWALK_API_KEY` / `BOARDWALK_BYO_PROVIDERS` in
83
+ // place for a run whose identity happens not to carry that field — e.g. an org with no BYO providers).
78
84
  env.RUN_ID = identity.run_id;
79
85
  env.BOARDWALK_CONTROL_PLANE_URL = identity.control_plane_url;
80
86
  env.BOARDWALK_RUN_TOKEN = identity.run_token;
81
- if (identity.api_token !== undefined && identity.api_token.length > 0) {
82
- env.BOARDWALK_API_KEY = identity.api_token;
83
- }
84
- if (identity.task_cpu_units !== undefined) {
85
- env.BOARDWALK_TASK_CPU_UNITS = String(identity.task_cpu_units);
86
- }
87
- if (identity.byo_providers !== undefined) {
88
- env.BOARDWALK_BYO_PROVIDERS = JSON.stringify(identity.byo_providers);
89
- }
87
+ setOrClear(env, "BOARDWALK_API_KEY", identity.api_token !== undefined && identity.api_token.length > 0
88
+ ? identity.api_token
89
+ : undefined);
90
+ setOrClear(env, "BOARDWALK_TASK_CPU_UNITS", identity.task_cpu_units !== undefined ? String(identity.task_cpu_units) : undefined);
91
+ setOrClear(env, "BOARDWALK_BYO_PROVIDERS", identity.byo_providers !== undefined ? JSON.stringify(identity.byo_providers) : undefined);
92
+ }
93
+ /** Assert a trusted value on a platform transport key, or DELETE the key when absent — so an
94
+ * author-overlaid value on that reserved-by-transport name never survives into the run env. */
95
+ function setOrClear(env, key, value) {
96
+ if (value !== undefined)
97
+ env[key] = value;
98
+ else
99
+ Reflect.deleteProperty(env, key);
90
100
  }
91
101
  /** Collect the worker_ready diagnostics. Version lookups are best-effort: the runner's own
92
102
  * package.json sits two levels above this module in both the src and published dist
@@ -3,7 +3,9 @@ import type { Run } from "./wire/run.js";
3
3
  import { type IdentityRelay } from "./identity_relay.js";
4
4
  import type { ByoInferenceProvider } from "../contract.js";
5
5
  import { type BrowserBackend } from "./browser_session.js";
6
+ import { type GuestBrowserConfig } from "./browser_session_backend.js";
6
7
  import { type CaptureBackend } from "./screen_capture.js";
8
+ import { type CaptureConfig } from "./screen_capture_backend.js";
7
9
  import { type ProgramWorkerDeps } from "./program_worker.js";
8
10
  /** Constructed primitives the pure assembly consumes (real ones in main(), fakes in tests). The
9
11
  * runner holds NO platform credential — only the per-run control-plane handle (run token + broker
@@ -54,6 +56,10 @@ export interface WorkerRuntime {
54
56
  /** Screen-capture backend (session recording + live-view frames), present ONLY when the runner IMAGE
55
57
  * ships the desktop stack (ffmpeg + an X display) and recording isn't disabled. Absent ⇒ no capture. */
56
58
  captureBackend?: CaptureBackend;
59
+ /** Path for the on-screen run-log mirror an xterm in the ambient desktop tails (BOARDWALK_RUN_LOG_FILE,
60
+ * set by the desktop guest image). Resolved by `main` from the trusted platform BOOT env so a run's
61
+ * author `meta.env` can't repoint it; absent off the desktop tier ⇒ no local sink. */
62
+ runLogFilePath?: string;
57
63
  }
58
64
  /** Durable events are deferred (durable event storage) — live fan-out via the broker only. */
59
65
  /** Synthesize the run's AuthContext. The run was already authorized at trigger time; the
@@ -109,4 +115,36 @@ export declare function publicApiOrigin(controlPlaneBaseUrl: string): string;
109
115
  * `env` (called once on `process.env` at the very top of `main`, before any run code can read it).
110
116
  */
111
117
  export declare function capturePlatformContext(env: NodeJS.ProcessEnv): PlatformContext;
118
+ /**
119
+ * The platform-owned config the worker reads for ITSELF: the browser/desktop tier, screen capture, and
120
+ * the run's sandbox roots / worker id / run-log mirror. All resolved from the passed env — which `main`
121
+ * captures from the trusted BOOT env (image-baked `/etc/bwimage.env`) BEFORE the identity relay overlays
122
+ * the run's author env onto process.env. Consumers use these TYPED fields, never process.env, so a
123
+ * workflow author's `meta.env` can't shadow platform behavior while the author still owns process.env
124
+ * outright (docs/RUN_ENV_AND_CREDS.md). Distinct from {@link capturePlatformContext}, which resolves the
125
+ * per-run CREDENTIALS the relay injects (read post-overlay, then scrubbed).
126
+ */
127
+ export interface PlatformConfig {
128
+ /** Browser/desktop tier backend config, or null when the image ships no browser stack. */
129
+ browser: GuestBrowserConfig | null;
130
+ /** Screen-capture config, or null when the image ships no desktop stack / recording is off. */
131
+ capture: CaptureConfig | null;
132
+ /** Stable worker id (WORKER_ID); absent ⇒ the caller derives one from the run id. */
133
+ workerId?: string;
134
+ /** Sandbox workspace root (WORKSPACE_ROOT), default `/workspace`. */
135
+ workspaceRoot: string;
136
+ /** Program-extraction root (PROGRAM_ROOT), default `<tmpdir>/bw-programs`. */
137
+ programRoot: string;
138
+ /** Self-hosted durable-workspace scope (PERSIST_SCOPE_DIR); set by the daemon only. */
139
+ persistScopeDir?: string;
140
+ /** Ambient-desktop run-log mirror path (BOARDWALK_RUN_LOG_FILE); set by the desktop image only. */
141
+ runLogFilePath?: string;
142
+ }
143
+ /**
144
+ * Resolve {@link PlatformConfig} from the trusted BOOT env. This is the ONE place env is read for the
145
+ * worker's own platform config — every other module consumes the typed result — so platform behavior
146
+ * never depends on the author-mutable process.env. Pure (unit-tested). MUST be called on the boot-env
147
+ * snapshot taken BEFORE the identity relay overlays the author env (see `main`).
148
+ */
149
+ export declare function capturePlatformConfig(bootEnv: NodeJS.ProcessEnv): PlatformConfig;
112
150
  export declare function main(): Promise<void>;
@@ -26,7 +26,7 @@
26
26
  //
27
27
  // Documented v0 deferral (a clear seam): durable events — a no-op AgentEventStore (live fan-out
28
28
  // via the broker only) until durable event storage lands.
29
- import { createLogger, newId, AppError, ErrorCode, DEFAULT_LEASE_MS } from "./support/index.js";
29
+ import { createLogger, configureLogging, 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";
32
32
  import { WorkerRunEventEmitter } from "./run_event_emitter.js";
@@ -44,7 +44,7 @@ import { WorkerWorkflowHost } from "./workflow_host.js";
44
44
  import { BrowserSessionManager } from "./browser_session.js";
45
45
  import { loadGuestBrowserConfig, makeGuestBrowserBackend, connectSessionMcp, } from "./browser_session_backend.js";
46
46
  import { ScreenCapture } from "./screen_capture.js";
47
- import { loadCaptureConfig, makeCaptureBackend } from "./screen_capture_backend.js";
47
+ import { loadCaptureConfig, makeCaptureBackend, } from "./screen_capture_backend.js";
48
48
  import { runProgramWorker, } from "./program_worker.js";
49
49
  import { RunnerControlClient } from "./runner_control_client.js";
50
50
  import { BrokerChildDispatcher } from "./broker_child_dispatcher.js";
@@ -131,9 +131,10 @@ export function assembleWorkerDeps(runtime) {
131
131
  // monotonic with no separate program band. Frames publish as `{cursor, event}` rows via the
132
132
  // broker telemetry path. The claim wrapper below bumps it past a previous session's frames.
133
133
  // Optional on-screen mirror: append the (already-redacted) event stream to a local file an xterm in
134
- // the ambient desktop tails, so the live-view/recording shows the run working. Opt-in via the env the
135
- // desktop guest image sets (BOARDWALK_RUN_LOG_FILE); absent everywhere else, so no cost off-desktop.
136
- const runLogFilePath = process.env.BOARDWALK_RUN_LOG_FILE?.trim();
134
+ // the ambient desktop tails, so the live-view/recording shows the run working. The path is injected
135
+ // (resolved by `main` from the trusted platform BOOT env), so env-reading stays out of this pure
136
+ // wiring and a run's author env can't repoint the mirror. Absent off the desktop tier ⇒ no sink.
137
+ const runLogFilePath = runtime.runLogFilePath?.trim();
137
138
  const runLogLocalSink = runLogFilePath !== undefined && runLogFilePath !== ""
138
139
  ? makeRunLogFileSink(runLogFilePath)
139
140
  : undefined;
@@ -660,7 +661,44 @@ export function capturePlatformContext(env) {
660
661
  Reflect.deleteProperty(env, key);
661
662
  return { runId, controlPlane, vcpus };
662
663
  }
664
+ /**
665
+ * Resolve {@link PlatformConfig} from the trusted BOOT env. This is the ONE place env is read for the
666
+ * worker's own platform config — every other module consumes the typed result — so platform behavior
667
+ * never depends on the author-mutable process.env. Pure (unit-tested). MUST be called on the boot-env
668
+ * snapshot taken BEFORE the identity relay overlays the author env (see `main`).
669
+ */
670
+ export function capturePlatformConfig(bootEnv) {
671
+ return {
672
+ browser: loadGuestBrowserConfig(bootEnv),
673
+ capture: loadCaptureConfig(bootEnv),
674
+ ...(bootEnv.WORKER_ID !== undefined ? { workerId: bootEnv.WORKER_ID } : {}),
675
+ workspaceRoot: bootEnv.WORKSPACE_ROOT ?? "/workspace",
676
+ // Never inside the workspace, and never `process.cwd()` (WORKSPACE_PERSISTENCE.md I2). `tmpdir()`
677
+ // honors TMPDIR, which the self-hosted daemon points at the per-run dir — so concurrent daemons on
678
+ // one machine don't collide, and the hosted lanes get the VM's own /tmp.
679
+ programRoot: bootEnv.PROGRAM_ROOT ?? join(tmpdir(), "bw-programs"),
680
+ ...(bootEnv.PERSIST_SCOPE_DIR !== undefined
681
+ ? { persistScopeDir: bootEnv.PERSIST_SCOPE_DIR }
682
+ : {}),
683
+ ...(bootEnv.BOARDWALK_RUN_LOG_FILE !== undefined
684
+ ? { runLogFilePath: bootEnv.BOARDWALK_RUN_LOG_FILE }
685
+ : {}),
686
+ };
687
+ }
663
688
  export async function main() {
689
+ // Snapshot the platform-owned BOOT env (image-baked `/etc/bwimage.env`, plus anything the
690
+ // launcher/daemon set) BEFORE the identity relay overlays the run's AUTHOR env onto process.env below,
691
+ // and resolve the worker's OWN config from it up front. The worker reads platform behavior only from
692
+ // these trusted values — never the author-mutable process.env — so a workflow's `meta.env` can't
693
+ // shadow it (`BOARDWALK_RECORDING_ENABLED=0`, `BOARDWALK_BROWSER_TIER=0`, `BOARDWALK_RUNNER_LOG_LEVEL`,
694
+ // repointing `WORKSPACE_ROOT`, ...). The author still owns process.env outright — no reserved keys
695
+ // (docs/RUN_ENV_AND_CREDS.md); an eslint rule bans `process.env.BOARDWALK_*` reads to keep it that way.
696
+ // Per-run values the relay ITSELF delivers (run token, api token, task size, BYO providers) are a
697
+ // separate channel: `applyIdentityToEnv` set-or-clears them over the author env, so they stay trusted
698
+ // in process.env. On env-boot substrates (Fargate/self-hosted, no relay) this equals the boot env.
699
+ const platformBootEnv = { ...process.env };
700
+ configureLogging(platformBootEnv);
701
+ const platformConfig = capturePlatformConfig(platformBootEnv);
664
702
  // Relay-mode bootstrap (the snapshot-based microVM substrate): when the guest init handed
665
703
  // us an identity relay fd, park here — warm, generic, pre-identity; this await is what the
666
704
  // base snapshot freezes — until the run's identity arrives, then map it onto process.env
@@ -690,27 +728,30 @@ export async function main() {
690
728
  // API token (the run env/credential rules). WORKER_ID / WORKSPACE_ROOT are non-secret infra knobs.
691
729
  const platform = capturePlatformContext(process.env);
692
730
  const runId = platform.runId;
731
+ // BYO providers are a per-run value the relay delivers, NOT image-baked config — read post-overlay.
732
+ // `applyIdentityToEnv` set-or-clears this key over the author env, so the value here is the org's
733
+ // registry (or empty), never an author's `meta.env`. (Sanctioned platform-key read; the general ban
734
+ // on `process.env.BOARDWALK_*` is what routes image config through `platformBootEnv` above.)
735
+ // eslint-disable-next-line no-restricted-syntax -- relay-asserted per-run key, trusted post-overlay
693
736
  const byoProviders = parseByoProviders(process.env.BOARDWALK_BYO_PROVIDERS);
694
737
  Reflect.deleteProperty(process.env, "BOARDWALK_BYO_PROVIDERS");
695
- // Browser tier: only when the runner IMAGE declares the browser stack (BOARDWALK_BROWSER_TIER=1 +
696
- // a Chrome path). The backend is run-independent (buildHost builds the per-run manager over it);
697
- // absent ⇒ `computer.openBrowser()` fails clearly. Read here so env-reading stays out of the pure wiring.
698
- const guestBrowserConfig = loadGuestBrowserConfig(process.env);
699
- const browserBackend = guestBrowserConfig !== null ? makeGuestBrowserBackend(guestBrowserConfig) : undefined;
700
- // Screen capture (recording + live-view): present only when the image ships the desktop stack
701
- // (ffmpeg + a display) and recording isn't disabled. Read here so env-reading stays out of the wiring.
702
- const captureConfig = loadCaptureConfig(process.env);
703
- const captureBackend = captureConfig !== null ? makeCaptureBackend(captureConfig) : undefined;
738
+ // Construct the image-tier backends from the typed platform config (env already read once, above).
739
+ // Browser tier: present only when the image declares the browser stack (BOARDWALK_BROWSER_TIER=1 + a
740
+ // Chrome path); absent ⇒ `computer.openBrowser()` fails clearly. Screen capture: present only when the
741
+ // image ships the desktop stack (ffmpeg + a display) and recording isn't disabled.
742
+ const browserBackend = platformConfig.browser !== null ? makeGuestBrowserBackend(platformConfig.browser) : undefined;
743
+ const captureBackend = platformConfig.capture !== null ? makeCaptureBackend(platformConfig.capture) : undefined;
704
744
  const deps = assembleWorkerDeps({
705
- workerId: process.env.WORKER_ID ?? `worker-${runId}`,
706
- workspaceRoot: process.env.WORKSPACE_ROOT ?? "/workspace",
707
- // Never inside the workspace, and never `process.cwd()` (WORKSPACE_PERSISTENCE.md I2). `tmpdir()`
708
- // honors TMPDIR, which the self-hosted daemon points at the per-run dir — so concurrent daemons on
709
- // one machine don't collide, and the hosted lanes get the VM's own /tmp.
710
- programRoot: process.env.PROGRAM_ROOT ?? join(tmpdir(), "bw-programs"),
711
- // Set by the self-hosted daemon only (both isolation lanes); hosted images never set it.
712
- ...(process.env.PERSIST_SCOPE_DIR !== undefined
713
- ? { persistScopeDir: process.env.PERSIST_SCOPE_DIR }
745
+ // Worker-self config from the typed platform config (trusted boot env), never process.env so a
746
+ // run's `meta.env` can't repoint its own workspace/program roots, worker id, or run-log mirror.
747
+ workerId: platformConfig.workerId ?? `worker-${runId}`,
748
+ workspaceRoot: platformConfig.workspaceRoot,
749
+ programRoot: platformConfig.programRoot,
750
+ ...(platformConfig.persistScopeDir !== undefined
751
+ ? { persistScopeDir: platformConfig.persistScopeDir }
752
+ : {}),
753
+ ...(platformConfig.runLogFilePath !== undefined
754
+ ? { runLogFilePath: platformConfig.runLogFilePath }
714
755
  : {}),
715
756
  runId,
716
757
  controlPlane: platform.controlPlane,
@@ -16,4 +16,7 @@ export interface CaptureConfig {
16
16
  }
17
17
  /** Read the capture config from env, or null when the desktop stack is absent or recording is off. */
18
18
  export declare function loadCaptureConfig(env: NodeJS.ProcessEnv): CaptureConfig | null;
19
+ /** ffmpeg args: one x11grab input, two outputs (change-driven segmented MP4 + a single overwritten
20
+ * JPEG). Exported for unit testing. */
21
+ export declare function ffmpegArgs(cfg: CaptureConfig, dir: string): string[];
19
22
  export declare function makeCaptureBackend(cfg: CaptureConfig): CaptureBackend;
@@ -2,6 +2,11 @@
2
2
  // the X display `:0` with two outputs — rolling MP4 segments (recording) and a single low-fps JPEG that
3
3
  // is continuously overwritten (the live-view frame). See docs/SCREEN_CAPTURE.md §4.
4
4
  //
5
+ // The recording is CHANGE-DRIVEN (mpdecimate + VFR): it stays always-on but only encodes frames that
6
+ // differ from the previous one, so an idle/headless desktop encodes ~nothing (a blank desktop collapses
7
+ // to ~1 frame) while a changing screen records in full. This keeps the always-on desktop cheap enough
8
+ // to run on every VM without a "record only when watched" gate.
9
+ //
5
10
  // Only runs where the runner IMAGE ships the desktop stack (Xvfb + ffmpeg), gated by
6
11
  // BOARDWALK_BROWSER_TIER=1 (the same "desktop present" signal the browser tier uses) + a
7
12
  // BOARDWALK_RECORDING_ENABLED kill switch (default on). Off on Fargate / self-hosted images with no
@@ -42,8 +47,9 @@ export function loadCaptureConfig(env) {
42
47
  wantedPollIntervalMs: intFromEnv(env.BOARDWALK_LIVEVIEW_WANTED_POLL_MS, 3000),
43
48
  };
44
49
  }
45
- /** ffmpeg args: one x11grab input, two outputs (segmented MP4 + a single overwritten JPEG). */
46
- function ffmpegArgs(cfg, dir) {
50
+ /** ffmpeg args: one x11grab input, two outputs (change-driven segmented MP4 + a single overwritten
51
+ * JPEG). Exported for unit testing. */
52
+ export function ffmpegArgs(cfg, dir) {
47
53
  const liveFps = Math.max(1, Math.round(1000 / cfg.liveFrameIntervalMs));
48
54
  return [
49
55
  "-y",
@@ -58,8 +64,17 @@ function ffmpegArgs(cfg, dir) {
58
64
  "-i",
59
65
  cfg.display,
60
66
  // Recording: H.264 MP4 segments, each a standalone playable file (docs/SCREEN_CAPTURE.md §4.2).
67
+ // Change-driven: `mpdecimate` drops near-duplicate frames and `-fps_mode vfr` lets the muxer honor
68
+ // those drops, so a static/idle desktop encodes ~nothing (collapses to ~1 frame) while a changing
69
+ // screen still records in full. Always-on — cost tracks on-screen activity, not a flat framerate.
70
+ // (`-preset ultrafast` would cut per-frame CPU further on busy screens but inflates file size; kept
71
+ // `veryfast` so the change is a pure win with no storage/bandwidth regression.)
61
72
  "-map",
62
73
  "0:v",
74
+ "-vf",
75
+ "mpdecimate",
76
+ "-fps_mode",
77
+ "vfr",
63
78
  "-c:v",
64
79
  "libx264",
65
80
  "-preset",
@@ -37,6 +37,18 @@ export interface Logger {
37
37
  warn(message: string, fields?: LogFields): void;
38
38
  error(message: string, fields?: LogFields): void;
39
39
  }
40
+ /**
41
+ * Freeze the runner's log level from a TRUSTED env — the platform boot env, snapshotted before the
42
+ * identity relay overlays a run's author env onto process.env (see `main`). Call once at bootstrap.
43
+ *
44
+ * Until it's called — in tests, and in the CLI/daemon before boot — {@link activeLevel} falls back to
45
+ * reading process.env live, so an operator's `--verbose`/`--debug` (which sets the env BEFORE boot,
46
+ * bin.ts) still applies. After it's called, a workflow author's `meta.env` (overlaid onto process.env
47
+ * at run time) can no longer raise the runner's own log verbosity. An author-facing verbosity control
48
+ * belongs in the manifest `meta` (e.g. a `logVerbosity` field), delivered over the trusted control
49
+ * plane — never an env knob.
50
+ */
51
+ export declare function configureLogging(env: NodeJS.ProcessEnv): void;
40
52
  export declare function createLogger(module: string): Logger;
41
53
  export type OrgRole = "owner" | "admin" | "member" | "viewer";
42
54
  export type AuthSource = "session_jwt" | "oauth_jwt" | "api_key" | "workflow";
@@ -71,15 +71,33 @@ export function newId() {
71
71
  return randomUUID();
72
72
  }
73
73
  const LEVEL_ORDER = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
74
- /** Active log level: BOARDWALK_RUNNER_LOG_LEVEL (debug|info|warn|error), default info.
75
- * BOARDWALK_RUNNER_DEBUG=1 is a legacy alias for debug. Read per call so a CLI flag that
76
- * sets the env (e.g. `--verbose` / `--debug`) applies without logger re-creation. */
77
- function activeLevel() {
78
- if (process.env.BOARDWALK_RUNNER_DEBUG === "1")
74
+ /** Numeric log level from an env: BOARDWALK_RUNNER_LOG_LEVEL (debug|info|warn|error), default info;
75
+ * BOARDWALK_RUNNER_DEBUG=1 is a legacy alias for debug. Reads the passed `env`, not process.env. */
76
+ function levelFromEnv(env) {
77
+ if (env.BOARDWALK_RUNNER_DEBUG === "1")
79
78
  return LEVEL_ORDER.DEBUG ?? 10;
80
- const name = process.env.BOARDWALK_RUNNER_LOG_LEVEL?.toUpperCase() ?? "INFO";
79
+ const name = env.BOARDWALK_RUNNER_LOG_LEVEL?.toUpperCase() ?? "INFO";
81
80
  return LEVEL_ORDER[name] ?? 20;
82
81
  }
82
+ /** The level frozen at worker bootstrap from the TRUSTED boot env, or null until then. */
83
+ let configuredLevel = null;
84
+ /**
85
+ * Freeze the runner's log level from a TRUSTED env — the platform boot env, snapshotted before the
86
+ * identity relay overlays a run's author env onto process.env (see `main`). Call once at bootstrap.
87
+ *
88
+ * Until it's called — in tests, and in the CLI/daemon before boot — {@link activeLevel} falls back to
89
+ * reading process.env live, so an operator's `--verbose`/`--debug` (which sets the env BEFORE boot,
90
+ * bin.ts) still applies. After it's called, a workflow author's `meta.env` (overlaid onto process.env
91
+ * at run time) can no longer raise the runner's own log verbosity. An author-facing verbosity control
92
+ * belongs in the manifest `meta` (e.g. a `logVerbosity` field), delivered over the trusted control
93
+ * plane — never an env knob.
94
+ */
95
+ export function configureLogging(env) {
96
+ configuredLevel = levelFromEnv(env);
97
+ }
98
+ function activeLevel() {
99
+ return configuredLevel ?? levelFromEnv(process.env);
100
+ }
83
101
  function emit(level, module, message, fields) {
84
102
  if ((LEVEL_ORDER[level] ?? 20) < activeLevel())
85
103
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boardwalk-labs/runner",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
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": {