@boardwalk-labs/runner 0.1.7 → 0.1.9

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.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import { type IsolationConfig } from "./daemon/index.js";
3
+ /** Build the isolation config from CLI flags: containerized by default; `--host` is the escape hatch. */
4
+ export declare function isolationFromFlags(): IsolationConfig;
package/dist/bin.js CHANGED
@@ -14,14 +14,12 @@
14
14
  //
15
15
  // Corporate proxies: launch with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY set — Node's fetch (the
16
16
  // daemon) and the spawned run processes both honor it.
17
- import { spawn as nodeSpawn } from "node:child_process";
18
17
  import { readFileSync } from "node:fs";
19
- import { fileURLToPath } from "node:url";
20
18
  import * as path from "node:path";
21
19
  import * as os from "node:os";
22
20
  import { createLogger } from "./runtime/support/index.js";
23
21
  import { runnerOsSchema, runnerArchSchema } from "./contract.js";
24
- import { PoolClient, defaultIdentityDir, loadIdentity, removeIdentity, saveIdentity, startDaemon, } from "./daemon/index.js";
22
+ import { PoolClient, defaultIdentityDir, loadIdentity, removeIdentity, saveIdentity, startRunner, } from "./daemon/index.js";
25
23
  const log = createLogger("boardwalk-runner");
