machine-bridge-mcp 0.14.0 → 0.16.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 (50) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +18 -8
  3. package/SECURITY.md +10 -3
  4. package/browser-extension/browser-operations.js +515 -0
  5. package/browser-extension/devtools-input.js +17 -2
  6. package/browser-extension/manifest.json +2 -2
  7. package/browser-extension/page-automation.js +245 -60
  8. package/browser-extension/pairing.js +1 -1
  9. package/browser-extension/service-worker.js +151 -354
  10. package/docs/ARCHITECTURE.md +6 -4
  11. package/docs/AUDIT.md +24 -1
  12. package/docs/LOCAL_AUTOMATION.md +10 -10
  13. package/docs/LOGGING.md +7 -1
  14. package/docs/OPERATIONS.md +13 -5
  15. package/docs/POLICY_REFERENCE.md +88 -0
  16. package/docs/TESTING.md +9 -2
  17. package/package.json +16 -3
  18. package/scripts/coverage-check.mjs +108 -0
  19. package/scripts/generate-policy-reference.mjs +95 -0
  20. package/src/local/agent-context.mjs +1 -103
  21. package/src/local/app-automation.mjs +7 -9
  22. package/src/local/browser-bridge.mjs +69 -143
  23. package/src/local/browser-extension-protocol.mjs +75 -0
  24. package/src/local/browser-pairing-store.mjs +83 -0
  25. package/src/local/call-registry.mjs +113 -0
  26. package/src/local/capability-ranking.mjs +103 -0
  27. package/src/local/cli-local-admin.mjs +308 -0
  28. package/src/local/cli-options.mjs +151 -0
  29. package/src/local/cli-policy.mjs +77 -0
  30. package/src/local/cli.mjs +16 -507
  31. package/src/local/errors.mjs +122 -0
  32. package/src/local/git-service.mjs +88 -0
  33. package/src/local/lifecycle.mjs +64 -0
  34. package/src/local/log.mjs +50 -19
  35. package/src/local/managed-job-plan.mjs +235 -0
  36. package/src/local/managed-jobs.mjs +16 -220
  37. package/src/local/observability.mjs +83 -0
  38. package/src/local/policy.mjs +148 -0
  39. package/src/local/process-execution.mjs +153 -0
  40. package/src/local/process-sessions.mjs +10 -20
  41. package/src/local/process-tracker.mjs +55 -0
  42. package/src/local/runtime.mjs +154 -672
  43. package/src/local/service.mjs +8 -3
  44. package/src/local/stdio.mjs +3 -11
  45. package/src/local/tool-executor.mjs +102 -0
  46. package/src/local/tools.mjs +21 -104
  47. package/src/local/workspace-file-service.mjs +451 -0
  48. package/src/shared/policy-contract.json +54 -0
  49. package/src/shared/tool-catalog.json +11 -11
  50. package/src/worker/index.ts +69 -524
