machine-bridge-mcp 1.2.7 → 1.2.9
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 +23 -0
- package/CONTRIBUTING.md +37 -19
- package/README.md +120 -410
- package/SECURITY.md +2 -0
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +13 -4
- package/docs/AUDIT.md +22 -0
- package/docs/ENGINEERING.md +2 -2
- package/docs/OVERVIEW.md +113 -0
- package/docs/PROJECT_STANDARDS.md +9 -5
- package/docs/RELEASING.md +78 -33
- package/docs/TESTING.md +14 -7
- package/docs/THREAT_MODEL.md +142 -0
- package/package.json +18 -6
- package/scripts/check-plan.mjs +91 -0
- package/scripts/coverage-check.mjs +22 -0
- package/scripts/github-push.mjs +49 -0
- package/scripts/github-release.mjs +15 -8
- package/scripts/local-release-acceptance.mjs +186 -0
- package/scripts/release-acceptance.mjs +181 -0
- package/scripts/release-impact-check.mjs +19 -3
- package/scripts/release-state.mjs +1 -1
- package/scripts/run-checks.mjs +29 -0
- package/scripts/start-release-candidate.mjs +113 -0
- package/src/local/agent-context-projection.mjs +158 -0
- package/src/local/agent-context.mjs +23 -332
- package/src/local/agent-skill-discovery.mjs +230 -0
- package/src/local/agent-text-file.mjs +41 -0
- package/src/local/browser-bridge-http.mjs +48 -0
- package/src/local/browser-bridge.mjs +48 -222
- package/src/local/browser-broker-routes.mjs +136 -0
- package/src/local/browser-broker-server.mjs +59 -0
- package/src/local/browser-request-registry.mjs +67 -0
- package/src/local/managed-job-lock.mjs +99 -0
- package/src/local/managed-job-projection.mjs +68 -0
- package/src/local/managed-job-runner.mjs +73 -0
- package/src/local/managed-job-storage.mjs +93 -0
- package/src/local/managed-jobs.mjs +12 -297
- package/src/local/runtime-paths.mjs +107 -0
- package/src/local/runtime-relay.mjs +73 -0
- package/src/local/runtime-tool-handlers.mjs +66 -0
- package/src/local/runtime.mjs +22 -204
- package/src/local/windows-launcher.mjs +57 -0
- package/src/local/windows-service.mjs +7 -56
- package/src/worker/index.ts +9 -118
- package/src/worker/mcp-jsonrpc.ts +94 -0
- package/src/worker/oauth-authorization-page.ts +70 -0
- package/src/worker/oauth-controller.ts +9 -58
- package/src/worker/websocket-protocol.ts +24 -0
- package/tsconfig.local.json +6 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { lstatSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
5
|
+
import { currentProcessStartTimeMs, inspectProcessInstance, processStartTimeMs } from "./process-identity.mjs";
|
|
6
|
+
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
7
|
+
import { ownerOnlyFile } from "./state.mjs";
|
|
8
|
+
|
|
9
|
+
export function acquireRecoveryLock(dir) {
|
|
10
|
+
return acquirePidLock(join(dir, "recovery.lock"), { allowHandoff: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function acquireJobTransitionLock(dir) {
|
|
14
|
+
return acquirePidLock(join(dir, "transition.lock"));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function acquirePidLock(file, { allowHandoff = false } = {}) {
|
|
18
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
19
|
+
const owner = pidLockOwner(process.pid, currentProcessStartTimeMs());
|
|
20
|
+
try {
|
|
21
|
+
createExclusiveFileSync(file, `${JSON.stringify(owner)}\n`, { mode: 0o600 });
|
|
22
|
+
return {
|
|
23
|
+
...(allowHandoff ? {
|
|
24
|
+
handoff(pid) {
|
|
25
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
26
|
+
const nextOwner = { ...pidLockOwner(pid, processStartTimeMs(pid)), token: owner.token };
|
|
27
|
+
replacePrivateTextFile(file, `${JSON.stringify(nextOwner)}\n`);
|
|
28
|
+
},
|
|
29
|
+
} : {}),
|
|
30
|
+
token: owner.token,
|
|
31
|
+
release() { removePidLockOwnedBy(file, owner.token); },
|
|
32
|
+
};
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error?.code !== "EEXIST") throw error;
|
|
35
|
+
const snapshot = readPidLockSnapshot(file);
|
|
36
|
+
if (!snapshot) continue;
|
|
37
|
+
const age = Date.now() - snapshot.info.mtimeMs;
|
|
38
|
+
const identity = snapshot.owner ? inspectProcessInstance(snapshot.owner, { maxAgeMs: 5 * 60_000 }) : null;
|
|
39
|
+
const definitelyStale = !snapshot.owner ? age >= 60_000 : identity.reclaimable === true;
|
|
40
|
+
if (!definitelyStale) return null;
|
|
41
|
+
removePidLockSnapshot(file, snapshot);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pidLockOwner(pid, startedAtMs) {
|
|
48
|
+
return {
|
|
49
|
+
pid,
|
|
50
|
+
token: randomBytes(16).toString("hex"),
|
|
51
|
+
startedAt: new Date().toISOString(),
|
|
52
|
+
processStartedAt: Number.isFinite(startedAtMs) && startedAtMs > 0 ? new Date(startedAtMs).toISOString() : null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readPidLockSnapshot(file) {
|
|
57
|
+
let info;
|
|
58
|
+
try { info = lstatSync(file); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
59
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("job lock must be a regular non-symbolic-link file");
|
|
60
|
+
let owner = null;
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(readBoundedRegularFileSync(file, 1024).toString("utf8").trim());
|
|
63
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) owner = parsed;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error?.code === "ENOENT") return null;
|
|
66
|
+
}
|
|
67
|
+
return { owner, info: pidLockIdentity(info) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function removePidLockOwnedBy(file, token) {
|
|
71
|
+
const snapshot = readPidLockSnapshot(file);
|
|
72
|
+
if (!snapshot || snapshot.owner?.token !== token) return false;
|
|
73
|
+
return removePidLockSnapshot(file, snapshot);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function removePidLockSnapshot(file, snapshot) {
|
|
77
|
+
let current;
|
|
78
|
+
try { current = lstatSync(file); } catch (error) { return error?.code === "ENOENT"; }
|
|
79
|
+
if (current.isSymbolicLink() || !current.isFile()) return false;
|
|
80
|
+
if (!samePidLockIdentity(snapshot.info, pidLockIdentity(current))) return false;
|
|
81
|
+
if (snapshot.owner?.token) {
|
|
82
|
+
const currentOwner = readPidLockSnapshot(file)?.owner;
|
|
83
|
+
if (currentOwner?.token !== snapshot.owner.token) return false;
|
|
84
|
+
}
|
|
85
|
+
try { rmSync(file); return true; } catch (error) { return error?.code === "ENOENT"; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pidLockIdentity(info) {
|
|
89
|
+
return { dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size), mtimeMs: Number(info.mtimeMs) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function samePidLockIdentity(left, right) {
|
|
93
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function replacePrivateTextFile(file, content) {
|
|
97
|
+
replaceFileAtomicallySync(file, content, { mode: 0o600 });
|
|
98
|
+
ownerOnlyFile(file);
|
|
99
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Return the review-safe job plan projection. Resource paths and values are absent by contract.
|
|
5
|
+
* @param {{
|
|
6
|
+
* version?: unknown,
|
|
7
|
+
* name?: unknown,
|
|
8
|
+
* workspace?: unknown,
|
|
9
|
+
* full_env?: unknown,
|
|
10
|
+
* resources?: Record<string, {kind?: unknown, size?: unknown, mode?: unknown, allowInsecurePermissions?: unknown}>,
|
|
11
|
+
* temporary_files?: unknown[],
|
|
12
|
+
* steps?: unknown[],
|
|
13
|
+
* finally_steps?: unknown[]
|
|
14
|
+
* }} plan
|
|
15
|
+
*/
|
|
16
|
+
export function reviewablePlan(plan) {
|
|
17
|
+
return {
|
|
18
|
+
version: plan.version,
|
|
19
|
+
name: plan.name,
|
|
20
|
+
workspace: plan.workspace,
|
|
21
|
+
full_env: plan.full_env === true,
|
|
22
|
+
resources: Object.fromEntries(Object.entries(plan.resources || {}).map(([name, value]) => [name, {
|
|
23
|
+
kind: value.kind,
|
|
24
|
+
size: value.size ?? null,
|
|
25
|
+
mode: value.mode ?? null,
|
|
26
|
+
allow_insecure_permissions: value.allowInsecurePermissions === true,
|
|
27
|
+
}])),
|
|
28
|
+
temporary_files: plan.temporary_files || [],
|
|
29
|
+
steps: plan.steps || [],
|
|
30
|
+
finally_steps: plan.finally_steps || [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Return the stable public status shape without runner identity or internal paths.
|
|
36
|
+
* @param {{
|
|
37
|
+
* job_id?: unknown,
|
|
38
|
+
* name?: unknown,
|
|
39
|
+
* status?: unknown,
|
|
40
|
+
* created_at?: unknown,
|
|
41
|
+
* started_at?: unknown,
|
|
42
|
+
* finished_at?: unknown,
|
|
43
|
+
* current_phase?: unknown,
|
|
44
|
+
* current_step?: unknown,
|
|
45
|
+
* approval?: unknown,
|
|
46
|
+
* plan_sha256?: unknown,
|
|
47
|
+
* cleanup_guarantee?: unknown,
|
|
48
|
+
* error_class?: unknown,
|
|
49
|
+
* recovery_attempts?: unknown
|
|
50
|
+
* }} status
|
|
51
|
+
*/
|
|
52
|
+
export function publicStatus(status) {
|
|
53
|
+
return {
|
|
54
|
+
job_id: status.job_id,
|
|
55
|
+
name: status.name,
|
|
56
|
+
status: status.status,
|
|
57
|
+
created_at: status.created_at,
|
|
58
|
+
started_at: status.started_at ?? null,
|
|
59
|
+
finished_at: status.finished_at ?? null,
|
|
60
|
+
current_phase: status.current_phase ?? null,
|
|
61
|
+
current_step: status.current_step ?? null,
|
|
62
|
+
approval: status.approval ?? null,
|
|
63
|
+
plan_sha256: status.plan_sha256 ?? null,
|
|
64
|
+
cleanup_guarantee: status.cleanup_guarantee ?? "best-effort-finally-and-recovery",
|
|
65
|
+
error_class: status.error_class ?? null,
|
|
66
|
+
recovery_attempts: Number(status.recovery_attempts || 0),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { inspectProcessInstance } from "./process-identity.mjs";
|
|
6
|
+
import { classifyOperationalError } from "./log.mjs";
|
|
7
|
+
import { ownerOnlyFile } from "./state.mjs";
|
|
8
|
+
import { openPrivateAppendFile, readBoundedFile, trimDiagnosticFile } from "./managed-job-storage.mjs";
|
|
9
|
+
|
|
10
|
+
const RUNNER_PATH = fileURLToPath(new URL("./job-runner.mjs", import.meta.url));
|
|
11
|
+
|
|
12
|
+
export function launchRunner(dir, recover = false, recoveryToken = "", options = {}) {
|
|
13
|
+
const args = [RUNNER_PATH, "--job-dir", dir];
|
|
14
|
+
if (recover) args.push("--recover");
|
|
15
|
+
const stdoutFile = join(dir, "runner.out.log");
|
|
16
|
+
const stderrFile = join(dir, "runner.err.log");
|
|
17
|
+
trimDiagnosticFile(stdoutFile);
|
|
18
|
+
trimDiagnosticFile(stderrFile);
|
|
19
|
+
let stdoutFd;
|
|
20
|
+
let stderrFd;
|
|
21
|
+
let child;
|
|
22
|
+
try {
|
|
23
|
+
stdoutFd = openPrivateAppendFile(stdoutFile);
|
|
24
|
+
stderrFd = openPrivateAppendFile(stderrFile);
|
|
25
|
+
const spawnProcess = typeof options.spawnProcess === "function" ? options.spawnProcess : spawn;
|
|
26
|
+
child = spawnProcess(process.execPath, args, {
|
|
27
|
+
detached: true,
|
|
28
|
+
stdio: ["ignore", stdoutFd, stderrFd],
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
shell: false,
|
|
31
|
+
env: recoveryToken ? { ...process.env, MBM_RECOVERY_LOCK_TOKEN: recoveryToken } : process.env,
|
|
32
|
+
});
|
|
33
|
+
} finally {
|
|
34
|
+
if (stdoutFd !== undefined) closeSync(stdoutFd);
|
|
35
|
+
if (stderrFd !== undefined) closeSync(stderrFd);
|
|
36
|
+
}
|
|
37
|
+
ownerOnlyFile(stdoutFile);
|
|
38
|
+
ownerOnlyFile(stderrFile);
|
|
39
|
+
const logger = options.logger || console;
|
|
40
|
+
child.once?.("error", (error) => {
|
|
41
|
+
logger.error?.("managed job runner process reported an asynchronous failure", {
|
|
42
|
+
job_id: basename(dir),
|
|
43
|
+
recovery: recover,
|
|
44
|
+
error_class: classifyOperationalError(error),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
const pid = Number(child.pid);
|
|
48
|
+
if (!Number.isInteger(pid) || pid <= 0) throw new Error("managed job runner did not receive a process id");
|
|
49
|
+
child.unref();
|
|
50
|
+
return pid;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function runnerProcessIsCurrent(status, dir, { ownerOnly = false } = {}) {
|
|
54
|
+
const fallback = ownerOnly ? status : {
|
|
55
|
+
pid: Number(status?.runner_pid) || undefined,
|
|
56
|
+
processStartedAt: status?.runner_process_started_at,
|
|
57
|
+
startedAt: status?.started_at || status?.updated_at || status?.created_at,
|
|
58
|
+
};
|
|
59
|
+
const owner = readRunnerOwner(dir, fallback);
|
|
60
|
+
if (!owner.pid) return false;
|
|
61
|
+
const identity = inspectProcessInstance(owner, { maxAgeMs: Number.POSITIVE_INFINITY });
|
|
62
|
+
return identity.current || (identity.alive && !identity.reclaimable);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readRunnerOwner(dir, fallback = {}) {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(readBoundedFile(join(dir, "runner.pid"), 1024).toString("utf8"));
|
|
68
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ...fallback };
|
|
69
|
+
return { ...fallback, ...parsed };
|
|
70
|
+
} catch {
|
|
71
|
+
return { ...fallback };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { closeSync, constants as fsConstants, ftruncateSync, readSync, readdirSync, writeSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
4
|
+
import { openRegularFileSync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
5
|
+
import { ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
|
|
6
|
+
|
|
7
|
+
export function atomicWriteJson(file, value, maxBytes) {
|
|
8
|
+
ensureOwnerOnlyDir(dirname(file));
|
|
9
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
10
|
+
if (Buffer.byteLength(text) > maxBytes) throw new Error(`JSON exceeds ${maxBytes} bytes`);
|
|
11
|
+
replaceFileAtomicallySync(file, text, { mode: 0o600 });
|
|
12
|
+
ownerOnlyFile(file);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readJson(file, maxBytes, label = "JSON") {
|
|
16
|
+
let buffer;
|
|
17
|
+
try { buffer = readBoundedFile(file, maxBytes); } catch (error) {
|
|
18
|
+
if (error?.code === "ENOENT") return null;
|
|
19
|
+
throw new Error(`${label} is unavailable (${resourceErrorClass(error)})`);
|
|
20
|
+
}
|
|
21
|
+
let text;
|
|
22
|
+
try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
|
|
23
|
+
throw new Error(`${label} is not valid UTF-8`);
|
|
24
|
+
}
|
|
25
|
+
try { return JSON.parse(text); } catch {
|
|
26
|
+
throw new Error(`${label} is not valid JSON`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readRequiredJson(file, maxBytes, label) {
|
|
31
|
+
const value = readJson(file, maxBytes, label);
|
|
32
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} is unavailable or invalid`);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readBoundedFile(file, maxBytes) {
|
|
37
|
+
return readBoundedRegularFileSync(file, maxBytes);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function openPrivateAppendFile(file) {
|
|
41
|
+
return openRegularFileSync(
|
|
42
|
+
file,
|
|
43
|
+
Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND),
|
|
44
|
+
{ label: "runner diagnostic path", mode: 0o600, chmod: 0o600 },
|
|
45
|
+
).fd;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
|
|
49
|
+
let fd;
|
|
50
|
+
try {
|
|
51
|
+
const opened = openRegularFileSync(file, fsConstants.O_RDWR, {
|
|
52
|
+
label: "runner diagnostic path",
|
|
53
|
+
chmod: 0o600,
|
|
54
|
+
});
|
|
55
|
+
fd = opened.fd;
|
|
56
|
+
if (opened.info.size <= maxBytes) return;
|
|
57
|
+
const length = Math.min(keepBytes, opened.info.size);
|
|
58
|
+
const buffer = Buffer.alloc(length);
|
|
59
|
+
let offset = 0;
|
|
60
|
+
while (offset < length) {
|
|
61
|
+
const count = readSync(fd, buffer, offset, length - offset, opened.info.size - length + offset);
|
|
62
|
+
if (!count) break;
|
|
63
|
+
offset += count;
|
|
64
|
+
}
|
|
65
|
+
let tail = buffer.subarray(0, offset);
|
|
66
|
+
const newline = tail.indexOf(0x0a);
|
|
67
|
+
if (newline >= 0 && newline < tail.length - 1) tail = tail.subarray(newline + 1);
|
|
68
|
+
ftruncateSync(fd, 0);
|
|
69
|
+
if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error?.code !== "ENOENT") throw error;
|
|
72
|
+
} finally {
|
|
73
|
+
if (fd !== undefined) {
|
|
74
|
+
try { closeSync(fd); } catch {
|
|
75
|
+
// Descriptor close is best effort after the trim result is already determined.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resourceErrorClass(error) {
|
|
82
|
+
const message = String(error?.message || error || "");
|
|
83
|
+
if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
|
|
84
|
+
if (/not found|ENOENT/i.test(message)) return "not_found";
|
|
85
|
+
if (/symbolic link/i.test(message)) return "symbolic_link_denied";
|
|
86
|
+
if (/readable by group|permissions/i.test(message)) return "insecure_permissions";
|
|
87
|
+
if (/exceeds/i.test(message)) return "size_limit";
|
|
88
|
+
return "resource_unavailable";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function safeReadDir(dir) {
|
|
92
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
93
|
+
}
|
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { assertStateMaintenanceAvailable, ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
|
|
7
|
-
import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
8
|
-
import { currentProcessStartTimeMs, inspectProcessInstance, processStartTimeMs } from "./process-identity.mjs";
|
|
9
|
-
import { openRegularFileSync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
2
|
+
import { existsSync, lstatSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { assertStateMaintenanceAvailable, ensureOwnerOnlyDir } from "./state.mjs";
|
|
10
5
|
import { createToolAuthorizer } from "./policy.mjs";
|
|
11
6
|
import { BridgeError } from "./errors.mjs";
|
|
12
7
|
import { inspectResourceFile, normalizeResourceRegistry, validatePlan } from "./managed-job-plan.mjs";
|
|
13
8
|
export { inspectResourceFile, publicResourceRegistry, validateResourceName } from "./managed-job-plan.mjs";
|
|
14
9
|
import { clampInteger } from "./numbers.mjs";
|
|
15
|
-
import {
|
|
10
|
+
import { acquireJobTransitionLock, acquireRecoveryLock } from "./managed-job-lock.mjs";
|
|
11
|
+
import { publicStatus, reviewablePlan } from "./managed-job-projection.mjs";
|
|
12
|
+
import {
|
|
13
|
+
atomicWriteJson, readBoundedFile, readJson, readRequiredJson, resourceErrorClass, safeReadDir,
|
|
14
|
+
} from "./managed-job-storage.mjs";
|
|
15
|
+
import { launchRunner, runnerProcessIsCurrent } from "./managed-job-runner.mjs";
|
|
16
|
+
export { launchRunner } from "./managed-job-runner.mjs";
|
|
16
17
|
|
|
17
18
|
const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
|
|
18
19
|
const MAX_JOBS = 50;
|
|
19
20
|
const JOB_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
21
|
const MAX_PLAN_BYTES = 1024 * 1024;
|
|
21
22
|
const MAX_RECOVERY_ATTEMPTS = 3;
|
|
22
|
-
const RUNNER_PATH = fileURLToPath(new URL("./job-runner.mjs", import.meta.url));
|
|
23
23
|
const ACTIVE_JOB_STATES = new Set(["queued", "running", "cleaning", "interrupted"]);
|
|
24
24
|
const PLAN_RETAINING_STATES = new Set(["staged", ...ACTIVE_JOB_STATES]);
|
|
25
25
|
|
|
@@ -393,8 +393,8 @@ export class ManagedJobManager {
|
|
|
393
393
|
continue;
|
|
394
394
|
}
|
|
395
395
|
if (!status) {
|
|
396
|
-
const
|
|
397
|
-
if (!runnerProcessIsCurrent(
|
|
396
|
+
const fallbackOwner = { startedAt: new Date(mtime).toISOString() };
|
|
397
|
+
if (!runnerProcessIsCurrent(fallbackOwner, dir, { ownerOnly: true }) && Date.now() - mtime > 60_000) {
|
|
398
398
|
rmSync(dir, { recursive: true, force: true });
|
|
399
399
|
continue;
|
|
400
400
|
}
|
|
@@ -526,10 +526,6 @@ function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts, recov
|
|
|
526
526
|
return launchRunner(dir, true, recoveryToken, { logger });
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
-
function acquireRecoveryLock(dir) {
|
|
530
|
-
return acquirePidLock(join(dir, "recovery.lock"), { allowHandoff: true });
|
|
531
|
-
}
|
|
532
|
-
|
|
533
529
|
function planSha256(plan) {
|
|
534
530
|
return createHash("sha256").update(JSON.stringify(plan)).digest("hex");
|
|
535
531
|
}
|
|
@@ -543,162 +539,6 @@ function assertPlanIntegrity(plan, status) {
|
|
|
543
539
|
return actual;
|
|
544
540
|
}
|
|
545
541
|
|
|
546
|
-
function acquireJobTransitionLock(dir) {
|
|
547
|
-
return acquirePidLock(join(dir, "transition.lock"));
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function acquirePidLock(file, { allowHandoff = false } = {}) {
|
|
551
|
-
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
552
|
-
const owner = pidLockOwner(process.pid, currentProcessStartTimeMs());
|
|
553
|
-
try {
|
|
554
|
-
createExclusiveFileSync(file, `${JSON.stringify(owner)}
|
|
555
|
-
`, { mode: 0o600 });
|
|
556
|
-
return {
|
|
557
|
-
...(allowHandoff ? {
|
|
558
|
-
handoff(pid) {
|
|
559
|
-
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
560
|
-
const nextOwner = { ...pidLockOwner(pid, processStartTimeMs(pid)), token: owner.token };
|
|
561
|
-
replacePrivateTextFile(file, `${JSON.stringify(nextOwner)}
|
|
562
|
-
`);
|
|
563
|
-
},
|
|
564
|
-
} : {}),
|
|
565
|
-
token: owner.token,
|
|
566
|
-
release() { removePidLockOwnedBy(file, owner.token); },
|
|
567
|
-
};
|
|
568
|
-
} catch (error) {
|
|
569
|
-
if (error?.code !== "EEXIST") throw error;
|
|
570
|
-
const snapshot = readPidLockSnapshot(file);
|
|
571
|
-
if (!snapshot) continue;
|
|
572
|
-
const age = Date.now() - snapshot.info.mtimeMs;
|
|
573
|
-
const identity = snapshot.owner ? inspectProcessInstance(snapshot.owner, { maxAgeMs: 5 * 60_000 }) : null;
|
|
574
|
-
const definitelyStale = !snapshot.owner
|
|
575
|
-
? age >= 60_000
|
|
576
|
-
: identity.reclaimable === true;
|
|
577
|
-
if (!definitelyStale) return null;
|
|
578
|
-
removePidLockSnapshot(file, snapshot);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
return null;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function pidLockOwner(pid, startedAtMs) {
|
|
585
|
-
return {
|
|
586
|
-
pid,
|
|
587
|
-
token: randomBytes(16).toString("hex"),
|
|
588
|
-
startedAt: new Date().toISOString(),
|
|
589
|
-
processStartedAt: Number.isFinite(startedAtMs) && startedAtMs > 0 ? new Date(startedAtMs).toISOString() : null,
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
function readPidLockSnapshot(file) {
|
|
594
|
-
let info;
|
|
595
|
-
try { info = lstatSync(file); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
596
|
-
if (info.isSymbolicLink() || !info.isFile()) throw new Error("job lock must be a regular non-symbolic-link file");
|
|
597
|
-
let owner = null;
|
|
598
|
-
try {
|
|
599
|
-
const parsed = JSON.parse(readBoundedFile(file, 1024).toString("utf8").trim());
|
|
600
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) owner = parsed;
|
|
601
|
-
} catch (error) {
|
|
602
|
-
if (error?.code === "ENOENT") return null;
|
|
603
|
-
}
|
|
604
|
-
return { owner, info: pidLockIdentity(info) };
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
function removePidLockOwnedBy(file, token) {
|
|
608
|
-
const snapshot = readPidLockSnapshot(file);
|
|
609
|
-
if (!snapshot || snapshot.owner?.token !== token) return false;
|
|
610
|
-
return removePidLockSnapshot(file, snapshot);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
function removePidLockSnapshot(file, snapshot) {
|
|
614
|
-
let current;
|
|
615
|
-
try { current = lstatSync(file); } catch (error) { return error?.code === "ENOENT"; }
|
|
616
|
-
if (current.isSymbolicLink() || !current.isFile()) return false;
|
|
617
|
-
if (!samePidLockIdentity(snapshot.info, pidLockIdentity(current))) return false;
|
|
618
|
-
if (snapshot.owner?.token) {
|
|
619
|
-
const currentOwner = readPidLockSnapshot(file)?.owner;
|
|
620
|
-
if (currentOwner?.token !== snapshot.owner.token) return false;
|
|
621
|
-
}
|
|
622
|
-
try { rmSync(file); return true; } catch (error) { return error?.code === "ENOENT"; }
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function pidLockIdentity(info) {
|
|
626
|
-
return { dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size), mtimeMs: Number(info.mtimeMs) };
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
function samePidLockIdentity(left, right) {
|
|
630
|
-
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
function replacePrivateTextFile(file, content) {
|
|
634
|
-
replaceFileAtomicallySync(file, content, { mode: 0o600 });
|
|
635
|
-
ownerOnlyFile(file);
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
export function launchRunner(dir, recover = false, recoveryToken = "", options = {}) {
|
|
639
|
-
const args = [RUNNER_PATH, "--job-dir", dir];
|
|
640
|
-
if (recover) args.push("--recover");
|
|
641
|
-
const stdoutFile = join(dir, "runner.out.log");
|
|
642
|
-
const stderrFile = join(dir, "runner.err.log");
|
|
643
|
-
trimDiagnosticFile(stdoutFile);
|
|
644
|
-
trimDiagnosticFile(stderrFile);
|
|
645
|
-
let stdoutFd;
|
|
646
|
-
let stderrFd;
|
|
647
|
-
let child;
|
|
648
|
-
try {
|
|
649
|
-
stdoutFd = openPrivateAppendFile(stdoutFile);
|
|
650
|
-
stderrFd = openPrivateAppendFile(stderrFile);
|
|
651
|
-
const spawnProcess = typeof options.spawnProcess === "function" ? options.spawnProcess : spawn;
|
|
652
|
-
child = spawnProcess(process.execPath, args, {
|
|
653
|
-
detached: true,
|
|
654
|
-
stdio: ["ignore", stdoutFd, stderrFd],
|
|
655
|
-
windowsHide: true,
|
|
656
|
-
shell: false,
|
|
657
|
-
env: recoveryToken ? { ...process.env, MBM_RECOVERY_LOCK_TOKEN: recoveryToken } : process.env,
|
|
658
|
-
});
|
|
659
|
-
} finally {
|
|
660
|
-
if (stdoutFd !== undefined) closeSync(stdoutFd);
|
|
661
|
-
if (stderrFd !== undefined) closeSync(stderrFd);
|
|
662
|
-
}
|
|
663
|
-
ownerOnlyFile(stdoutFile);
|
|
664
|
-
ownerOnlyFile(stderrFile);
|
|
665
|
-
const logger = options.logger || console;
|
|
666
|
-
child.once?.("error", (error) => {
|
|
667
|
-
logger.error?.("managed job runner process reported an asynchronous failure", {
|
|
668
|
-
job_id: basename(dir),
|
|
669
|
-
recovery: recover,
|
|
670
|
-
error_class: classifyOperationalError(error),
|
|
671
|
-
});
|
|
672
|
-
});
|
|
673
|
-
const pid = Number(child.pid);
|
|
674
|
-
if (!Number.isInteger(pid) || pid <= 0) throw new Error("managed job runner did not receive a process id");
|
|
675
|
-
child.unref();
|
|
676
|
-
return pid;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
function readRunnerOwner(dir, fallback = {}) {
|
|
681
|
-
try {
|
|
682
|
-
const parsed = JSON.parse(readBoundedFile(join(dir, "runner.pid"), 1024).toString("utf8"));
|
|
683
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ...fallback };
|
|
684
|
-
return { ...fallback, ...parsed };
|
|
685
|
-
} catch {
|
|
686
|
-
return { ...fallback };
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
function runnerProcessIsCurrent(status, dir, { ownerOnly = false } = {}) {
|
|
691
|
-
const fallback = ownerOnly ? status : {
|
|
692
|
-
pid: Number(status?.runner_pid) || undefined,
|
|
693
|
-
processStartedAt: status?.runner_process_started_at,
|
|
694
|
-
startedAt: status?.started_at || status?.updated_at || status?.created_at,
|
|
695
|
-
};
|
|
696
|
-
const owner = readRunnerOwner(dir, fallback);
|
|
697
|
-
if (!owner.pid) return false;
|
|
698
|
-
const identity = inspectProcessInstance(owner, { maxAgeMs: Number.POSITIVE_INFINITY });
|
|
699
|
-
return identity.current || (identity.alive && !identity.reclaimable);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
542
|
function scrubFinishedPlan(dir, status) {
|
|
703
543
|
if (PLAN_RETAINING_STATES.has(status.status)) return;
|
|
704
544
|
const safeRm = (path) => {
|
|
@@ -713,128 +553,3 @@ function scrubFinishedPlan(dir, status) {
|
|
|
713
553
|
safeRm(join(dir, "recovery.lock"));
|
|
714
554
|
safeRm(join(dir, "transition.lock"));
|
|
715
555
|
}
|
|
716
|
-
|
|
717
|
-
function reviewablePlan(plan) {
|
|
718
|
-
return {
|
|
719
|
-
version: plan.version,
|
|
720
|
-
name: plan.name,
|
|
721
|
-
workspace: plan.workspace,
|
|
722
|
-
full_env: plan.full_env === true,
|
|
723
|
-
resources: Object.fromEntries(Object.entries(plan.resources || {}).map(([name, value]) => [name, {
|
|
724
|
-
kind: value.kind,
|
|
725
|
-
size: value.size ?? null,
|
|
726
|
-
mode: value.mode ?? null,
|
|
727
|
-
allow_insecure_permissions: value.allowInsecurePermissions === true,
|
|
728
|
-
}])),
|
|
729
|
-
temporary_files: plan.temporary_files || [],
|
|
730
|
-
steps: plan.steps || [],
|
|
731
|
-
finally_steps: plan.finally_steps || [],
|
|
732
|
-
};
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
function publicStatus(status) {
|
|
736
|
-
return {
|
|
737
|
-
job_id: status.job_id,
|
|
738
|
-
name: status.name,
|
|
739
|
-
status: status.status,
|
|
740
|
-
created_at: status.created_at,
|
|
741
|
-
started_at: status.started_at ?? null,
|
|
742
|
-
finished_at: status.finished_at ?? null,
|
|
743
|
-
current_phase: status.current_phase ?? null,
|
|
744
|
-
current_step: status.current_step ?? null,
|
|
745
|
-
approval: status.approval ?? null,
|
|
746
|
-
plan_sha256: status.plan_sha256 ?? null,
|
|
747
|
-
cleanup_guarantee: status.cleanup_guarantee ?? "best-effort-finally-and-recovery",
|
|
748
|
-
error_class: status.error_class ?? null,
|
|
749
|
-
recovery_attempts: Number(status.recovery_attempts || 0),
|
|
750
|
-
};
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
function atomicWriteJson(file, value, maxBytes) {
|
|
754
|
-
ensureOwnerOnlyDir(dirname(file));
|
|
755
|
-
const text = `${JSON.stringify(value, null, 2)}
|
|
756
|
-
`;
|
|
757
|
-
if (Buffer.byteLength(text) > maxBytes) throw new Error(`JSON exceeds ${maxBytes} bytes`);
|
|
758
|
-
replaceFileAtomicallySync(file, text, { mode: 0o600 });
|
|
759
|
-
ownerOnlyFile(file);
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
function readJson(file, maxBytes, label = "JSON") {
|
|
763
|
-
let buffer;
|
|
764
|
-
try { buffer = readBoundedFile(file, maxBytes); } catch (error) {
|
|
765
|
-
if (error?.code === "ENOENT") return null;
|
|
766
|
-
throw new Error(`${label} is unavailable (${resourceErrorClass(error)})`);
|
|
767
|
-
}
|
|
768
|
-
let text;
|
|
769
|
-
try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
|
|
770
|
-
throw new Error(`${label} is not valid UTF-8`);
|
|
771
|
-
}
|
|
772
|
-
try { return JSON.parse(text); } catch {
|
|
773
|
-
throw new Error(`${label} is not valid JSON`);
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
function readRequiredJson(file, maxBytes, label) {
|
|
778
|
-
const value = readJson(file, maxBytes, label);
|
|
779
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} is unavailable or invalid`);
|
|
780
|
-
return value;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
function readBoundedFile(file, maxBytes) {
|
|
784
|
-
return readBoundedRegularFileSync(file, maxBytes);
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
function openPrivateAppendFile(file) {
|
|
788
|
-
return openRegularFileSync(
|
|
789
|
-
file,
|
|
790
|
-
Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND),
|
|
791
|
-
{ label: "runner diagnostic path", mode: 0o600, chmod: 0o600 },
|
|
792
|
-
).fd;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
|
|
796
|
-
let fd;
|
|
797
|
-
try {
|
|
798
|
-
const opened = openRegularFileSync(file, fsConstants.O_RDWR, {
|
|
799
|
-
label: "runner diagnostic path",
|
|
800
|
-
chmod: 0o600,
|
|
801
|
-
});
|
|
802
|
-
fd = opened.fd;
|
|
803
|
-
if (opened.info.size <= maxBytes) return;
|
|
804
|
-
const length = Math.min(keepBytes, opened.info.size);
|
|
805
|
-
const buffer = Buffer.alloc(length);
|
|
806
|
-
let offset = 0;
|
|
807
|
-
while (offset < length) {
|
|
808
|
-
const count = readSync(fd, buffer, offset, length - offset, opened.info.size - length + offset);
|
|
809
|
-
if (!count) break;
|
|
810
|
-
offset += count;
|
|
811
|
-
}
|
|
812
|
-
let tail = buffer.subarray(0, offset);
|
|
813
|
-
const newline = tail.indexOf(0x0a);
|
|
814
|
-
if (newline >= 0 && newline < tail.length - 1) tail = tail.subarray(newline + 1);
|
|
815
|
-
ftruncateSync(fd, 0);
|
|
816
|
-
if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
|
|
817
|
-
} catch (error) {
|
|
818
|
-
if (error?.code !== "ENOENT") throw error;
|
|
819
|
-
} finally {
|
|
820
|
-
if (fd !== undefined) {
|
|
821
|
-
try { closeSync(fd); } catch {
|
|
822
|
-
// Descriptor close is best effort after the trim result is already determined.
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
function resourceErrorClass(error) {
|
|
829
|
-
const message = String(error?.message || error || "");
|
|
830
|
-
if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
|
|
831
|
-
if (/not found|ENOENT/i.test(message)) return "not_found";
|
|
832
|
-
if (/symbolic link/i.test(message)) return "symbolic_link_denied";
|
|
833
|
-
if (/readable by group|permissions/i.test(message)) return "insecure_permissions";
|
|
834
|
-
if (/exceeds/i.test(message)) return "size_limit";
|
|
835
|
-
return "resource_unavailable";
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
function safeReadDir(dir) {
|
|
839
|
-
return readdirSync(dir, { withFileTypes: true });
|
|
840
|
-
}
|