machine-bridge-mcp 0.15.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.
- package/CHANGELOG.md +20 -0
- package/README.md +12 -2
- package/SECURITY.md +7 -0
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +5 -3
- package/docs/AUDIT.md +12 -1
- package/docs/LOGGING.md +7 -1
- package/docs/OPERATIONS.md +9 -2
- package/docs/POLICY_REFERENCE.md +88 -0
- package/docs/TESTING.md +7 -0
- package/package.json +14 -3
- package/scripts/coverage-check.mjs +108 -0
- package/scripts/generate-policy-reference.mjs +95 -0
- package/src/local/agent-context.mjs +1 -103
- package/src/local/app-automation.mjs +7 -9
- package/src/local/browser-bridge.mjs +26 -156
- package/src/local/browser-extension-protocol.mjs +75 -0
- package/src/local/browser-pairing-store.mjs +83 -0
- package/src/local/call-registry.mjs +113 -0
- package/src/local/capability-ranking.mjs +103 -0
- package/src/local/cli-local-admin.mjs +308 -0
- package/src/local/cli-options.mjs +151 -0
- package/src/local/cli-policy.mjs +77 -0
- package/src/local/cli.mjs +16 -521
- package/src/local/errors.mjs +122 -0
- package/src/local/git-service.mjs +88 -0
- package/src/local/lifecycle.mjs +64 -0
- package/src/local/log.mjs +50 -19
- package/src/local/managed-job-plan.mjs +235 -0
- package/src/local/managed-jobs.mjs +16 -220
- package/src/local/observability.mjs +83 -0
- package/src/local/policy.mjs +148 -0
- package/src/local/process-execution.mjs +153 -0
- package/src/local/process-sessions.mjs +10 -20
- package/src/local/process-tracker.mjs +55 -0
- package/src/local/runtime.mjs +154 -672
- package/src/local/service.mjs +1 -0
- package/src/local/stdio.mjs +3 -11
- package/src/local/tool-executor.mjs +102 -0
- package/src/local/tools.mjs +21 -104
- package/src/local/workspace-file-service.mjs +451 -0
- package/src/shared/policy-contract.json +54 -0
- package/src/shared/tool-catalog.json +4 -4
- package/src/worker/index.ts +69 -524
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { normalizeLogLevel } from "./log.mjs";
|
|
2
|
+
|
|
3
|
+
const BOOLEAN_OPTIONS = new Set([
|
|
4
|
+
"help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
|
|
5
|
+
"daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials",
|
|
6
|
+
"printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
|
|
7
|
+
"yes", "keepWorker", "allowInsecurePermissions", "showPaths",
|
|
8
|
+
]);
|
|
9
|
+
const VALUE_OPTIONS = new Set([
|
|
10
|
+
"workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel", "logFormat",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const LOG_FORMATS = new Set(["text", "json"]);
|
|
14
|
+
|
|
15
|
+
const COMMAND_OPTIONS = {
|
|
16
|
+
start: new Set([
|
|
17
|
+
"workspace", "stateDir", "workerName", "quiet", "json", "verbose", "logLevel", "logFormat", "rotateSecrets", "forceWorker",
|
|
18
|
+
"daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials", "printCredentials",
|
|
19
|
+
"profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
|
|
20
|
+
]),
|
|
21
|
+
stdio: new Set(["workspace", "stateDir", "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths", "verbose", "quiet", "logLevel", "logFormat"]),
|
|
22
|
+
"client-config": new Set(["workspace", "stateDir", "profile", "client", "json"]),
|
|
23
|
+
status: new Set(["workspace", "stateDir"]),
|
|
24
|
+
doctor: new Set(["workspace", "stateDir"]),
|
|
25
|
+
"full-test": new Set(["workspace", "stateDir", "json"]),
|
|
26
|
+
"rotate-secrets": new Set(["workspace", "stateDir", "workerName", "noPrintCredentials", "printMcpCredentials", "printCredentials", "quiet"]),
|
|
27
|
+
workspace: new Set(["workspace", "stateDir"]),
|
|
28
|
+
service: new Set(["workspace", "stateDir", "quiet"]),
|
|
29
|
+
autostart: new Set(["workspace", "stateDir", "quiet"]),
|
|
30
|
+
resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "showPaths", "json"]),
|
|
31
|
+
browser: new Set(["workspace", "stateDir", "json"]),
|
|
32
|
+
job: new Set(["workspace", "stateDir", "json", "yes"]),
|
|
33
|
+
uninstall: new Set(["stateDir", "keepWorker", "yes"]),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const SINGLE_WORKSPACE_POSITIONAL_COMMANDS = new Set(["start", "stdio", "status", "doctor", "full-test", "rotate-secrets"]);
|
|
37
|
+
const STATIC_POSITIONAL_RULES = Object.freeze({
|
|
38
|
+
"client-config": Object.freeze({ max: 1, tooMany: "client-config accepts at most one positional client name" }),
|
|
39
|
+
uninstall: Object.freeze({ max: 0, tooMany: "uninstall does not accept positional arguments" }),
|
|
40
|
+
});
|
|
41
|
+
const RESOURCE_POSITIONAL_LIMITS = Object.freeze({ add: 3, "generate-ssh-key": 3, remove: 2, check: 2 });
|
|
42
|
+
const JOB_POSITIONAL_LIMITS = Object.freeze({ read: 2, inspect: 2, cancel: 2, approve: 2, submit: 2 });
|
|
43
|
+
const ACTION_POSITIONAL_RULES = Object.freeze({
|
|
44
|
+
workspace(args) {
|
|
45
|
+
const action = String(args._[0] || "show");
|
|
46
|
+
return { max: action === "set" || action === "select" ? 2 : 1, tooMany: `workspace ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
|
|
47
|
+
},
|
|
48
|
+
service(args) {
|
|
49
|
+
const action = String(args._[0] || "status");
|
|
50
|
+
return { max: ["install", "status", "stop", "uninstall", "remove"].includes(action) ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
|
|
51
|
+
},
|
|
52
|
+
autostart(args) { return ACTION_POSITIONAL_RULES.service(args); },
|
|
53
|
+
resource(args) {
|
|
54
|
+
const action = String(args._[0] || "list");
|
|
55
|
+
return { max: RESOURCE_POSITIONAL_LIMITS[action] ?? 1, tooMany: `resource ${action} received too many positional arguments` };
|
|
56
|
+
},
|
|
57
|
+
browser(args) {
|
|
58
|
+
const action = String(args._[0] || "status");
|
|
59
|
+
return { max: 1, tooMany: `browser ${action} received too many positional arguments` };
|
|
60
|
+
},
|
|
61
|
+
job(args) {
|
|
62
|
+
const action = String(args._[0] || "list");
|
|
63
|
+
return { max: JOB_POSITIONAL_LIMITS[action] ?? 1, tooMany: `job ${action} received too many positional arguments` };
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export function normalizeCommand(argv) {
|
|
68
|
+
if (!argv.length || argv[0].startsWith("--")) return ["start", argv];
|
|
69
|
+
return [argv[0], argv.slice(1)];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseArgs(argv) {
|
|
73
|
+
const out = { _: [] };
|
|
74
|
+
let positionalOnly = false;
|
|
75
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
76
|
+
const raw = argv[index];
|
|
77
|
+
if (positionalOnly || raw === "-" || !raw.startsWith("--")) {
|
|
78
|
+
out._.push(raw);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (raw === "--") {
|
|
82
|
+
positionalOnly = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const separator = raw.indexOf("=");
|
|
86
|
+
const rawKey = raw.slice(2, separator >= 0 ? separator : undefined);
|
|
87
|
+
if (!rawKey) throw new Error("invalid empty option");
|
|
88
|
+
const key = toCamel(rawKey);
|
|
89
|
+
if (!BOOLEAN_OPTIONS.has(key) && !VALUE_OPTIONS.has(key)) throw new Error(`Unknown option: --${rawKey}`);
|
|
90
|
+
if (Object.prototype.hasOwnProperty.call(out, key)) throw new Error(`Duplicate option: --${rawKey}`);
|
|
91
|
+
if (BOOLEAN_OPTIONS.has(key)) {
|
|
92
|
+
out[key] = separator >= 0 ? parseBooleanOption(raw.slice(separator + 1), rawKey) : true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const value = separator >= 0 ? raw.slice(separator + 1) : argv[++index];
|
|
96
|
+
if (value === undefined || value === "" || value.startsWith("--")) throw new Error(`Option --${rawKey} requires a value`);
|
|
97
|
+
out[key] = value;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function validateCommandOptions(command, args) {
|
|
103
|
+
const allowed = COMMAND_OPTIONS[command];
|
|
104
|
+
if (!allowed) return;
|
|
105
|
+
for (const key of Object.keys(args)) {
|
|
106
|
+
if (key === "_" || key === "help" || key === "version") continue;
|
|
107
|
+
if (!allowed.has(key)) throw new Error(`Option --${toKebab(key)} is not valid for ${command}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function validateLoggingOptions(args = {}) {
|
|
112
|
+
if (args.quiet && args.verbose) throw new Error("--quiet and --verbose cannot be used together");
|
|
113
|
+
if (args.logLevel !== undefined && (args.quiet || args.verbose)) throw new Error("--log-level cannot be combined with --quiet or --verbose");
|
|
114
|
+
if (args.logLevel !== undefined) normalizeLogLevel(args.logLevel);
|
|
115
|
+
if (args.logFormat !== undefined && !LOG_FORMATS.has(String(args.logFormat).toLowerCase())) throw new Error("--log-format must be text or json");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function validatePositionals(command, args) {
|
|
119
|
+
const rule = positionalRule(command, args);
|
|
120
|
+
if (!rule) return;
|
|
121
|
+
if (args._.length > rule.max) throw new Error(rule.tooMany);
|
|
122
|
+
if (args.workspace && Number.isInteger(rule.workspaceConflictAfter) && args._.length > rule.workspaceConflictAfter) {
|
|
123
|
+
throw new Error("workspace path was provided both positionally and with --workspace");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function effectiveLogLevel(args = {}) {
|
|
128
|
+
if (args.logLevel !== undefined) return normalizeLogLevel(args.logLevel);
|
|
129
|
+
if (args.quiet) return "error";
|
|
130
|
+
if (args.verbose) return "debug";
|
|
131
|
+
return "info";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function effectiveLogFormat(args = {}) {
|
|
135
|
+
return String(args.logFormat || "text").toLowerCase();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function positionalRule(command, args) {
|
|
139
|
+
if (SINGLE_WORKSPACE_POSITIONAL_COMMANDS.has(command)) {
|
|
140
|
+
return { max: 1, tooMany: `${command} accepts at most one positional workspace path`, workspaceConflictAfter: 0 };
|
|
141
|
+
}
|
|
142
|
+
if (STATIC_POSITIONAL_RULES[command]) return STATIC_POSITIONAL_RULES[command];
|
|
143
|
+
return ACTION_POSITIONAL_RULES[command]?.(args) || null;
|
|
144
|
+
}
|
|
145
|
+
function parseBooleanOption(value, key) {
|
|
146
|
+
if (value === "true" || value === "1") return true;
|
|
147
|
+
if (value === "false" || value === "0") return false;
|
|
148
|
+
throw new Error(`Option --${key} expects true or false when using =`);
|
|
149
|
+
}
|
|
150
|
+
function toCamel(key) { return key.replace(/-([a-z])/g, (_, character) => character.toUpperCase()); }
|
|
151
|
+
function toKebab(value) { return String(value).replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`); }
|