machine-bridge-mcp 0.6.0 → 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 +83 -0
- package/CONTRIBUTING.md +23 -0
- package/README.md +32 -12
- package/SECURITY.md +20 -10
- package/docs/ARCHITECTURE.md +16 -8
- package/docs/CLIENTS.md +11 -1
- package/docs/LOGGING.md +13 -18
- package/docs/MANAGED_JOBS.md +20 -11
- package/docs/OPERATIONS.md +31 -10
- package/docs/PRIVACY.md +37 -0
- package/docs/RELEASING.md +9 -4
- package/docs/TESTING.md +22 -2
- package/package.json +39 -10
- 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/atomic-fs.mjs +37 -0
- package/src/local/cli.mjs +113 -15
- package/src/local/daemon.mjs +114 -15
- package/src/local/full-access-test.mjs +206 -0
- package/src/local/job-runner.mjs +73 -15
- package/src/local/log.mjs +26 -2
- package/src/local/managed-jobs.mjs +165 -75
- package/src/local/process-sessions.mjs +5 -5
- package/src/local/resource-operations.mjs +66 -0
- package/src/local/service.mjs +128 -30
- package/src/local/shell.mjs +1 -1
- package/src/local/ssh-key.mjs +127 -0
- package/src/local/state.mjs +12 -9
- package/src/local/stdio.mjs +78 -20
- package/src/local/tools.mjs +37 -3
- package/src/shared/server-metadata.json +4 -1
- package/src/shared/tool-catalog.json +37 -0
- package/src/worker/index.ts +41 -18
- package/wrangler.jsonc +1 -1
|
@@ -1,9 +1,10 @@
|
|
|
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,
|
|
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
|
+
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
7
8
|
|
|
8
9
|
const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
9
10
|
const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
|
|
@@ -94,36 +95,44 @@ export class ManagedJobManager {
|
|
|
94
95
|
approve(args = {}, { localOperator = false } = {}) {
|
|
95
96
|
if (!localOperator) this.assertEnabled("approve_job");
|
|
96
97
|
const dir = this.jobDir(args.job_id);
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
if (status.status !== "staged") throw new Error(`job is not staged: ${status.status}`);
|
|
100
|
-
readRequiredJson(join(dir, "plan.json"), MAX_PLAN_BYTES, "job plan");
|
|
101
|
-
status.status = "queued";
|
|
102
|
-
status.updated_at = new Date().toISOString();
|
|
103
|
-
status.approved_at = status.updated_at;
|
|
104
|
-
status.approval = localOperator ? "local-operator" : "mcp";
|
|
105
|
-
status.cleanup_guarantee = "best-effort-finally-and-recovery";
|
|
106
|
-
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");
|
|
107
100
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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();
|
|
112
135
|
}
|
|
113
|
-
return {
|
|
114
|
-
accepted: true,
|
|
115
|
-
job_id: status.job_id,
|
|
116
|
-
name: status.name,
|
|
117
|
-
status: "queued",
|
|
118
|
-
detached: true,
|
|
119
|
-
continues_without_mcp_connection: true,
|
|
120
|
-
approval: status.approval,
|
|
121
|
-
cleanup: {
|
|
122
|
-
resource_copies: "best-effort",
|
|
123
|
-
finally_steps: "best-effort-if-declared",
|
|
124
|
-
restart_recovery: "best-effort-on-next-runtime-or-cli-start",
|
|
125
|
-
},
|
|
126
|
-
};
|
|
127
136
|
}
|
|
128
137
|
|
|
129
138
|
createJob(args, { launch }) {
|
|
@@ -224,48 +233,55 @@ export class ManagedJobManager {
|
|
|
224
233
|
this.reconcileStatus(dir);
|
|
225
234
|
const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
|
|
226
235
|
const plan = readJson(join(dir, "plan.json"), MAX_PLAN_BYTES);
|
|
236
|
+
if (plan) assertPlanIntegrity(plan, status);
|
|
227
237
|
return {
|
|
228
238
|
...publicStatus(status),
|
|
239
|
+
plan_integrity_verified: Boolean(plan),
|
|
229
240
|
...(plan ? { review_plan: reviewablePlan(plan) } : {}),
|
|
230
241
|
};
|
|
231
242
|
}
|
|
232
243
|
|
|
233
244
|
cancel(args = {}) {
|
|
234
245
|
const dir = this.jobDir(args.job_id);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
status
|
|
240
|
-
status.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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();
|
|
260
284
|
}
|
|
261
|
-
writeFileSync(join(dir, "cancel"), `${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
262
|
-
return {
|
|
263
|
-
...publicStatus(status),
|
|
264
|
-
cancellation_requested: true,
|
|
265
|
-
cancellation_delivery: "runner-poll",
|
|
266
|
-
cleanup_will_run: true,
|
|
267
|
-
};
|
|
268
|
-
|
|
269
285
|
}
|
|
270
286
|
|
|
271
287
|
currentResources() {
|
|
@@ -342,6 +358,7 @@ export class ManagedJobManager {
|
|
|
342
358
|
status.recovery_attempts = recoveryAttempts + 1;
|
|
343
359
|
atomicWriteJson(file, status, 256 * 1024);
|
|
344
360
|
rmSync(join(dir, "runtime"), { recursive: true, force: true });
|
|
361
|
+
rmSync(join(dir, "runner.pid"), { force: true });
|
|
345
362
|
const runnerPid = launchRunner(dir, true);
|
|
346
363
|
recoveryLock.handoff(runnerPid);
|
|
347
364
|
handedOff = true;
|
|
@@ -444,6 +461,7 @@ export function inspectResourceFile(inputPath, { allowInsecurePermissions = fals
|
|
|
444
461
|
return {
|
|
445
462
|
kind: "file",
|
|
446
463
|
path: canonical,
|
|
464
|
+
pathAliases: normalizeResourcePathAliases([path, canonical]),
|
|
447
465
|
size: info.size,
|
|
448
466
|
mode: process.platform === "win32" ? null : `0${(info.mode & 0o777).toString(8)}`,
|
|
449
467
|
updatedAt: new Date().toISOString(),
|
|
@@ -452,15 +470,16 @@ export function inspectResourceFile(inputPath, { allowInsecurePermissions = fals
|
|
|
452
470
|
};
|
|
453
471
|
}
|
|
454
472
|
|
|
455
|
-
export function publicResourceRegistry(resources = {}) {
|
|
473
|
+
export function publicResourceRegistry(resources = {}, { includePaths = false } = {}) {
|
|
456
474
|
const normalized = normalizeResourceRegistry(resources);
|
|
457
475
|
return Object.fromEntries(Object.entries(normalized).map(([name, value]) => [name, {
|
|
458
476
|
kind: value.kind,
|
|
459
|
-
path: value.path,
|
|
460
477
|
size: value.size ?? null,
|
|
461
478
|
mode: value.mode ?? null,
|
|
462
479
|
updatedAt: value.updatedAt ?? null,
|
|
463
480
|
allowInsecurePermissions: value.allowInsecurePermissions === true,
|
|
481
|
+
paths_exposed: includePaths,
|
|
482
|
+
...(includePaths ? { path: value.path } : {}),
|
|
464
483
|
}]));
|
|
465
484
|
}
|
|
466
485
|
|
|
@@ -579,7 +598,11 @@ function referencedResources(steps, registry) {
|
|
|
579
598
|
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true, includeHash: true });
|
|
580
599
|
totalBytes += inspected.size;
|
|
581
600
|
if (totalBytes > MAX_JOB_RESOURCE_BYTES) throw new Error(`job resources exceed ${MAX_JOB_RESOURCE_BYTES} bytes`);
|
|
582
|
-
out[name] = {
|
|
601
|
+
out[name] = {
|
|
602
|
+
...inspected,
|
|
603
|
+
pathAliases: normalizeResourcePathAliases([...(resource.pathAliases || []), ...(inspected.pathAliases || [])]),
|
|
604
|
+
allowInsecurePermissions: resource.allowInsecurePermissions === true,
|
|
605
|
+
};
|
|
583
606
|
}
|
|
584
607
|
return out;
|
|
585
608
|
}
|
|
@@ -593,6 +616,7 @@ function normalizeResourceRegistry(resources) {
|
|
|
593
616
|
out[name] = {
|
|
594
617
|
kind: "file",
|
|
595
618
|
path: resolve(rawValue.path),
|
|
619
|
+
pathAliases: normalizeResourcePathAliases([rawValue.path, ...(Array.isArray(rawValue.pathAliases) ? rawValue.pathAliases : [])]),
|
|
596
620
|
size: Number.isFinite(Number(rawValue.size)) ? Number(rawValue.size) : null,
|
|
597
621
|
mode: rawValue.mode ?? null,
|
|
598
622
|
updatedAt: rawValue.updatedAt ?? null,
|
|
@@ -602,6 +626,17 @@ function normalizeResourceRegistry(resources) {
|
|
|
602
626
|
return out;
|
|
603
627
|
}
|
|
604
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
|
+
|
|
605
640
|
function resolveJobCwd(value, workspace, unrestrictedPaths) {
|
|
606
641
|
const raw = boundedString(value, 4096, "cwd");
|
|
607
642
|
const candidate = isAbsolute(raw) ? resolve(raw) : resolve(workspace, raw);
|
|
@@ -655,7 +690,42 @@ function acquireRecoveryLock(dir) {
|
|
|
655
690
|
let owner = 0;
|
|
656
691
|
try { owner = Number.parseInt(readBoundedFile(file, 64).toString("utf8").trim(), 10); } catch {}
|
|
657
692
|
const age = Date.now() - safeMtime(file);
|
|
658
|
-
|
|
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;
|
|
659
729
|
rmSync(file, { force: true });
|
|
660
730
|
}
|
|
661
731
|
}
|
|
@@ -669,18 +739,20 @@ function launchRunner(dir, recover = false) {
|
|
|
669
739
|
const stderrFile = join(dir, "runner.err.log");
|
|
670
740
|
trimDiagnosticFile(stdoutFile);
|
|
671
741
|
trimDiagnosticFile(stderrFile);
|
|
672
|
-
|
|
673
|
-
|
|
742
|
+
let stdoutFd;
|
|
743
|
+
let stderrFd;
|
|
674
744
|
let child;
|
|
675
745
|
try {
|
|
746
|
+
stdoutFd = openPrivateAppendFile(stdoutFile);
|
|
747
|
+
stderrFd = openPrivateAppendFile(stderrFile);
|
|
676
748
|
child = spawn(process.execPath, args, {
|
|
677
749
|
detached: true,
|
|
678
750
|
stdio: ["ignore", stdoutFd, stderrFd],
|
|
679
751
|
windowsHide: true,
|
|
680
752
|
});
|
|
681
753
|
} finally {
|
|
682
|
-
closeSync(stdoutFd);
|
|
683
|
-
closeSync(stderrFd);
|
|
754
|
+
if (stdoutFd !== undefined) closeSync(stdoutFd);
|
|
755
|
+
if (stderrFd !== undefined) closeSync(stderrFd);
|
|
684
756
|
}
|
|
685
757
|
ownerOnlyFile(stdoutFile);
|
|
686
758
|
ownerOnlyFile(stderrFile);
|
|
@@ -703,6 +775,7 @@ function scrubFinishedPlan(dir, status) {
|
|
|
703
775
|
rmSync(join(dir, "plan.json"), { force: true });
|
|
704
776
|
rmSync(join(dir, "runner.pid"), { force: true });
|
|
705
777
|
rmSync(join(dir, "recovery.lock"), { force: true });
|
|
778
|
+
rmSync(join(dir, "transition.lock"), { force: true });
|
|
706
779
|
}
|
|
707
780
|
|
|
708
781
|
function reviewablePlan(plan) {
|
|
@@ -747,7 +820,7 @@ function atomicWriteJson(file, value, maxBytes) {
|
|
|
747
820
|
if (Buffer.byteLength(text) > maxBytes) throw new Error(`JSON exceeds ${maxBytes} bytes`);
|
|
748
821
|
const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
749
822
|
writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
|
|
750
|
-
|
|
823
|
+
replaceFileSync(temp, file);
|
|
751
824
|
ownerOnlyFile(file);
|
|
752
825
|
}
|
|
753
826
|
|
|
@@ -785,10 +858,27 @@ function readBoundedFileWithInfo(file, maxBytes) {
|
|
|
785
858
|
}
|
|
786
859
|
}
|
|
787
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
|
+
|
|
788
878
|
function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
|
|
789
879
|
let fd;
|
|
790
880
|
try {
|
|
791
|
-
const flags = Number(fsConstants.
|
|
881
|
+
const flags = Number(fsConstants.O_RDWR) | Number(fsConstants.O_NOFOLLOW || 0);
|
|
792
882
|
fd = openSync(file, flags);
|
|
793
883
|
const info = fstatSync(fd);
|
|
794
884
|
if (!info.isFile() || info.size <= maxBytes) return;
|
|
@@ -803,9 +893,9 @@ function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
|
|
|
803
893
|
let tail = buffer.subarray(0, offset);
|
|
804
894
|
const newline = tail.indexOf(0x0a);
|
|
805
895
|
if (newline >= 0 && newline < tail.length - 1) tail = tail.subarray(newline + 1);
|
|
806
|
-
|
|
807
|
-
fd
|
|
808
|
-
|
|
896
|
+
ftruncateSync(fd, 0);
|
|
897
|
+
if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
|
|
898
|
+
try { chmodSync(file, 0o600); } catch {}
|
|
809
899
|
} catch {
|
|
810
900
|
} finally {
|
|
811
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 }) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { inspectResourceFile, validateResourceName } from "./managed-jobs.mjs";
|
|
5
|
+
import { generateSshKeyPair } from "./ssh-key.mjs";
|
|
6
|
+
import { acquireStartupLock, loadState, saveState } from "./state.mjs";
|
|
7
|
+
|
|
8
|
+
export async function generateRegisteredSshKey({ workspace, stateDir, name: rawName, targetPath, comment = "" }) {
|
|
9
|
+
const name = validateResourceName(rawName);
|
|
10
|
+
if (typeof targetPath !== "string" || !targetPath.trim()) throw new Error("SSH private key target path is required");
|
|
11
|
+
const target = resolve(targetPath);
|
|
12
|
+
const state = loadState(workspace, { stateDir });
|
|
13
|
+
const lock = acquireStartupLock(state);
|
|
14
|
+
if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
|
|
15
|
+
let key = null;
|
|
16
|
+
try {
|
|
17
|
+
state.resources ||= {};
|
|
18
|
+
const existing = state.resources[name];
|
|
19
|
+
if (existing?.path && !samePathIdentity(existing.path, target)) {
|
|
20
|
+
throw new Error(`local resource ${name} is already registered to a different file; remove it first`);
|
|
21
|
+
}
|
|
22
|
+
if (!existing && Object.keys(state.resources).length >= 64) throw new Error("local resource registry limit reached (64)");
|
|
23
|
+
key = await generateSshKeyPair({
|
|
24
|
+
privateKeyPath: target,
|
|
25
|
+
type: "ed25519",
|
|
26
|
+
comment: comment || `machine-mcp:${name}`,
|
|
27
|
+
});
|
|
28
|
+
const inspected = inspectResourceFile(key.privateKeyPath);
|
|
29
|
+
state.resources[name] = inspected;
|
|
30
|
+
try {
|
|
31
|
+
saveState(state);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (key.created) {
|
|
34
|
+
await rm(key.privateKeyPath, { force: true }).catch(() => {});
|
|
35
|
+
await rm(key.publicKeyPath, { force: true }).catch(() => {});
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
name,
|
|
41
|
+
created: key.created,
|
|
42
|
+
registered: true,
|
|
43
|
+
privateKeyPath: key.privateKeyPath,
|
|
44
|
+
publicKeyPath: key.publicKeyPath,
|
|
45
|
+
fingerprint: key.fingerprint,
|
|
46
|
+
keyType: key.publicKeyType,
|
|
47
|
+
privateMode: key.privateMode,
|
|
48
|
+
publicMode: key.publicMode,
|
|
49
|
+
privateKeyContentExposed: false,
|
|
50
|
+
availableToNewJobsImmediately: true,
|
|
51
|
+
};
|
|
52
|
+
} finally {
|
|
53
|
+
lock.release();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function samePathIdentity(left, right) {
|
|
58
|
+
const a = canonicalIfExisting(left);
|
|
59
|
+
const b = canonicalIfExisting(right);
|
|
60
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function canonicalIfExisting(value) {
|
|
64
|
+
const absolute = resolve(String(value));
|
|
65
|
+
try { return realpathSync.native ? realpathSync.native(absolute) : realpathSync(absolute); } catch { return absolute; }
|
|
66
|
+
}
|