@boardwalk-labs/runner 0.1.5 → 0.1.7
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/runtime/index.js +6 -1
- package/dist/runtime/program_log_capture.d.ts +7 -1
- package/dist/runtime/program_log_capture.js +10 -3
- package/dist/runtime/program_runner.d.ts +5 -0
- package/dist/runtime/program_runner.js +17 -2
- package/dist/runtime/program_worker.js +3 -1
- package/dist/runtime/workspace_store.js +10 -1
- package/package.json +1 -1
package/dist/runtime/index.js
CHANGED
|
@@ -299,7 +299,12 @@ export function assembleWorkerDeps(runtime) {
|
|
|
299
299
|
// throws) so no language-server process leaks past the run.
|
|
300
300
|
lsp: lspService,
|
|
301
301
|
// The orchestrator emits the program's declared output onto the wire (`output` kind).
|
|
302
|
-
|
|
302
|
+
// Redact the declared-output EVENT (observability): a secret the program put in output()
|
|
303
|
+
// must not surface in the run's event stream. The FUNCTIONAL output program_runner returns
|
|
304
|
+
// for `workflows.call` is a separate path and stays raw, so cross-workflow data flow is intact.
|
|
305
|
+
activity: {
|
|
306
|
+
output: (value) => void runEvents.emit({ kind: "output", value: redactor.redactValue(value) }),
|
|
307
|
+
},
|
|
303
308
|
// Filled by the runner once the artifact extracts; the leaf's `skillsDir` thunk reads it.
|
|
304
309
|
setProgramDir: (dir) => {
|
|
305
310
|
programDir = dir;
|
|
@@ -4,7 +4,13 @@ export type LogStream = "stdout" | "stderr";
|
|
|
4
4
|
* Patch the global console so each call is forwarded to the original AND handed (formatted) to
|
|
5
5
|
* `sink`. Returns a restore function — ALWAYS call it (the worker runs `restore()` in a finally).
|
|
6
6
|
*/
|
|
7
|
-
export declare function captureConsole(sink: (stream: LogStream, text: string) => void
|
|
7
|
+
export declare function captureConsole(sink: (stream: LogStream, text: string) => void,
|
|
8
|
+
/** Scrub known secret values from each formatted line before it reaches EITHER sink. A program
|
|
9
|
+
* that `console.log`s a resolved secret must not leak it — to the run's `program_output` events
|
|
10
|
+
* OR to container stdout (CloudWatch). We format+redact once and print the SAME redacted string
|
|
11
|
+
* to the original console (equivalent output; `util.format` is what console does internally).
|
|
12
|
+
* Defaults to identity (tests/local). */
|
|
13
|
+
redact?: (text: string) => string): () => void;
|
|
8
14
|
export interface ProgramLogSinkOptions {
|
|
9
15
|
/** The run's shared event emitter — program logs ride the one ordered stream (`log` channel). */
|
|
10
16
|
sink: TurnEventSink;
|
|
@@ -24,7 +24,13 @@ const STREAM_BY_METHOD = {
|
|
|
24
24
|
* Patch the global console so each call is forwarded to the original AND handed (formatted) to
|
|
25
25
|
* `sink`. Returns a restore function — ALWAYS call it (the worker runs `restore()` in a finally).
|
|
26
26
|
*/
|
|
27
|
-
export function captureConsole(sink
|
|
27
|
+
export function captureConsole(sink,
|
|
28
|
+
/** Scrub known secret values from each formatted line before it reaches EITHER sink. A program
|
|
29
|
+
* that `console.log`s a resolved secret must not leak it — to the run's `program_output` events
|
|
30
|
+
* OR to container stdout (CloudWatch). We format+redact once and print the SAME redacted string
|
|
31
|
+
* to the original console (equivalent output; `util.format` is what console does internally).
|
|
32
|
+
* Defaults to identity (tests/local). */
|
|
33
|
+
redact = (t) => t) {
|
|
28
34
|
const console_ = globalThis.console;
|
|
29
35
|
const originals = {};
|
|
30
36
|
for (const [method, stream] of Object.entries(STREAM_BY_METHOD)) {
|
|
@@ -33,9 +39,10 @@ export function captureConsole(sink) {
|
|
|
33
39
|
continue;
|
|
34
40
|
originals[method] = original;
|
|
35
41
|
console_[method] = (...args) => {
|
|
36
|
-
|
|
42
|
+
const text = redact(format(...args));
|
|
43
|
+
original.call(console_, text); // print the REDACTED line to container stdout (CloudWatch)
|
|
37
44
|
try {
|
|
38
|
-
sink(stream,
|
|
45
|
+
sink(stream, text);
|
|
39
46
|
}
|
|
40
47
|
catch {
|
|
41
48
|
// best-effort — a telemetry hiccup must never break the program's own logging
|
|
@@ -10,6 +10,11 @@ import { type SuspendSignal } from "./suspension.js";
|
|
|
10
10
|
* still exist.
|
|
11
11
|
*/
|
|
12
12
|
export declare function ensureSdkLink(execDir: string): Promise<void>;
|
|
13
|
+
/** Resolve the program's entry module inside the extraction dir, refusing any path that escapes it.
|
|
14
|
+
* The control plane validates `entry` at deploy, but a self-hosted runner may be pointed at an
|
|
15
|
+
* arbitrary control plane, so this is defense-in-depth: an absolute path or a `..` that resolves
|
|
16
|
+
* outside `dir` throws rather than importing code from elsewhere on the machine. */
|
|
17
|
+
export declare function resolveEntryPath(dir: string, entry: string): string;
|
|
13
18
|
export interface RunProgramArgs {
|
|
14
19
|
/** Run id — used for the temp dir path + correlation. */
|
|
15
20
|
runId: string;
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// (no checkpoint, no exit). A crash mid-run restarts the run from the top (handled by the
|
|
22
22
|
// worker/scheduler-sweep, not here). Output capture is deferred (v0 returns null).
|
|
23
23
|
import { mkdir, writeFile, rm, symlink, lstat } from "node:fs/promises";
|
|
24
|
-
import { dirname, join } from "node:path";
|
|
24
|
+
import { dirname, join, isAbsolute, relative, sep } from "node:path";
|
|
25
25
|
import { createRequire } from "node:module";
|
|
26
26
|
import { pathToFileURL } from "node:url";
|
|
27
27
|
import { randomUUID } from "node:crypto";
|
|
@@ -69,6 +69,18 @@ export async function ensureSdkLink(execDir) {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
+
/** Resolve the program's entry module inside the extraction dir, refusing any path that escapes it.
|
|
73
|
+
* The control plane validates `entry` at deploy, but a self-hosted runner may be pointed at an
|
|
74
|
+
* arbitrary control plane, so this is defense-in-depth: an absolute path or a `..` that resolves
|
|
75
|
+
* outside `dir` throws rather than importing code from elsewhere on the machine. */
|
|
76
|
+
export function resolveEntryPath(dir, entry) {
|
|
77
|
+
const resolved = join(dir, ...entry.split("/"));
|
|
78
|
+
const rel = relative(dir, resolved);
|
|
79
|
+
if (isAbsolute(entry) || rel === "" || rel.startsWith("..") || rel.startsWith(`..${sep}`)) {
|
|
80
|
+
throw new AppError(ErrorCode.VALIDATION_FAILED, `Program entry "${entry}" escapes the program directory.`);
|
|
81
|
+
}
|
|
82
|
+
return resolved;
|
|
83
|
+
}
|
|
72
84
|
/** Scratch filename for the in-flight artifact tarball inside a run's dir. */
|
|
73
85
|
const ARTIFACT_FILE = "__program.tgz";
|
|
74
86
|
/**
|
|
@@ -96,7 +108,10 @@ export async function runWorkflowProgram(args, deps) {
|
|
|
96
108
|
// Make the bare `@boardwalk-labs/workflow` import resolve from ANY work root — hosted images
|
|
97
109
|
// provide it via an ancestor node_modules, but a self-hosted daemon's workspace has none.
|
|
98
110
|
await ensureSdkLink(dir);
|
|
99
|
-
|
|
111
|
+
// Re-validate the entry here even though the control plane checked it at deploy: a self-hosted
|
|
112
|
+
// runner may point at an arbitrary control plane, so refuse an entry that could escape the
|
|
113
|
+
// extraction dir (absolute path or `..` segment) before importing it.
|
|
114
|
+
const entryPath = resolveEntryPath(dir, args.entry);
|
|
100
115
|
// Run the program body. A SUSPEND is surfaced two ways and both land here as a `suspended`
|
|
101
116
|
// result: (a) out of band via `suspendSignal` — racing the body, immune to a program's own
|
|
102
117
|
// try/catch (the suspending seam never resolves); (b) a thrown {@link SuspendError} on the
|
|
@@ -162,7 +162,9 @@ export async function runProgramWorker(runId, deps) {
|
|
|
162
162
|
// by `flushFinal()` after the body (on every path except a lease_lost handoff).
|
|
163
163
|
const runtimeFlush = deps.startRuntimeFlush?.({ run: claimed, startedAtMs: sessionStartMs });
|
|
164
164
|
// Capture the program's console.* as `log` run-events for the duration of the body (best-effort).
|
|
165
|
-
const restoreConsole = deps.onProgramLog !== undefined
|
|
165
|
+
const restoreConsole = deps.onProgramLog !== undefined
|
|
166
|
+
? captureConsole(deps.onProgramLog, (text) => redactor.redactText(text))
|
|
167
|
+
: () => undefined;
|
|
166
168
|
let result;
|
|
167
169
|
try {
|
|
168
170
|
result = await runWorkflowProgram({
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
import { execFile } from "node:child_process";
|
|
14
14
|
import { promisify } from "node:util";
|
|
15
15
|
import { stat, mkdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
16
18
|
import { createLogger } from "./support/index.js";
|
|
17
19
|
const log = createLogger("WorkspaceStore");
|
|
18
20
|
const exec = promisify(execFile);
|
|
@@ -30,7 +32,12 @@ export class WorkspaceStore {
|
|
|
30
32
|
maxSnapshotBytes;
|
|
31
33
|
constructor(deps) {
|
|
32
34
|
this.deps = deps;
|
|
33
|
-
|
|
35
|
+
// Scratch tarball path. Default to os.tmpdir() (which honors TMPDIR) rather than a machine-global
|
|
36
|
+
// `/tmp/workspace-snapshot.tgz`: on a self-hosted runner the daemon points TMPDIR at the PER-RUN
|
|
37
|
+
// dir, so the snapshot can't collide between concurrent daemons and doesn't sit in world-shared
|
|
38
|
+
// `/tmp` where the crashed-window archive of a whole workspace would be readable. On the hosted
|
|
39
|
+
// single-tenant worker it's the container's own `/tmp` (one run per container).
|
|
40
|
+
this.tmpPath = deps.tmpPath ?? join(tmpdir(), "bw-workspace-snapshot.tgz");
|
|
34
41
|
this.maxSnapshotBytes = deps.maxSnapshotBytes ?? WORKSPACE_SNAPSHOT_MAX_BYTES;
|
|
35
42
|
}
|
|
36
43
|
/** Restore the workflow's last `/workspace` snapshot at run start. No-op (logged) on any failure or
|
|
@@ -43,6 +50,7 @@ export class WorkspaceStore {
|
|
|
43
50
|
const bytes = await this.deps.broker.downloadBytes(url);
|
|
44
51
|
if (bytes === null)
|
|
45
52
|
return; // 404 — no snapshot yet (the workflow's first run)
|
|
53
|
+
await mkdir(dirname(this.tmpPath), { recursive: true }); // per-run TMPDIR may not exist yet
|
|
46
54
|
await this.deps.fs.writeFile(this.tmpPath, bytes);
|
|
47
55
|
await this.deps.archiver.extract(this.tmpPath, this.deps.workspaceRoot);
|
|
48
56
|
await this.deps.fs.rm(this.tmpPath);
|
|
@@ -62,6 +70,7 @@ export class WorkspaceStore {
|
|
|
62
70
|
* only redundant archive is a self-hosted+persist run, where the broker returns a null URL. */
|
|
63
71
|
async persist() {
|
|
64
72
|
try {
|
|
73
|
+
await mkdir(dirname(this.tmpPath), { recursive: true }); // per-run TMPDIR may not exist yet
|
|
65
74
|
const size = await this.deps.archiver.archive(this.deps.workspaceRoot, this.tmpPath);
|
|
66
75
|
// Guardrail: an oversized snapshot is dropped (logged), never read into memory or uploaded — the
|
|
67
76
|
// workflow re-does filesystem work next run, as it would without persistence. Checked on the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boardwalk-labs/runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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": {
|