machine-bridge-mcp 0.6.2 → 0.8.0
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/CHANGELOG.md +71 -0
- package/CONTRIBUTING.md +31 -0
- package/README.md +20 -17
- package/SECURITY.md +11 -7
- package/docs/ARCHITECTURE.md +14 -12
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +159 -0
- package/docs/LOGGING.md +65 -26
- package/docs/MANAGED_JOBS.md +14 -13
- package/docs/OPERATIONS.md +15 -6
- package/docs/PRIVACY.md +43 -0
- package/docs/RELEASING.md +9 -4
- package/docs/TESTING.md +23 -1
- package/package.json +35 -8
- package/scripts/network-retry.mjs +47 -0
- package/scripts/privacy-check.mjs +177 -0
- package/scripts/release-impact-check.mjs +78 -0
- package/src/local/cli.mjs +369 -272
- package/src/local/full-access-test.mjs +2 -2
- package/src/local/job-runner.mjs +73 -31
- package/src/local/log.mjs +28 -2
- package/src/local/managed-jobs.mjs +194 -125
- package/src/local/process-sessions.mjs +5 -5
- package/src/local/relay-connection.mjs +495 -0
- package/src/local/{daemon.mjs → runtime.mjs} +188 -198
- package/src/local/secure-file.mjs +27 -0
- package/src/local/service.mjs +128 -30
- package/src/local/shell.mjs +1 -1
- package/src/local/ssh-key.mjs +41 -21
- package/src/local/state.mjs +7 -5
- package/src/local/stdio.mjs +157 -81
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +5 -1
- package/src/worker/index.ts +45 -21
- package/wrangler.jsonc +1 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
|
|
7
7
|
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
8
|
+
import { readBoundedRegularFileSync, readBoundedRegularFileWithInfoSync } from "./secure-file.mjs";
|
|
8
9
|
|
|
9
10
|
const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
10
11
|
const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
|
|
@@ -95,36 +96,44 @@ export class ManagedJobManager {
|
|
|
95
96
|
approve(args = {}, { localOperator = false } = {}) {
|
|
96
97
|
if (!localOperator) this.assertEnabled("approve_job");
|
|
97
98
|
const dir = this.jobDir(args.job_id);
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
if (status.status !== "staged") throw new Error(`job is not staged: ${status.status}`);
|
|
101
|
-
readRequiredJson(join(dir, "plan.json"), MAX_PLAN_BYTES, "job plan");
|
|
102
|
-
status.status = "queued";
|
|
103
|
-
status.updated_at = new Date().toISOString();
|
|
104
|
-
status.approved_at = status.updated_at;
|
|
105
|
-
status.approval = localOperator ? "local-operator" : "mcp";
|
|
106
|
-
status.cleanup_guarantee = "best-effort-finally-and-recovery";
|
|
107
|
-
atomicWriteJson(statusFile, status, 256 * 1024);
|
|
99
|
+
const transition = acquireJobTransitionLock(dir);
|
|
100
|
+
if (!transition) throw new Error("job state is being modified by another process; retry after inspecting its current status");
|
|
108
101
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
102
|
+
const statusFile = join(dir, "status.json");
|
|
103
|
+
const status = readRequiredJson(statusFile, 256 * 1024, "job status");
|
|
104
|
+
if (status.status !== "staged") throw new Error(`job is not staged: ${status.status}`);
|
|
105
|
+
const plan = readRequiredJson(join(dir, "plan.json"), MAX_PLAN_BYTES, "job plan");
|
|
106
|
+
assertPlanIntegrity(plan, status);
|
|
107
|
+
status.status = "queued";
|
|
108
|
+
status.updated_at = new Date().toISOString();
|
|
109
|
+
status.approved_at = status.updated_at;
|
|
110
|
+
status.approval = localOperator ? "local-operator" : "mcp";
|
|
111
|
+
status.cleanup_guarantee = "best-effort-finally-and-recovery";
|
|
112
|
+
atomicWriteJson(statusFile, status, 256 * 1024);
|
|
113
|
+
try {
|
|
114
|
+
launchRunner(dir);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
failRunnerLaunch(dir, status, error);
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
accepted: true,
|
|
121
|
+
job_id: status.job_id,
|
|
122
|
+
name: status.name,
|
|
123
|
+
status: "queued",
|
|
124
|
+
detached: true,
|
|
125
|
+
continues_without_mcp_connection: true,
|
|
126
|
+
approval: status.approval,
|
|
127
|
+
plan_sha256: status.plan_sha256,
|
|
128
|
+
cleanup: {
|
|
129
|
+
resource_copies: "best-effort",
|
|
130
|
+
finally_steps: "best-effort-if-declared",
|
|
131
|
+
restart_recovery: "best-effort-on-next-runtime-or-cli-start",
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
} finally {
|
|
135
|
+
transition.release();
|
|
113
136
|
}
|
|
114
|
-
return {
|
|
115
|
-
accepted: true,
|
|
116
|
-
job_id: status.job_id,
|
|
117
|
-
name: status.name,
|
|
118
|
-
status: "queued",
|
|
119
|
-
detached: true,
|
|
120
|
-
continues_without_mcp_connection: true,
|
|
121
|
-
approval: status.approval,
|
|
122
|
-
cleanup: {
|
|
123
|
-
resource_copies: "best-effort",
|
|
124
|
-
finally_steps: "best-effort-if-declared",
|
|
125
|
-
restart_recovery: "best-effort-on-next-runtime-or-cli-start",
|
|
126
|
-
},
|
|
127
|
-
};
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
createJob(args, { launch }) {
|
|
@@ -225,48 +234,55 @@ export class ManagedJobManager {
|
|
|
225
234
|
this.reconcileStatus(dir);
|
|
226
235
|
const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
|
|
227
236
|
const plan = readJson(join(dir, "plan.json"), MAX_PLAN_BYTES);
|
|
237
|
+
if (plan) assertPlanIntegrity(plan, status);
|
|
228
238
|
return {
|
|
229
239
|
...publicStatus(status),
|
|
240
|
+
plan_integrity_verified: Boolean(plan),
|
|
230
241
|
...(plan ? { review_plan: reviewablePlan(plan) } : {}),
|
|
231
242
|
};
|
|
232
243
|
}
|
|
233
244
|
|
|
234
245
|
cancel(args = {}) {
|
|
235
246
|
const dir = this.jobDir(args.job_id);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
status
|
|
241
|
-
status.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
247
|
+
const transition = acquireJobTransitionLock(dir);
|
|
248
|
+
if (!transition) throw new Error("job state is being modified by another process; retry after inspecting its current status");
|
|
249
|
+
try {
|
|
250
|
+
this.reconcileStatus(dir);
|
|
251
|
+
const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
|
|
252
|
+
if (status.status === "staged") {
|
|
253
|
+
const now = new Date().toISOString();
|
|
254
|
+
status.status = "cancelled_before_start";
|
|
255
|
+
status.updated_at = now;
|
|
256
|
+
status.finished_at = now;
|
|
257
|
+
status.error_class = "cancelled";
|
|
258
|
+
status.cleanup_guarantee = "not-started";
|
|
259
|
+
atomicWriteJson(join(dir, "status.json"), status, 256 * 1024);
|
|
260
|
+
atomicWriteJson(join(dir, "result.json"), {
|
|
261
|
+
job_id: status.job_id,
|
|
262
|
+
name: status.name,
|
|
263
|
+
status: status.status,
|
|
264
|
+
steps: [],
|
|
265
|
+
finally_steps: [],
|
|
266
|
+
error_class: "cancelled",
|
|
267
|
+
cleanup_error_class: null,
|
|
268
|
+
finished_at: now,
|
|
269
|
+
}, 4 * 1024 * 1024);
|
|
270
|
+
scrubFinishedPlan(dir, status);
|
|
271
|
+
return { ...publicStatus(status), cancellation_requested: true, cleanup_will_run: false, execution_started: false };
|
|
272
|
+
}
|
|
273
|
+
if (!ACTIVE_JOB_STATES.has(status.status)) {
|
|
274
|
+
return { ...publicStatus(status), cancellation_requested: false, already_finished: true };
|
|
275
|
+
}
|
|
276
|
+
writeFileSync(join(dir, "cancel"), `${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
277
|
+
return {
|
|
278
|
+
...publicStatus(status),
|
|
279
|
+
cancellation_requested: true,
|
|
280
|
+
cancellation_delivery: "runner-poll",
|
|
281
|
+
cleanup_will_run: true,
|
|
282
|
+
};
|
|
283
|
+
} finally {
|
|
284
|
+
transition.release();
|
|
261
285
|
}
|
|
262
|
-
writeFileSync(join(dir, "cancel"), `${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
263
|
-
return {
|
|
264
|
-
...publicStatus(status),
|
|
265
|
-
cancellation_requested: true,
|
|
266
|
-
cancellation_delivery: "runner-poll",
|
|
267
|
-
cleanup_will_run: true,
|
|
268
|
-
};
|
|
269
|
-
|
|
270
286
|
}
|
|
271
287
|
|
|
272
288
|
currentResources() {
|
|
@@ -312,38 +328,10 @@ export class ManagedJobManager {
|
|
|
312
328
|
if (Number.isInteger(pid) && pid > 0 && isPidAlive(pid)) return;
|
|
313
329
|
const recoveryAttempts = Number(status.recovery_attempts || 0);
|
|
314
330
|
if (recoveryAttempts >= MAX_RECOVERY_ATTEMPTS) {
|
|
315
|
-
|
|
316
|
-
status.status = "recovery_exhausted";
|
|
317
|
-
status.updated_at = now;
|
|
318
|
-
status.finished_at = now;
|
|
319
|
-
status.error_class = "recovery_exhausted";
|
|
320
|
-
status.current_phase = null;
|
|
321
|
-
status.current_step = null;
|
|
322
|
-
atomicWriteJson(file, status, 256 * 1024);
|
|
323
|
-
atomicWriteJson(join(dir, "result.json"), {
|
|
324
|
-
job_id: status.job_id,
|
|
325
|
-
name: status.name,
|
|
326
|
-
status: status.status,
|
|
327
|
-
recovered: true,
|
|
328
|
-
steps: [],
|
|
329
|
-
finally_steps: [],
|
|
330
|
-
error_class: "recovery_exhausted",
|
|
331
|
-
cleanup_error_class: "recovery_exhausted",
|
|
332
|
-
recovery_attempts: recoveryAttempts,
|
|
333
|
-
finished_at: now,
|
|
334
|
-
}, 4 * 1024 * 1024);
|
|
335
|
-
rmSync(join(dir, "runtime"), { recursive: true, force: true });
|
|
336
|
-
scrubFinishedPlan(dir, status);
|
|
331
|
+
markRecoveryExhausted(dir, file, status, recoveryAttempts);
|
|
337
332
|
return;
|
|
338
333
|
}
|
|
339
|
-
|
|
340
|
-
status.updated_at = new Date().toISOString();
|
|
341
|
-
status.finished_at = status.updated_at;
|
|
342
|
-
status.error_class = "runner_interrupted";
|
|
343
|
-
status.recovery_attempts = recoveryAttempts + 1;
|
|
344
|
-
atomicWriteJson(file, status, 256 * 1024);
|
|
345
|
-
rmSync(join(dir, "runtime"), { recursive: true, force: true });
|
|
346
|
-
const runnerPid = launchRunner(dir, true);
|
|
334
|
+
const runnerPid = relaunchInterruptedJob(dir, file, status, recoveryAttempts);
|
|
347
335
|
recoveryLock.handoff(runnerPid);
|
|
348
336
|
handedOff = true;
|
|
349
337
|
} finally {
|
|
@@ -438,13 +426,14 @@ export function validateResourceName(value) {
|
|
|
438
426
|
export function inspectResourceFile(inputPath, { allowInsecurePermissions = false, includeHash = false } = {}) {
|
|
439
427
|
const path = resolve(String(inputPath || ""));
|
|
440
428
|
const canonical = realpathFile(path);
|
|
441
|
-
const { buffer: content, info } =
|
|
429
|
+
const { buffer: content, info } = readBoundedRegularFileWithInfoSync(canonical, MAX_RESOURCE_BYTES);
|
|
442
430
|
if (process.platform !== "win32" && !allowInsecurePermissions && (info.mode & 0o077) !== 0) {
|
|
443
431
|
throw new Error("resource file is readable by group or others; restrict permissions or use --allow-insecure-permissions");
|
|
444
432
|
}
|
|
445
433
|
return {
|
|
446
434
|
kind: "file",
|
|
447
435
|
path: canonical,
|
|
436
|
+
pathAliases: normalizeResourcePathAliases([path, canonical]),
|
|
448
437
|
size: info.size,
|
|
449
438
|
mode: process.platform === "win32" ? null : `0${(info.mode & 0o777).toString(8)}`,
|
|
450
439
|
updatedAt: new Date().toISOString(),
|
|
@@ -453,15 +442,16 @@ export function inspectResourceFile(inputPath, { allowInsecurePermissions = fals
|
|
|
453
442
|
};
|
|
454
443
|
}
|
|
455
444
|
|
|
456
|
-
export function publicResourceRegistry(resources = {}) {
|
|
445
|
+
export function publicResourceRegistry(resources = {}, { includePaths = false } = {}) {
|
|
457
446
|
const normalized = normalizeResourceRegistry(resources);
|
|
458
447
|
return Object.fromEntries(Object.entries(normalized).map(([name, value]) => [name, {
|
|
459
448
|
kind: value.kind,
|
|
460
|
-
path: value.path,
|
|
461
449
|
size: value.size ?? null,
|
|
462
450
|
mode: value.mode ?? null,
|
|
463
451
|
updatedAt: value.updatedAt ?? null,
|
|
464
452
|
allowInsecurePermissions: value.allowInsecurePermissions === true,
|
|
453
|
+
paths_exposed: includePaths,
|
|
454
|
+
...(includePaths ? { path: value.path } : {}),
|
|
465
455
|
}]));
|
|
466
456
|
}
|
|
467
457
|
|
|
@@ -580,7 +570,11 @@ function referencedResources(steps, registry) {
|
|
|
580
570
|
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true, includeHash: true });
|
|
581
571
|
totalBytes += inspected.size;
|
|
582
572
|
if (totalBytes > MAX_JOB_RESOURCE_BYTES) throw new Error(`job resources exceed ${MAX_JOB_RESOURCE_BYTES} bytes`);
|
|
583
|
-
out[name] = {
|
|
573
|
+
out[name] = {
|
|
574
|
+
...inspected,
|
|
575
|
+
pathAliases: normalizeResourcePathAliases([...(resource.pathAliases || []), ...(inspected.pathAliases || [])]),
|
|
576
|
+
allowInsecurePermissions: resource.allowInsecurePermissions === true,
|
|
577
|
+
};
|
|
584
578
|
}
|
|
585
579
|
return out;
|
|
586
580
|
}
|
|
@@ -594,6 +588,7 @@ function normalizeResourceRegistry(resources) {
|
|
|
594
588
|
out[name] = {
|
|
595
589
|
kind: "file",
|
|
596
590
|
path: resolve(rawValue.path),
|
|
591
|
+
pathAliases: normalizeResourcePathAliases([rawValue.path, ...(Array.isArray(rawValue.pathAliases) ? rawValue.pathAliases : [])]),
|
|
597
592
|
size: Number.isFinite(Number(rawValue.size)) ? Number(rawValue.size) : null,
|
|
598
593
|
mode: rawValue.mode ?? null,
|
|
599
594
|
updatedAt: rawValue.updatedAt ?? null,
|
|
@@ -603,6 +598,17 @@ function normalizeResourceRegistry(resources) {
|
|
|
603
598
|
return out;
|
|
604
599
|
}
|
|
605
600
|
|
|
601
|
+
function normalizeResourcePathAliases(values) {
|
|
602
|
+
const aliases = [];
|
|
603
|
+
for (const value of values) {
|
|
604
|
+
if (typeof value !== "string" || !value || value.includes("\0") || value.length > 4096) continue;
|
|
605
|
+
const absolute = resolve(value);
|
|
606
|
+
if (!aliases.includes(absolute)) aliases.push(absolute);
|
|
607
|
+
if (aliases.length >= 8) break;
|
|
608
|
+
}
|
|
609
|
+
return aliases;
|
|
610
|
+
}
|
|
611
|
+
|
|
606
612
|
function resolveJobCwd(value, workspace, unrestrictedPaths) {
|
|
607
613
|
const raw = boundedString(value, 4096, "cwd");
|
|
608
614
|
const candidate = isAbsolute(raw) ? resolve(raw) : resolve(workspace, raw);
|
|
@@ -640,15 +646,76 @@ function failRunnerLaunch(dir, status, error) {
|
|
|
640
646
|
scrubFinishedPlan(dir, failed);
|
|
641
647
|
}
|
|
642
648
|
|
|
649
|
+
function markRecoveryExhausted(dir, statusFile, status, recoveryAttempts) {
|
|
650
|
+
const now = new Date().toISOString();
|
|
651
|
+
Object.assign(status, {
|
|
652
|
+
status: "recovery_exhausted",
|
|
653
|
+
updated_at: now,
|
|
654
|
+
finished_at: now,
|
|
655
|
+
error_class: "recovery_exhausted",
|
|
656
|
+
current_phase: null,
|
|
657
|
+
current_step: null,
|
|
658
|
+
});
|
|
659
|
+
atomicWriteJson(statusFile, status, 256 * 1024);
|
|
660
|
+
atomicWriteJson(join(dir, "result.json"), {
|
|
661
|
+
job_id: status.job_id,
|
|
662
|
+
name: status.name,
|
|
663
|
+
status: status.status,
|
|
664
|
+
recovered: true,
|
|
665
|
+
steps: [],
|
|
666
|
+
finally_steps: [],
|
|
667
|
+
error_class: "recovery_exhausted",
|
|
668
|
+
cleanup_error_class: "recovery_exhausted",
|
|
669
|
+
recovery_attempts: recoveryAttempts,
|
|
670
|
+
finished_at: now,
|
|
671
|
+
}, 4 * 1024 * 1024);
|
|
672
|
+
rmSync(join(dir, "runtime"), { recursive: true, force: true });
|
|
673
|
+
scrubFinishedPlan(dir, status);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts) {
|
|
677
|
+
status.status = "interrupted";
|
|
678
|
+
status.updated_at = new Date().toISOString();
|
|
679
|
+
status.finished_at = status.updated_at;
|
|
680
|
+
status.error_class = "runner_interrupted";
|
|
681
|
+
status.recovery_attempts = recoveryAttempts + 1;
|
|
682
|
+
atomicWriteJson(statusFile, status, 256 * 1024);
|
|
683
|
+
rmSync(join(dir, "runtime"), { recursive: true, force: true });
|
|
684
|
+
rmSync(join(dir, "runner.pid"), { force: true });
|
|
685
|
+
return launchRunner(dir, true);
|
|
686
|
+
}
|
|
687
|
+
|
|
643
688
|
function acquireRecoveryLock(dir) {
|
|
644
|
-
|
|
689
|
+
return acquirePidLock(join(dir, "recovery.lock"), { allowHandoff: true });
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function planSha256(plan) {
|
|
693
|
+
return createHash("sha256").update(JSON.stringify(plan)).digest("hex");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function assertPlanIntegrity(plan, status) {
|
|
697
|
+
const expected = String(status?.plan_sha256 || "");
|
|
698
|
+
const actual = planSha256(plan);
|
|
699
|
+
if (!/^[a-f0-9]{64}$/.test(expected) || actual !== expected) {
|
|
700
|
+
throw new Error("managed job plan integrity check failed; inspect the plan and do not approve it");
|
|
701
|
+
}
|
|
702
|
+
return actual;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function acquireJobTransitionLock(dir) {
|
|
706
|
+
return acquirePidLock(join(dir, "transition.lock"));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function acquirePidLock(file, { allowHandoff = false } = {}) {
|
|
645
710
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
646
711
|
try {
|
|
647
712
|
writeFileSync(file, `${process.pid}\n`, { mode: 0o600, flag: "wx" });
|
|
648
713
|
return {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
714
|
+
...(allowHandoff ? {
|
|
715
|
+
handoff(pid) {
|
|
716
|
+
if (Number.isInteger(pid) && pid > 0) writeFileSync(file, `${pid}\n`, { mode: 0o600 });
|
|
717
|
+
},
|
|
718
|
+
} : {}),
|
|
652
719
|
release() { rmSync(file, { force: true }); },
|
|
653
720
|
};
|
|
654
721
|
} catch (error) {
|
|
@@ -656,7 +723,9 @@ function acquireRecoveryLock(dir) {
|
|
|
656
723
|
let owner = 0;
|
|
657
724
|
try { owner = Number.parseInt(readBoundedFile(file, 64).toString("utf8").trim(), 10); } catch {}
|
|
658
725
|
const age = Date.now() - safeMtime(file);
|
|
659
|
-
|
|
726
|
+
const ownerAlive = owner > 0 && isPidAlive(owner);
|
|
727
|
+
const definitelyStale = age >= 5 * 60_000 || (!ownerAlive && age >= 60_000);
|
|
728
|
+
if (!definitelyStale) return null;
|
|
660
729
|
rmSync(file, { force: true });
|
|
661
730
|
}
|
|
662
731
|
}
|
|
@@ -670,18 +739,20 @@ function launchRunner(dir, recover = false) {
|
|
|
670
739
|
const stderrFile = join(dir, "runner.err.log");
|
|
671
740
|
trimDiagnosticFile(stdoutFile);
|
|
672
741
|
trimDiagnosticFile(stderrFile);
|
|
673
|
-
|
|
674
|
-
|
|
742
|
+
let stdoutFd;
|
|
743
|
+
let stderrFd;
|
|
675
744
|
let child;
|
|
676
745
|
try {
|
|
746
|
+
stdoutFd = openPrivateAppendFile(stdoutFile);
|
|
747
|
+
stderrFd = openPrivateAppendFile(stderrFile);
|
|
677
748
|
child = spawn(process.execPath, args, {
|
|
678
749
|
detached: true,
|
|
679
750
|
stdio: ["ignore", stdoutFd, stderrFd],
|
|
680
751
|
windowsHide: true,
|
|
681
752
|
});
|
|
682
753
|
} finally {
|
|
683
|
-
closeSync(stdoutFd);
|
|
684
|
-
closeSync(stderrFd);
|
|
754
|
+
if (stdoutFd !== undefined) closeSync(stdoutFd);
|
|
755
|
+
if (stderrFd !== undefined) closeSync(stderrFd);
|
|
685
756
|
}
|
|
686
757
|
ownerOnlyFile(stdoutFile);
|
|
687
758
|
ownerOnlyFile(stderrFile);
|
|
@@ -704,6 +775,7 @@ function scrubFinishedPlan(dir, status) {
|
|
|
704
775
|
rmSync(join(dir, "plan.json"), { force: true });
|
|
705
776
|
rmSync(join(dir, "runner.pid"), { force: true });
|
|
706
777
|
rmSync(join(dir, "recovery.lock"), { force: true });
|
|
778
|
+
rmSync(join(dir, "transition.lock"), { force: true });
|
|
707
779
|
}
|
|
708
780
|
|
|
709
781
|
function reviewablePlan(plan) {
|
|
@@ -763,33 +835,30 @@ function readRequiredJson(file, maxBytes, label) {
|
|
|
763
835
|
}
|
|
764
836
|
|
|
765
837
|
function readBoundedFile(file, maxBytes) {
|
|
766
|
-
return
|
|
838
|
+
return readBoundedRegularFileSync(file, maxBytes);
|
|
767
839
|
}
|
|
768
840
|
|
|
769
|
-
function
|
|
770
|
-
|
|
771
|
-
|
|
841
|
+
function openPrivateAppendFile(file) {
|
|
842
|
+
if (existsSync(file)) {
|
|
843
|
+
const info = lstatSync(file);
|
|
844
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("runner diagnostic path must be a regular file and not a symbolic link");
|
|
845
|
+
}
|
|
846
|
+
const flags = Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND) | Number(fsConstants.O_NOFOLLOW || 0);
|
|
847
|
+
const fd = openSync(file, flags, 0o600);
|
|
772
848
|
try {
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
let offset = 0;
|
|
778
|
-
while (offset < buffer.length) {
|
|
779
|
-
const count = readSync(fd, buffer, offset, buffer.length - offset, offset);
|
|
780
|
-
if (!count) break;
|
|
781
|
-
offset += count;
|
|
782
|
-
}
|
|
783
|
-
return { buffer: buffer.subarray(0, offset), info };
|
|
784
|
-
} finally {
|
|
849
|
+
if (!fstatSync(fd).isFile()) throw new Error("runner diagnostic path is not a regular file");
|
|
850
|
+
chmodSync(file, 0o600);
|
|
851
|
+
return fd;
|
|
852
|
+
} catch (error) {
|
|
785
853
|
closeSync(fd);
|
|
854
|
+
throw error;
|
|
786
855
|
}
|
|
787
856
|
}
|
|
788
857
|
|
|
789
858
|
function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
|
|
790
859
|
let fd;
|
|
791
860
|
try {
|
|
792
|
-
const flags = Number(fsConstants.
|
|
861
|
+
const flags = Number(fsConstants.O_RDWR) | Number(fsConstants.O_NOFOLLOW || 0);
|
|
793
862
|
fd = openSync(file, flags);
|
|
794
863
|
const info = fstatSync(fd);
|
|
795
864
|
if (!info.isFile() || info.size <= maxBytes) return;
|
|
@@ -804,9 +873,9 @@ function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
|
|
|
804
873
|
let tail = buffer.subarray(0, offset);
|
|
805
874
|
const newline = tail.indexOf(0x0a);
|
|
806
875
|
if (newline >= 0 && newline < tail.length - 1) tail = tail.subarray(newline + 1);
|
|
807
|
-
|
|
808
|
-
fd
|
|
809
|
-
|
|
876
|
+
ftruncateSync(fd, 0);
|
|
877
|
+
if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
|
|
878
|
+
try { chmodSync(file, 0o600); } catch {}
|
|
810
879
|
} catch {
|
|
811
880
|
} finally {
|
|
812
881
|
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
@@ -4,11 +4,11 @@ import { basename } from "node:path";
|
|
|
4
4
|
import { executionEnv } from "./shell.mjs";
|
|
5
5
|
|
|
6
6
|
export const MAX_COMMAND_BYTES = 64 * 1024;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
const MAX_ARGV_ITEMS = 256;
|
|
8
|
+
const MAX_PROCESS_SESSIONS = 8;
|
|
9
|
+
const MAX_SESSION_OUTPUT_BYTES = 1024 * 1024;
|
|
10
|
+
const MAX_PROCESS_STDIN_BYTES = 64 * 1024;
|
|
11
|
+
const PROCESS_SESSION_RETENTION_MS = 30 * 60 * 1000;
|
|
12
12
|
|
|
13
13
|
export class ProcessSessionManager {
|
|
14
14
|
constructor({ workspace, policy, runtimeDir, activeProcesses, callProcesses, resolveCwd, displayPath, throwIfCancelled }) {
|