machine-bridge-mcp 0.18.1 → 1.0.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 +39 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +3 -1
- package/SECURITY.md +8 -6
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +0 -1
- package/docs/AGENT_CONTEXT.md +2 -1
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/AUDIT.md +33 -0
- package/docs/ENGINEERING.md +5 -1
- package/docs/GETTING_STARTED.md +3 -0
- package/docs/LOCAL_AUTOMATION.md +1 -1
- package/docs/OPERATIONS.md +21 -1
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/TESTING.md +9 -3
- package/package.json +11 -3
- package/scripts/privacy-check.mjs +13 -13
- package/scripts/sarif-security-gate.mjs +146 -0
- package/src/local/account-access.mjs +0 -1
- package/src/local/atomic-fs.mjs +1 -1
- package/src/local/browser-bridge.mjs +2 -2
- package/src/local/browser-extension-protocol.mjs +1 -1
- package/src/local/browser-pairing-store.mjs +3 -4
- package/src/local/cli.mjs +22 -55
- package/src/local/default-instructions.mjs +4 -3
- package/src/local/errors.mjs +1 -5
- package/src/local/exclusive-file.mjs +9 -3
- package/src/local/full-access-test.mjs +5 -5
- package/src/local/job-runner.mjs +1 -1
- package/src/local/managed-jobs.mjs +27 -51
- package/src/local/relay-connection.mjs +7 -0
- package/src/local/runtime.mjs +20 -6
- package/src/local/secure-file.mjs +89 -10
- package/src/local/service.mjs +25 -35
- package/src/local/shell.mjs +32 -10
- package/src/local/ssh-key.mjs +71 -45
- package/src/local/state.mjs +46 -56
- package/src/local/worker-secret-file.mjs +130 -0
- package/src/local/workspace-file-service.mjs +10 -2
- package/src/shared/server-metadata.json +2 -3
- package/src/worker/http.ts +7 -8
- package/src/worker/index.ts +31 -41
- package/src/worker/mcp-session.ts +72 -0
- package/src/worker/oauth-state.ts +1 -1
- package/src/worker/pending-calls.ts +36 -5
- package/src/worker/tool-timeout.ts +18 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { readBoundedRegularFileSync } from "../src/local/secure-file.mjs";
|
|
3
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const targets = args.filter((value) => !value.startsWith("--"));
|
|
9
|
+
const allowlistOption = args.find((value) => value.startsWith("--allowlist="));
|
|
10
|
+
const allowlistPath = resolve(root, allowlistOption ? allowlistOption.slice("--allowlist=".length) : ".github/codeql-accepted-findings.json");
|
|
11
|
+
if (!targets.length) throw new Error("usage: node scripts/sarif-security-gate.mjs SARIF_PATH [...SARIF_PATH]");
|
|
12
|
+
|
|
13
|
+
const accepted = loadAcceptedFindings(allowlistPath);
|
|
14
|
+
const sarifFiles = targets.flatMap((target) => collectSarifFiles(resolve(target)));
|
|
15
|
+
if (!sarifFiles.length) throw new Error("CodeQL security gate received no SARIF files");
|
|
16
|
+
|
|
17
|
+
const blocked = [];
|
|
18
|
+
const acknowledged = [];
|
|
19
|
+
for (const file of sarifFiles) {
|
|
20
|
+
const document = readSarif(file);
|
|
21
|
+
for (const run of document.runs || []) inspectRun(run, file, accepted, blocked, acknowledged);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const finding of acknowledged) {
|
|
25
|
+
process.stderr.write(`accepted CodeQL finding: ${finding.ruleId} at ${finding.path} (${finding.reason}; expires ${finding.expires})\n`);
|
|
26
|
+
}
|
|
27
|
+
if (blocked.length) {
|
|
28
|
+
for (const finding of blocked.slice(0, 100)) {
|
|
29
|
+
process.stderr.write(`${finding.path}:${finding.line}: ${finding.ruleId}: ${finding.message}\n`);
|
|
30
|
+
}
|
|
31
|
+
if (blocked.length > 100) process.stderr.write(`... ${blocked.length - 100} additional security findings omitted\n`);
|
|
32
|
+
throw new Error(`CodeQL security gate rejected ${blocked.length} unaccepted security finding(s)`);
|
|
33
|
+
}
|
|
34
|
+
process.stderr.write(`CodeQL security gate ok (${sarifFiles.length} SARIF file(s); ${acknowledged.length} explicitly accepted finding(s))\n`);
|
|
35
|
+
|
|
36
|
+
function inspectRun(run, file, allowlist, blockedFindings, acceptedFindings) {
|
|
37
|
+
const rules = new Map();
|
|
38
|
+
for (const rule of run.tool?.driver?.rules || []) rules.set(rule.id, rule);
|
|
39
|
+
for (const result of run.results || []) {
|
|
40
|
+
const ruleId = String(result.ruleId || "");
|
|
41
|
+
const rule = rules.get(ruleId) || {};
|
|
42
|
+
if (!isSecurityRule(rule)) continue;
|
|
43
|
+
const location = primaryLocation(result);
|
|
44
|
+
const path = normalizeSarifPath(location.path, run, file);
|
|
45
|
+
const line = Number(location.line) || 1;
|
|
46
|
+
const message = boundedMessage(result.message?.text || result.message?.markdown || rule.shortDescription?.text || "security finding");
|
|
47
|
+
const exception = allowlist.get(`${ruleId}\0${path}`);
|
|
48
|
+
if (exception) acceptedFindings.push({ ruleId, path, ...exception });
|
|
49
|
+
else blockedFindings.push({ ruleId, path, line, message });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isSecurityRule(rule) {
|
|
54
|
+
const properties = rule?.properties || {};
|
|
55
|
+
const tags = Array.isArray(properties.tags) ? properties.tags.map((value) => String(value).toLowerCase()) : [];
|
|
56
|
+
const severity = Number(properties["security-severity"]);
|
|
57
|
+
return tags.includes("security") || (Number.isFinite(severity) && severity > 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function primaryLocation(result) {
|
|
61
|
+
const physical = result.locations?.[0]?.physicalLocation || {};
|
|
62
|
+
return {
|
|
63
|
+
path: physical.artifactLocation?.uri || "<unknown>",
|
|
64
|
+
line: physical.region?.startLine || 1,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeSarifPath(value, run, sarifFile) {
|
|
69
|
+
let path = String(value || "<unknown>").replaceAll("\\", "/");
|
|
70
|
+
try { path = decodeURIComponent(path); } catch {}
|
|
71
|
+
if (path.startsWith("file://")) path = fileURLToPath(path).replaceAll("\\", "/");
|
|
72
|
+
const baseId = run.originalUriBaseIds?.["%SRCROOT%"]?.uri;
|
|
73
|
+
if (baseId && path.startsWith(String(baseId))) path = path.slice(String(baseId).length);
|
|
74
|
+
if (isAbsolute(path)) {
|
|
75
|
+
const repositoryRelative = relative(root, path).split(sep).join("/");
|
|
76
|
+
path = repositoryRelative === ".." || repositoryRelative.startsWith("../") ? path : repositoryRelative;
|
|
77
|
+
}
|
|
78
|
+
path = path.replace(/^\.\//, "").replace(/^\/+/, "");
|
|
79
|
+
if (!path || path === ".") return `<unknown:${basename(sarifFile)}>`;
|
|
80
|
+
return path;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function loadAcceptedFindings(file) {
|
|
84
|
+
let document;
|
|
85
|
+
try { document = JSON.parse(readFileSync(file, "utf8")); } catch (error) {
|
|
86
|
+
if (error?.code === "ENOENT") return new Map();
|
|
87
|
+
throw new Error(`CodeQL accepted-findings file is invalid: ${file}`, { cause: error });
|
|
88
|
+
}
|
|
89
|
+
if (document?.schemaVersion !== 1 || !Array.isArray(document.accepted)) {
|
|
90
|
+
throw new Error("CodeQL accepted-findings file must use schemaVersion 1 and an accepted array");
|
|
91
|
+
}
|
|
92
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
93
|
+
const entries = new Map();
|
|
94
|
+
for (const item of document.accepted) {
|
|
95
|
+
const ruleId = String(item?.ruleId || "");
|
|
96
|
+
const path = String(item?.path || "").replaceAll("\\", "/").replace(/^\.\//, "");
|
|
97
|
+
const reason = String(item?.reason || "").trim();
|
|
98
|
+
const expires = String(item?.expires || "");
|
|
99
|
+
if (!ruleId || !path || reason.length < 40 || !/^\d{4}-\d{2}-\d{2}$/.test(expires)) {
|
|
100
|
+
throw new Error("each accepted CodeQL finding requires ruleId, path, a substantive reason, and YYYY-MM-DD expires");
|
|
101
|
+
}
|
|
102
|
+
if (expires < today) throw new Error(`accepted CodeQL finding expired: ${ruleId} at ${path}`);
|
|
103
|
+
const key = `${ruleId}\0${path}`;
|
|
104
|
+
if (entries.has(key)) throw new Error(`duplicate accepted CodeQL finding: ${ruleId} at ${path}`);
|
|
105
|
+
entries.set(key, { reason, expires });
|
|
106
|
+
}
|
|
107
|
+
return entries;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function collectSarifFiles(input) {
|
|
111
|
+
const info = statSync(input);
|
|
112
|
+
if (info.isFile()) return isSarifName(input) ? [input] : [];
|
|
113
|
+
if (!info.isDirectory()) return [];
|
|
114
|
+
const files = [];
|
|
115
|
+
const stack = [input];
|
|
116
|
+
while (stack.length) {
|
|
117
|
+
const directory = stack.pop();
|
|
118
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
119
|
+
const path = join(directory, entry.name);
|
|
120
|
+
if (entry.isDirectory()) stack.push(path);
|
|
121
|
+
else if (entry.isFile() && isSarifName(path)) files.push(path);
|
|
122
|
+
if (files.length > 100) throw new Error("CodeQL security gate received more than 100 SARIF files");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return files.sort();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isSarifName(file) {
|
|
129
|
+
const name = file.toLowerCase();
|
|
130
|
+
return extname(name) === ".sarif" || name.endsWith(".sarif.json");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readSarif(file) {
|
|
134
|
+
const repositoryPath = relative(root, file).split(sep).join("/");
|
|
135
|
+
const bytes = readBoundedRegularFileSync(file, 100 * 1024 * 1024, "SARIF file");
|
|
136
|
+
let text;
|
|
137
|
+
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
|
|
138
|
+
catch { throw new Error(`SARIF file is not valid UTF-8: ${repositoryPath}`); }
|
|
139
|
+
const document = JSON.parse(text);
|
|
140
|
+
if (!Array.isArray(document?.runs)) throw new Error(`invalid SARIF document: ${repositoryPath}`);
|
|
141
|
+
return document;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function boundedMessage(value) {
|
|
145
|
+
return String(value || "").replace(/\s+/g, " ").slice(0, 500);
|
|
146
|
+
}
|
|
@@ -6,7 +6,6 @@ export const ACCOUNT_ACCESS_REVISION = Number(accessContract.revision);
|
|
|
6
6
|
export const ACCOUNT_ROLES = Object.freeze(Object.fromEntries(
|
|
7
7
|
Object.entries(accessContract.roles).map(([name, value]) => [name, Object.freeze({ ...value })]),
|
|
8
8
|
));
|
|
9
|
-
export const DEFAULT_ACCOUNT_ROLE = String(accessContract.defaultRole);
|
|
10
9
|
export const OWNER_ACCOUNT_ROLE = String(accessContract.ownerRole);
|
|
11
10
|
|
|
12
11
|
export function normalizeAccountRole(value) {
|
package/src/local/atomic-fs.mjs
CHANGED
|
@@ -7,7 +7,7 @@ export function replaceFileSync(source, target, options = {}) {
|
|
|
7
7
|
const rename = typeof options.rename === "function" ? options.rename : renameSync;
|
|
8
8
|
const sleep = typeof options.sleep === "function" ? options.sleep : sleepSync;
|
|
9
9
|
const random = typeof options.random === "function" ? options.random : Math.random;
|
|
10
|
-
const attempts = clampInteger(options.attempts,
|
|
10
|
+
const attempts = clampInteger(options.attempts, 32, 1, 64);
|
|
11
11
|
const baseDelayMs = clampInteger(options.baseDelayMs, 15, 0, 1000);
|
|
12
12
|
const maxDelayMs = Math.max(baseDelayMs, clampInteger(options.maxDelayMs, 250, 0, 2000));
|
|
13
13
|
let lastError;
|
|
@@ -5,11 +5,11 @@ import { WebSocket, WebSocketServer } from "ws";
|
|
|
5
5
|
import { createToolAuthorizer } from "./policy.mjs";
|
|
6
6
|
import { assertStateMaintenanceAvailable, packageRoot } from "./state.mjs";
|
|
7
7
|
import {
|
|
8
|
-
BROWSER_EXTENSION_PROTOCOL, EXPECTED_EXTENSION_VERSION, MAX_BROWSER_MESSAGE_BYTES,
|
|
8
|
+
BROWSER_EXTENSION_PROTOCOL, EXPECTED_EXTENSION_VERSION, MAX_BROWSER_MESSAGE_BYTES,
|
|
9
9
|
closeProtocolSocket, normalizeCompatibleExtensionInfo, parseBrowserSocketMessage, parseExtensionHello, safeSocketSend,
|
|
10
10
|
} from "./browser-extension-protocol.mjs";
|
|
11
11
|
import {
|
|
12
|
-
|
|
12
|
+
isAllowedExtensionOrigin, isAllowedLoopbackHost, loadOrCreatePairing,
|
|
13
13
|
pairingHtml, savePairing, securityHeaders, sendJson,
|
|
14
14
|
} from "./browser-pairing-store.mjs";
|
|
15
15
|
import {
|
|
@@ -4,7 +4,7 @@ import { packageRoot } from "./state.mjs";
|
|
|
4
4
|
|
|
5
5
|
export const BROWSER_EXTENSION_PROTOCOL = 3;
|
|
6
6
|
export const EXPECTED_EXTENSION_VERSION = extensionVersion();
|
|
7
|
-
|
|
7
|
+
const REQUIRED_EXTENSION_CAPABILITIES = Object.freeze([
|
|
8
8
|
"semantic_snapshot_refs", "actionability_waits", "trusted_input", "tab_management", "explicit_waits",
|
|
9
9
|
]);
|
|
10
10
|
export const MAX_BROWSER_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdir } from "node:fs/promises";
|
|
4
3
|
import { join } from "node:path";
|
|
5
4
|
import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
6
5
|
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
7
|
-
import { assertStateMaintenanceAvailable, ownerOnlyFile } from "./state.mjs";
|
|
6
|
+
import { assertStateMaintenanceAvailable, ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
|
|
8
7
|
import { EXPECTED_EXTENSION_VERSION } from "./browser-extension-protocol.mjs";
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
const DEFAULT_BROWSER_PORT = 39393;
|
|
11
10
|
const PAIRING_FILE = "browser-bridge.json";
|
|
12
11
|
|
|
13
12
|
export async function loadOrCreatePairing(stateRoot) {
|
|
14
13
|
if (!stateRoot) return { token: randomBytes(32).toString("base64url"), port: DEFAULT_BROWSER_PORT };
|
|
15
14
|
assertStateMaintenanceAvailable(stateRoot);
|
|
16
|
-
|
|
15
|
+
ensureOwnerOnlyDir(stateRoot);
|
|
17
16
|
const file = join(stateRoot, PAIRING_FILE);
|
|
18
17
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
19
18
|
if (existsSync(file)) {
|
package/src/local/cli.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync,
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
3
|
import path, { join, resolve } from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import readline from "node:readline/promises";
|
|
6
6
|
import { LocalRuntime } from "./runtime.mjs";
|
|
7
7
|
import { acquireDaemonLockWithTakeover, inspectWorkspaceDaemon, stopWorkspaceServiceDaemon } from "./daemon-process.mjs";
|
|
8
|
+
import { inspectProcessInstance } from "./process-identity.mjs";
|
|
8
9
|
import { runStdioServer } from "./stdio.mjs";
|
|
9
10
|
import { assertCanonicalFullPolicy, POLICY_PROFILES, toolsForPolicy } from "./tools.mjs";
|
|
10
11
|
import { resolvePolicy } from "./cli-policy.mjs";
|
|
@@ -14,25 +15,22 @@ import { generateAccountPassword } from "./account-admin.mjs";
|
|
|
14
15
|
import { accountAdminClient, createAccountCommand } from "./cli-account-admin.mjs";
|
|
15
16
|
export { resolvePolicy } from "./cli-policy.mjs";
|
|
16
17
|
export { parseArgs, validateCommandOptions, validateLoggingOptions, validatePositionals } from "./cli-options.mjs";
|
|
17
|
-
import { classifyOperationalError, createLogger,
|
|
18
|
-
import {
|
|
18
|
+
import { classifyOperationalError, createLogger, sanitizeLogText } from "./log.mjs";
|
|
19
|
+
import { runExecutable, runWrangler } from "./shell.mjs";
|
|
19
20
|
import { runFullAccessTest } from "./full-access-test.mjs";
|
|
20
|
-
import { inspectProcessInstance } from "./process-identity.mjs";
|
|
21
21
|
import { stopAndRemoveAutostart } from "./service-lifecycle.mjs";
|
|
22
22
|
import { activeStateJobs, activeStateLocks, knownProfileStates, knownWorkerNames } from "./state-inventory.mjs";
|
|
23
|
-
import {
|
|
23
|
+
import { withWorkerSecretsFile } from "./worker-secret-file.mjs";
|
|
24
24
|
import {
|
|
25
25
|
acquireMaintenanceLock,
|
|
26
26
|
acquireStartupLockWithWait,
|
|
27
27
|
appName,
|
|
28
28
|
daemonLockPathForState,
|
|
29
29
|
defaultStateRoot,
|
|
30
|
-
ensureOwnerOnlyDir,
|
|
31
30
|
ensureWorkerSecrets,
|
|
32
31
|
expandHome,
|
|
33
32
|
loadGlobalConfig,
|
|
34
33
|
loadState,
|
|
35
|
-
ownerOnlyFile,
|
|
36
34
|
packageRoot,
|
|
37
35
|
readDaemonLockOwner,
|
|
38
36
|
redactState,
|
|
@@ -362,7 +360,7 @@ async function clientConfigCommand(args) {
|
|
|
362
360
|
|
|
363
361
|
async function ensureWorker(state, args) {
|
|
364
362
|
const logger = createLogger({ level: args.json ? "error" : effectiveLogLevel(args), format: effectiveLogFormat(args), component: "worker" });
|
|
365
|
-
const desiredHash =
|
|
363
|
+
const desiredHash = workerDeploymentFingerprint(state);
|
|
366
364
|
const expectedVersion = currentPackageVersion();
|
|
367
365
|
const complete = state.worker.url && state.worker.mcpServerUrl && state.worker.accountAdminSecret && state.worker.daemonSecret && state.worker.oauthTokenVersion && state.worker.name;
|
|
368
366
|
if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
|
|
@@ -383,7 +381,7 @@ async function ensureWorker(state, args) {
|
|
|
383
381
|
}
|
|
384
382
|
|
|
385
383
|
logger.info("Deploying Cloudflare Worker", { name: state.worker.name });
|
|
386
|
-
const deploy = await
|
|
384
|
+
const deploy = await withWorkerSecretsFile(state, secretFile => runWrangler([
|
|
387
385
|
"deploy",
|
|
388
386
|
"--name", state.worker.name,
|
|
389
387
|
"--minify",
|
|
@@ -409,52 +407,21 @@ async function ensureWorker(state, args) {
|
|
|
409
407
|
return state.worker;
|
|
410
408
|
}
|
|
411
409
|
|
|
412
|
-
async function withSecretsFile(state, callback) {
|
|
413
|
-
const dir = state.paths.profileDir;
|
|
414
|
-
ensureOwnerOnlyDir(dir);
|
|
415
|
-
cleanupStaleSecretFiles(dir);
|
|
416
|
-
const tempPath = resolve(dir, `worker-secrets-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}.json`);
|
|
417
|
-
const payload = {
|
|
418
|
-
ACCOUNT_ADMIN_SECRET: state.worker.accountAdminSecret,
|
|
419
|
-
DAEMON_SHARED_SECRET: state.worker.daemonSecret,
|
|
420
|
-
OAUTH_TOKEN_VERSION: state.worker.oauthTokenVersion,
|
|
421
|
-
};
|
|
422
|
-
createExclusiveFileSync(tempPath, JSON.stringify(payload), { mode: 0o600 });
|
|
423
|
-
ownerOnlyFile(tempPath);
|
|
424
|
-
try {
|
|
425
|
-
return await callback(tempPath);
|
|
426
|
-
} finally {
|
|
427
|
-
try { unlinkSync(tempPath); } catch {}
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
export function cleanupStaleSecretFiles(dir) {
|
|
432
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
433
|
-
if (!entry.isFile()) continue;
|
|
434
|
-
const match = /^worker-secrets-(\d+)-(\d+)(?:-[a-f0-9]+)?\.json$/.exec(entry.name);
|
|
435
|
-
if (!match) continue;
|
|
436
|
-
const file = resolve(dir, entry.name);
|
|
437
|
-
try {
|
|
438
|
-
const pid = Number(match[1]);
|
|
439
|
-
const createdAt = Number(match[2]);
|
|
440
|
-
const identity = inspectProcessInstance({ pid, startedAt: new Date(createdAt).toISOString() });
|
|
441
|
-
if (!identity.current) unlinkSync(file);
|
|
442
|
-
} catch {}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
410
|
|
|
446
|
-
function
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
411
|
+
function workerDeploymentFingerprint(state) {
|
|
412
|
+
const keyMaterial = [
|
|
413
|
+
String(state.worker.accountAdminSecret || ""),
|
|
414
|
+
String(state.worker.daemonSecret || ""),
|
|
415
|
+
String(state.worker.oauthTokenVersion || ""),
|
|
416
|
+
].join("\0");
|
|
417
|
+
const fingerprint = createHmac("sha256", keyMaterial);
|
|
418
|
+
fingerprint.update("mbm-worker-deploy-v3");
|
|
419
|
+
fingerprint.update(String(state.worker.name || ""));
|
|
453
420
|
for (const file of workerDeployHashFiles()) {
|
|
454
|
-
|
|
455
|
-
|
|
421
|
+
fingerprint.update(path.relative(packageRoot, file));
|
|
422
|
+
fingerprint.update(workerHashContent(file));
|
|
456
423
|
}
|
|
457
|
-
return
|
|
424
|
+
return fingerprint.digest("hex");
|
|
458
425
|
}
|
|
459
426
|
|
|
460
427
|
function workerHashContent(file) {
|
|
@@ -606,7 +573,7 @@ async function doctorCommand(args) {
|
|
|
606
573
|
const checks = [];
|
|
607
574
|
checks.push({ name: "node", ok: isSupportedNodeVersion(), detail: process.version });
|
|
608
575
|
const npmCommand = npmVersionCommand();
|
|
609
|
-
const npm = await
|
|
576
|
+
const npm = await runExecutable(npmCommand.file, npmCommand.args, { capture: true, allowFailure: true, timeoutMs: 10_000 });
|
|
610
577
|
const npmDetail = sanitizeLines(npm.stdout || npm.stderr);
|
|
611
578
|
checks.push({ name: "npm", ok: npm.code === 0 && isSupportedNpmVersion(npmDetail), detail: npmDetail || "unavailable" });
|
|
612
579
|
const wrangler = await runWrangler(["--version"], { capture: true, allowFailure: true });
|
|
@@ -805,7 +772,7 @@ async function stopAutostartBestEffort(logger) {
|
|
|
805
772
|
async function uninstallCommand(args) {
|
|
806
773
|
const stateRoot = stateRootFromArgs(args);
|
|
807
774
|
const deleteRemote = !args.keepWorker;
|
|
808
|
-
|
|
775
|
+
validateStateRootForRemoval(stateRoot);
|
|
809
776
|
const action = deleteRemote
|
|
810
777
|
? `delete deployed Worker(s), remove autostart entries, and remove local state at ${stateRoot}`
|
|
811
778
|
: `remove autostart entries and local state at ${stateRoot} while keeping deployed Worker(s)`;
|
|
@@ -8,8 +8,8 @@ const MAX_PROJECT_CONTEXT_BYTES = 16 * 1024;
|
|
|
8
8
|
const MAX_SCRIPT_NAMES = 24;
|
|
9
9
|
const MAX_WORKFLOW_FILES = 20;
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const BUILTIN_INSTRUCTIONS_SOURCE = "machine-bridge://defaults/working-agreements";
|
|
12
|
+
const AUTOMATIC_PROJECT_CONTEXT_SOURCE = "machine-bridge://project-context/current";
|
|
13
13
|
|
|
14
14
|
const BUILTIN_INSTRUCTIONS = `# Machine Bridge default working agreements
|
|
15
15
|
|
|
@@ -43,8 +43,9 @@ These are conservative defaults. Explicit current-user requests and more specifi
|
|
|
43
43
|
|
|
44
44
|
## Git and delivery
|
|
45
45
|
|
|
46
|
+
- Before any GitHub read or mutation, read the effective project instructions. If they require local git, gh, and gh api through Machine Bridge, never call a hosted GitHub connector or ChatGPT GitHub plugin; if that local control plane is unavailable, stop and report the boundary instead of substituting another control plane.
|
|
46
47
|
- Do not amend, rebase, force-push, delete branches or tags, or discard working-tree changes unless explicitly requested.
|
|
47
|
-
- Commit or push only when the current task or repository instructions authorize it.
|
|
48
|
+
- Commit or push only when the current task or repository instructions authorize it. Follow repository-specific source-release ownership when it explicitly authorizes tags and GitHub Releases; otherwise do not create them merely because a version changed.
|
|
48
49
|
- Summarize what changed, which checks ran, and any remaining limitations or operator steps.
|
|
49
50
|
`;
|
|
50
51
|
|
package/src/local/errors.mjs
CHANGED
|
@@ -19,10 +19,6 @@ export class BridgeError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export function bridgeError(code, message, options) {
|
|
23
|
-
return new BridgeError(code, message, options);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
22
|
export function normalizeBridgeError(error, options = {}) {
|
|
27
23
|
if (error instanceof BridgeError) return error;
|
|
28
24
|
const code = errorCode(error, options.defaultCode);
|
|
@@ -70,7 +66,7 @@ export function remoteBridgeError(value, fallbackMessage = "remote operation fai
|
|
|
70
66
|
return new BridgeError("execution_failed", fallbackMessage);
|
|
71
67
|
}
|
|
72
68
|
|
|
73
|
-
|
|
69
|
+
function normalizeErrorCode(value, fallback = "execution_failed") {
|
|
74
70
|
const code = String(value || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, 64);
|
|
75
71
|
return ERROR_CODES.has(code) ? code : fallback;
|
|
76
72
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
fchmodSync,
|
|
4
4
|
closeSync,
|
|
5
5
|
fsyncSync,
|
|
6
6
|
linkSync,
|
|
@@ -21,12 +21,12 @@ export function createExclusiveFileSync(target, content, options = {}) {
|
|
|
21
21
|
try {
|
|
22
22
|
fd = openSync(temporary, "wx", mode);
|
|
23
23
|
writeFileSync(fd, content);
|
|
24
|
+
setDescriptorMode(fd, mode);
|
|
24
25
|
fsyncSync(fd);
|
|
25
26
|
closeSync(fd);
|
|
26
27
|
fd = undefined;
|
|
27
28
|
linkSync(temporary, target);
|
|
28
29
|
linked = true;
|
|
29
|
-
try { chmodSync(target, mode); } catch {}
|
|
30
30
|
return { created: true, path: target };
|
|
31
31
|
} finally {
|
|
32
32
|
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
@@ -44,11 +44,11 @@ export function replaceFileAtomicallySync(target, content, options = {}) {
|
|
|
44
44
|
try {
|
|
45
45
|
fd = openSync(temporary, "wx", mode);
|
|
46
46
|
writeFileSync(fd, content);
|
|
47
|
+
setDescriptorMode(fd, mode);
|
|
47
48
|
fsyncSync(fd);
|
|
48
49
|
closeSync(fd);
|
|
49
50
|
fd = undefined;
|
|
50
51
|
replaceFileSync(temporary, target);
|
|
51
|
-
try { chmodSync(target, mode); } catch {}
|
|
52
52
|
return { replaced: true, path: target };
|
|
53
53
|
} catch (error) {
|
|
54
54
|
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
@@ -92,3 +92,9 @@ function sameIdentity(left, right) {
|
|
|
92
92
|
&& Number(left.size) === Number(right.size)
|
|
93
93
|
&& Number(left.mtimeMs) === Number(right.mtimeMs);
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
function setDescriptorMode(fd, mode) {
|
|
97
|
+
try { fchmodSync(fd, mode); } catch (error) {
|
|
98
|
+
if (process.platform !== "win32") throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -4,7 +4,7 @@ import { join, resolve } from "node:path";
|
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import { LocalRuntime } from "./runtime.mjs";
|
|
6
6
|
import { generateSshKeyPair } from "./ssh-key.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { runExecutable } from "./shell.mjs";
|
|
8
8
|
import { allToolNames, assertCanonicalFullPolicy, policyProfile } from "./tools.mjs";
|
|
9
9
|
|
|
10
10
|
const TERMINAL_JOB_STATES = new Set([
|
|
@@ -90,7 +90,7 @@ export async function runFullAccessTest({ workspace, policy = policyProfile("ful
|
|
|
90
90
|
}));
|
|
91
91
|
|
|
92
92
|
const nullConfig = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
93
|
-
const sshConfig = await
|
|
93
|
+
const sshConfig = await runExecutable("ssh", ["-F", nullConfig, "-G", "localhost"], {
|
|
94
94
|
capture: true,
|
|
95
95
|
allowFailure: true,
|
|
96
96
|
timeoutMs: 15_000,
|
|
@@ -98,14 +98,14 @@ export async function runFullAccessTest({ workspace, policy = policyProfile("ful
|
|
|
98
98
|
});
|
|
99
99
|
checks.push(check("ssh-client", sshConfig.code === 0));
|
|
100
100
|
|
|
101
|
-
const gcloud = await
|
|
101
|
+
const gcloud = await runExecutable("gcloud", ["--version"], {
|
|
102
102
|
capture: true,
|
|
103
103
|
allowFailure: true,
|
|
104
104
|
timeoutMs: 30_000,
|
|
105
105
|
maxOutputBytes: 64 * 1024,
|
|
106
106
|
});
|
|
107
107
|
const osLoginHelp = gcloud.code === 0
|
|
108
|
-
? await
|
|
108
|
+
? await runExecutable("gcloud", ["help", "compute", "os-login", "ssh-keys", "add"], {
|
|
109
109
|
capture: true,
|
|
110
110
|
allowFailure: true,
|
|
111
111
|
timeoutMs: 30_000,
|
|
@@ -119,7 +119,7 @@ export async function runFullAccessTest({ workspace, policy = policyProfile("ful
|
|
|
119
119
|
|
|
120
120
|
const sudo = process.platform === "win32"
|
|
121
121
|
? { code: 0, skipped: true }
|
|
122
|
-
: await
|
|
122
|
+
: await runExecutable("sudo", ["-n", "true"], {
|
|
123
123
|
capture: true,
|
|
124
124
|
allowFailure: true,
|
|
125
125
|
timeoutMs: 10_000,
|
package/src/local/job-runner.mjs
CHANGED
|
@@ -54,8 +54,8 @@ for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
|
54
54
|
const initial = readJson(statusFile, MAX_STATUS_BYTES);
|
|
55
55
|
assertLaunchState(initial);
|
|
56
56
|
createExclusiveFileSync(runnerPidFile, `${JSON.stringify({ pid: process.pid, processStartedAt: RUNNER_PROCESS_STARTED_AT })}\n`, { mode: 0o600 });
|
|
57
|
-
if (recover) rmSync(join(jobDir, "recovery.lock"), { force: true });
|
|
58
57
|
try {
|
|
58
|
+
if (recover) await releaseRecoveryClaim();
|
|
59
59
|
const plan = readJson(planFile, 1024 * 1024);
|
|
60
60
|
assertPlanIntegrity(plan, initial);
|
|
61
61
|
await main(plan, initial);
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { closeSync, constants as fsConstants, existsSync, ftruncateSync, lstatSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { assertStateMaintenanceAvailable, ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
|
|
7
7
|
import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
8
8
|
import { currentProcessStartTimeMs, inspectProcessInstance, processStartTimeMs } from "./process-identity.mjs";
|
|
9
|
-
import {
|
|
9
|
+
import { openRegularFileSync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
10
10
|
import { createToolAuthorizer } from "./policy.mjs";
|
|
11
11
|
import { BridgeError } from "./errors.mjs";
|
|
12
|
-
import { inspectResourceFile, normalizeResourceRegistry,
|
|
12
|
+
import { inspectResourceFile, normalizeResourceRegistry, validatePlan } from "./managed-job-plan.mjs";
|
|
13
13
|
export { inspectResourceFile, publicResourceRegistry, validateResourceName } from "./managed-job-plan.mjs";
|
|
14
14
|
import { clampInteger } from "./numbers.mjs";
|
|
15
15
|
|
|
@@ -120,7 +120,7 @@ export class ManagedJobManager {
|
|
|
120
120
|
status.status = "queued";
|
|
121
121
|
status.updated_at = new Date().toISOString();
|
|
122
122
|
status.approved_at = status.updated_at;
|
|
123
|
-
status.approval =
|
|
123
|
+
status.approval = "local-operator";
|
|
124
124
|
status.cleanup_guarantee = "best-effort-finally-and-recovery";
|
|
125
125
|
atomicWriteJson(statusFile, status, 256 * 1024);
|
|
126
126
|
try {
|
|
@@ -589,22 +589,14 @@ function pidLockOwner(pid, startedAtMs) {
|
|
|
589
589
|
};
|
|
590
590
|
}
|
|
591
591
|
|
|
592
|
-
function readPidLockOwner(file) {
|
|
593
|
-
return readPidLockSnapshot(file)?.owner || null;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
592
|
function readPidLockSnapshot(file) {
|
|
597
593
|
let info;
|
|
598
594
|
try { info = lstatSync(file); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
599
595
|
if (info.isSymbolicLink() || !info.isFile()) throw new Error("job lock must be a regular non-symbolic-link file");
|
|
600
596
|
let owner = null;
|
|
601
597
|
try {
|
|
602
|
-
const
|
|
603
|
-
if (
|
|
604
|
-
else {
|
|
605
|
-
const parsed = JSON.parse(text);
|
|
606
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) owner = parsed;
|
|
607
|
-
}
|
|
598
|
+
const parsed = JSON.parse(readBoundedFile(file, 1024).toString("utf8").trim());
|
|
599
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) owner = parsed;
|
|
608
600
|
} catch (error) {
|
|
609
601
|
if (error?.code === "ENOENT") return null;
|
|
610
602
|
}
|
|
@@ -780,34 +772,27 @@ function readBoundedFile(file, maxBytes) {
|
|
|
780
772
|
}
|
|
781
773
|
|
|
782
774
|
function openPrivateAppendFile(file) {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
const fd = openSync(file, flags, 0o600);
|
|
789
|
-
try {
|
|
790
|
-
if (!fstatSync(fd).isFile()) throw new Error("runner diagnostic path is not a regular file");
|
|
791
|
-
chmodSync(file, 0o600);
|
|
792
|
-
return fd;
|
|
793
|
-
} catch (error) {
|
|
794
|
-
closeSync(fd);
|
|
795
|
-
throw error;
|
|
796
|
-
}
|
|
775
|
+
return openRegularFileSync(
|
|
776
|
+
file,
|
|
777
|
+
Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND),
|
|
778
|
+
{ label: "runner diagnostic path", mode: 0o600, chmod: 0o600 },
|
|
779
|
+
).fd;
|
|
797
780
|
}
|
|
798
781
|
|
|
799
782
|
function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
|
|
800
783
|
let fd;
|
|
801
784
|
try {
|
|
802
|
-
const
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
785
|
+
const opened = openRegularFileSync(file, fsConstants.O_RDWR, {
|
|
786
|
+
label: "runner diagnostic path",
|
|
787
|
+
chmod: 0o600,
|
|
788
|
+
});
|
|
789
|
+
fd = opened.fd;
|
|
790
|
+
if (opened.info.size <= maxBytes) return;
|
|
791
|
+
const length = Math.min(keepBytes, opened.info.size);
|
|
807
792
|
const buffer = Buffer.alloc(length);
|
|
808
793
|
let offset = 0;
|
|
809
794
|
while (offset < length) {
|
|
810
|
-
const count = readSync(fd, buffer, offset, length - offset, info.size - length + offset);
|
|
795
|
+
const count = readSync(fd, buffer, offset, length - offset, opened.info.size - length + offset);
|
|
811
796
|
if (!count) break;
|
|
812
797
|
offset += count;
|
|
813
798
|
}
|
|
@@ -816,20 +801,17 @@ function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
|
|
|
816
801
|
if (newline >= 0 && newline < tail.length - 1) tail = tail.subarray(newline + 1);
|
|
817
802
|
ftruncateSync(fd, 0);
|
|
818
803
|
if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
|
|
819
|
-
|
|
820
|
-
|
|
804
|
+
} catch (error) {
|
|
805
|
+
if (error?.code !== "ENOENT") throw error;
|
|
821
806
|
} finally {
|
|
822
|
-
if (fd !== undefined)
|
|
807
|
+
if (fd !== undefined) {
|
|
808
|
+
try { closeSync(fd); } catch {
|
|
809
|
+
// Descriptor close is best effort after the trim result is already determined.
|
|
810
|
+
}
|
|
811
|
+
}
|
|
823
812
|
}
|
|
824
813
|
}
|
|
825
814
|
|
|
826
|
-
function realpathFile(path) {
|
|
827
|
-
const input = resolve(path);
|
|
828
|
-
const linkInfo = lstatSync(input);
|
|
829
|
-
if (!linkInfo.isFile() && !linkInfo.isSymbolicLink()) throw new Error("resource path is not a regular file");
|
|
830
|
-
return realpathSync.native ? realpathSync.native(input) : realpathSync(input);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
815
|
function resourceErrorClass(error) {
|
|
834
816
|
const message = String(error?.message || error || "");
|
|
835
817
|
if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
|
|
@@ -843,9 +825,3 @@ function resourceErrorClass(error) {
|
|
|
843
825
|
function safeReadDir(dir) {
|
|
844
826
|
return readdirSync(dir, { withFileTypes: true });
|
|
845
827
|
}
|
|
846
|
-
|
|
847
|
-
function boundedString(value, maxBytes, label) {
|
|
848
|
-
if (typeof value !== "string" || value.includes("\0")) throw new Error(`${label} must be a string without NUL bytes`);
|
|
849
|
-
if (Buffer.byteLength(value) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`);
|
|
850
|
-
return value;
|
|
851
|
-
}
|
|
@@ -118,6 +118,13 @@ export class RelayConnection {
|
|
|
118
118
|
return this.sendOnSocket(this.socket, value);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
interrupt(category = "relay_transport_error") {
|
|
122
|
+
if (this.closed || !this.socket) return false;
|
|
123
|
+
this.pendingCloseCategory = String(category || "relay_transport_error");
|
|
124
|
+
terminateSocket(this.socket);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
121
128
|
observeWelcome(message = {}) {
|
|
122
129
|
const socket = this.socket;
|
|
123
130
|
if (this.closed || this.ready || !this.isSocketOpen(socket)) return false;
|