@@ -0,0 +1,83 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
6
+ import { readBoundedRegularFileSync } from "./secure-file.mjs";
7
+ import { assertStateMaintenanceAvailable, ownerOnlyFile } from "./state.mjs";
8
+ import { EXPECTED_EXTENSION_VERSION } from "./browser-extension-protocol.mjs";
9
+
10
+ export const DEFAULT_BROWSER_PORT = 39393;
11
+ const PAIRING_FILE = "browser-bridge.json";
12
+
13
+ export async function loadOrCreatePairing(stateRoot) {
14
+ if (!stateRoot) return { token: randomBytes(32).toString("base64url"), port: DEFAULT_BROWSER_PORT };
15
+ assertStateMaintenanceAvailable(stateRoot);
16
+ await mkdir(stateRoot, { recursive: true, mode: 0o700 });
17
+ const file = join(stateRoot, PAIRING_FILE);
18
+ for (let attempt = 0; attempt < 2; attempt += 1) {
19
+ if (existsSync(file)) {
20
+ ownerOnlyFile(file);
21
+ let parsed;
22
+ try { parsed = JSON.parse(readBoundedRegularFileSync(file, 64 * 1024).toString("utf8")); }
23
+ catch { throw new Error("browser pairing state is not valid bounded JSON"); }
24
+ if (!/^[A-Za-z0-9_-]{32,100}$/.test(parsed.token) || !Number.isInteger(parsed.port) || parsed.port < 1024 || parsed.port > 65535) {
25
+ throw new Error("browser pairing state is invalid");
26
+ }
27
+ return parsed;
28
+ }
29
+ const value = { token: randomBytes(32).toString("base64url"), port: DEFAULT_BROWSER_PORT };
30
+ try {
31
+ createExclusiveFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
32
+ ownerOnlyFile(file);
33
+ return value;
34
+ } catch (error) {
35
+ if (error?.code !== "EEXIST") throw error;
36
+ }
37
+ }
38
+ throw new Error("browser pairing state could not be initialized");
39
+ }
40
+
41
+ export async function savePairing(stateRoot, value) {
42
+ assertStateMaintenanceAvailable(stateRoot);
43
+ const file = join(stateRoot, PAIRING_FILE);
44
+ replaceFileAtomicallySync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
45
+ ownerOnlyFile(file);
46
+ }
47
+
48
+ export function pairingHtml(port, token) {
49
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="machine-bridge-browser-pair" content="1"><meta name="machine-bridge-browser-port" content="${port}"><meta name="machine-bridge-browser-token" content="${token}"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Machine Bridge browser pairing</title></head><body><h1>Machine Bridge browser pairing</h1><p>Expected extension build: <strong>${EXPECTED_EXTENSION_VERSION}</strong>. Reload the unpacked extension after every Machine Bridge upgrade.</p><p>The installed extension reads pairing material from this loopback-only page and stores it in browser-local extension storage. It is not sent to any website.</p><p id="status">Waiting for the Machine Bridge extension.</p></body></html>`;
50
+ }
51
+
52
+ export function isAllowedExtensionOrigin(origin) {
53
+ let parsed;
54
+ try { parsed = new URL(String(origin || "")); } catch { return false; }
55
+ return parsed.protocol === "chrome-extension:"
56
+ && /^[a-p]{32}$/.test(parsed.hostname)
57
+ && !parsed.username
58
+ && !parsed.password
59
+ && !parsed.port
60
+ && (parsed.pathname === "" || parsed.pathname === "/")
61
+ && !parsed.search
62
+ && !parsed.hash;
63
+ }
64
+
65
+ export function isAllowedLoopbackHost(host, port) {
66
+ const normalized = String(host || "").toLowerCase();
67
+ return normalized === `127.0.0.1:${port}` || normalized === `localhost:${port}` || normalized === `[::1]:${port}`;
68
+ }
69
+
70
+ export function securityHeaders(contentType) {
71
+ return {
72
+ "content-type": contentType,
73
+ "cache-control": "no-store",
74
+ "content-security-policy": "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
75
+ "x-content-type-options": "nosniff",
76
+ "referrer-policy": "no-referrer",
77
+ };
78
+ }
79
+
80
+ export function sendJson(response, value) {
81
+ response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
82
+ response.end(`${JSON.stringify(value)}\n`);
83
+ }
@@ -0,0 +1,113 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { BridgeError } from "./errors.mjs";
3
+
4
+ export class CallRegistry {
5
+ constructor(options = {}) {
6
+ this.maximum = positiveInteger(options.maximum, 16);
7
+ this.now = typeof options.now === "function" ? options.now : Date.now;
8
+ this.scheduler = options.scheduler || { setTimeout, clearTimeout };
9
+ this.onCancel = typeof options.onCancel === "function" ? options.onCancel : () => {};
10
+ this.onFinish = typeof options.onFinish === "function" ? options.onFinish : () => {};
11
+ this.calls = new Map();
12
+ }
13
+
14
+ open({ callId = "", tool = "", origin = "local", timeoutMs = 0 } = {}) {
15
+ const id = String(callId || `call_${randomBytes(16).toString("hex")}`);
16
+ if (this.calls.has(id)) throw new BridgeError("conflict", "duplicate in-flight call id");
17
+ if (this.calls.size >= this.maximum) throw new BridgeError("limit_exceeded", `too many concurrent tool calls (${this.maximum})`, { retryable: true });
18
+ const controller = new AbortController();
19
+ const startedAt = this.now();
20
+ const timeout = positiveInteger(timeoutMs, 0);
21
+ const record = {
22
+ id,
23
+ tool: String(tool || ""),
24
+ origin: String(origin || "local"),
25
+ startedAt,
26
+ deadlineAt: timeout ? startedAt + timeout : null,
27
+ controller,
28
+ cancelReason: "",
29
+ timer: null,
30
+ };
31
+ if (timeout) {
32
+ record.timer = this.scheduler.setTimeout(() => this.cancel(id, "deadline exceeded", "timeout"), timeout);
33
+ record.timer?.unref?.();
34
+ }
35
+ this.calls.set(id, record);
36
+ return Object.freeze({
37
+ callId: id,
38
+ tool: record.tool,
39
+ origin: record.origin,
40
+ startedAt,
41
+ deadlineAt: record.deadlineAt,
42
+ signal: controller.signal,
43
+ });
44
+ }
45
+
46
+ cancel(callId, reason = "cancelled", code = "cancelled") {
47
+ const record = this.calls.get(String(callId || ""));
48
+ if (!record || record.controller.signal.aborted) return false;
49
+ record.cancelReason = String(reason || "cancelled").slice(0, 256);
50
+ record.controller.abort(new BridgeError(code, code === "timeout" ? "tool call timed out" : "tool call cancelled"));
51
+ this.onCancel(record);
52
+ return true;
53
+ }
54
+
55
+ finish(callId) {
56
+ const id = String(callId || "");
57
+ const record = this.calls.get(id);
58
+ if (!record) return false;
59
+ if (record.timer) this.scheduler.clearTimeout(record.timer);
60
+ this.calls.delete(id);
61
+ this.onFinish(record);
62
+ return true;
63
+ }
64
+
65
+ cancelAll(reason = "runtime stopped") {
66
+ for (const id of [...this.calls.keys()]) {
67
+ this.cancel(id, reason);
68
+ this.finish(id);
69
+ }
70
+ }
71
+
72
+ context(callId) {
73
+ const record = this.calls.get(String(callId || ""));
74
+ if (!record) return null;
75
+ return {
76
+ callId: record.id,
77
+ tool: record.tool,
78
+ origin: record.origin,
79
+ startedAt: record.startedAt,
80
+ deadlineAt: record.deadlineAt,
81
+ signal: record.controller.signal,
82
+ };
83
+ }
84
+
85
+ throwIfCancelled(context = {}) {
86
+ const signal = context.signal || this.calls.get(String(context.callId || ""))?.controller.signal;
87
+ if (!signal?.aborted) return;
88
+ const reason = signal.reason;
89
+ if (reason instanceof Error) throw reason;
90
+ throw new BridgeError("cancelled", "tool call cancelled");
91
+ }
92
+
93
+ snapshot() {
94
+ const now = this.now();
95
+ const byOrigin = {};
96
+ let oldestMs = 0;
97
+ for (const call of this.calls.values()) {
98
+ byOrigin[call.origin] = (byOrigin[call.origin] || 0) + 1;
99
+ oldestMs = Math.max(oldestMs, now - call.startedAt);
100
+ }
101
+ return {
102
+ active: this.calls.size,
103
+ maximum: this.maximum,
104
+ by_origin: byOrigin,
105
+ oldest_ms: oldestMs,
106
+ };
107
+ }
108
+ }
109
+
110
+ function positiveInteger(value, fallback) {
111
+ const number = Number(value);
112
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
113
+ }
@@ -0,0 +1,103 @@
1
+ const TOKEN_STOP_WORDS = new Set(["the", "and", "for", "with", "from", "this", "that", "use", "using", "into", "of", "to", "a", "an", "in", "on", "is", "are", "or", "及", "和", "的", "了", "在", "用", "使用", "进行", "根据"]);
2
+ const ENGLISH_TOKEN_CANONICAL = new Map([
3
+ ["created", "create"], ["creating", "create"], ["creation", "create"], ["creator", "create"],
4
+ ["improved", "improve"], ["improving", "improve"], ["improvement", "improve"],
5
+ ["installed", "install"], ["installing", "install"], ["installation", "install"], ["installer", "install"],
6
+ ["searched", "search"], ["searching", "search"], ["finder", "find"],
7
+ ["documentation", "docs"], ["document", "docs"], ["documents", "docs"],
8
+ ["validated", "verify"], ["validate", "verify"], ["validation", "verify"], ["verification", "verify"],
9
+ ["factcheck", "verify"], ["fact-check", "verify"], ["testing", "test"], ["tests", "test"],
10
+ ["building", "build"], ["built", "build"], ["deployment", "deploy"], ["deployed", "deploy"],
11
+ ]);
12
+ const HAN_TOKEN_ALIASES = Object.freeze([
13
+ [/创建|新建|编写/u, ["create", "creator"]],
14
+ [/改进|优化|更新|维护/u, ["improve", "update"]],
15
+ [/技能/u, ["skill"]],
16
+ [/安装/u, ["install", "installer"]],
17
+ [/部署/u, ["deploy", "deployment"]],
18
+ [/查找|搜索|检索/u, ["search", "find"]],
19
+ [/最新|当前/u, ["latest", "current"]],
20
+ [/官方/u, ["official"]],
21
+ [/文档|资料/u, ["docs", "documentation"]],
22
+ [/事实核查|核查|验证|校验/u, ["verify", "factcheck"]],
23
+ [/测试/u, ["test"]],
24
+ [/前端/u, ["frontend"]],
25
+ [/设计/u, ["design"]],
26
+ [/邮件|邮箱/u, ["email"]],
27
+ [/浏览器|网页|网站/u, ["browser", "web"]],
28
+ [/性能/u, ["performance"]],
29
+ [/安全|漏洞/u, ["security", "audit"]],
30
+ ]);
31
+
32
+ export function commandMatchText(command) {
33
+ return `${command.name} ${command.description} ${command.argv.join(" ")} ${command.searchTerms || ""}`;
34
+ }
35
+
36
+ export function relevanceScore(task, candidate, identity = "") {
37
+ const taskTokens = tokenize(task);
38
+ const candidateTokens = tokenize(candidate);
39
+ if (!taskTokens.size || !candidateTokens.size) return 0;
40
+ const identityTokens = tokenize(identity);
41
+ let score = 0;
42
+ for (const token of taskTokens) {
43
+ if (candidateTokens.has(token)) score += token.length >= 6 ? 2 : 1;
44
+ if (identityTokens.has(token)) score += token.length >= 6 ? 4 : 3;
45
+ }
46
+ const taskComparable = comparableText(task);
47
+ const candidateComparable = comparableText(candidate);
48
+ const identityComparable = comparableText(identity);
49
+ if (candidateComparable.includes(taskComparable) || taskComparable.includes(candidateComparable)) score += 4;
50
+ if (identityComparable.length >= 3 && ` ${taskComparable} `.includes(` ${identityComparable} `)) score += 12;
51
+ return score;
52
+ }
53
+
54
+ export function recommendTools(task, { commandsAvailable, commandRelevant, skillRelevant }) {
55
+ const lower = String(task || "").toLowerCase();
56
+ const tools = ["agent_context"];
57
+ if (skillRelevant) tools.push("load_local_skill");
58
+ if (commandRelevant) tools.push("run_local_command");
59
+ if (/browser|chrome|edge|brave|网页|浏览器|表单|网站/.test(lower)) tools.push("browser_status", "browser_list_tabs", "browser_manage_tabs", "browser_inspect_page", "browser_wait", "browser_action", "browser_fill_form");
60
+ if (/app|application|gui|window|应用|软件|窗口|界面/.test(lower)) tools.push("list_local_applications", "inspect_local_application", "operate_local_application");
61
+ if (/git|commit|branch|diff|仓库|提交|分支/.test(lower)) tools.push("git_status", "git_diff");
62
+ if (/test|build|lint|command|terminal|测试|构建|命令|终端/.test(lower)) tools.push(commandsAvailable ? "run_local_command" : "run_process");
63
+ if (/file|code|source|edit|write|文件|代码|源码|修改|写入/.test(lower)) tools.push("read_file", "search_text", "edit_file", "apply_patch");
64
+ return [...new Set(tools)];
65
+ }
66
+
67
+ export function tokenize(value) {
68
+ const text = String(value || "").toLowerCase();
69
+ const tokens = new Set();
70
+ for (const raw of text.match(/[a-z0-9_][a-z0-9_.-]{1,}/g) || []) {
71
+ const token = raw.replace(/^[.-]+|[.-]+$/g, "");
72
+ addToken(tokens, token);
73
+ for (const part of token.split(/[._-]+/)) addToken(tokens, part);
74
+ }
75
+ for (const sequence of text.match(/[\p{Script=Han}]{1,}/gu) || []) {
76
+ addToken(tokens, sequence);
77
+ const minimumSize = sequence.length === 1 ? 1 : 2;
78
+ for (let size = minimumSize; size <= Math.min(3, sequence.length); size += 1) {
79
+ for (let index = 0; index + size <= sequence.length; index += 1) addToken(tokens, sequence.slice(index, index + size));
80
+ }
81
+ }
82
+ for (const [pattern, aliases] of HAN_TOKEN_ALIASES) {
83
+ if (!pattern.test(text)) continue;
84
+ for (const alias of aliases) addToken(tokens, alias);
85
+ }
86
+ return tokens;
87
+ }
88
+
89
+ function comparableText(value) {
90
+ return String(value || "")
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9\p{Script=Han}]+/gu, " ")
93
+ .trim()
94
+ .replace(/\s+/g, " ");
95
+ }
96
+
97
+ function addToken(tokens, raw) {
98
+ const token = String(raw || "").replace(/^[.-]+|[.-]+$/g, "");
99
+ if (token.length < 2 || TOKEN_STOP_WORDS.has(token)) return;
100
+ tokens.add(token);
101
+ const canonical = ENGLISH_TOKEN_CANONICAL.get(token);
102
+ if (canonical) tokens.add(canonical);
103
+ }
@@ -0,0 +1,308 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import process from "node:process";
5
+ import { createLogger } from "./log.mjs";
6
+ import { inspectResourceFile, loadManagedJobPlan, ManagedJobManager, publicResourceRegistry, validateResourceName } from "./managed-jobs.mjs";
7
+ import { generateRegisteredSshKey } from "./resource-operations.mjs";
8
+ import { readBoundedRegularFileSync } from "./secure-file.mjs";
9
+ import {
10
+ acquireStartupLockWithWait, expandHome, loadState, ownerOnlyFile, packageRoot, saveState,
11
+ } from "./state.mjs";
12
+ import { resolvePolicy } from "./cli-policy.mjs";
13
+
14
+ export function createLocalAdminCommands(dependencies) {
15
+ const chooseWorkspace = dependencies.chooseWorkspace;
16
+ const confirm = dependencies.confirm;
17
+ if (typeof chooseWorkspace !== "function" || typeof confirm !== "function") {
18
+ throw new TypeError("local admin commands require chooseWorkspace and confirm dependencies");
19
+ }
20
+ const context = Object.freeze({ chooseWorkspace, confirm });
21
+ return Object.freeze({
22
+ resourceCommand: (args) => resourceCommand(args, context),
23
+ browserCommand: (args) => browserCommand(args, context),
24
+ jobCommand: (args) => jobCommand(args, context),
25
+ });
26
+ }
27
+
28
+ const RESOURCE_ACTION_HANDLERS = Object.freeze({
29
+ list: resourceListAction,
30
+ add: resourceAddAction,
31
+ "generate-ssh-key": resourceGenerateSshKeyAction,
32
+ remove: resourceRemoveAction,
33
+ check: resourceCheckAction,
34
+ });
35
+
36
+ async function resourceCommand(args, { chooseWorkspace }) {
37
+ const action = String(args._[0] || "list").toLowerCase();
38
+ const handler = RESOURCE_ACTION_HANDLERS[action];
39
+ if (!handler) throw new Error(`Unknown resource action: ${action}`);
40
+ const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
41
+ const state = loadState(workspace, { stateDir: args.stateDir });
42
+ state.resources ||= {};
43
+ return handler({ args, workspace, state });
44
+ }
45
+
46
+ function resourceListAction({ args, workspace, state }) {
47
+ const includePaths = args.showPaths === true;
48
+ const resources = publicResourceRegistry(state.resources, { includePaths });
49
+ if (args.json) {
50
+ console.log(JSON.stringify({
51
+ workspace: includePaths ? workspace : "<local-workspace>",
52
+ paths_exposed: includePaths,
53
+ resources,
54
+ }, null, 2));
55
+ return;
56
+ }
57
+ if (!Object.keys(resources).length) {
58
+ console.log("No local resources registered.");
59
+ return;
60
+ }
61
+ for (const [name, value] of Object.entries(resources)) {
62
+ const fields = [name, value.mode || "n/a", `${value.size ?? "n/a"} bytes`];
63
+ if (includePaths) fields.splice(1, 0, value.path);
64
+ console.log(fields.join(" "));
65
+ }
66
+ }
67
+
68
+ async function resourceAddAction({ args, workspace, state }) {
69
+ const name = validateResourceName(args._[1]);
70
+ const inputPath = args._[2];
71
+ if (!inputPath) throw new Error("resource add requires NAME and FILE_PATH");
72
+ const lock = await acquireStartupLockWithWait(state, { operation: "resource-add" });
73
+ try {
74
+ const latest = loadState(workspace, { stateDir: args.stateDir });
75
+ latest.resources ||= {};
76
+ const inspected = inspectResourceFile(expandHome(inputPath), { allowInsecurePermissions: args.allowInsecurePermissions === true });
77
+ if (!Object.prototype.hasOwnProperty.call(latest.resources, name) && Object.keys(latest.resources).length >= 64) {
78
+ throw new Error("local resource registry limit reached (64)");
79
+ }
80
+ latest.resources[name] = inspected;
81
+ saveState(latest);
82
+ const result = publicResourceInspection(name, inspected, {
83
+ includePath: args.showPaths === true,
84
+ available_to_new_jobs_immediately: true,
85
+ });
86
+ if (args.json) console.log(JSON.stringify(result, null, 2));
87
+ else {
88
+ console.log(`Registered local resource: ${name}`);
89
+ if (args.showPaths === true) console.log(`Path: ${inspected.path}`);
90
+ console.log(`Mode: ${inspected.mode || "n/a"}; size: ${inspected.size} bytes`);
91
+ console.log("The resource is available to newly submitted managed jobs immediately.");
92
+ }
93
+ } finally {
94
+ lock.release();
95
+ }
96
+ }
97
+
98
+ async function resourceGenerateSshKeyAction({ args, workspace }) {
99
+ const name = validateResourceName(args._[1]);
100
+ const home = process.env.HOME || process.env.USERPROFILE;
101
+ if (!home) throw new Error("HOME or USERPROFILE is required to choose a default SSH key path");
102
+ const requestedPath = args._[2] ? expandHome(args._[2]) : join(home, ".ssh", `machine-mcp-${name}-ed25519`);
103
+ const key = await generateRegisteredSshKey({
104
+ workspace,
105
+ stateDir: args.stateDir,
106
+ name,
107
+ targetPath: requestedPath,
108
+ comment: `machine-mcp:${name}`,
109
+ });
110
+ const includePaths = args.showPaths === true;
111
+ const result = {
112
+ name: key.name,
113
+ created: key.created,
114
+ fingerprint: key.fingerprint,
115
+ key_type: key.keyType,
116
+ private_mode: key.privateMode,
117
+ public_mode: key.publicMode,
118
+ private_key_content_exposed: key.privateKeyContentExposed,
119
+ registered: key.registered,
120
+ available_to_new_jobs_immediately: key.availableToNewJobsImmediately,
121
+ paths_exposed: includePaths,
122
+ ...(includePaths ? { private_key_path: key.privateKeyPath, public_key_path: key.publicKeyPath } : {}),
123
+ };
124
+ if (args.json) console.log(JSON.stringify(result, null, 2));
125
+ else {
126
+ console.log(`${key.created ? "Generated and registered" : "Reused and registered"} SSH key resource: ${name}`);
127
+ if (includePaths) {
128
+ console.log(`Private key: ${key.privateKeyPath}`);
129
+ console.log(`Public key: ${key.publicKeyPath}`);
130
+ }
131
+ console.log(`Fingerprint: ${key.fingerprint}`);
132
+ console.log("Private key content was not printed or sent through MCP.");
133
+ }
134
+ }
135
+
136
+ async function resourceRemoveAction({ args, workspace, state }) {
137
+ const name = validateResourceName(args._[1]);
138
+ const lock = await acquireStartupLockWithWait(state, { operation: "resource-remove" });
139
+ try {
140
+ const latest = loadState(workspace, { stateDir: args.stateDir });
141
+ latest.resources ||= {};
142
+ const existed = Object.prototype.hasOwnProperty.call(latest.resources, name);
143
+ delete latest.resources[name];
144
+ saveState(latest);
145
+ const result = { name, removed: existed, affects_new_jobs_immediately: true };
146
+ if (args.json) console.log(JSON.stringify(result, null, 2));
147
+ else {
148
+ console.log(existed ? `Removed local resource: ${name}` : `Local resource was not registered: ${name}`);
149
+ console.log("The change applies to newly submitted managed jobs immediately.");
150
+ }
151
+ } finally {
152
+ lock.release();
153
+ }
154
+ }
155
+
156
+ function resourceCheckAction({ args, state }) {
157
+ const name = validateResourceName(args._[1]);
158
+ const resource = state.resources[name];
159
+ if (!resource) throw new Error(`local resource is not registered: ${name}`);
160
+ const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
161
+ const result = publicResourceInspection(name, inspected, { includePath: args.showPaths === true });
162
+ if (args.json) console.log(JSON.stringify(result, null, 2));
163
+ else {
164
+ const pathDetail = args.showPaths === true ? ` at ${inspected.path}` : "";
165
+ console.log(`${name}: available${pathDetail} (${inspected.mode || "n/a"}, ${inspected.size} bytes)`);
166
+ }
167
+ }
168
+
169
+ function publicResourceInspection(name, inspected, { includePath = false, ...extra } = {}) {
170
+ return {
171
+ name,
172
+ kind: inspected.kind,
173
+ size: inspected.size ?? null,
174
+ mode: inspected.mode ?? null,
175
+ updated_at: inspected.updatedAt ?? null,
176
+ allow_insecure_permissions: inspected.allowInsecurePermissions === true,
177
+ ...extra,
178
+ paths_exposed: includePath,
179
+ contents_exposed: false,
180
+ ...(includePath ? { path: inspected.path } : {}),
181
+ };
182
+ }
183
+
184
+ async function browserCommand(args, { chooseWorkspace }) {
185
+ const action = String(args._[0] || "status").toLowerCase();
186
+ const extensionPath = resolve(packageRoot, "browser-extension");
187
+ if (action === "path") {
188
+ if (args.json) console.log(JSON.stringify({ extension_path: extensionPath }, null, 2));
189
+ else console.log(extensionPath);
190
+ return;
191
+ }
192
+ if (!["status", "setup", "pair"].includes(action)) throw new Error(`Unknown browser action: ${action}`);
193
+ const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
194
+ const state = loadState(workspace, { stateDir: args.stateDir });
195
+ const pairingFile = join(state.paths.stateRoot, "browser-bridge.json");
196
+ if (!existsSync(pairingFile)) {
197
+ throw new Error("browser bridge is not initialized; start machine-mcp once, then run this command again");
198
+ }
199
+ ownerOnlyFile(pairingFile);
200
+ let pairing;
201
+ try {
202
+ pairing = JSON.parse(readBoundedRegularFileSync(pairingFile, 64 * 1024).toString("utf8"));
203
+ } catch {
204
+ throw new Error("browser bridge state is invalid; restart machine-mcp to repair it");
205
+ }
206
+ const port = Number(pairing.port);
207
+ if (!/^[A-Za-z0-9_-]{32,100}$/.test(String(pairing.token || ""))) throw new Error("browser bridge state contains an invalid token");
208
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("browser bridge state contains an invalid port");
209
+ const pairingUrl = `http://127.0.0.1:${port}/pair`;
210
+ const healthUrl = `http://127.0.0.1:${port}/healthz`;
211
+ const health = await fetch(healthUrl, { signal: AbortSignal.timeout(2000), cache: "no-store" })
212
+ .then(async (response) => response.ok ? await response.json() : null)
213
+ .catch(() => null);
214
+ const result = {
215
+ running: health?.ok === true && health?.broker === "machine-bridge-browser",
216
+ connected: health?.broker === "machine-bridge-browser" && health?.connected === true,
217
+ extension_path: extensionPath,
218
+ pairing_url: pairingUrl,
219
+ expected_extension_version: typeof health?.expected_extension_version === "string" ? health.expected_extension_version : "",
220
+ extension_protocol: Number.isInteger(health?.extension_protocol) ? health.extension_protocol : null,
221
+ extension_version: typeof health?.extension_version === "string" ? health.extension_version : "",
222
+ extension_capabilities: Array.isArray(health?.extension_capabilities) ? health.extension_capabilities : [],
223
+ extension_reload_required: health?.extension_reload_required === true,
224
+ controls_existing_profile: health?.controls_existing_profile === true,
225
+ controls_extension_profile: health?.controls_extension_profile === true,
226
+ machine_bridge_launches_browser: health?.machine_bridge_launches_browser === true,
227
+ profile_identity_verifiable: health?.profile_identity_verifiable === true,
228
+ token_exposed: false,
229
+ };
230
+ if (action === "status") {
231
+ if (args.json) console.log(JSON.stringify(result, null, 2));
232
+ else {
233
+ console.log(`Browser bridge: ${result.running ? "running" : "not reachable"}`);
234
+ console.log(`Extension: ${result.connected ? "connected" : result.extension_reload_required ? "reload required" : "not connected"}`);
235
+ if (result.expected_extension_version) console.log(`Expected extension build: ${result.expected_extension_version}`);
236
+ if (result.extension_version || result.extension_protocol) console.log(`Connected extension build: ${result.extension_version || "unknown"} (protocol ${result.extension_protocol ?? "unknown"})`);
237
+ console.log(`Browser profile: ${result.controls_extension_profile ? "the Chromium profile where this extension is installed" : "unknown"}`);
238
+ if (result.controls_extension_profile) console.log(`Profile provenance: Machine Bridge did not launch the browser; daily-vs-isolated profile identity is not machine-verifiable.`);
239
+ console.log(`Extension path: ${extensionPath}`);
240
+ }
241
+ return;
242
+ }
243
+ if (!result.running) throw new Error("browser bridge is not reachable; keep machine-mcp running and retry");
244
+ await openExternal(pairingUrl);
245
+ if (args.json) console.log(JSON.stringify({ ...result, pairing_page_opened: true }, null, 2));
246
+ else {
247
+ console.log(`Extension path: ${extensionPath}`);
248
+ console.log("Load this directory in the Chromium profile you use every day; Machine Bridge does not install it into Playwright or a separate automation profile.");
249
+ console.log("Enable Developer mode, choose Load unpacked, and reload the extension after each Machine Bridge upgrade.");
250
+ console.log(`Pairing page opened: ${pairingUrl}`);
251
+ }
252
+ }
253
+
254
+ function openExternal(target) {
255
+ const command = process.platform === "darwin"
256
+ ? { file: "open", args: [target] }
257
+ : process.platform === "win32"
258
+ ? { file: "cmd.exe", args: ["/d", "/s", "/c", "start", "", target] }
259
+ : { file: "xdg-open", args: [target] };
260
+ return new Promise((resolvePromise, rejectPromise) => {
261
+ const child = spawn(command.file, command.args, { detached: true, stdio: "ignore", windowsHide: true });
262
+ child.once("spawn", () => {
263
+ child.unref();
264
+ resolvePromise();
265
+ });
266
+ child.once("error", rejectPromise);
267
+ });
268
+ }
269
+
270
+ async function jobCommand(args, { chooseWorkspace, confirm }) {
271
+ const action = String(args._[0] || "list").toLowerCase();
272
+ const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
273
+ const state = loadState(workspace, { stateDir: args.stateDir });
274
+ const manager = new ManagedJobManager({
275
+ jobRoot: join(state.paths.profileDir, "jobs"),
276
+ workspace,
277
+ policy: resolvePolicy({}, state.policy),
278
+ resources: state.resources,
279
+ resourceStatePath: state.paths.statePath,
280
+ stateRoot: state.paths.stateRoot,
281
+ logger: createLogger({ level: "warn", component: "job" }),
282
+ });
283
+ let result;
284
+ if (action === "list") result = manager.list({ limit: 50 });
285
+ else if (action === "read") result = manager.read({ job_id: args._[1] });
286
+ else if (action === "inspect") result = manager.inspectLocal({ job_id: args._[1] });
287
+ else if (action === "cancel") result = manager.cancel({ job_id: args._[1] });
288
+ else if (action === "approve") {
289
+ if (args.json && !args.yes) throw new Error("job approve --json requires --yes");
290
+ const inspection = manager.inspectLocal({ job_id: args._[1] });
291
+ if (!args.yes) {
292
+ console.log(JSON.stringify(inspection, null, 2));
293
+ const approved = await confirm(`Approve and execute managed job ${args._[1]}?`, false);
294
+ if (!approved) {
295
+ console.log("Managed job approval cancelled. Re-run with --yes after review to skip confirmation.");
296
+ return;
297
+ }
298
+ }
299
+ result = manager.approve({ job_id: args._[1] }, { localOperator: true });
300
+ }
301
+ else if (action === "submit") {
302
+ const planPath = args._[1];
303
+ if (!planPath) throw new Error("job submit requires a JSON plan file");
304
+ const plan = loadManagedJobPlan(expandHome(planPath));
305
+ result = manager.start(plan);
306
+ } else throw new Error(`Unknown job action: ${action}`);
307
+ console.log(JSON.stringify(result, null, 2));
308
+ }