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.
Files changed (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +2 -0
  4. package/SECURITY.md +8 -6
  5. package/browser-extension/manifest.json +2 -2
  6. package/browser-extension/page-automation.js +0 -1
  7. package/docs/AGENT_CONTEXT.md +2 -1
  8. package/docs/ARCHITECTURE.md +3 -3
  9. package/docs/AUDIT.md +23 -0
  10. package/docs/ENGINEERING.md +3 -1
  11. package/docs/GETTING_STARTED.md +3 -0
  12. package/docs/LOCAL_AUTOMATION.md +1 -1
  13. package/docs/OPERATIONS.md +21 -1
  14. package/docs/PROJECT_STANDARDS.md +1 -1
  15. package/docs/TESTING.md +6 -2
  16. package/package.json +6 -3
  17. package/scripts/privacy-check.mjs +13 -13
  18. package/scripts/sarif-security-gate.mjs +146 -0
  19. package/src/local/account-access.mjs +0 -1
  20. package/src/local/atomic-fs.mjs +1 -1
  21. package/src/local/browser-bridge.mjs +2 -2
  22. package/src/local/browser-extension-protocol.mjs +1 -1
  23. package/src/local/browser-pairing-store.mjs +3 -4
  24. package/src/local/cli.mjs +21 -55
  25. package/src/local/default-instructions.mjs +4 -3
  26. package/src/local/errors.mjs +1 -5
  27. package/src/local/exclusive-file.mjs +9 -3
  28. package/src/local/full-access-test.mjs +5 -5
  29. package/src/local/job-runner.mjs +1 -1
  30. package/src/local/managed-jobs.mjs +27 -51
  31. package/src/local/relay-connection.mjs +7 -0
  32. package/src/local/runtime.mjs +20 -6
  33. package/src/local/secure-file.mjs +89 -10
  34. package/src/local/service.mjs +25 -35
  35. package/src/local/shell.mjs +26 -7
  36. package/src/local/ssh-key.mjs +71 -45
  37. package/src/local/state.mjs +46 -56
  38. package/src/local/worker-secret-file.mjs +130 -0
  39. package/src/local/workspace-file-service.mjs +10 -2
  40. package/src/shared/server-metadata.json +2 -3
  41. package/src/worker/http.ts +7 -8
  42. package/src/worker/index.ts +31 -41
  43. package/src/worker/mcp-session.ts +72 -0
  44. package/src/worker/oauth-state.ts +1 -1
  45. package/src/worker/pending-calls.ts +36 -5
  46. 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) {
@@ -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, 16, 1, 64);
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, REQUIRED_EXTENSION_CAPABILITIES,
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
- DEFAULT_BROWSER_PORT, isAllowedExtensionOrigin, isAllowedLoopbackHost, loadOrCreatePairing,
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
- export const REQUIRED_EXTENSION_CAPABILITIES = Object.freeze([
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
- export const DEFAULT_BROWSER_PORT = 39393;
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
- await mkdir(stateRoot, { recursive: true, mode: 0o700 });
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 { createHash, randomBytes } from "node:crypto";
2
- import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
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, normalizeLogLevel, sanitizeLogText } from "./log.mjs";
18
- import { run, runWrangler } from "./shell.mjs";
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 { createExclusiveFileSync } from "./exclusive-file.mjs";
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 = workerDeployHash(state);
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 withSecretsFile(state, secretFile => runWrangler([
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 workerDeployHash(state) {
447
- const hash = createHash("sha256");
448
- hash.update("mbm-worker-deploy-v2");
449
- hash.update(String(state.worker.name || ""));
450
- hash.update(String(state.worker.accountAdminSecret || ""));
451
- hash.update(String(state.worker.daemonSecret || ""));
452
- hash.update(String(state.worker.oauthTokenVersion || ""));
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
- hash.update(path.relative(packageRoot, file));
455
- hash.update(workerHashContent(file));
420
+ fingerprint.update(path.relative(packageRoot, file));
421
+ fingerprint.update(workerHashContent(file));
456
422
  }
457
- return hash.digest("hex");
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 run(npmCommand.file, npmCommand.args, { capture: true, allowFailure: true, timeoutMs: 10_000 });
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
- const validation = validateStateRootForRemoval(stateRoot);
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
- export const BUILTIN_INSTRUCTIONS_SOURCE = "machine-bridge://defaults/working-agreements";
12
- export const AUTOMATIC_PROJECT_CONTEXT_SOURCE = "machine-bridge://project-context/current";
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. Do not create tags, releases, or package publications merely because a version changed.
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
 
@@ -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
- export function normalizeErrorCode(value, fallback = "execution_failed") {
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
- chmodSync,
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 { run } from "./shell.mjs";
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 run("ssh", ["-F", nullConfig, "-G", "localhost"], {
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 run("gcloud", ["--version"], {
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 run("gcloud", ["help", "compute", "os-login", "ssh-keys", "add"], {
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 run("sudo", ["-n", "true"], {
122
+ : await runExecutable("sudo", ["-n", "true"], {
123
123
  capture: true,
124
124
  allowFailure: true,
125
125
  timeoutMs: 10_000,
@@ -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 { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, openSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
4
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
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 { readBoundedRegularFileSync, readBoundedRegularFileWithInfoSync } from "./secure-file.mjs";
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, publicResourceRegistry, validatePlan, validateResourceName } from "./managed-job-plan.mjs";
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 = localOperator ? "local-operator" : "mcp";
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 text = readBoundedFile(file, 1024).toString("utf8").trim();
603
- if (/^\d+$/.test(text)) owner = { pid: Number(text), startedAt: new Date(info.mtimeMs).toISOString() };
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
- if (existsSync(file)) {
784
- const info = lstatSync(file);
785
- if (info.isSymbolicLink() || !info.isFile()) throw new Error("runner diagnostic path must be a regular file and not a symbolic link");
786
- }
787
- const flags = Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND) | Number(fsConstants.O_NOFOLLOW || 0);
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 flags = Number(fsConstants.O_RDWR) | Number(fsConstants.O_NOFOLLOW || 0);
803
- fd = openSync(file, flags);
804
- const info = fstatSync(fd);
805
- if (!info.isFile() || info.size <= maxBytes) return;
806
- const length = Math.min(keepBytes, info.size);
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
- try { chmodSync(file, 0o600); } catch {}
820
- } catch {
804
+ } catch (error) {
805
+ if (error?.code !== "ENOENT") throw error;
821
806
  } finally {
822
- if (fd !== undefined) try { closeSync(fd); } catch {}
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;