machine-bridge-mcp 0.6.2 → 0.7.1
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 +42 -0
- package/CONTRIBUTING.md +23 -0
- package/README.md +16 -13
- package/SECURITY.md +11 -7
- package/docs/ARCHITECTURE.md +6 -6
- package/docs/CLIENTS.md +2 -2
- package/docs/LOGGING.md +4 -0
- package/docs/MANAGED_JOBS.md +14 -13
- package/docs/OPERATIONS.md +9 -6
- package/docs/PRIVACY.md +37 -0
- package/docs/RELEASING.md +9 -4
- package/docs/TESTING.md +17 -1
- package/package.json +31 -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 +54 -16
- package/src/local/daemon.mjs +49 -12
- package/src/local/job-runner.mjs +70 -13
- package/src/local/log.mjs +26 -2
- package/src/local/managed-jobs.mjs +163 -74
- package/src/local/process-sessions.mjs +5 -5
- package/src/local/service.mjs +128 -30
- package/src/local/shell.mjs +1 -1
- package/src/local/ssh-key.mjs +4 -2
- package/src/local/state.mjs +7 -5
- package/src/local/stdio.mjs +76 -16
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +5 -1
- package/src/worker/index.ts +35 -18
- package/wrangler.jsonc +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
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";
|
|
@@ -95,36 +95,44 @@ export class ManagedJobManager {
|
|
|
95
95
|
approve(args = {}, { localOperator = false } = {}) {
|
|
96
96
|
if (!localOperator) this.assertEnabled("approve_job");
|
|
97
97
|
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);
|
|
98
|
+
const transition = acquireJobTransitionLock(dir);
|
|
99
|
+
if (!transition) throw new Error("job state is being modified by another process; retry after inspecting its current status");
|
|
108
100
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
101
|
+
const statusFile = join(dir, "status.json");
|
|
102
|
+
const status = readRequiredJson(statusFile, 256 * 1024, "job status");
|
|
103
|
+
if (status.status !== "staged") throw new Error(`job is not staged: ${status.status}`);
|
|
104
|
+
const plan = readRequiredJson(join(dir, "plan.json"), MAX_PLAN_BYTES, "job plan");
|
|
105
|
+
assertPlanIntegrity(plan, status);
|
|
106
|
+
status.status = "queued";
|
|
107
|
+
status.updated_at = new Date().toISOString();
|
|
108
|
+
status.approved_at = status.updated_at;
|
|
109
|
+
status.approval = localOperator ? "local-operator" : "mcp";
|
|
110
|
+
status.cleanup_guarantee = "best-effort-finally-and-recovery";
|
|
111
|
+
atomicWriteJson(statusFile, status, 256 * 1024);
|
|
112
|
+
try {
|
|
113
|
+
launchRunner(dir);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
failRunnerLaunch(dir, status, error);
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
accepted: true,
|
|
120
|
+
job_id: status.job_id,
|
|
121
|
+
name: status.name,
|
|
122
|
+
status: "queued",
|
|
123
|
+
detached: true,
|
|
124
|
+
continues_without_mcp_connection: true,
|
|
125
|
+
approval: status.approval,
|
|
126
|
+
plan_sha256: status.plan_sha256,
|
|
127
|
+
cleanup: {
|
|
128
|
+
resource_copies: "best-effort",
|
|
129
|
+
finally_steps: "best-effort-if-declared",
|
|
130
|
+
restart_recovery: "best-effort-on-next-runtime-or-cli-start",
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
} finally {
|
|
134
|
+
transition.release();
|
|
113
135
|
}
|
|
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
136
|
}
|
|
129
137
|
|
|
130
138
|
createJob(args, { launch }) {
|
|
@@ -225,48 +233,55 @@ export class ManagedJobManager {
|
|
|
225
233
|
this.reconcileStatus(dir);
|
|
226
234
|
const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
|
|
227
235
|
const plan = readJson(join(dir, "plan.json"), MAX_PLAN_BYTES);
|
|
236
|
+
if (plan) assertPlanIntegrity(plan, status);
|
|
228
237
|
return {
|
|
229
238
|
...publicStatus(status),
|
|
239
|
+
plan_integrity_verified: Boolean(plan),
|
|
230
240
|
...(plan ? { review_plan: reviewablePlan(plan) } : {}),
|
|
231
241
|
};
|
|
232
242
|
}
|
|
233
243
|
|
|
234
244
|
cancel(args = {}) {
|
|
235
245
|
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
|
-
|
|
246
|
+
const transition = acquireJobTransitionLock(dir);
|
|
247
|
+
if (!transition) throw new Error("job state is being modified by another process; retry after inspecting its current status");
|
|
248
|
+
try {
|
|
249
|
+
this.reconcileStatus(dir);
|
|
250
|
+
const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
|
|
251
|
+
if (status.status === "staged") {
|
|
252
|
+
const now = new Date().toISOString();
|
|
253
|
+
status.status = "cancelled_before_start";
|
|
254
|
+
status.updated_at = now;
|
|
255
|
+
status.finished_at = now;
|
|
256
|
+
status.error_class = "cancelled";
|
|
257
|
+
status.cleanup_guarantee = "not-started";
|
|
258
|
+
atomicWriteJson(join(dir, "status.json"), status, 256 * 1024);
|
|
259
|
+
atomicWriteJson(join(dir, "result.json"), {
|
|
260
|
+
job_id: status.job_id,
|
|
261
|
+
name: status.name,
|
|
262
|
+
status: status.status,
|
|
263
|
+
steps: [],
|
|
264
|
+
finally_steps: [],
|
|
265
|
+
error_class: "cancelled",
|
|
266
|
+
cleanup_error_class: null,
|
|
267
|
+
finished_at: now,
|
|
268
|
+
}, 4 * 1024 * 1024);
|
|
269
|
+
scrubFinishedPlan(dir, status);
|
|
270
|
+
return { ...publicStatus(status), cancellation_requested: true, cleanup_will_run: false, execution_started: false };
|
|
271
|
+
}
|
|
272
|
+
if (!ACTIVE_JOB_STATES.has(status.status)) {
|
|
273
|
+
return { ...publicStatus(status), cancellation_requested: false, already_finished: true };
|
|
274
|
+
}
|
|
275
|
+
writeFileSync(join(dir, "cancel"), `${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
276
|
+
return {
|
|
277
|
+
...publicStatus(status),
|
|
278
|
+
cancellation_requested: true,
|
|
279
|
+
cancellation_delivery: "runner-poll",
|
|
280
|
+
cleanup_will_run: true,
|
|
281
|
+
};
|
|
282
|
+
} finally {
|
|
283
|
+
transition.release();
|
|
261
284
|
}
|
|
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
285
|
}
|
|
271
286
|
|
|
272
287
|
currentResources() {
|
|
@@ -343,6 +358,7 @@ export class ManagedJobManager {
|
|
|
343
358
|
status.recovery_attempts = recoveryAttempts + 1;
|
|
344
359
|
atomicWriteJson(file, status, 256 * 1024);
|
|
345
360
|
rmSync(join(dir, "runtime"), { recursive: true, force: true });
|
|
361
|
+
rmSync(join(dir, "runner.pid"), { force: true });
|
|
346
362
|
const runnerPid = launchRunner(dir, true);
|
|
347
363
|
recoveryLock.handoff(runnerPid);
|
|
348
364
|
handedOff = true;
|
|
@@ -445,6 +461,7 @@ export function inspectResourceFile(inputPath, { allowInsecurePermissions = fals
|
|
|
445
461
|
return {
|
|
446
462
|
kind: "file",
|
|
447
463
|
path: canonical,
|
|
464
|
+
pathAliases: normalizeResourcePathAliases([path, canonical]),
|
|
448
465
|
size: info.size,
|
|
449
466
|
mode: process.platform === "win32" ? null : `0${(info.mode & 0o777).toString(8)}`,
|
|
450
467
|
updatedAt: new Date().toISOString(),
|
|
@@ -453,15 +470,16 @@ export function inspectResourceFile(inputPath, { allowInsecurePermissions = fals
|
|
|
453
470
|
};
|
|
454
471
|
}
|
|
455
472
|
|
|
456
|
-
export function publicResourceRegistry(resources = {}) {
|
|
473
|
+
export function publicResourceRegistry(resources = {}, { includePaths = false } = {}) {
|
|
457
474
|
const normalized = normalizeResourceRegistry(resources);
|
|
458
475
|
return Object.fromEntries(Object.entries(normalized).map(([name, value]) => [name, {
|
|
459
476
|
kind: value.kind,
|
|
460
|
-
path: value.path,
|
|
461
477
|
size: value.size ?? null,
|
|
462
478
|
mode: value.mode ?? null,
|
|
463
479
|
updatedAt: value.updatedAt ?? null,
|
|
464
480
|
allowInsecurePermissions: value.allowInsecurePermissions === true,
|
|
481
|
+
paths_exposed: includePaths,
|
|
482
|
+
...(includePaths ? { path: value.path } : {}),
|
|
465
483
|
}]));
|
|
466
484
|
}
|
|
467
485
|
|
|
@@ -580,7 +598,11 @@ function referencedResources(steps, registry) {
|
|
|
580
598
|
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true, includeHash: true });
|
|
581
599
|
totalBytes += inspected.size;
|
|
582
600
|
if (totalBytes > MAX_JOB_RESOURCE_BYTES) throw new Error(`job resources exceed ${MAX_JOB_RESOURCE_BYTES} bytes`);
|
|
583
|
-
out[name] = {
|
|
601
|
+
out[name] = {
|
|
602
|
+
...inspected,
|
|
603
|
+
pathAliases: normalizeResourcePathAliases([...(resource.pathAliases || []), ...(inspected.pathAliases || [])]),
|
|
604
|
+
allowInsecurePermissions: resource.allowInsecurePermissions === true,
|
|
605
|
+
};
|
|
584
606
|
}
|
|
585
607
|
return out;
|
|
586
608
|
}
|
|
@@ -594,6 +616,7 @@ function normalizeResourceRegistry(resources) {
|
|
|
594
616
|
out[name] = {
|
|
595
617
|
kind: "file",
|
|
596
618
|
path: resolve(rawValue.path),
|
|
619
|
+
pathAliases: normalizeResourcePathAliases([rawValue.path, ...(Array.isArray(rawValue.pathAliases) ? rawValue.pathAliases : [])]),
|
|
597
620
|
size: Number.isFinite(Number(rawValue.size)) ? Number(rawValue.size) : null,
|
|
598
621
|
mode: rawValue.mode ?? null,
|
|
599
622
|
updatedAt: rawValue.updatedAt ?? null,
|
|
@@ -603,6 +626,17 @@ function normalizeResourceRegistry(resources) {
|
|
|
603
626
|
return out;
|
|
604
627
|
}
|
|
605
628
|
|
|
629
|
+
function normalizeResourcePathAliases(values) {
|
|
630
|
+
const aliases = [];
|
|
631
|
+
for (const value of values) {
|
|
632
|
+
if (typeof value !== "string" || !value || value.includes("\0") || value.length > 4096) continue;
|
|
633
|
+
const absolute = resolve(value);
|
|
634
|
+
if (!aliases.includes(absolute)) aliases.push(absolute);
|
|
635
|
+
if (aliases.length >= 8) break;
|
|
636
|
+
}
|
|
637
|
+
return aliases;
|
|
638
|
+
}
|
|
639
|
+
|
|
606
640
|
function resolveJobCwd(value, workspace, unrestrictedPaths) {
|
|
607
641
|
const raw = boundedString(value, 4096, "cwd");
|
|
608
642
|
const candidate = isAbsolute(raw) ? resolve(raw) : resolve(workspace, raw);
|
|
@@ -656,7 +690,42 @@ function acquireRecoveryLock(dir) {
|
|
|
656
690
|
let owner = 0;
|
|
657
691
|
try { owner = Number.parseInt(readBoundedFile(file, 64).toString("utf8").trim(), 10); } catch {}
|
|
658
692
|
const age = Date.now() - safeMtime(file);
|
|
659
|
-
|
|
693
|
+
const ownerAlive = owner > 0 && isPidAlive(owner);
|
|
694
|
+
const definitelyStale = age >= 5 * 60_000 || (!ownerAlive && age >= 60_000);
|
|
695
|
+
if (!definitelyStale) return null;
|
|
696
|
+
rmSync(file, { force: true });
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function planSha256(plan) {
|
|
703
|
+
return createHash("sha256").update(JSON.stringify(plan)).digest("hex");
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function assertPlanIntegrity(plan, status) {
|
|
707
|
+
const expected = String(status?.plan_sha256 || "");
|
|
708
|
+
const actual = planSha256(plan);
|
|
709
|
+
if (!/^[a-f0-9]{64}$/.test(expected) || actual !== expected) {
|
|
710
|
+
throw new Error("managed job plan integrity check failed; inspect the plan and do not approve it");
|
|
711
|
+
}
|
|
712
|
+
return actual;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function acquireJobTransitionLock(dir) {
|
|
716
|
+
const file = join(dir, "transition.lock");
|
|
717
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
718
|
+
try {
|
|
719
|
+
writeFileSync(file, `${process.pid}\n`, { mode: 0o600, flag: "wx" });
|
|
720
|
+
return { release() { rmSync(file, { force: true }); } };
|
|
721
|
+
} catch (error) {
|
|
722
|
+
if (error?.code !== "EEXIST") throw error;
|
|
723
|
+
let owner = 0;
|
|
724
|
+
try { owner = Number.parseInt(readBoundedFile(file, 64).toString("utf8").trim(), 10); } catch {}
|
|
725
|
+
const age = Date.now() - safeMtime(file);
|
|
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) {
|
|
@@ -786,10 +858,27 @@ function readBoundedFileWithInfo(file, maxBytes) {
|
|
|
786
858
|
}
|
|
787
859
|
}
|
|
788
860
|
|
|
861
|
+
function openPrivateAppendFile(file) {
|
|
862
|
+
if (existsSync(file)) {
|
|
863
|
+
const info = lstatSync(file);
|
|
864
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("runner diagnostic path must be a regular file and not a symbolic link");
|
|
865
|
+
}
|
|
866
|
+
const flags = Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND) | Number(fsConstants.O_NOFOLLOW || 0);
|
|
867
|
+
const fd = openSync(file, flags, 0o600);
|
|
868
|
+
try {
|
|
869
|
+
if (!fstatSync(fd).isFile()) throw new Error("runner diagnostic path is not a regular file");
|
|
870
|
+
chmodSync(file, 0o600);
|
|
871
|
+
return fd;
|
|
872
|
+
} catch (error) {
|
|
873
|
+
closeSync(fd);
|
|
874
|
+
throw error;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
789
878
|
function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
|
|
790
879
|
let fd;
|
|
791
880
|
try {
|
|
792
|
-
const flags = Number(fsConstants.
|
|
881
|
+
const flags = Number(fsConstants.O_RDWR) | Number(fsConstants.O_NOFOLLOW || 0);
|
|
793
882
|
fd = openSync(file, flags);
|
|
794
883
|
const info = fstatSync(fd);
|
|
795
884
|
if (!info.isFile() || info.size <= maxBytes) return;
|
|
@@ -804,9 +893,9 @@ function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
|
|
|
804
893
|
let tail = buffer.subarray(0, offset);
|
|
805
894
|
const newline = tail.indexOf(0x0a);
|
|
806
895
|
if (newline >= 0 && newline < tail.length - 1) tail = tail.subarray(newline + 1);
|
|
807
|
-
|
|
808
|
-
fd
|
|
809
|
-
|
|
896
|
+
ftruncateSync(fd, 0);
|
|
897
|
+
if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
|
|
898
|
+
try { chmodSync(file, 0o600); } catch {}
|
|
810
899
|
} catch {
|
|
811
900
|
} finally {
|
|
812
901
|
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 }) {
|
package/src/local/service.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { run } from "./shell.mjs";
|
|
5
|
-
import { ensureOwnerOnlyDir, expandHome } from "./state.mjs";
|
|
5
|
+
import { ensureOwnerOnlyDir, expandHome, ownerOnlyFile } from "./state.mjs";
|
|
6
|
+
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
6
7
|
|
|
7
8
|
const LABEL = "dev.machine-bridge-mcp.daemon";
|
|
8
9
|
const WINDOWS_TASK = "MachineBridgeMCP";
|
|
@@ -55,23 +56,29 @@ export function trimAutostartLogs(stateRoot, options = {}) {
|
|
|
55
56
|
const logs = path.join(root, "logs");
|
|
56
57
|
for (const name of ["daemon.out.log", "daemon.err.log"]) {
|
|
57
58
|
const file = path.join(logs, name);
|
|
59
|
+
let fd;
|
|
58
60
|
try {
|
|
59
|
-
|
|
61
|
+
if (!existsSync(file)) continue;
|
|
62
|
+
const before = lstatSync(file);
|
|
63
|
+
if (before.isSymbolicLink() || !before.isFile()) continue;
|
|
64
|
+
const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
|
|
65
|
+
fd = openSync(file, Number(fsConstants.O_RDWR) | noFollow);
|
|
66
|
+
const info = fstatSync(fd);
|
|
67
|
+
if (!info.isFile()) continue;
|
|
60
68
|
if (info.size > maxBytes) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
readSync(fd, buffer, 0, length, Math.max(0, current.size - length));
|
|
68
|
-
} finally {
|
|
69
|
-
closeSync(fd);
|
|
70
|
-
}
|
|
71
|
-
writeFileSync(file, lineSafeTail(buffer), { mode: 0o600 });
|
|
69
|
+
const length = Math.min(keepBytes, info.size);
|
|
70
|
+
const buffer = Buffer.alloc(length);
|
|
71
|
+
readSync(fd, buffer, 0, length, Math.max(0, info.size - length));
|
|
72
|
+
const tail = lineSafeTail(buffer);
|
|
73
|
+
ftruncateSync(fd, 0);
|
|
74
|
+
if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
|
|
72
75
|
}
|
|
73
|
-
chmodSync(file, 0o600);
|
|
74
|
-
} catch {
|
|
76
|
+
try { chmodSync(file, 0o600); } catch {}
|
|
77
|
+
} catch {
|
|
78
|
+
// Operational log maintenance is best effort and must not stop startup.
|
|
79
|
+
} finally {
|
|
80
|
+
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
81
|
+
}
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
|
@@ -87,22 +94,76 @@ function lineSafeTail(buffer) {
|
|
|
87
94
|
function serviceSpec({ workspace, stateRoot, entryScript }) {
|
|
88
95
|
const root = expandHome(stateRoot);
|
|
89
96
|
const logs = path.join(root, "logs");
|
|
97
|
+
const resolvedEntryScript = path.resolve(entryScript);
|
|
98
|
+
const node = stableNodeExecutable();
|
|
90
99
|
ensureOwnerOnlyDir(root);
|
|
91
100
|
ensureOwnerOnlyDir(logs);
|
|
92
|
-
for (const file of [path.join(logs, "daemon.out.log"), path.join(logs, "daemon.err.log")])
|
|
93
|
-
writeFileSync(file, "", { flag: "a", mode: 0o600 });
|
|
94
|
-
try { chmodSync(file, 0o600); } catch {}
|
|
95
|
-
}
|
|
101
|
+
for (const file of [path.join(logs, "daemon.out.log"), path.join(logs, "daemon.err.log")]) ensurePrivateLogFile(file);
|
|
96
102
|
return {
|
|
97
103
|
workspace,
|
|
98
104
|
stateRoot: root,
|
|
99
|
-
entryScript:
|
|
100
|
-
node
|
|
105
|
+
entryScript: resolvedEntryScript,
|
|
106
|
+
node,
|
|
107
|
+
pathEnv: serviceEnvironmentPath({ node, entryScript: resolvedEntryScript }),
|
|
101
108
|
stdout: path.join(logs, "daemon.out.log"),
|
|
102
109
|
stderr: path.join(logs, "daemon.err.log"),
|
|
103
110
|
};
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
export function stableNodeExecutable(options = {}) {
|
|
114
|
+
const platform = String(options.platform || process.platform);
|
|
115
|
+
const execPath = path.resolve(String(options.execPath || process.execPath));
|
|
116
|
+
const pathEnv = String(options.pathEnv ?? process.env.PATH ?? "");
|
|
117
|
+
let canonicalExec;
|
|
118
|
+
try { canonicalExec = realpathSync(execPath); } catch { return execPath; }
|
|
119
|
+
const executableName = platform === "win32" ? "node.exe" : "node";
|
|
120
|
+
for (const directory of pathEnv.split(path.delimiter).filter(Boolean)) {
|
|
121
|
+
const candidate = path.resolve(directory, executableName);
|
|
122
|
+
try {
|
|
123
|
+
const info = lstatSync(candidate);
|
|
124
|
+
if (!info.isFile() && !info.isSymbolicLink()) continue;
|
|
125
|
+
const candidateCanonical = realpathSync(candidate);
|
|
126
|
+
const sameExecutable = platform === "win32"
|
|
127
|
+
? candidateCanonical.toLowerCase() === canonicalExec.toLowerCase()
|
|
128
|
+
: candidateCanonical === canonicalExec;
|
|
129
|
+
if (!sameExecutable) continue;
|
|
130
|
+
if (platform !== "win32" && (statSync(candidate).mode & 0o111) === 0) continue;
|
|
131
|
+
return candidate;
|
|
132
|
+
} catch {}
|
|
133
|
+
}
|
|
134
|
+
return execPath;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function serviceEnvironmentPath(options = {}) {
|
|
138
|
+
const platform = String(options.platform || process.platform);
|
|
139
|
+
const delimiter = String(options.delimiter || (platform === "win32" ? ";" : ":"));
|
|
140
|
+
const pathEnv = String(options.pathEnv ?? process.env.PATH ?? "");
|
|
141
|
+
const node = String(options.node || process.execPath || "");
|
|
142
|
+
const entryScript = String(options.entryScript || "");
|
|
143
|
+
const defaults = platform === "darwin"
|
|
144
|
+
? ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
|
|
145
|
+
: platform === "win32"
|
|
146
|
+
? []
|
|
147
|
+
: ["/usr/local/bin", "/usr/bin", "/bin", "/usr/local/sbin", "/usr/sbin", "/sbin"];
|
|
148
|
+
const candidates = [
|
|
149
|
+
node ? path.dirname(path.resolve(node)) : "",
|
|
150
|
+
entryScript ? path.dirname(path.resolve(entryScript)) : "",
|
|
151
|
+
...pathEnv.split(delimiter),
|
|
152
|
+
...defaults,
|
|
153
|
+
];
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
const entries = [];
|
|
156
|
+
for (const raw of candidates) {
|
|
157
|
+
if (!raw || !path.isAbsolute(raw)) continue;
|
|
158
|
+
const normalized = path.resolve(raw);
|
|
159
|
+
const key = platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
160
|
+
if (seen.has(key)) continue;
|
|
161
|
+
seen.add(key);
|
|
162
|
+
entries.push(normalized);
|
|
163
|
+
}
|
|
164
|
+
return entries.join(delimiter);
|
|
165
|
+
}
|
|
166
|
+
|
|
106
167
|
export function daemonArgs(spec) {
|
|
107
168
|
return [
|
|
108
169
|
spec.entryScript,
|
|
@@ -115,6 +176,38 @@ export function daemonArgs(spec) {
|
|
|
115
176
|
];
|
|
116
177
|
}
|
|
117
178
|
|
|
179
|
+
function ensurePrivateLogFile(file) {
|
|
180
|
+
if (existsSync(file)) {
|
|
181
|
+
const info = lstatSync(file);
|
|
182
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("autostart log path must be a regular non-symbolic-link file");
|
|
183
|
+
}
|
|
184
|
+
const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
|
|
185
|
+
const fd = openSync(file, Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND) | noFollow, 0o600);
|
|
186
|
+
try {
|
|
187
|
+
if (!fstatSync(fd).isFile()) throw new Error("autostart log path is not a regular file");
|
|
188
|
+
} finally {
|
|
189
|
+
closeSync(fd);
|
|
190
|
+
}
|
|
191
|
+
ownerOnlyFile(file);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function writePrivateServiceFile(file, content) {
|
|
195
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
196
|
+
if (existsSync(file)) {
|
|
197
|
+
const info = lstatSync(file);
|
|
198
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("autostart configuration path must be a regular non-symbolic-link file");
|
|
199
|
+
}
|
|
200
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
201
|
+
try {
|
|
202
|
+
writeFileSync(temporary, content, { mode: 0o600, flag: "wx" });
|
|
203
|
+
replaceFileSync(temporary, file);
|
|
204
|
+
ownerOnlyFile(file);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
try { rmSync(temporary, { force: true }); } catch {}
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
118
211
|
function launchdPlistPath() {
|
|
119
212
|
return path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
120
213
|
}
|
|
@@ -123,8 +216,8 @@ async function installLaunchd(spec, logger) {
|
|
|
123
216
|
const plistPath = launchdPlistPath();
|
|
124
217
|
mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
125
218
|
const args = [spec.node, ...daemonArgs(spec)];
|
|
126
|
-
|
|
127
|
-
logger.info?.(
|
|
219
|
+
writePrivateServiceFile(plistPath, launchdPlist({ args, pathEnv: spec.pathEnv, stdout: spec.stdout, stderr: spec.stderr }));
|
|
220
|
+
logger.info?.("Autostart installed for next login.");
|
|
128
221
|
return { ok: true, provider: "launchd", path: plistPath };
|
|
129
222
|
}
|
|
130
223
|
|
|
@@ -151,7 +244,7 @@ async function uninstallLaunchd(logger) {
|
|
|
151
244
|
await stopLaunchd(logger).catch(() => {});
|
|
152
245
|
const plistPath = launchdPlistPath();
|
|
153
246
|
if (existsSync(plistPath)) rmSync(plistPath, { force: true });
|
|
154
|
-
logger.info?.(
|
|
247
|
+
logger.info?.("Autostart removed.");
|
|
155
248
|
return { ok: true, provider: "launchd", path: plistPath };
|
|
156
249
|
}
|
|
157
250
|
|
|
@@ -162,7 +255,7 @@ async function statusLaunchd() {
|
|
|
162
255
|
return { ok: existsSync(plistPath), provider: "launchd", installed: existsSync(plistPath), path: plistPath, active: result.code === 0, detail: result.stdout || result.stderr };
|
|
163
256
|
}
|
|
164
257
|
|
|
165
|
-
function launchdPlist({ args, stdout, stderr }) {
|
|
258
|
+
export function launchdPlist({ args, pathEnv, stdout, stderr }) {
|
|
166
259
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
167
260
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
168
261
|
<plist version="1.0">
|
|
@@ -172,6 +265,10 @@ function launchdPlist({ args, stdout, stderr }) {
|
|
|
172
265
|
<array>
|
|
173
266
|
${args.map(arg => ` <string>${escapeXml(arg)}</string>`).join("\n")}
|
|
174
267
|
</array>
|
|
268
|
+
<key>EnvironmentVariables</key>
|
|
269
|
+
<dict>
|
|
270
|
+
<key>PATH</key><string>${escapeXml(pathEnv)}</string>
|
|
271
|
+
</dict>
|
|
175
272
|
<key>RunAtLoad</key><true/>
|
|
176
273
|
<key>KeepAlive</key>
|
|
177
274
|
<dict>
|
|
@@ -191,11 +288,11 @@ function systemdPath() {
|
|
|
191
288
|
async function installSystemd(spec, logger) {
|
|
192
289
|
const servicePath = systemdPath();
|
|
193
290
|
mkdirSync(path.dirname(servicePath), { recursive: true });
|
|
194
|
-
|
|
291
|
+
writePrivateServiceFile(servicePath, systemdUnit(spec));
|
|
195
292
|
const reload = await serviceRun("systemctl", ["--user", "daemon-reload"]);
|
|
196
293
|
const enable = await serviceRun("systemctl", ["--user", "enable", "machine-bridge-mcp.service"]);
|
|
197
294
|
const linger = await serviceRun("loginctl", ["enable-linger", os.userInfo().username]);
|
|
198
|
-
logger.info?.(
|
|
295
|
+
logger.info?.("Autostart installed.");
|
|
199
296
|
return { ok: reload.code === 0 && enable.code === 0, provider: "systemd", path: servicePath, reload, enable, linger };
|
|
200
297
|
}
|
|
201
298
|
|
|
@@ -204,7 +301,7 @@ async function uninstallSystemd(logger) {
|
|
|
204
301
|
const servicePath = systemdPath();
|
|
205
302
|
if (existsSync(servicePath)) rmSync(servicePath, { force: true });
|
|
206
303
|
await serviceRun("systemctl", ["--user", "daemon-reload"]);
|
|
207
|
-
logger.info?.(
|
|
304
|
+
logger.info?.("Autostart removed.");
|
|
208
305
|
return { ok: true, provider: "systemd", path: servicePath };
|
|
209
306
|
}
|
|
210
307
|
|
|
@@ -214,7 +311,7 @@ async function statusSystemd() {
|
|
|
214
311
|
return { ok: existsSync(servicePath), provider: "systemd", installed: existsSync(servicePath), path: servicePath, active: result.code === 0, detail: result.stdout || result.stderr };
|
|
215
312
|
}
|
|
216
313
|
|
|
217
|
-
function systemdUnit(spec) {
|
|
314
|
+
export function systemdUnit(spec) {
|
|
218
315
|
const execArgs = [spec.node, ...daemonArgs(spec)].map(systemdQuote).join(" ");
|
|
219
316
|
return `[Unit]
|
|
220
317
|
Description=Machine Bridge MCP daemon
|
|
@@ -223,6 +320,7 @@ After=network-online.target
|
|
|
223
320
|
[Service]
|
|
224
321
|
Type=simple
|
|
225
322
|
ExecStart=${execArgs}
|
|
323
|
+
Environment=${systemdQuote(`PATH=${spec.pathEnv}`)}
|
|
226
324
|
Restart=on-failure
|
|
227
325
|
RestartSec=5
|
|
228
326
|
StandardOutput=append:${spec.stdout}
|
package/src/local/shell.mjs
CHANGED
|
@@ -85,7 +85,7 @@ function finalizeOutput(value, truncated) {
|
|
|
85
85
|
return truncated > 0 ? `${value}\n\n[truncated ${truncated} bytes]` : value;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
function findWranglerCommand() {
|
|
89
89
|
const suffix = process.platform === "win32" ? ".cmd" : "";
|
|
90
90
|
const local = path.join(packageRoot, "node_modules", ".bin", `wrangler${suffix}`);
|
|
91
91
|
if (existsSync(local)) return { cmd: local, argsPrefix: [] };
|
package/src/local/ssh-key.mjs
CHANGED
|
@@ -72,13 +72,15 @@ export async function inspectSshKeyPair(privateKeyPath, publicKeyPath = `${priva
|
|
|
72
72
|
timeoutMs: 15_000,
|
|
73
73
|
maxOutputBytes: 64 * 1024,
|
|
74
74
|
});
|
|
75
|
+
const fingerprintValue = fingerprint.stdout.trim().split(/\s+/)[1] || "";
|
|
76
|
+
if (!/^SHA256:[A-Za-z0-9+/=]+$/.test(fingerprintValue)) throw new Error("SSH key fingerprint output is invalid");
|
|
75
77
|
return {
|
|
76
78
|
created,
|
|
77
79
|
privateKeyPath: resolve(privateKeyPath),
|
|
78
80
|
publicKeyPath: resolve(publicKeyPath),
|
|
79
81
|
privateMode: process.platform === "win32" ? null : `0${(privateInfo.mode & 0o777).toString(8)}`,
|
|
80
82
|
publicMode: process.platform === "win32" ? null : `0${(publicInfo.mode & 0o777).toString(8)}`,
|
|
81
|
-
fingerprint:
|
|
83
|
+
fingerprint: fingerprintValue,
|
|
82
84
|
publicKeyType: publicLine.split(/\s+/, 1)[0],
|
|
83
85
|
};
|
|
84
86
|
}
|
|
@@ -112,7 +114,7 @@ async function safeLstat(path) {
|
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
function boundedComment(value) {
|
|
115
|
-
const comment = String(value || "").replace(/[\r\n\0]/g, " ").trim();
|
|
117
|
+
const comment = String(value || "").replace(/[\r\n\0\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, " ").replace(/\s+/g, " ").trim();
|
|
116
118
|
if (!comment) return "machine-mcp";
|
|
117
119
|
if (Buffer.byteLength(comment) > 256) throw new Error("SSH key comment exceeds 256 bytes");
|
|
118
120
|
return comment;
|