@boardwalk-labs/runner 0.1.8 → 0.1.10
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 +3 -1
- package/dist/bin.js +33 -68
- package/dist/daemon/container.d.ts +46 -0
- package/dist/daemon/container.js +145 -0
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.js +2 -0
- package/dist/daemon/start.d.ts +52 -0
- package/dist/daemon/start.js +152 -0
- package/dist/runtime/identity_relay.d.ts +54 -0
- package/dist/runtime/identity_relay.js +168 -0
- package/dist/runtime/index.js +15 -0
- package/package.json +1 -1
package/dist/bin.d.ts
CHANGED
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,
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|
package/dist/daemon/index.d.ts
CHANGED
package/dist/daemon/index.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Duplex } from "node:stream";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/** Env var naming the inherited relay fd. Set by the guest init; absent everywhere else
|
|
4
|
+
* (Fargate, self-hosted daemon), where the worker env-boots as always. */
|
|
5
|
+
export declare const RELAY_FD_ENV = "BOARDWALK_IDENTITY_RELAY_FD";
|
|
6
|
+
/** The identity payload — the env contract's fields, as JSON instead of env vars. */
|
|
7
|
+
export declare const relayIdentitySchema: z.ZodObject<{
|
|
8
|
+
run_id: z.ZodString;
|
|
9
|
+
control_plane_url: z.ZodString;
|
|
10
|
+
run_token: z.ZodString;
|
|
11
|
+
api_token: z.ZodOptional<z.ZodString>;
|
|
12
|
+
task_cpu_units: z.ZodOptional<z.ZodNumber>;
|
|
13
|
+
byo_providers: z.ZodOptional<z.ZodUnknown>;
|
|
14
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
15
|
+
}, z.core.$strip>;
|
|
16
|
+
export type RelayIdentity = z.infer<typeof relayIdentitySchema>;
|
|
17
|
+
/**
|
|
18
|
+
* Read the relay fd from `env`, deleting the key (bootstrap-only plumbing; run code has no
|
|
19
|
+
* business seeing it). Returns null when unset — the normal env-boot path.
|
|
20
|
+
*/
|
|
21
|
+
export declare function relayFdFromEnv(env: NodeJS.ProcessEnv): number | null;
|
|
22
|
+
/**
|
|
23
|
+
* Map a relayed identity onto `env` for `capturePlatformContext`. User env lands FIRST and
|
|
24
|
+
* the platform keys LAST, so nothing user-supplied can shadow a platform value.
|
|
25
|
+
*/
|
|
26
|
+
export declare function applyIdentityToEnv(identity: RelayIdentity, env: NodeJS.ProcessEnv): void;
|
|
27
|
+
/** One end of the init↔worker relay. Wraps any Duplex so tests run over in-memory streams. */
|
|
28
|
+
export declare class IdentityRelay {
|
|
29
|
+
private readonly stream;
|
|
30
|
+
private buffer;
|
|
31
|
+
private closed;
|
|
32
|
+
private wake;
|
|
33
|
+
constructor(stream: Duplex);
|
|
34
|
+
/** Announce the pre-identity park. Init forwards this as the base-snapshot gate. */
|
|
35
|
+
announceReady(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Block until init relays the run identity. THIS is the point the base snapshot freezes:
|
|
38
|
+
* everything before it is generic warm-up shared by every run; everything after belongs
|
|
39
|
+
* to one run. Unknown message types are ignored (forward-compatible), malformed lines are
|
|
40
|
+
* logged and skipped (init is trusted; a torn line must not kill PID 1's only worker), but
|
|
41
|
+
* a malformed IDENTITY payload is a hard error — same as a missing env var today.
|
|
42
|
+
*/
|
|
43
|
+
awaitIdentity(): Promise<RelayIdentity>;
|
|
44
|
+
/** Confirm capture. Init acks its host only after this arrives. */
|
|
45
|
+
acceptIdentity(): void;
|
|
46
|
+
private writeLine;
|
|
47
|
+
private readLine;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Open the relay over the inherited fd. Wrapping the fd in a net.Socket also marks it
|
|
51
|
+
* close-on-exec (libuv does this on adoption), so subprocesses the run later spawns cannot
|
|
52
|
+
* inherit the relay and speak to init.
|
|
53
|
+
*/
|
|
54
|
+
export declare function connectIdentityRelayFd(fd: number): IdentityRelay;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Identity relay — the microVM bootstrap boundary.
|
|
2
|
+
//
|
|
3
|
+
// On Boardwalk's snapshot-based microVM substrate the worker starts BEFORE it has a run: the
|
|
4
|
+
// guest init (PID 1) spawns it warm (Node up, this code loaded, parked pre-identity), the
|
|
5
|
+
// platform snapshots the whole VM, and every run restores from that snapshot and INJECTS the
|
|
6
|
+
// run identity over an in-guest relay instead of container env. The relay is an inherited
|
|
7
|
+
// AF_UNIX socketpair; init tells us its fd via `BOARDWALK_IDENTITY_RELAY_FD`. Wire: one JSON
|
|
8
|
+
// object per LF-terminated line —
|
|
9
|
+
//
|
|
10
|
+
// {"type":"worker_ready"} worker → init the pre-identity park is reached
|
|
11
|
+
// {"type":"identity","payload":{...}} init → worker the run identity (see schema below)
|
|
12
|
+
// {"type":"identity_accepted"} worker → init captured; init acks its host
|
|
13
|
+
//
|
|
14
|
+
// The payload carries exactly the platform env contract (snake_case) plus the resolved user
|
|
15
|
+
// env; `applyIdentityToEnv` maps it onto `process.env` so `capturePlatformContext` runs
|
|
16
|
+
// UNCHANGED afterward — same fields, same capture-and-delete discipline, two transports.
|
|
17
|
+
// The stream stays open after identity: later platform phases speak suspend/wake over it.
|
|
18
|
+
import { Socket } from "node:net";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
import { createLogger } from "./support/index.js";
|
|
21
|
+
const log = createLogger("identity_relay");
|
|
22
|
+
/** Env var naming the inherited relay fd. Set by the guest init; absent everywhere else
|
|
23
|
+
* (Fargate, self-hosted daemon), where the worker env-boots as always. */
|
|
24
|
+
export const RELAY_FD_ENV = "BOARDWALK_IDENTITY_RELAY_FD";
|
|
25
|
+
/** The identity payload — the env contract's fields, as JSON instead of env vars. */
|
|
26
|
+
export const relayIdentitySchema = z.object({
|
|
27
|
+
run_id: z.string().min(1),
|
|
28
|
+
control_plane_url: z.string().min(1),
|
|
29
|
+
run_token: z.string().min(1),
|
|
30
|
+
api_token: z.string().optional(),
|
|
31
|
+
task_cpu_units: z.number().int().positive().optional(),
|
|
32
|
+
/** The BYO inference provider registry, verbatim (stringified into BOARDWALK_BYO_PROVIDERS). */
|
|
33
|
+
byo_providers: z.unknown().optional(),
|
|
34
|
+
/** Resolved NON-secret user env. Platform keys always win over these (applied last). */
|
|
35
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
36
|
+
});
|
|
37
|
+
const relayMessageSchema = z.object({
|
|
38
|
+
type: z.string(),
|
|
39
|
+
payload: z.unknown().optional(),
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* Read the relay fd from `env`, deleting the key (bootstrap-only plumbing; run code has no
|
|
43
|
+
* business seeing it). Returns null when unset — the normal env-boot path.
|
|
44
|
+
*/
|
|
45
|
+
export function relayFdFromEnv(env) {
|
|
46
|
+
const raw = env[RELAY_FD_ENV];
|
|
47
|
+
Reflect.deleteProperty(env, RELAY_FD_ENV);
|
|
48
|
+
if (raw === undefined || raw.trim().length === 0)
|
|
49
|
+
return null;
|
|
50
|
+
const fd = Number(raw);
|
|
51
|
+
if (!Number.isInteger(fd) || fd < 3) {
|
|
52
|
+
throw new Error(`${RELAY_FD_ENV} must name an inherited fd (>= 3), got ${raw}`);
|
|
53
|
+
}
|
|
54
|
+
return fd;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Map a relayed identity onto `env` for `capturePlatformContext`. User env lands FIRST and
|
|
58
|
+
* the platform keys LAST, so nothing user-supplied can shadow a platform value.
|
|
59
|
+
*/
|
|
60
|
+
export function applyIdentityToEnv(identity, env) {
|
|
61
|
+
for (const [key, value] of Object.entries(identity.env ?? {})) {
|
|
62
|
+
env[key] = value;
|
|
63
|
+
}
|
|
64
|
+
env.RUN_ID = identity.run_id;
|
|
65
|
+
env.BOARDWALK_CONTROL_PLANE_URL = identity.control_plane_url;
|
|
66
|
+
env.BOARDWALK_RUN_TOKEN = identity.run_token;
|
|
67
|
+
if (identity.api_token !== undefined && identity.api_token.length > 0) {
|
|
68
|
+
env.BOARDWALK_API_KEY = identity.api_token;
|
|
69
|
+
}
|
|
70
|
+
if (identity.task_cpu_units !== undefined) {
|
|
71
|
+
env.BOARDWALK_TASK_CPU_UNITS = String(identity.task_cpu_units);
|
|
72
|
+
}
|
|
73
|
+
if (identity.byo_providers !== undefined) {
|
|
74
|
+
env.BOARDWALK_BYO_PROVIDERS = JSON.stringify(identity.byo_providers);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** One end of the init↔worker relay. Wraps any Duplex so tests run over in-memory streams. */
|
|
78
|
+
export class IdentityRelay {
|
|
79
|
+
stream;
|
|
80
|
+
buffer = "";
|
|
81
|
+
closed = false;
|
|
82
|
+
wake = null;
|
|
83
|
+
constructor(stream) {
|
|
84
|
+
this.stream = stream;
|
|
85
|
+
// One persistent listener for the relay's lifetime: a flowing stream DROPS chunks
|
|
86
|
+
// emitted while nobody listens, so attach-per-read would lose lines between reads.
|
|
87
|
+
stream.on("data", (chunk) => {
|
|
88
|
+
this.buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
89
|
+
this.wake?.();
|
|
90
|
+
});
|
|
91
|
+
const onClose = () => {
|
|
92
|
+
this.closed = true;
|
|
93
|
+
this.wake?.();
|
|
94
|
+
};
|
|
95
|
+
stream.on("end", onClose);
|
|
96
|
+
stream.on("error", onClose);
|
|
97
|
+
}
|
|
98
|
+
/** Announce the pre-identity park. Init forwards this as the base-snapshot gate. */
|
|
99
|
+
announceReady() {
|
|
100
|
+
this.writeLine({ type: "worker_ready" });
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Block until init relays the run identity. THIS is the point the base snapshot freezes:
|
|
104
|
+
* everything before it is generic warm-up shared by every run; everything after belongs
|
|
105
|
+
* to one run. Unknown message types are ignored (forward-compatible), malformed lines are
|
|
106
|
+
* logged and skipped (init is trusted; a torn line must not kill PID 1's only worker), but
|
|
107
|
+
* a malformed IDENTITY payload is a hard error — same as a missing env var today.
|
|
108
|
+
*/
|
|
109
|
+
async awaitIdentity() {
|
|
110
|
+
for (;;) {
|
|
111
|
+
const line = await this.readLine();
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(line);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
log.warn("relay_malformed_line_skipped", { length: line.length });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const message = relayMessageSchema.safeParse(parsed);
|
|
121
|
+
if (!message.success) {
|
|
122
|
+
log.warn("relay_malformed_message_skipped", {});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (message.data.type !== "identity") {
|
|
126
|
+
log.warn("relay_unexpected_type_ignored", { type: message.data.type });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const identity = relayIdentitySchema.safeParse(message.data.payload);
|
|
130
|
+
if (!identity.success) {
|
|
131
|
+
throw new Error(`Relayed identity payload is invalid: ${identity.error.message}`);
|
|
132
|
+
}
|
|
133
|
+
return identity.data;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Confirm capture. Init acks its host only after this arrives. */
|
|
137
|
+
acceptIdentity() {
|
|
138
|
+
this.writeLine({ type: "identity_accepted" });
|
|
139
|
+
}
|
|
140
|
+
writeLine(message) {
|
|
141
|
+
this.stream.write(JSON.stringify(message) + "\n");
|
|
142
|
+
}
|
|
143
|
+
async readLine() {
|
|
144
|
+
for (;;) {
|
|
145
|
+
const newline = this.buffer.indexOf("\n");
|
|
146
|
+
if (newline >= 0) {
|
|
147
|
+
const line = this.buffer.slice(0, newline);
|
|
148
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
149
|
+
return line;
|
|
150
|
+
}
|
|
151
|
+
if (this.closed) {
|
|
152
|
+
throw new Error("identity relay closed before the run identity arrived");
|
|
153
|
+
}
|
|
154
|
+
await new Promise((resolve) => {
|
|
155
|
+
this.wake = resolve;
|
|
156
|
+
});
|
|
157
|
+
this.wake = null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Open the relay over the inherited fd. Wrapping the fd in a net.Socket also marks it
|
|
163
|
+
* close-on-exec (libuv does this on adoption), so subprocesses the run later spawns cannot
|
|
164
|
+
* inherit the relay and speak to init.
|
|
165
|
+
*/
|
|
166
|
+
export function connectIdentityRelayFd(fd) {
|
|
167
|
+
return new IdentityRelay(new Socket({ fd, readable: true, writable: true }));
|
|
168
|
+
}
|
package/dist/runtime/index.js
CHANGED
|
@@ -35,6 +35,7 @@ import { SecretRedactor } from "./agent/secret_redactor.js";
|
|
|
35
35
|
import { RecordingSecretResolver } from "./recording_secret_resolver.js";
|
|
36
36
|
import { EngineLeafExecutor } from "./leaf_executor.js";
|
|
37
37
|
import { parseByoProviders } from "./direct_inference.js";
|
|
38
|
+
import { applyIdentityToEnv, connectIdentityRelayFd, relayFdFromEnv } from "./identity_relay.js";
|
|
38
39
|
import { WorkerWorkflowHost } from "./workflow_host.js";
|
|
39
40
|
import { runProgramWorker, } from "./program_worker.js";
|
|
40
41
|
import { RunnerControlClient } from "./runner_control_client.js";
|
|
@@ -484,6 +485,20 @@ export function capturePlatformContext(env) {
|
|
|
484
485
|
return { runId, controlPlane, vcpus };
|
|
485
486
|
}
|
|
486
487
|
export async function main() {
|
|
488
|
+
// Relay-mode bootstrap (the snapshot-based microVM substrate): when the guest init handed
|
|
489
|
+
// us an identity relay fd, park here — warm, generic, pre-identity; this await is what the
|
|
490
|
+
// base snapshot freezes — until the run's identity arrives, then map it onto process.env
|
|
491
|
+
// so the capture below is transport-agnostic. Everywhere else (Fargate, self-hosted
|
|
492
|
+
// daemon) the fd is absent and the worker env-boots exactly as before. The post-restore
|
|
493
|
+
// uniqueness reseed will hook in at this boundary, before any run code executes.
|
|
494
|
+
const relayFd = relayFdFromEnv(process.env);
|
|
495
|
+
if (relayFd !== null) {
|
|
496
|
+
const relay = connectIdentityRelayFd(relayFd);
|
|
497
|
+
relay.announceReady();
|
|
498
|
+
const identity = await relay.awaitIdentity();
|
|
499
|
+
applyIdentityToEnv(identity, process.env);
|
|
500
|
+
relay.acceptIdentity();
|
|
501
|
+
}
|
|
487
502
|
// Capture the platform context into private state and remove it from process.env BEFORE anything
|
|
488
503
|
// else — so no user program / agent tool / subprocess we later spawn can read the run token or
|
|
489
504
|
// API token (the run env/credential rules). WORKER_ID / WORKSPACE_ROOT are non-secret infra knobs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boardwalk-labs/runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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": {
|