26
24
  function flag(name) {
27
25
  const i = process.argv.indexOf(`--${name}`);
@@ -48,47 +46,6 @@ function machineArch() {
48
46
  const parsed = runnerArchSchema.safeParse(process.arch);
49
47
  return parsed.success ? parsed.data : undefined;
50
48
  }
51
- /** Real spawner: one Node process per run, executing the SAME runtime a hosted worker boots.
52
- * Env is exactly the platform contract + the claim's resolved vars, over a minimal base
53
- * (PATH + proxy knobs from the daemon's own environment; HOME = the run's workspace). */
54
- function realSpawn(opts) {
55
- const base = {};
56
- for (const key of [
57
- "PATH",
58
- "LANG",
59
- "NODE_USE_ENV_PROXY",
60
- "HTTPS_PROXY",
61
- "https_proxy",
62
- "HTTP_PROXY",
63
- "http_proxy",
64
- "NO_PROXY",
65
- "no_proxy",
66
- "BOARDWALK_RUNNER_DEBUG",
67
- ]) {
68
- const v = process.env[key];
69
- if (v !== undefined)
70
- base[key] = v;
71
- }
72
- const child = nodeSpawn(process.execPath, [opts.entry], {
73
- cwd: opts.cwd,
74
- env: { ...base, HOME: opts.cwd, TMPDIR: path.join(opts.cwd, "..", "tmp"), ...opts.env },
75
- stdio: "inherit",
76
- });
77
- const exit = new Promise((resolve) => {
78
- child.on("exit", (code, signal) => {
79
- resolve(code ?? (signal !== null ? 143 : 1));
80
- });
81
- child.on("error", () => {
82
- resolve(1);
83
- });
84
- });
85
- return {
86
- wait: () => exit,
87
- kill: () => {
88
- child.kill("SIGTERM");
89
- },
90
- };
91
- }
92
49
  async function cmdRegister() {
93
50
  const baseUrl = requireFlag("url");
94
51
  const token = requireFlag("token");
@@ -114,6 +71,23 @@ async function cmdRegister() {
114
71
  log.info("registered", { runnerId: res.runner_id, pool: res.pool, identity: file });
115
72
  process.stdout.write(`Registered runner ${res.runner_id} in pool '${res.pool}'.\nStart it with: boardwalk-runner start --url ${baseUrl} --pool ${res.pool}\n`);
116
73
  }
74
+ /** Build the isolation config from CLI flags: containerized by default; `--host` is the escape hatch. */
75
+ export function isolationFromFlags() {
76
+ if (hasFlag("host"))
77
+ return { mode: "host" };
78
+ const image = flag("image");
79
+ const network = flag("network");
80
+ const mounts = (flag("mount") ?? "")
81
+ .split(",")
82
+ .map((m) => m.trim())
83
+ .filter((m) => m.length > 0);
84
+ return {
85
+ mode: "container",
86
+ ...(image !== undefined ? { image } : {}),
87
+ ...(network !== undefined ? { network } : {}),
88
+ ...(mounts.length > 0 ? { mounts } : {}),
89
+ };
90
+ }
117
91
  async function cmdStart() {
118
92
  const baseUrl = requireFlag("url");
119
93
  const pool = flag("pool") ?? "default";
@@ -123,32 +97,15 @@ async function cmdStart() {
123
97
  process.stderr.write(`No saved identity for ${baseUrl} pool '${pool}'. Run boardwalk-runner register first.\n`);
124
98
  process.exit(1);
125
99
  }
126
- const workDir = flag("work-dir") ?? path.join(identityDir, "work");
127
- const runtimeEntry = fileURLToPath(new URL("./runtime/main.js", import.meta.url));
128
- const client = new PoolClient({ baseUrl, runnerToken: identity.runner_token });
129
- const daemon = startDaemon({
130
- client,
131
- runtimeEntry,
132
- workDir,
133
- runnerId: identity.runner_id,
134
- spawn: realSpawn,
100
+ // The daemon lifecycle + isolation decision live in the shared startRunner — this binary and the
101
+ // main CLI both call it, so their behavior can't drift.
102
+ await startRunner({
103
+ baseUrl,
104
+ identity,
105
+ isolation: isolationFromFlags(),
106
+ workDir: flag("work-dir") ?? path.join(identityDir, "work"),
135
107
  ...(hasFlag("once") ? { once: true } : {}),
136
108
  });
137
- let interrupts = 0;
138
- for (const signal of ["SIGINT", "SIGTERM"]) {
139
- process.on(signal, () => {
140
- interrupts += 1;
141
- if (interrupts === 1) {
142
- process.stderr.write("\nDraining: finishing the current run, claiming nothing new. Ctrl-C again to force-quit.\n");
143
- daemon.drain();
144
- }
145
- else {
146
- process.exit(130);
147
- }
148
- });
149
- }
150
- process.stdout.write(`Runner ${identity.name} online in pool '${pool}'. Waiting for runs...\n`);
151
- await daemon.done;
152
109
  }
153
110
  async function cmdDeregister() {
154
111
  const baseUrl = requireFlag("url");
@@ -183,8 +140,16 @@ const USAGE = `boardwalk-runner <register|start|deregister> [flags]
183
140
 
184
141
  register --url <control-plane> --token <bwkreg_…> [--name] [--labels a,b] [--identity-dir]
185
142
  start --url <control-plane> [--pool default] [--work-dir] [--once] [--identity-dir]
143
+ [--host] [--image <ref>] [--network <mode>] [--mount host:container[:ro],…]
186
144
  deregister --url <control-plane> [--pool default] [--identity-dir]
187
145
 
146
+ Isolation (start): runs are CONTAINERIZED by default (docker/podman) — each run sees only its
147
+ workspace + the machine's network, not your home dir, creds, or the rest of the machine.
148
+ --host raw process mode: full machine access (trusted workflows only / no runtime)
149
+ --image <ref> runner image to run (default: the version-pinned ghcr.io image)
150
+ --network <mode> container network (default: host — preserves LAN/VPN/localhost reach)
151
+ --mount a:b[:ro] extra host paths to expose to the run (comma-separated)
152
+
188
153
  --verbose debug-level daemon logs (poll cycles, heartbeats)
189
154
  --debug --verbose, plus debug logging inside each spawned run process
190
155
  `;
@@ -0,0 +1,46 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import type { RunProcessHandle } from "./daemon.js";
3
+ export interface ContainerSpawnConfig {
4
+ /** Container runtime binary: `docker` or `podman`. */
5
+ runtime: string;
6
+ /** Fully-qualified runner image ref (e.g. `ghcr.io/boardwalk-labs/runner:0.1.9`). */
7
+ image: string;
8
+ /** Docker network mode. Default `host` — preserves the machine's LAN/VPN/localhost reach. */
9
+ network?: string;
10
+ /** Extra host bind mounts (`hostPath:containerPath[:ro]`) the user opted into for this fleet. */
11
+ mounts?: readonly string[];
12
+ /** Run the container as this `uid:gid` — the invoking host user — so writes to the bind-mounted
13
+ * workspace match host ownership instead of the image's `node` (uid 1000). Set on Linux; omit on
14
+ * Docker Desktop (macOS/Windows), which maps ownership through its file sharing. */
15
+ user?: string;
16
+ /** Test seam. */
17
+ spawn?: typeof nodeSpawn;
18
+ }
19
+ /** The env the container receives: the claim's env + platform contract, with the in-container
20
+ * filesystem coordinates overridden (the host paths are meaningless inside the container). */
21
+ export declare function containerEnv(runEnv: Record<string, string>): Record<string, string>;
22
+ /** Derive the run id from the per-run workspace path (`<workDir>/runs/<runId>/workspace`). Used only
23
+ * for a human-readable container name; a fallback keeps a non-standard cwd from throwing. */
24
+ export declare function runIdFromCwd(cwd: string): string;
25
+ /**
26
+ * Build the `docker run` argv. PURE + exported so the isolation guarantees are unit-asserted:
27
+ * - the ONLY bind mount is the per-run workspace (+ any explicit user mounts) — the identity dir,
28
+ * the user's home, and the rest of the host FS are never mounted;
29
+ * - per-run credentials are passed by NAME (`-e BOARDWALK_RUN_TOKEN`), so their VALUES come from the
30
+ * docker client's env and never appear in the argv (which `ps` exposes);
31
+ * - `--rm` (no leftover container), `--init` (proper signal handling / zombie reaping).
32
+ */
33
+ export declare function buildContainerArgs(cfg: ContainerSpawnConfig, opts: {
34
+ env: Record<string, string>;
35
+ cwd: string;
36
+ }): string[];
37
+ /** A `RunSpawner` that runs each run in a throwaway container. */
38
+ export declare function createContainerSpawner(cfg: ContainerSpawnConfig): (opts: {
39
+ entry: string;
40
+ env: Record<string, string>;
41
+ cwd: string;
42
+ }) => RunProcessHandle;
43
+ /** Probe that a container runtime is installed AND its daemon is reachable. Returns the runtime
44
+ * binary name on success, or null (so `start` can hard-fail with a clear message rather than
45
+ * failing every run later). `docker info` / `podman info` exits non-zero when the daemon is down. */
46
+ export declare function detectContainerRuntime(candidates?: readonly string[], run?: (bin: string) => Promise<void>): Promise<string | null>;
@@ -0,0 +1,145 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Container run spawner — the DEFAULT isolation model for a self-hosted runner. Each run executes
3
+ // inside a throwaway container (Docker/Podman) instead of as a same-UID child process, so the run
4
+ // (and the LLM-decided `agent()` tool calls inside it) sees ONLY its own workspace, never the
5
+ // user's home dir, the runner's identity file, SSH/cloud creds, or the rest of the machine. The
6
+ // machine's network IS preserved (`--network host`) so a run still reaches the org's LAN, VPN, and
7
+ // localhost services — the reason to self-host. Full host access stays possible, but only as an
8
+ // EXPLICIT mount, never the silent default. `--host` (a separate spawner) is the escape hatch.
9
+ //
10
+ // Security-critical surface: `buildContainerArgs` is a pure function so the argv can be asserted
11
+ // exhaustively — the identity dir is NEVER bind-mounted, and per-run credentials ride the docker
12
+ // client's ENV (name-only `-e KEY`), never the argv (which `ps` would expose).
13
+ import { spawn as nodeSpawn, execFile } from "node:child_process";
14
+ import { promisify } from "node:util";
15
+ import * as path from "node:path";
16
+ const exec = promisify(execFile);
17
+ /** Env keys forwarded from the daemon's OWN environment into the container (proxy + locale only —
18
+ * NOT the machine's PATH/HOME, which would leak host layout; the image provides its own). */
19
+ const FORWARDED_ENV_KEYS = [
20
+ "LANG",
21
+ "NODE_USE_ENV_PROXY",
22
+ "HTTPS_PROXY",
23
+ "https_proxy",
24
+ "HTTP_PROXY",
25
+ "http_proxy",
26
+ "NO_PROXY",
27
+ "no_proxy",
28
+ "BOARDWALK_RUNNER_DEBUG",
29
+ ];
30
+ /** The env the container receives: the claim's env + platform contract, with the in-container
31
+ * filesystem coordinates overridden (the host paths are meaningless inside the container). */
32
+ export function containerEnv(runEnv) {
33
+ const env = {
34
+ // Forward the daemon's proxy/locale knobs so egress + a corporate proxy still work.
35
+ ...forwardedEnv(),
36
+ // The claim's resolved non-secret vars + platform contract (RUN_ID, BOARDWALK_RUN_TOKEN, …).
37
+ ...runEnv,
38
+ // In-container coordinates — the bind mount lands the workspace at /workspace.
39
+ WORKSPACE_ROOT: "/workspace",
40
+ HOME: "/workspace",
41
+ TMPDIR: "/tmp",
42
+ };
43
+ return env;
44
+ }
45
+ function forwardedEnv() {
46
+ const out = {};
47
+ for (const key of FORWARDED_ENV_KEYS) {
48
+ const v = process.env[key];
49
+ if (v !== undefined)
50
+ out[key] = v;
51
+ }
52
+ return out;
53
+ }
54
+ /** Derive the run id from the per-run workspace path (`<workDir>/runs/<runId>/workspace`). Used only
55
+ * for a human-readable container name; a fallback keeps a non-standard cwd from throwing. */
56
+ export function runIdFromCwd(cwd) {
57
+ const runId = path.basename(path.dirname(cwd));
58
+ return /^[A-Za-z0-9_-]{1,64}$/.test(runId) ? runId : "run";
59
+ }
60
+ /**
61
+ * Build the `docker run` argv. PURE + exported so the isolation guarantees are unit-asserted:
62
+ * - the ONLY bind mount is the per-run workspace (+ any explicit user mounts) — the identity dir,
63
+ * the user's home, and the rest of the host FS are never mounted;
64
+ * - per-run credentials are passed by NAME (`-e BOARDWALK_RUN_TOKEN`), so their VALUES come from the
65
+ * docker client's env and never appear in the argv (which `ps` exposes);
66
+ * - `--rm` (no leftover container), `--init` (proper signal handling / zombie reaping).
67
+ */
68
+ export function buildContainerArgs(cfg, opts) {
69
+ const args = [
70
+ "run",
71
+ "--rm",
72
+ "--init",
73
+ "--name",
74
+ `bw-run-${runIdFromCwd(opts.cwd)}`,
75
+ "--network",
76
+ cfg.network ?? "host",
77
+ // The per-run workspace — the ONLY host path the run can see by default.
78
+ "-v",
79
+ `${opts.cwd}:/workspace`,
80
+ "-w",
81
+ "/workspace",
82
+ ];
83
+ // Run as the invoking host user (Linux) so the bind-mounted workspace is writable — the image's
84
+ // `node` (uid 1000) otherwise can't write a host dir it doesn't own.
85
+ if (cfg.user !== undefined) {
86
+ args.push("--user", cfg.user);
87
+ }
88
+ // Explicit, user-opted-in host mounts (fleet config) — the honest way to say "I grant this path".
89
+ for (const m of cfg.mounts ?? []) {
90
+ args.push("-v", m);
91
+ }
92
+ // Env by NAME only — values are read from the docker client's environment, never the argv.
93
+ for (const key of Object.keys(containerEnv(opts.env))) {
94
+ args.push("-e", key);
95
+ }
96
+ args.push(cfg.image);
97
+ return args;
98
+ }
99
+ /** A `RunSpawner` that runs each run in a throwaway container. */
100
+ export function createContainerSpawner(cfg) {
101
+ const spawnImpl = cfg.spawn ?? nodeSpawn;
102
+ return (opts) => {
103
+ const args = buildContainerArgs(cfg, opts);
104
+ const child = spawnImpl(cfg.runtime, args, {
105
+ // The docker client reads the name-only `-e KEY` values from ITS environment — set them here so
106
+ // they never touch the argv. The daemon's own secrets are NOT here (only forwarded + claim env).
107
+ env: { ...process.env, ...containerEnv(opts.env) },
108
+ stdio: "inherit",
109
+ });
110
+ const exit = new Promise((resolve) => {
111
+ child.on("exit", (code, signal) => {
112
+ resolve(code ?? (signal !== null ? 143 : 1));
113
+ });
114
+ child.on("error", () => {
115
+ resolve(1);
116
+ });
117
+ });
118
+ return {
119
+ wait: () => exit,
120
+ kill: () => {
121
+ // `docker run` proxies SIGTERM to the container; also best-effort `docker kill` by name in
122
+ // case signal proxying is off, so a lost lease / drain actually stops the container.
123
+ child.kill("SIGTERM");
124
+ void exec(cfg.runtime, ["kill", `bw-run-${runIdFromCwd(opts.cwd)}`]).catch(() => undefined);
125
+ },
126
+ };
127
+ };
128
+ }
129
+ /** Probe that a container runtime is installed AND its daemon is reachable. Returns the runtime
130
+ * binary name on success, or null (so `start` can hard-fail with a clear message rather than
131
+ * failing every run later). `docker info` / `podman info` exits non-zero when the daemon is down. */
132
+ export async function detectContainerRuntime(candidates = ["docker", "podman"], run = async (bin) => {
133
+ await exec(bin, ["info"], { timeout: 10_000 });
134
+ }) {
135
+ for (const bin of candidates) {
136
+ try {
137
+ await run(bin);
138
+ return bin;
139
+ }
140
+ catch {
141
+ // not installed, or the daemon isn't running — try the next candidate
142
+ }
143
+ }
144
+ return null;
145
+ }
@@ -2,10 +2,20 @@
2
2
  // Persisted runner identity — the standing credential + coordinates a machine keeps between
3
3
  // restarts (`runner start` after a reboot skips registration). One JSON file per
4
4
  // (control plane, pool), mode 0600: the runner token is a credential.
5
- import { mkdir, readFile, writeFile, unlink } from "node:fs/promises";
5
+ import { mkdir, readFile, writeFile, unlink, chmod } from "node:fs/promises";
6
6
  import * as path from "node:path";
7
7
  import * as os from "node:os";
8
8
  import { z } from "zod";
9
+ /** Re-assert restrictive perms on a path (POSIX only). `mkdir`/`writeFile` set mode only when they
10
+ * CREATE, so a pre-existing dir/file could be group/world-readable; this makes the credential
11
+ * fail-safe regardless. No-op on Windows (no POSIX mode bits). Best-effort — a chmod failure must
12
+ * not break enrollment. NOTE: this hardens the file at rest; it does NOT stop a same-UID process
13
+ * (an in-process run program) from reading it — that requires per-run UID/container isolation. */
14
+ async function hardenPerms(target, mode) {
15
+ if (process.platform === "win32")
16
+ return;
17
+ await chmod(target, mode).catch(() => undefined);
18
+ }
9
19
  export const runnerIdentitySchema = z.strictObject({
10
20
  runner_id: z.string().min(1),
11
21
  runner_token: z.string().min(1),
@@ -25,15 +35,21 @@ function identityFile(dir, controlPlaneUrl, pool) {
25
35
  }
26
36
  export async function saveIdentity(dir, identity) {
27
37
  await mkdir(dir, { recursive: true, mode: 0o700 });
38
+ await hardenPerms(dir, 0o700); // re-assert: mkdir's mode only applies when it CREATES the dir
28
39
  const file = identityFile(dir, identity.control_plane_url, identity.pool);
29
40
  await writeFile(file, `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 });
41
+ await hardenPerms(file, 0o600); // re-assert: writeFile's mode only applies to a NEW file
30
42
  return file;
31
43
  }
32
44
  export async function loadIdentity(dir, controlPlaneUrl, pool) {
33
45
  try {
34
- const raw = await readFile(identityFile(dir, controlPlaneUrl, pool), "utf8");
46
+ const file = identityFile(dir, controlPlaneUrl, pool);
47
+ const raw = await readFile(file, "utf8");
35
48
  const parsed = runnerIdentitySchema.safeParse(JSON.parse(raw));
36
- return parsed.success ? parsed.data : null;
49
+ if (!parsed.success)
50
+ return null;
51
+ await hardenPerms(file, 0o600); // repair perms if something loosened them since save
52
+ return parsed.data;
37
53
  }
38
54
  catch {
39
55
  return null;
@@ -1,3 +1,5 @@
1
1
  export * from "./pool_client.js";
2
2
  export * from "./identity.js";
3
3
  export * from "./daemon.js";
4
+ export * from "./container.js";
5
+ export * from "./start.js";
@@ -2,3 +2,5 @@
2
2
  export * from "./pool_client.js";
3
3
  export * from "./identity.js";
4
4
  export * from "./daemon.js";
5
+ export * from "./container.js";
6
+ export * from "./start.js";
@@ -0,0 +1,52 @@
1
+ import { type RunProcessHandle, type RunSpawner } from "./daemon.js";
2
+ import { detectContainerRuntime } from "./container.js";
3
+ import type { RunnerIdentity } from "./identity.js";
4
+ export type IsolationMode = "container" | "host";
5
+ export interface IsolationConfig {
6
+ /** `container` (default) = throwaway container per run; `host` = raw process, full machine access. */
7
+ mode: IsolationMode;
8
+ /** Container image ref (container mode). Defaults to the version-pinned ghcr.io/boardwalk-labs image. */
9
+ image?: string;
10
+ /** Container network mode (container mode). Defaults to `host` — preserves LAN/VPN/localhost reach. */
11
+ network?: string;
12
+ /** Extra host bind mounts to expose to the run (container mode). */
13
+ mounts?: readonly string[];
14
+ }
15
+ /** This package's version (the runner image tag is pinned to it). Read from package.json so a
16
+ * release bump is one file. */
17
+ export declare function packageVersion(): string;
18
+ /** Default runner image ref — the version-pinned public image (base + this runtime). */
19
+ export declare function defaultImage(): string;
20
+ /** Raw-process spawner (the `--host` escape hatch): one Node process per run, full machine access.
21
+ * Env = the platform contract + the claim's resolved vars over a minimal base (PATH + proxy knobs;
22
+ * HOME = the run's workspace). */
23
+ export declare function processSpawn(opts: {
24
+ entry: string;
25
+ env: Record<string, string>;
26
+ cwd: string;
27
+ }): RunProcessHandle;
28
+ /** Error thrown when container isolation is requested but no runtime is available — the caller
29
+ * surfaces the message and exits (never silently drop to the unisolated path). */
30
+ export declare class NoContainerRuntimeError extends Error {
31
+ constructor();
32
+ }
33
+ /** Pick the run spawner from the isolation config. Container is default; `host` is the escape hatch;
34
+ * container-with-no-runtime throws {@link NoContainerRuntimeError}. */
35
+ export declare function resolveSpawner(iso: IsolationConfig, log: (line: string) => void, detect?: typeof detectContainerRuntime): Promise<RunSpawner>;
36
+ export interface StartRunnerOptions {
37
+ baseUrl: string;
38
+ identity: RunnerIdentity;
39
+ isolation: IsolationConfig;
40
+ workDir: string;
41
+ once?: boolean;
42
+ /** Progress line sink (stdout by default). */
43
+ log?: (line: string) => void;
44
+ /** Wire SIGINT/SIGTERM → drain (Ctrl-C finishes the current run; a second forces quit). Default on. */
45
+ handleSignals?: boolean;
46
+ }
47
+ /**
48
+ * Run the daemon to completion: resolve the spawner (isolation), poll → claim → execute → heartbeat,
49
+ * and drain cleanly on Ctrl-C. Both entry points call this after obtaining an identity, so there is
50
+ * exactly one implementation of the run loop + isolation decision.
51
+ */
52
+ export declare function startRunner(opts: StartRunnerOptions): Promise<void>;
@@ -0,0 +1,152 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // startRunner — the ONE implementation of "run this machine as a runner". Both entry points
3
+ // (`boardwalk-runner start` and the CLI's `boardwalk runner start`) obtain an identity their own
4
+ // way (two-step token vs one-step management-API enrollment) and then call THIS. There is no second
5
+ // copy of the daemon lifecycle, the isolation decision, or the drain handling to drift out of sync.
6
+ //
7
+ // Isolation is a `RunSpawner` choice: `container` (default) runs each run in a throwaway container;
8
+ // `host` (the `--host` escape hatch) runs it as a raw child process with full machine access. A
9
+ // future macOS/Windows native sandbox is simply a third RunSpawner behind this same seam.
10
+ import { spawn as nodeSpawn } from "node:child_process";
11
+ import { readFileSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import * as path from "node:path";
14
+ import { PoolClient } from "./pool_client.js";
15
+ import { startDaemon } from "./daemon.js";
16
+ import { createContainerSpawner, detectContainerRuntime } from "./container.js";
17
+ /** This package's version (the runner image tag is pinned to it). Read from package.json so a
18
+ * release bump is one file. */
19
+ export function packageVersion() {
20
+ try {
21
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
22
+ if (typeof pkg === "object" && pkg !== null && "version" in pkg) {
23
+ const v = pkg.version;
24
+ if (typeof v === "string")
25
+ return v;
26
+ }
27
+ }
28
+ catch {
29
+ // fall through
30
+ }
31
+ return "0.0.0";
32
+ }
33
+ /** Default runner image ref — the version-pinned public image (base + this runtime). */
34
+ export function defaultImage() {
35
+ return `ghcr.io/boardwalk-labs/runner:${packageVersion()}`;
36
+ }
37
+ /** Raw-process spawner (the `--host` escape hatch): one Node process per run, full machine access.
38
+ * Env = the platform contract + the claim's resolved vars over a minimal base (PATH + proxy knobs;
39
+ * HOME = the run's workspace). */
40
+ export function processSpawn(opts) {
41
+ const base = {};
42
+ for (const key of [
43
+ "PATH",
44
+ "LANG",
45
+ "NODE_USE_ENV_PROXY",
46
+ "HTTPS_PROXY",
47
+ "https_proxy",
48
+ "HTTP_PROXY",
49
+ "http_proxy",
50
+ "NO_PROXY",
51
+ "no_proxy",
52
+ "BOARDWALK_RUNNER_DEBUG",
53
+ ]) {
54
+ const v = process.env[key];
55
+ if (v !== undefined)
56
+ base[key] = v;
57
+ }
58
+ const child = nodeSpawn(process.execPath, [opts.entry], {
59
+ cwd: opts.cwd,
60
+ env: { ...base, HOME: opts.cwd, TMPDIR: path.join(opts.cwd, "..", "tmp"), ...opts.env },
61
+ stdio: "inherit",
62
+ });
63
+ const exit = new Promise((resolve) => {
64
+ child.on("exit", (code, signal) => {
65
+ resolve(code ?? (signal !== null ? 143 : 1));
66
+ });
67
+ child.on("error", () => {
68
+ resolve(1);
69
+ });
70
+ });
71
+ return {
72
+ wait: () => exit,
73
+ kill: () => {
74
+ child.kill("SIGTERM");
75
+ },
76
+ };
77
+ }
78
+ /** Error thrown when container isolation is requested but no runtime is available — the caller
79
+ * surfaces the message and exits (never silently drop to the unisolated path). */
80
+ export class NoContainerRuntimeError extends Error {
81
+ constructor() {
82
+ super("No container runtime found (looked for docker, podman). Self-hosted runs are containerized " +
83
+ "by default for isolation.\n" +
84
+ " • Install Docker or Podman and make sure it's running, or\n" +
85
+ " • pass --host to run without isolation (full machine access; trusted workflows only).");
86
+ this.name = "NoContainerRuntimeError";
87
+ }
88
+ }
89
+ /** Pick the run spawner from the isolation config. Container is default; `host` is the escape hatch;
90
+ * container-with-no-runtime throws {@link NoContainerRuntimeError}. */
91
+ export async function resolveSpawner(iso, log, detect = detectContainerRuntime) {
92
+ if (iso.mode === "host") {
93
+ log("Running WITHOUT isolation (--host): runs get full access to this machine as your user. " +
94
+ "Only run workflows you trust.\n");
95
+ return processSpawn;
96
+ }
97
+ const runtime = await detect();
98
+ if (runtime === null)
99
+ throw new NoContainerRuntimeError();
100
+ const image = iso.image ?? defaultImage();
101
+ // Linux: run as the invoking user so the bind-mounted workspace is writable. Docker Desktop
102
+ // (macOS/Windows) maps ownership via file sharing, so leave the image's user in place there.
103
+ const user = process.platform === "linux" && process.getuid !== undefined && process.getgid !== undefined
104
+ ? `${String(process.getuid())}:${String(process.getgid())}`
105
+ : undefined;
106
+ log(`Isolation: ${runtime} container (${image}); workspace-only + ${iso.network ?? "host"} network.\n`);
107
+ return createContainerSpawner({
108
+ runtime,
109
+ image,
110
+ ...(iso.network !== undefined ? { network: iso.network } : {}),
111
+ ...(iso.mounts !== undefined && iso.mounts.length > 0 ? { mounts: iso.mounts } : {}),
112
+ ...(user !== undefined ? { user } : {}),
113
+ });
114
+ }
115
+ /**
116
+ * Run the daemon to completion: resolve the spawner (isolation), poll → claim → execute → heartbeat,
117
+ * and drain cleanly on Ctrl-C. Both entry points call this after obtaining an identity, so there is
118
+ * exactly one implementation of the run loop + isolation decision.
119
+ */
120
+ export async function startRunner(opts) {
121
+ const log = opts.log ?? ((line) => void process.stdout.write(line));
122
+ const spawn = await resolveSpawner(opts.isolation, log);
123
+ // Process mode imports this entry directly; container mode ignores it (the image's entrypoint runs
124
+ // the runtime), but the daemon still passes it through the spawner interface.
125
+ const runtimeEntry = fileURLToPath(new URL("../runtime/main.js", import.meta.url));
126
+ const client = new PoolClient({ baseUrl: opts.baseUrl, runnerToken: opts.identity.runner_token });
127
+ const daemon = startDaemon({
128
+ client,
129
+ runtimeEntry,
130
+ workDir: opts.workDir,
131
+ runnerId: opts.identity.runner_id,
132
+ spawn,
133
+ ...(opts.once === true ? { once: true } : {}),
134
+ });
135
+ if (opts.handleSignals !== false) {
136
+ let interrupts = 0;
137
+ for (const signal of ["SIGINT", "SIGTERM"]) {
138
+ process.on(signal, () => {
139
+ interrupts += 1;
140
+ if (interrupts === 1) {
141
+ log("\nDraining: finishing the current run, claiming nothing new. Ctrl-C again to force-quit.\n");
142
+ daemon.drain();
143
+ }
144
+ else {
145
+ process.exit(130);
146
+ }
147
+ });
148
+ }
149
+ }
150
+ log(`Runner ${opts.identity.name} online in pool '${opts.identity.pool}'. Waiting for runs...\n`);
151
+ await daemon.done;
152
+ }
@@ -15,6 +15,7 @@ import { promisify } from "node:util";
15
15
  import { stat, mkdir, readFile, writeFile, rm } from "node:fs/promises";
16
16
  import { tmpdir } from "node:os";
17
17
  import { dirname, join } from "node:path";
18
+ import { extract as tarExtract } from "tar";
18
19
  import { createLogger } from "./support/index.js";
19
20
  const log = createLogger("WorkspaceStore");
20
21
  const exec = promisify(execFile);
@@ -107,7 +108,13 @@ export class TarWorkspaceArchiver {
107
108
  }
108
109
  async extract(srcPath, dir) {
109
110
  await mkdir(dir, { recursive: true });
110
- await exec("tar", ["xzf", srcPath, "-C", dir]);
111
+ // Extract via node-tar, NOT `tar xzf`: extraction reads UNTRUSTED input (a program artifact
112
+ // from an arbitrary control plane on a self-hosted runner, or a prior run's workspace snapshot),
113
+ // and node-tar is hardened + portable — with default `preservePaths: false` it strips absolute
114
+ // paths and `..` members, and refuses to write THROUGH a symlink (unlinking it first), closing
115
+ // the traversal/symlink-escape gaps that differ between GNU tar and bsdtar. Same behavior on
116
+ // macOS and Linux, no dependency on which `tar` the OS ships.
117
+ await tarExtract({ file: srcPath, cwd: dir });
111
118
  }
112
119
  }
113
120
  /** Production fs — node's fs/promises. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boardwalk-labs/runner",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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": {