machine-bridge-mcp 0.5.0 → 0.6.2

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.
@@ -0,0 +1,206 @@
1
+ import { chmod, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import process from "node:process";
5
+ import { LocalDaemon } from "./daemon.mjs";
6
+ import { generateSshKeyPair } from "./ssh-key.mjs";
7
+ import { run } from "./shell.mjs";
8
+ import { allToolNames, assertCanonicalFullPolicy, policyProfile } from "./tools.mjs";
9
+
10
+ const TERMINAL_JOB_STATES = new Set([
11
+ "succeeded", "failed", "cancelled", "runner_failed", "runner_launch_failed",
12
+ "recovery_failed", "recovery_exhausted", "succeeded_cleanup_failed",
13
+ "failed_cleanup_failed", "cancelled_cleanup_failed",
14
+ ]);
15
+
16
+ export async function runFullAccessTest({ workspace, policy = policyProfile("full", "explicit") } = {}) {
17
+ const canonicalPolicy = assertCanonicalFullPolicy(policy);
18
+ const root = await mkdtemp(join(tmpdir(), "machine-mcp-full-test-"));
19
+ const jobRoot = join(root, "jobs");
20
+ const outsideDir = join(root, "outside-workspace");
21
+ const keyPath = join(root, "ssh", "operator-ed25519");
22
+ const authorizedKeysPath = join(root, "authorized_keys");
23
+ const mainMarker = join(root, "managed-main.txt");
24
+ const cleanupMarker = join(root, "managed-cleanup.txt");
25
+ const checks = [];
26
+ const sentinelKey = `MBM_FULL_TEST_${Date.now()}`;
27
+ const previousSentinel = process.env[sentinelKey];
28
+ process.env[sentinelKey] = "visible";
29
+ let runtime;
30
+
31
+ try {
32
+ runtime = new LocalDaemon({
33
+ workspace: resolve(workspace),
34
+ policy: canonicalPolicy,
35
+ jobRoot,
36
+ resources: {},
37
+ logger: silentLogger(),
38
+ recoverJobs: false,
39
+ });
40
+
41
+ const toolNames = runtime.tools();
42
+ checks.push(check("full-policy-invariant", toolNames.length === allToolNames().length - 1, {
43
+ profile: canonicalPolicy.profile,
44
+ exposed_tools: toolNames.length + 1,
45
+ catalog_tools: allToolNames().length,
46
+ }));
47
+
48
+ const outsideFile = join(outsideDir, "outside.txt");
49
+ const written = await runtime.writeFile({ path: outsideFile, content: "full-outside-write\n", create_only: true });
50
+ const read = await runtime.readFile(outsideFile, 1024);
51
+ const canonicalOutsideFile = await realpath(outsideFile);
52
+ checks.push(check("unrestricted-filesystem", written.ok === true && read.content === "full-outside-write\n", {
53
+ absolute_path_returned: await equivalentPath(read.path, canonicalOutsideFile),
54
+ }));
55
+
56
+ const direct = await runtime.runDirectProcess({
57
+ argv: [process.execPath, "-e", "process.stdout.write(process.cwd())"],
58
+ cwd: outsideDir,
59
+ timeout_seconds: 10,
60
+ });
61
+ checks.push(check("direct-process-outside-workspace", direct.code === 0 && await equivalentPath(direct.stdout.trim(), outsideDir)));
62
+
63
+ const inherited = await runtime.runDirectProcess({
64
+ argv: [process.execPath, "-e", `process.stdout.write(process.env[${JSON.stringify(sentinelKey)}] || '')`],
65
+ timeout_seconds: 10,
66
+ });
67
+ checks.push(check("full-parent-environment", inherited.code === 0 && inherited.stdout === "visible"));
68
+
69
+ const shellCommand = process.platform === "win32" ? "[Console]::Out.Write('full-shell')" : "printf full-shell";
70
+ const shell = await runtime.execCommand(shellCommand, 10);
71
+ checks.push(check("shell-execution", shell.code === 0 && shell.stdout.trim() === "full-shell"));
72
+
73
+ const key = await generateSshKeyPair({
74
+ privateKeyPath: keyPath,
75
+ type: "ed25519",
76
+ comment: "machine-mcp-full-test",
77
+ });
78
+ const publicLine = (await readFile(key.publicKeyPath, "utf8")).trim();
79
+ await runtime.writeFile({ path: authorizedKeysPath, content: `${publicLine}\n`, create_only: true });
80
+ if (process.platform !== "win32") await chmod(authorizedKeysPath, 0o600);
81
+ const authorized = await readFile(authorizedKeysPath, "utf8");
82
+ checks.push(check("ssh-key-generation", key.created && key.publicKeyType === "ssh-ed25519", {
83
+ private_mode: key.privateMode,
84
+ public_mode: key.publicMode,
85
+ fingerprint_available: Boolean(key.fingerprint),
86
+ private_key_content_exposed: false,
87
+ }));
88
+ checks.push(check("authorized-keys-sandbox-write", authorized === `${publicLine}\n`, {
89
+ target_is_temporary_sandbox: true,
90
+ }));
91
+
92
+ const nullConfig = process.platform === "win32" ? "NUL" : "/dev/null";
93
+ const sshConfig = await run("ssh", ["-F", nullConfig, "-G", "localhost"], {
94
+ capture: true,
95
+ allowFailure: true,
96
+ timeoutMs: 15_000,
97
+ maxOutputBytes: 256 * 1024,
98
+ });
99
+ checks.push(check("ssh-client", sshConfig.code === 0));
100
+
101
+ const gcloud = await run("gcloud", ["--version"], {
102
+ capture: true,
103
+ allowFailure: true,
104
+ timeoutMs: 30_000,
105
+ maxOutputBytes: 64 * 1024,
106
+ });
107
+ const osLoginHelp = gcloud.code === 0
108
+ ? await run("gcloud", ["help", "compute", "os-login", "ssh-keys", "add"], {
109
+ capture: true,
110
+ allowFailure: true,
111
+ timeoutMs: 30_000,
112
+ maxOutputBytes: 128 * 1024,
113
+ })
114
+ : { code: 127 };
115
+ checks.push(check("google-cloud-cli", gcloud.code === 0, {
116
+ os_login_key_command_available: osLoginHelp.code === 0,
117
+ external_changes_made: false,
118
+ }));
119
+
120
+ const sudo = process.platform === "win32"
121
+ ? { code: 0, skipped: true }
122
+ : await run("sudo", ["-n", "true"], {
123
+ capture: true,
124
+ allowFailure: true,
125
+ timeoutMs: 10_000,
126
+ maxOutputBytes: 16 * 1024,
127
+ });
128
+ checks.push({
129
+ name: "noninteractive-sudo-probe",
130
+ ok: true,
131
+ available: process.platform === "win32" ? null : sudo.code === 0,
132
+ password_or_policy_required: process.platform === "win32" ? null : sudo.code !== 0,
133
+ skipped: process.platform === "win32",
134
+ state_changed: false,
135
+ });
136
+
137
+ const accepted = runtime.managedJobManager.start({
138
+ name: "full access lifecycle test",
139
+ temporary_files: [{ name: "main.js", content: "require('node:fs').writeFileSync(process.argv[2],'main')" }],
140
+ steps: [{ argv: [process.execPath, "{{temp:main.js}}", mainMarker], timeout_seconds: 10 }],
141
+ finally_steps: [{ argv: [process.execPath, "-e", "require('node:fs').writeFileSync(process.argv[1],'cleanup')", cleanupMarker], timeout_seconds: 10 }],
142
+ });
143
+ const job = await waitForJob(runtime.managedJobManager, accepted.job_id, 15_000);
144
+ checks.push(check("detached-managed-job", job.status === "succeeded"
145
+ && await fileEquals(mainMarker, "main")
146
+ && await fileEquals(cleanupMarker, "cleanup"), {
147
+ status: job.status,
148
+ error_class: job.error_class ?? job.result?.error_class ?? null,
149
+ cleanup_error_class: job.result?.cleanup_error_class ?? null,
150
+ cleanup_attempted: true,
151
+ }));
152
+
153
+ const coreChecks = checks.filter((item) => !["google-cloud-cli", "noninteractive-sudo-probe"].includes(item.name));
154
+ const operatorChecks = checks.filter((item) => ["ssh-key-generation", "authorized-keys-sandbox-write", "ssh-client", "google-cloud-cli"].includes(item.name));
155
+ return {
156
+ ok: coreChecks.every((item) => item.ok === true),
157
+ operator_workflow_ready: operatorChecks.every((item) => item.ok === true),
158
+ machine: {
159
+ platform: process.platform,
160
+ architecture: process.arch,
161
+ node: process.version,
162
+ },
163
+ policy: canonicalPolicy,
164
+ checks,
165
+ guarantees: {
166
+ machine_bridge_internal_policy_denials_under_full: false,
167
+ host_or_connector_policy_overridden: false,
168
+ operating_system_policy_overridden: false,
169
+ external_cloud_or_remote_state_changed: false,
170
+ },
171
+ };
172
+ } finally {
173
+ runtime?.stop();
174
+ if (previousSentinel === undefined) delete process.env[sentinelKey];
175
+ else process.env[sentinelKey] = previousSentinel;
176
+ await rm(root, { recursive: true, force: true });
177
+ }
178
+ }
179
+
180
+ async function waitForJob(manager, jobId, timeoutMs) {
181
+ const deadline = Date.now() + timeoutMs;
182
+ while (Date.now() < deadline) {
183
+ const value = manager.read({ job_id: jobId });
184
+ if (TERMINAL_JOB_STATES.has(value.status)) return value;
185
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
186
+ }
187
+ throw new Error("full access managed-job test timed out");
188
+ }
189
+
190
+ async function equivalentPath(left, right) {
191
+ try { return await realpath(resolve(left)) === await realpath(resolve(right)); } catch { return false; }
192
+ }
193
+
194
+ async function fileEquals(path, expected) {
195
+ try { return await readFile(path, "utf8") === expected; } catch { return false; }
196
+ }
197
+
198
+ function check(name, ok, detail = {}) {
199
+ return { name, ok: Boolean(ok), ...detail };
200
+ }
201
+
202
+ function silentLogger() {
203
+ return {
204
+ info() {}, success() {}, warn() {}, error() {}, debug() {},
205
+ };
206
+ }
@@ -0,0 +1,471 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readSync, rmSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { executionEnv } from "./shell.mjs";
6
+ import { terminateProcessTree } from "./process-sessions.mjs";
7
+ import { replaceFileSync } from "./atomic-fs.mjs";
8
+
9
+ const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
10
+ const TEMP_TOKEN = /\{\{temp:([a-z][a-z0-9._-]{0,63})\}\}/g;
11
+ const MAX_RESOURCE_BYTES = 1024 * 1024;
12
+ const MAX_OUTPUT_BYTES = 64 * 1024;
13
+ const MAX_JOB_CAPTURE_BYTES = 256 * 1024;
14
+ const MAX_RESULT_BYTES = 4 * 1024 * 1024;
15
+ const MAX_STATUS_BYTES = 256 * 1024;
16
+
17
+ const options = parseArgs(process.argv.slice(2));
18
+ const jobDir = resolve(options.jobDir || "");
19
+ if (!jobDir) throw new Error("--job-dir is required");
20
+ const recover = options.recover === true;
21
+ const planFile = join(jobDir, "plan.json");
22
+ const statusFile = join(jobDir, "status.json");
23
+ const resultFile = join(jobDir, "result.json");
24
+ const cancelFile = join(jobDir, "cancel");
25
+ const runtimeDir = join(jobDir, "runtime");
26
+ const resourcesDir = join(runtimeDir, "resources");
27
+ const temporaryFilesDir = join(runtimeDir, "files");
28
+ const runnerPidFile = join(jobDir, "runner.pid");
29
+
30
+ class JobCancelledError extends Error {
31
+ constructor() {
32
+ super("job cancellation requested");
33
+ this.name = "JobCancelledError";
34
+ }
35
+ }
36
+
37
+
38
+ let activeChild = null;
39
+ let activeChildCancellationAware = false;
40
+ let cancellationEscalation = null;
41
+ let cancelRequested = false;
42
+ for (const signal of ["SIGTERM", "SIGINT"]) {
43
+ process.on(signal, () => requestCancellation());
44
+ }
45
+
46
+ writeFileSync(runnerPidFile, `${process.pid}\n`, { mode: 0o600 });
47
+ if (recover) rmSync(join(jobDir, "recovery.lock"), { force: true });
48
+ try {
49
+ await main();
50
+ } catch (error) {
51
+ recordFatalRunnerError(error);
52
+ }
53
+
54
+ async function main() {
55
+ const plan = readJson(planFile, 1024 * 1024);
56
+ const initial = readJson(statusFile, MAX_STATUS_BYTES);
57
+ const status = {
58
+ ...initial,
59
+ runner_pid: process.pid,
60
+ started_at: initial.started_at || new Date().toISOString(),
61
+ updated_at: new Date().toISOString(),
62
+ status: recover ? "cleaning" : "running",
63
+ current_phase: recover ? "recovery-cleanup" : "steps",
64
+ current_step: null,
65
+ cleanup_guarantee: "best-effort-finally-and-recovery",
66
+ };
67
+ writeJson(statusFile, status, MAX_STATUS_BYTES);
68
+
69
+ const mainResults = [];
70
+ const cleanupResults = [];
71
+ const captureBudget = { remaining: MAX_JOB_CAPTURE_BYTES };
72
+ let mainError = null;
73
+ let cleanupError = null;
74
+ let resourceContext = { paths: {}, bytes: {}, redactions: {}, temporaryPaths: {} };
75
+
76
+ try {
77
+ resourceContext = materializeResources(plan.resources || {});
78
+ resourceContext.temporaryPaths = materializeTemporaryFiles(plan.temporary_files || []);
79
+ if (!recover) {
80
+ for (let index = 0; index < plan.steps.length; index += 1) {
81
+ if (isCancellationRequested()) throw new JobCancelledError();
82
+ updateStatus(status, { status: "running", current_phase: "steps", current_step: index });
83
+ const result = await runStep(plan.steps[index], index, "steps", plan, resourceContext, true, captureBudget);
84
+ mainResults.push(result);
85
+ if (result.timed_out && !plan.steps[index].allow_failure) throw new Error(`step ${index + 1} timed out`);
86
+ if (result.code !== 0 && !plan.steps[index].allow_failure) throw new Error(`step ${index + 1} exited ${result.code}`);
87
+ }
88
+ }
89
+ } catch (error) {
90
+ mainError = error;
91
+ } finally {
92
+ updateStatus(status, { status: "cleaning", current_phase: recover ? "recovery-cleanup" : "finally_steps", current_step: null });
93
+ try {
94
+ for (let index = 0; index < plan.finally_steps.length; index += 1) {
95
+ updateStatus(status, { status: "cleaning", current_phase: recover ? "recovery-cleanup" : "finally_steps", current_step: index });
96
+ const result = await runStep(plan.finally_steps[index], index, "finally_steps", plan, resourceContext, false, captureBudget);
97
+ cleanupResults.push(result);
98
+ if (result.timed_out && !plan.finally_steps[index].allow_failure && !cleanupError) cleanupError = new Error(`cleanup step ${index + 1} timed out`);
99
+ if (result.code !== 0 && !plan.finally_steps[index].allow_failure && !cleanupError) cleanupError = new Error(`cleanup step ${index + 1} exited ${result.code}`);
100
+ }
101
+ } catch (error) {
102
+ cleanupError ||= error;
103
+ }
104
+ rmSync(runtimeDir, { recursive: true, force: true });
105
+ }
106
+
107
+ const cancelled = mainError instanceof JobCancelledError || existsSync(cancelFile);
108
+ let finalStatus;
109
+ if (recover) finalStatus = cleanupError ? "recovery_failed" : "recovered";
110
+ else if (cancelled) finalStatus = cleanupError ? "cancelled_cleanup_failed" : "cancelled";
111
+ else if (mainError) finalStatus = cleanupError ? "failed_cleanup_failed" : "failed";
112
+ else finalStatus = cleanupError ? "succeeded_cleanup_failed" : "succeeded";
113
+
114
+ const result = {
115
+ job_id: status.job_id,
116
+ name: plan.name,
117
+ status: finalStatus,
118
+ recovered: recover,
119
+ steps: mainResults,
120
+ finally_steps: cleanupResults,
121
+ error_class: classifyError(mainError),
122
+ cleanup_error_class: classifyError(cleanupError),
123
+ capture_limit_bytes: MAX_JOB_CAPTURE_BYTES,
124
+ capture_remaining_bytes: captureBudget.remaining,
125
+ finished_at: new Date().toISOString(),
126
+ };
127
+ writeJson(resultFile, result, MAX_RESULT_BYTES);
128
+ updateStatus(status, {
129
+ status: finalStatus,
130
+ current_phase: null,
131
+ current_step: null,
132
+ finished_at: result.finished_at,
133
+ error_class: result.error_class || result.cleanup_error_class,
134
+ });
135
+ try { rmSync(planFile, { force: true }); } catch {}
136
+ try { rmSync(runnerPidFile, { force: true }); } catch {}
137
+ try { rmSync(cancelFile, { force: true }); } catch {}
138
+ }
139
+
140
+ function recordFatalRunnerError(error) {
141
+ rmSync(runtimeDir, { recursive: true, force: true });
142
+ const now = new Date().toISOString();
143
+ let status = {};
144
+ try { status = readJson(statusFile, MAX_STATUS_BYTES); } catch {}
145
+ const finalStatus = recover ? "recovery_failed" : "runner_failed";
146
+ const result = {
147
+ job_id: status.job_id ?? null,
148
+ name: status.name ?? "managed job",
149
+ status: finalStatus,
150
+ recovered: recover,
151
+ steps: [],
152
+ finally_steps: [],
153
+ error_class: classifyError(error),
154
+ cleanup_error_class: recover ? classifyError(error) : null,
155
+ finished_at: now,
156
+ };
157
+ try { writeJson(resultFile, result, MAX_RESULT_BYTES); } catch {}
158
+ try {
159
+ writeJson(statusFile, {
160
+ ...status,
161
+ status: finalStatus,
162
+ current_phase: null,
163
+ current_step: null,
164
+ runner_pid: process.pid,
165
+ updated_at: now,
166
+ finished_at: now,
167
+ error_class: result.error_class,
168
+ cleanup_guarantee: "best-effort-finally-and-recovery",
169
+ }, MAX_STATUS_BYTES);
170
+ } catch {}
171
+ try { rmSync(planFile, { force: true }); } catch {}
172
+ try { rmSync(runnerPidFile, { force: true }); } catch {}
173
+ try { rmSync(cancelFile, { force: true }); } catch {}
174
+ }
175
+
176
+ async function runStep(step, index, phase, plan, resourceContext, cancellationAware, captureBudget) {
177
+ const argv = step.argv.map((value) => substitute(value, plan, resourceContext));
178
+ const envOverrides = Object.fromEntries(Object.entries(step.env || {}).map(([key, value]) => [key, substitute(value, plan, resourceContext)]));
179
+ const envResourceValues = Object.fromEntries(Object.entries(step.env_resources || {}).map(([key, name]) => [key, resourceEnvValue(name, resourceContext.bytes)]));
180
+ const env = {
181
+ ...executionEnv(plan.workspace, { fullEnv: plan.full_env === true, runtimeDir }),
182
+ ...envOverrides,
183
+ ...envResourceValues,
184
+ };
185
+ const input = step.stdin_resource
186
+ ? readResourceBytes(step.stdin_resource, resourceContext.paths)
187
+ : step.stdin === null || step.stdin === undefined
188
+ ? null
189
+ : Buffer.from(step.stdin, "utf8");
190
+ const started = Date.now();
191
+ const raw = await spawnStep(argv, {
192
+ cwd: step.cwd,
193
+ env,
194
+ input,
195
+ timeoutMs: Number(step.timeout_seconds) * 1000,
196
+ cancellationAware,
197
+ captureOutput: step.capture_output !== "discard",
198
+ captureBudget,
199
+ });
200
+ return {
201
+ index,
202
+ phase,
203
+ name: step.name,
204
+ command: basename(argv[0]),
205
+ code: raw.code,
206
+ signal: raw.signal,
207
+ timed_out: raw.timedOut,
208
+ duration_ms: Date.now() - started,
209
+ stdout: step.capture_output === "discard" ? "" : redactOutput(raw.stdout, resourceContext),
210
+ stderr: step.capture_output === "discard" ? "" : redactOutput(raw.stderr, resourceContext),
211
+ output_discarded: step.capture_output === "discard",
212
+ stdout_truncated_bytes: step.capture_output === "discard" ? 0 : raw.stdoutTruncated,
213
+ stderr_truncated_bytes: step.capture_output === "discard" ? 0 : raw.stderrTruncated,
214
+ stdout_omitted_bytes: step.capture_output === "discard" ? raw.stdoutTruncated : 0,
215
+ stderr_omitted_bytes: step.capture_output === "discard" ? raw.stderrTruncated : 0,
216
+ };
217
+ }
218
+
219
+ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captureOutput, captureBudget }) {
220
+ return new Promise((resolvePromise, rejectPromise) => {
221
+ if (cancellationAware && isCancellationRequested()) return rejectPromise(new JobCancelledError());
222
+ const child = spawn(argv[0], argv.slice(1), {
223
+ cwd,
224
+ env,
225
+ detached: process.platform !== "win32",
226
+ windowsHide: true,
227
+ stdio: ["pipe", "pipe", "pipe"],
228
+ });
229
+ activeChild = child;
230
+ activeChildCancellationAware = cancellationAware;
231
+ let stdout = Buffer.alloc(0);
232
+ let stderr = Buffer.alloc(0);
233
+ let stdoutTruncated = 0;
234
+ let stderrTruncated = 0;
235
+ let timedOut = false;
236
+ let closed = false;
237
+ const timer = setTimeout(() => {
238
+ timedOut = true;
239
+ terminateProcessTree(child, "SIGTERM");
240
+ const kill = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
241
+ kill.unref?.();
242
+ }, timeoutMs);
243
+ timer.unref?.();
244
+ const cancellationPoll = setInterval(() => {
245
+ if (!cancellationAware || !isCancellationRequested()) return;
246
+ requestCancellation();
247
+ }, 250);
248
+ cancellationPoll.unref?.();
249
+
250
+ child.stdout.on("data", (chunk) => {
251
+ if (!captureOutput) { stdoutTruncated += chunk.length; return; }
252
+ const next = appendLimited(stdout, chunk, MAX_OUTPUT_BYTES, captureBudget);
253
+ stdout = next.buffer;
254
+ stdoutTruncated += next.truncated;
255
+ });
256
+ child.stderr.on("data", (chunk) => {
257
+ if (!captureOutput) { stderrTruncated += chunk.length; return; }
258
+ const next = appendLimited(stderr, chunk, MAX_OUTPUT_BYTES, captureBudget);
259
+ stderr = next.buffer;
260
+ stderrTruncated += next.truncated;
261
+ });
262
+ child.on("error", (error) => finish(() => rejectPromise(error)));
263
+ child.on("close", (code, signal) => finish(() => {
264
+ if (cancellationAware && isCancellationRequested()) return rejectPromise(new JobCancelledError());
265
+ resolvePromise({
266
+ code: Number.isInteger(code) ? code : 1,
267
+ signal: signal ? String(signal) : null,
268
+ timedOut,
269
+ stdout,
270
+ stderr,
271
+ stdoutTruncated,
272
+ stderrTruncated,
273
+ });
274
+ }));
275
+ if (input && input.length) child.stdin.end(input);
276
+ else child.stdin.end();
277
+
278
+ function finish(callback) {
279
+ if (closed) return;
280
+ closed = true;
281
+ clearTimeout(timer);
282
+ clearInterval(cancellationPoll);
283
+ activeChild = null;
284
+ activeChildCancellationAware = false;
285
+ if (cancellationEscalation) clearTimeout(cancellationEscalation);
286
+ cancellationEscalation = null;
287
+ callback();
288
+ }
289
+ });
290
+ }
291
+
292
+ function materializeResources(resources) {
293
+ mkdirSync(resourcesDir, { recursive: true, mode: 0o700 });
294
+ chmodSync(resourcesDir, 0o700);
295
+ const paths = {};
296
+ const bytes = {};
297
+ const redactions = {};
298
+ for (const [name, resource] of Object.entries(resources)) {
299
+ if (!resource || resource.kind !== "file") throw new Error(`unsupported resource kind: ${name}`);
300
+ const data = readBoundedFile(resource.path, MAX_RESOURCE_BYTES);
301
+ const actualHash = createHash("sha256").update(data).digest("hex");
302
+ if (!resource.sha256 || actualHash !== resource.sha256) throw new Error(`local resource changed after job submission: ${name}`);
303
+ const target = join(resourcesDir, name);
304
+ writeFileSync(target, data, { mode: 0o600, flag: "wx" });
305
+ paths[name] = target;
306
+ bytes[name] = data;
307
+ const patterns = [];
308
+ try {
309
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(data);
310
+ if (text.length > 0) patterns.push(text);
311
+ const trimmed = text.replace(/[\r\n]+$/, "");
312
+ if (trimmed.length > 0 && trimmed !== text) patterns.push(trimmed);
313
+ } catch {}
314
+ if (data.length > 0 && data.length <= 64 * 1024) {
315
+ patterns.push(data.toString("base64"), data.toString("hex"));
316
+ }
317
+ redactions[name] = [...new Set(patterns.filter((value) => value.length > 0))].sort((a, b) => b.length - a.length);
318
+ }
319
+ return { paths, bytes, redactions };
320
+ }
321
+
322
+ function materializeTemporaryFiles(files) {
323
+ mkdirSync(temporaryFilesDir, { recursive: true, mode: 0o700 });
324
+ chmodSync(temporaryFilesDir, 0o700);
325
+ const paths = {};
326
+ for (const file of files) {
327
+ const target = join(temporaryFilesDir, file.name);
328
+ writeFileSync(target, file.content, { mode: file.executable ? 0o700 : 0o600, flag: "wx" });
329
+ paths[file.name] = target;
330
+ }
331
+ return paths;
332
+ }
333
+
334
+ function readResourceBytes(name, paths) {
335
+ const path = paths[name];
336
+ if (!path) throw new Error(`resource was not materialized: ${name}`);
337
+ return readBoundedFile(path, MAX_RESOURCE_BYTES);
338
+ }
339
+
340
+ function resourceEnvValue(name, bytes) {
341
+ const data = bytes[name];
342
+ if (!Buffer.isBuffer(data)) throw new Error(`resource was not materialized: ${name}`);
343
+ if (data.length > 64 * 1024) throw new Error(`resource is too large for an environment variable: ${name}`);
344
+ let value;
345
+ try { value = new TextDecoder("utf-8", { fatal: true }).decode(data); } catch {
346
+ throw new Error(`resource is not UTF-8 text for environment injection: ${name}`);
347
+ }
348
+ value = value.replace(/[\r\n]+$/, "");
349
+ if (value.includes("\0")) throw new Error(`resource contains a NUL byte and cannot be used as an environment variable: ${name}`);
350
+ return value;
351
+ }
352
+
353
+ function substitute(value, plan, context) {
354
+ return String(value)
355
+ .replaceAll("{{job:runtime}}", runtimeDir)
356
+ .replaceAll("{{job:workspace}}", plan.workspace)
357
+ .replace(RESOURCE_TOKEN, (_, name) => {
358
+ const path = context.paths[name];
359
+ if (!path) throw new Error(`resource was not materialized: ${name}`);
360
+ return path;
361
+ })
362
+ .replace(TEMP_TOKEN, (_, name) => {
363
+ const path = context.temporaryPaths[name];
364
+ if (!path) throw new Error(`temporary file was not materialized: ${name}`);
365
+ return path;
366
+ });
367
+ }
368
+
369
+ function redactOutput(buffer, context) {
370
+ let text = new TextDecoder("utf-8").decode(buffer);
371
+ for (const [name, path] of Object.entries(context.paths)) {
372
+ text = text.split(path).join(`<resource:${name}>`);
373
+ }
374
+ for (const [name, path] of Object.entries(context.temporaryPaths)) {
375
+ text = text.split(path).join(`<temp:${name}>`);
376
+ }
377
+ text = text.split(runtimeDir).join("<job-runtime>");
378
+ for (const [name, patterns] of Object.entries(context.redactions)) {
379
+ for (const value of patterns) text = text.split(value).join(`<redacted-resource:${name}>`);
380
+ }
381
+ return text;
382
+ }
383
+
384
+ function updateStatus(status, changes) {
385
+ Object.assign(status, changes, { runner_pid: process.pid, updated_at: new Date().toISOString() });
386
+ writeJson(statusFile, status, MAX_STATUS_BYTES);
387
+ }
388
+
389
+ function requestCancellation() {
390
+ cancelRequested = true;
391
+ const child = activeChild;
392
+ if (!child || !activeChildCancellationAware) return;
393
+ terminateProcessTree(child, "SIGTERM");
394
+ if (cancellationEscalation) return;
395
+ cancellationEscalation = setTimeout(() => {
396
+ if (activeChild === child && activeChildCancellationAware) terminateProcessTree(child, "SIGKILL");
397
+ }, 2000);
398
+ cancellationEscalation.unref?.();
399
+ }
400
+
401
+ function isCancellationRequested() {
402
+ return cancelRequested || existsSync(cancelFile);
403
+ }
404
+
405
+ function appendLimited(current, chunk, limit, budget) {
406
+ const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
407
+ const streamRemaining = Math.max(0, limit - current.length);
408
+ const jobRemaining = Math.max(0, Number(budget?.remaining || 0));
409
+ const acceptedLength = Math.min(input.length, streamRemaining, jobRemaining);
410
+ const accepted = input.subarray(0, acceptedLength);
411
+ if (budget) budget.remaining = Math.max(0, jobRemaining - acceptedLength);
412
+ return {
413
+ buffer: accepted.length ? (current.length ? Buffer.concat([current, accepted]) : Buffer.from(accepted)) : current,
414
+ truncated: input.length - accepted.length,
415
+ };
416
+ }
417
+
418
+ function writeJson(file, value, maxBytes) {
419
+ const text = `${JSON.stringify(value, null, 2)}\n`;
420
+ if (Buffer.byteLength(text) > maxBytes) throw new Error(`job JSON exceeds ${maxBytes} bytes`);
421
+ const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
422
+ writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
423
+ replaceFileSync(temp, file);
424
+ chmodSync(file, 0o600);
425
+ }
426
+
427
+ function readJson(file, maxBytes) {
428
+ return JSON.parse(readBoundedFile(file, maxBytes).toString("utf8"));
429
+ }
430
+
431
+ function readBoundedFile(file, maxBytes) {
432
+ const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
433
+ const fd = openSync(file, flags);
434
+ try {
435
+ const info = fstatSync(fd);
436
+ if (!info.isFile()) throw new Error("path is not a regular file");
437
+ if (info.size > maxBytes) throw new Error(`file exceeds ${maxBytes} bytes`);
438
+ const buffer = Buffer.alloc(info.size);
439
+ let offset = 0;
440
+ while (offset < buffer.length) {
441
+ const count = readSync(fd, buffer, offset, buffer.length - offset, offset);
442
+ if (!count) break;
443
+ offset += count;
444
+ }
445
+ return buffer.subarray(0, offset);
446
+ } finally {
447
+ closeSync(fd);
448
+ }
449
+ }
450
+
451
+ function classifyError(error) {
452
+ if (!error) return null;
453
+ if (error instanceof JobCancelledError) return "cancelled";
454
+ const message = String(error?.message || error);
455
+ if (/timed out/i.test(message)) return "timeout";
456
+ if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
457
+ if (/not found|ENOENT/i.test(message)) return "not_found";
458
+ if (/resource/i.test(message)) return "resource_error";
459
+ return "execution_failed";
460
+ }
461
+
462
+ function parseArgs(argv) {
463
+ const out = {};
464
+ for (let index = 0; index < argv.length; index += 1) {
465
+ const value = argv[index];
466
+ if (value === "--recover") out.recover = true;
467
+ else if (value === "--job-dir") out.jobDir = argv[++index];
468
+ else throw new Error(`unknown runner option: ${value}`);
469
+ }
470
+ return out;
471
+ }