machine-bridge-mcp 0.18.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -0
- 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 +23 -0
- package/docs/ENGINEERING.md +3 -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 +6 -2
- package/package.json +6 -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 +21 -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 +26 -7
- 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,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync,
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, 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";
|
|
@@ -14,25 +14,22 @@ import { generateAccountPassword } from "./account-admin.mjs";
|
|
|
14
14
|
import { accountAdminClient, createAccountCommand } from "./cli-account-admin.mjs";
|
|
15
15
|
export { resolvePolicy } from "./cli-policy.mjs";
|
|
16
16
|
export { parseArgs, validateCommandOptions, validateLoggingOptions, validatePositionals } from "./cli-options.mjs";
|
|
17
|
-
import { classifyOperationalError, createLogger,
|
|
18
|
-
import {
|
|
17
|
+
import { classifyOperationalError, createLogger, sanitizeLogText } from "./log.mjs";
|
|
18
|
+
import { runExecutable, runWrangler } from "./shell.mjs";
|
|
19
19
|
import { runFullAccessTest } from "./full-access-test.mjs";
|
|
20
|
-
import { inspectProcessInstance } from "./process-identity.mjs";
|
|
21
20
|
import { stopAndRemoveAutostart } from "./service-lifecycle.mjs";
|
|
22
21
|
import { activeStateJobs, activeStateLocks, knownProfileStates, knownWorkerNames } from "./state-inventory.mjs";
|
|
23
|
-
import {
|
|
22
|
+
import { withWorkerSecretsFile } from "./worker-secret-file.mjs";
|
|
24
23
|
import {
|
|
25
24
|
acquireMaintenanceLock,
|
|
26
25
|
acquireStartupLockWithWait,
|
|
27
26
|
appName,
|
|
28
27
|
daemonLockPathForState,
|
|
29
28
|
defaultStateRoot,
|
|
30
|
-
ensureOwnerOnlyDir,
|
|
31
29
|
ensureWorkerSecrets,
|
|
32
30
|
expandHome,
|
|
33
31
|
loadGlobalConfig,
|
|
34
32
|
loadState,
|
|
35
|
-
ownerOnlyFile,
|
|
36
33
|
packageRoot,
|
|
37
34
|
readDaemonLockOwner,
|
|
38
35
|
redactState,
|
|
@@ -362,7 +359,7 @@ async function clientConfigCommand(args) {
|
|
|
362
359
|
|
|
363
360
|
async function ensureWorker(state, args) {
|
|
364
361
|
const logger = createLogger({ level: args.json ? "error" : effectiveLogLevel(args), format: effectiveLogFormat(args), component: "worker" });
|
|
365
|
-
const desiredHash =
|
|
362
|
+
const desiredHash = workerDeploymentFingerprint(state);
|
|
366
363
|
const expectedVersion = currentPackageVersion();
|
|
367
364
|
const complete = state.worker.url && state.worker.mcpServerUrl && state.worker.accountAdminSecret && state.worker.daemonSecret && state.worker.oauthTokenVersion && state.worker.name;
|
|
368
365
|
if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
|
|
@@ -383,7 +380,7 @@ async function ensureWorker(state, args) {
|
|
|
383
380
|
}
|
|
384
381
|
|
|
385
382
|
logger.info("Deploying Cloudflare Worker", { name: state.worker.name });
|
|
386
|
-
const deploy = await
|
|
383
|
+
const deploy = await withWorkerSecretsFile(state, secretFile => runWrangler([
|
|
387
384
|
"deploy",
|
|
388
385
|
"--name", state.worker.name,
|
|
389
386
|
"--minify",
|
|
@@ -409,52 +406,21 @@ async function ensureWorker(state, args) {
|
|
|
409
406
|
return state.worker;
|
|
410
407
|
}
|
|
411
408
|
|
|
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
409
|
|
|
446
|
-
function
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
410
|
+
function workerDeploymentFingerprint(state) {
|
|
411
|
+
const keyMaterial = [
|
|
412
|
+
String(state.worker.accountAdminSecret || ""),
|
|
413
|
+
String(state.worker.daemonSecret || ""),
|
|
414
|
+
String(state.worker.oauthTokenVersion || ""),
|
|
415
|
+
].join("\0");
|
|
416
|
+
const fingerprint = createHmac("sha256", keyMaterial);
|
|
417
|
+
fingerprint.update("mbm-worker-deploy-v3");
|
|
418
|
+
fingerprint.update(String(state.worker.name || ""));
|
|
453
419
|
for (const file of workerDeployHashFiles()) {
|
|
454
|
-
|
|
455
|
-
|
|
420
|
+
fingerprint.update(path.relative(packageRoot, file));
|
|
421
|
+
fingerprint.update(workerHashContent(file));
|
|
456
422
|
}
|
|
457
|
-
return
|
|
423
|
+
return fingerprint.digest("hex");
|
|
458
424
|
}
|
|
459
425
|
|
|
460
426
|
function workerHashContent(file) {
|
|
@@ -606,7 +572,7 @@ async function doctorCommand(args) {
|
|
|
606
572
|
const checks = [];
|
|
607
573
|
checks.push({ name: "node", ok: isSupportedNodeVersion(), detail: process.version });
|
|
608
574
|
const npmCommand = npmVersionCommand();
|
|
609
|
-
const npm = await
|
|
575
|
+
const npm = await runExecutable(npmCommand.file, npmCommand.args, { capture: true, allowFailure: true, timeoutMs: 10_000 });
|
|
610
576
|
const npmDetail = sanitizeLines(npm.stdout || npm.stderr);
|
|
611
577
|
checks.push({ name: "npm", ok: npm.code === 0 && isSupportedNpmVersion(npmDetail), detail: npmDetail || "unavailable" });
|
|
612
578
|
const wrangler = await runWrangler(["--version"], { capture: true, allowFailure: true });
|
|
@@ -805,7 +771,7 @@ async function stopAutostartBestEffort(logger) {
|
|
|
805
771
|
async function uninstallCommand(args) {
|
|
806
772
|
const stateRoot = stateRootFromArgs(args);
|
|
807
773
|
const deleteRemote = !args.keepWorker;
|
|
808
|
-
|
|
774
|
+
validateStateRootForRemoval(stateRoot);
|
|
809
775
|
const action = deleteRemote
|
|
810
776
|
? `delete deployed Worker(s), remove autostart entries, and remove local state at ${stateRoot}`
|
|
811
777
|
: `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;
|