machine-bridge-mcp 0.4.2 → 0.6.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 +51 -0
- package/README.md +110 -14
- package/SECURITY.md +38 -6
- package/docs/ARCHITECTURE.md +50 -15
- package/docs/CLIENTS.md +18 -1
- package/docs/LOGGING.md +82 -0
- package/docs/MANAGED_JOBS.md +274 -0
- package/docs/OPERATIONS.md +80 -23
- package/docs/TESTING.md +13 -5
- package/package.json +8 -7
- package/src/local/cli.mjs +345 -80
- package/src/local/daemon.mjs +159 -42
- package/src/local/job-runner.mjs +470 -0
- package/src/local/log.mjs +56 -15
- package/src/local/managed-jobs.mjs +854 -0
- package/src/local/service.mjs +40 -21
- package/src/local/state.mjs +94 -26
- package/src/local/stdio.mjs +14 -38
- package/src/local/tools.mjs +28 -13
- package/src/shared/server-metadata.json +24 -0
- package/src/shared/tool-catalog.json +500 -2
- package/src/worker/index.ts +13 -11
package/src/local/service.mjs
CHANGED
|
@@ -6,6 +6,15 @@ import { ensureOwnerOnlyDir, expandHome } from "./state.mjs";
|
|
|
6
6
|
|
|
7
7
|
const LABEL = "dev.machine-bridge-mcp.daemon";
|
|
8
8
|
const WINDOWS_TASK = "MachineBridgeMCP";
|
|
9
|
+
const SERVICE_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
|
10
|
+
|
|
11
|
+
function serviceRun(command, args) {
|
|
12
|
+
return run(command, args, {
|
|
13
|
+
capture: true,
|
|
14
|
+
allowFailure: true,
|
|
15
|
+
maxOutputBytes: SERVICE_COMMAND_OUTPUT_BYTES,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
9
18
|
|
|
10
19
|
export async function installAutostart({ workspace, stateRoot, entryScript, logger = console }) {
|
|
11
20
|
const spec = serviceSpec({ workspace, stateRoot, entryScript });
|
|
@@ -28,14 +37,14 @@ export async function autostartStatus({ logger = console } = {}) {
|
|
|
28
37
|
|
|
29
38
|
export async function startAutostart({ logger = console } = {}) {
|
|
30
39
|
if (process.platform === "darwin") return startLaunchd(logger);
|
|
31
|
-
if (process.platform === "win32") return
|
|
32
|
-
return
|
|
40
|
+
if (process.platform === "win32") return serviceRun("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
|
|
41
|
+
return serviceRun("systemctl", ["--user", "start", "machine-bridge-mcp.service"]);
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export async function stopAutostart({ logger = console } = {}) {
|
|
36
45
|
if (process.platform === "darwin") return stopLaunchd(logger);
|
|
37
|
-
if (process.platform === "win32") return
|
|
38
|
-
return
|
|
46
|
+
if (process.platform === "win32") return serviceRun("schtasks", ["/End", "/TN", WINDOWS_TASK]);
|
|
47
|
+
return serviceRun("systemctl", ["--user", "stop", "machine-bridge-mcp.service"]);
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
|
|
@@ -50,21 +59,31 @@ export function trimAutostartLogs(stateRoot, options = {}) {
|
|
|
50
59
|
const info = statSync(file);
|
|
51
60
|
if (info.size > maxBytes) {
|
|
52
61
|
const fd = openSync(file, "r");
|
|
62
|
+
let buffer;
|
|
53
63
|
try {
|
|
54
64
|
const current = fstatSync(fd);
|
|
55
65
|
const length = Math.min(keepBytes, current.size);
|
|
56
|
-
|
|
66
|
+
buffer = Buffer.alloc(length);
|
|
57
67
|
readSync(fd, buffer, 0, length, Math.max(0, current.size - length));
|
|
58
|
-
writeFileSync(file, buffer, { mode: 0o600 });
|
|
59
68
|
} finally {
|
|
60
69
|
closeSync(fd);
|
|
61
70
|
}
|
|
71
|
+
writeFileSync(file, lineSafeTail(buffer), { mode: 0o600 });
|
|
62
72
|
}
|
|
63
73
|
chmodSync(file, 0o600);
|
|
64
74
|
} catch {}
|
|
65
75
|
}
|
|
66
76
|
}
|
|
67
77
|
|
|
78
|
+
function lineSafeTail(buffer) {
|
|
79
|
+
if (!Buffer.isBuffer(buffer) || buffer.length === 0) return Buffer.alloc(0);
|
|
80
|
+
let text = buffer.toString("utf8");
|
|
81
|
+
const firstNewline = text.indexOf("\n");
|
|
82
|
+
if (firstNewline >= 0 && firstNewline < text.length - 1) text = text.slice(firstNewline + 1);
|
|
83
|
+
else text = text.replace(/^\uFFFD+/, "");
|
|
84
|
+
return Buffer.from(text, "utf8");
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
function serviceSpec({ workspace, stateRoot, entryScript }) {
|
|
69
88
|
const root = expandHome(stateRoot);
|
|
70
89
|
const logs = path.join(root, "logs");
|
|
@@ -92,7 +111,7 @@ export function daemonArgs(spec) {
|
|
|
92
111
|
"--workspace", spec.workspace,
|
|
93
112
|
"--state-dir", spec.stateRoot,
|
|
94
113
|
"--no-print-credentials",
|
|
95
|
-
"--
|
|
114
|
+
"--log-level", "warn",
|
|
96
115
|
];
|
|
97
116
|
}
|
|
98
117
|
|
|
@@ -113,9 +132,9 @@ async function startLaunchd(logger) {
|
|
|
113
132
|
const plistPath = launchdPlistPath();
|
|
114
133
|
const target = `gui/${process.getuid?.() ?? ""}`;
|
|
115
134
|
if (!existsSync(plistPath)) return { ok: false, error: "launchd plist not installed" };
|
|
116
|
-
await
|
|
117
|
-
const boot = await
|
|
118
|
-
const kick = await
|
|
135
|
+
await serviceRun("launchctl", ["bootout", target, plistPath]);
|
|
136
|
+
const boot = await serviceRun("launchctl", ["bootstrap", target, plistPath]);
|
|
137
|
+
const kick = await serviceRun("launchctl", ["kickstart", "-k", `${target}/${LABEL}`]);
|
|
119
138
|
logger.info?.("launchd service started");
|
|
120
139
|
return { ok: boot.code === 0 || kick.code === 0, bootstrap: boot, kickstart: kick };
|
|
121
140
|
}
|
|
@@ -123,7 +142,7 @@ async function startLaunchd(logger) {
|
|
|
123
142
|
async function stopLaunchd(logger) {
|
|
124
143
|
const plistPath = launchdPlistPath();
|
|
125
144
|
const target = `gui/${process.getuid?.() ?? ""}`;
|
|
126
|
-
const result = await
|
|
145
|
+
const result = await serviceRun("launchctl", ["bootout", target, plistPath]);
|
|
127
146
|
logger.info?.("launchd service stopped");
|
|
128
147
|
return result;
|
|
129
148
|
}
|
|
@@ -139,7 +158,7 @@ async function uninstallLaunchd(logger) {
|
|
|
139
158
|
async function statusLaunchd() {
|
|
140
159
|
const plistPath = launchdPlistPath();
|
|
141
160
|
const target = `gui/${process.getuid?.() ?? ""}/${LABEL}`;
|
|
142
|
-
const result = await
|
|
161
|
+
const result = await serviceRun("launchctl", ["print", target]);
|
|
143
162
|
return { ok: existsSync(plistPath), provider: "launchd", installed: existsSync(plistPath), path: plistPath, active: result.code === 0, detail: result.stdout || result.stderr };
|
|
144
163
|
}
|
|
145
164
|
|
|
@@ -173,25 +192,25 @@ async function installSystemd(spec, logger) {
|
|
|
173
192
|
const servicePath = systemdPath();
|
|
174
193
|
mkdirSync(path.dirname(servicePath), { recursive: true });
|
|
175
194
|
writeFileSync(servicePath, systemdUnit(spec), { mode: 0o644 });
|
|
176
|
-
const reload = await
|
|
177
|
-
const enable = await
|
|
178
|
-
const linger = await
|
|
195
|
+
const reload = await serviceRun("systemctl", ["--user", "daemon-reload"]);
|
|
196
|
+
const enable = await serviceRun("systemctl", ["--user", "enable", "machine-bridge-mcp.service"]);
|
|
197
|
+
const linger = await serviceRun("loginctl", ["enable-linger", os.userInfo().username]);
|
|
179
198
|
logger.info?.(`Autostart installed: ${servicePath}`);
|
|
180
199
|
return { ok: reload.code === 0 && enable.code === 0, provider: "systemd", path: servicePath, reload, enable, linger };
|
|
181
200
|
}
|
|
182
201
|
|
|
183
202
|
async function uninstallSystemd(logger) {
|
|
184
|
-
await
|
|
203
|
+
await serviceRun("systemctl", ["--user", "disable", "--now", "machine-bridge-mcp.service"]);
|
|
185
204
|
const servicePath = systemdPath();
|
|
186
205
|
if (existsSync(servicePath)) rmSync(servicePath, { force: true });
|
|
187
|
-
await
|
|
206
|
+
await serviceRun("systemctl", ["--user", "daemon-reload"]);
|
|
188
207
|
logger.info?.(`Autostart removed: ${servicePath}`);
|
|
189
208
|
return { ok: true, provider: "systemd", path: servicePath };
|
|
190
209
|
}
|
|
191
210
|
|
|
192
211
|
async function statusSystemd() {
|
|
193
212
|
const servicePath = systemdPath();
|
|
194
|
-
const result = await
|
|
213
|
+
const result = await serviceRun("systemctl", ["--user", "status", "machine-bridge-mcp.service", "--no-pager"]);
|
|
195
214
|
return { ok: existsSync(servicePath), provider: "systemd", installed: existsSync(servicePath), path: servicePath, active: result.code === 0, detail: result.stdout || result.stderr };
|
|
196
215
|
}
|
|
197
216
|
|
|
@@ -216,19 +235,19 @@ WantedBy=default.target
|
|
|
216
235
|
|
|
217
236
|
async function installWindowsTask(spec, logger) {
|
|
218
237
|
const command = windowsCommand(spec);
|
|
219
|
-
const result = await
|
|
238
|
+
const result = await serviceRun("schtasks", ["/Create", "/TN", WINDOWS_TASK, "/SC", "ONLOGON", "/TR", command, "/F"]);
|
|
220
239
|
logger.info?.("Windows Scheduled Task installed for logon");
|
|
221
240
|
return { ok: result.code === 0, provider: "schtasks", task: WINDOWS_TASK, result };
|
|
222
241
|
}
|
|
223
242
|
|
|
224
243
|
async function uninstallWindowsTask(logger) {
|
|
225
|
-
const result = await
|
|
244
|
+
const result = await serviceRun("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
|
|
226
245
|
logger.info?.("Windows Scheduled Task removed");
|
|
227
246
|
return { ok: result.code === 0 || /cannot find/i.test(result.stderr), provider: "schtasks", task: WINDOWS_TASK, result };
|
|
228
247
|
}
|
|
229
248
|
|
|
230
249
|
async function statusWindowsTask() {
|
|
231
|
-
const result = await
|
|
250
|
+
const result = await serviceRun("schtasks", ["/Query", "/TN", WINDOWS_TASK, "/FO", "LIST"]);
|
|
232
251
|
return { ok: result.code === 0, provider: "schtasks", installed: result.code === 0, task: WINDOWS_TASK, active: result.code === 0, detail: result.stdout || result.stderr };
|
|
233
252
|
}
|
|
234
253
|
|
package/src/local/state.mjs
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync,
|
|
2
|
+
import { closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, renameSync, writeFileSync, chmodSync, realpathSync, rmSync, unlinkSync, readdirSync, statSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
|
|
6
7
|
|
|
7
8
|
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
8
|
-
export const appName =
|
|
9
|
+
export const appName = String(serverMetadata.name);
|
|
9
10
|
const STATE_MARKER = ".machine-bridge-mcp-state";
|
|
11
|
+
const MAX_STATE_JSON_BYTES = 2 * 1024 * 1024;
|
|
12
|
+
const MAX_LOCK_BYTES = 64 * 1024;
|
|
13
|
+
const MAX_MARKER_BYTES = 4096;
|
|
10
14
|
|
|
11
15
|
export function expandHome(input = "") {
|
|
12
16
|
if (!input || input === "~") return os.homedir();
|
|
@@ -16,7 +20,7 @@ export function expandHome(input = "") {
|
|
|
16
20
|
|
|
17
21
|
export function resolveWorkspace(input = process.cwd()) {
|
|
18
22
|
const resolved = path.resolve(expandHome(input));
|
|
19
|
-
return realpathSync(resolved);
|
|
23
|
+
return realpathSync.native ? realpathSync.native(resolved) : realpathSync(resolved);
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export function defaultStateRoot() {
|
|
@@ -29,7 +33,7 @@ export function defaultStateRoot() {
|
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
function configPath(stateRoot = defaultStateRoot()) {
|
|
33
37
|
return path.join(expandHome(stateRoot), "config.json");
|
|
34
38
|
}
|
|
35
39
|
|
|
@@ -48,9 +52,10 @@ export function saveGlobalConfig(config, stateRoot = defaultStateRoot()) {
|
|
|
48
52
|
|
|
49
53
|
export function setSelectedWorkspace(workspace, stateRoot = defaultStateRoot()) {
|
|
50
54
|
const root = expandHome(stateRoot);
|
|
55
|
+
const canonicalWorkspace = resolveWorkspace(workspace);
|
|
51
56
|
const config = loadGlobalConfig(root);
|
|
52
|
-
config.selectedWorkspace =
|
|
53
|
-
config.selectedWorkspaceHash = workspaceHash(
|
|
57
|
+
config.selectedWorkspace = canonicalWorkspace;
|
|
58
|
+
config.selectedWorkspaceHash = workspaceHash(canonicalWorkspace);
|
|
54
59
|
saveGlobalConfig(config, root);
|
|
55
60
|
return config;
|
|
56
61
|
}
|
|
@@ -75,20 +80,53 @@ export function removeStateRoot(stateRoot = defaultStateRoot()) {
|
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
export function workspaceHash(workspace) {
|
|
78
|
-
|
|
83
|
+
const canonical = resolveWorkspace(workspace);
|
|
84
|
+
const identity = process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
85
|
+
return createHash("sha256").update(identity).digest("hex").slice(0, 24);
|
|
79
86
|
}
|
|
80
87
|
|
|
81
|
-
|
|
88
|
+
function profileDirForWorkspace(workspace, stateRoot = defaultStateRoot()) {
|
|
82
89
|
return path.join(expandHome(stateRoot), "profiles", workspaceHash(workspace));
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
|
|
93
|
+
function matchingLegacyProfileDir(canonicalWorkspace, stateRoot) {
|
|
94
|
+
const profilesRoot = path.join(stateRoot, "profiles");
|
|
95
|
+
if (!existsSync(profilesRoot)) return "";
|
|
96
|
+
const matches = [];
|
|
97
|
+
for (const entry of readdirSync(profilesRoot, { withFileTypes: true })) {
|
|
98
|
+
if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
|
|
99
|
+
const profileDir = path.join(profilesRoot, entry.name);
|
|
100
|
+
const stateFile = path.join(profileDir, "state.json");
|
|
101
|
+
if (!existsSync(stateFile)) continue;
|
|
102
|
+
try {
|
|
103
|
+
const value = JSON.parse(readBoundedUtf8(stateFile, MAX_STATE_JSON_BYTES, "state JSON"));
|
|
104
|
+
const storedWorkspace = value?.workspace?.path;
|
|
105
|
+
if (typeof storedWorkspace !== "string") continue;
|
|
106
|
+
if (sameWorkspaceIdentity(storedWorkspace, canonicalWorkspace)) matches.push(profileDir);
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
if (matches.length > 1) throw new Error("multiple Machine Bridge profiles refer to the same canonical workspace; remove or merge the duplicate state profiles");
|
|
110
|
+
return matches[0] || "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sameWorkspaceIdentity(left, right) {
|
|
114
|
+
try {
|
|
115
|
+
const a = resolveWorkspace(left);
|
|
116
|
+
const b = resolveWorkspace(right);
|
|
117
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
87
121
|
}
|
|
88
122
|
|
|
89
123
|
export function loadState(workspace, options = {}) {
|
|
124
|
+
const canonicalWorkspace = resolveWorkspace(workspace);
|
|
90
125
|
const stateRoot = ensureStateRoot(options.stateDir ? expandHome(options.stateDir) : defaultStateRoot());
|
|
91
|
-
const
|
|
126
|
+
const canonicalProfileDir = profileDirForWorkspace(canonicalWorkspace, stateRoot);
|
|
127
|
+
const profileDir = existsSync(canonicalProfileDir)
|
|
128
|
+
? canonicalProfileDir
|
|
129
|
+
: matchingLegacyProfileDir(canonicalWorkspace, stateRoot) || canonicalProfileDir;
|
|
92
130
|
const statePath = path.join(profileDir, "state.json");
|
|
93
131
|
ensureOwnerOnlyDir(profileDir);
|
|
94
132
|
let state = {};
|
|
@@ -96,15 +134,16 @@ export function loadState(workspace, options = {}) {
|
|
|
96
134
|
ownerOnlyFile(statePath);
|
|
97
135
|
state = readJsonObjectOrBackup(statePath);
|
|
98
136
|
}
|
|
99
|
-
state.schemaVersion =
|
|
137
|
+
state.schemaVersion = 5;
|
|
100
138
|
state.workspace = {
|
|
101
|
-
path:
|
|
102
|
-
hash:
|
|
139
|
+
path: canonicalWorkspace,
|
|
140
|
+
hash: path.basename(profileDir),
|
|
103
141
|
updatedAt: new Date().toISOString(),
|
|
104
142
|
};
|
|
105
143
|
state.paths = { stateRoot, profileDir, statePath };
|
|
106
144
|
state.worker ||= {};
|
|
107
145
|
state.policy ||= {};
|
|
146
|
+
state.resources ||= {};
|
|
108
147
|
delete state.localApi;
|
|
109
148
|
return state;
|
|
110
149
|
}
|
|
@@ -189,7 +228,7 @@ function releaseProcessLock(lockPath, token) {
|
|
|
189
228
|
|
|
190
229
|
export function readDaemonLockOwner(lockPath) {
|
|
191
230
|
try {
|
|
192
|
-
const parsed = JSON.parse(
|
|
231
|
+
const parsed = JSON.parse(readBoundedUtf8(lockPath, MAX_LOCK_BYTES, "lock file"));
|
|
193
232
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
194
233
|
} catch {
|
|
195
234
|
return null;
|
|
@@ -207,9 +246,31 @@ function isPidAlive(pid) {
|
|
|
207
246
|
}
|
|
208
247
|
}
|
|
209
248
|
|
|
249
|
+
function readBoundedUtf8(filePath, maxBytes, label) {
|
|
250
|
+
const pathInfo = lstatSync(filePath);
|
|
251
|
+
if (pathInfo.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link`);
|
|
252
|
+
const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
|
|
253
|
+
const fd = openSync(filePath, Number(fsConstants.O_RDONLY) | noFollow);
|
|
254
|
+
try {
|
|
255
|
+
const info = fstatSync(fd);
|
|
256
|
+
if (!info.isFile()) throw new Error(`${label} is not a regular file`);
|
|
257
|
+
if (info.size > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`);
|
|
258
|
+
const buffer = Buffer.alloc(info.size);
|
|
259
|
+
let offset = 0;
|
|
260
|
+
while (offset < buffer.length) {
|
|
261
|
+
const count = readSync(fd, buffer, offset, buffer.length - offset, offset);
|
|
262
|
+
if (count === 0) break;
|
|
263
|
+
offset += count;
|
|
264
|
+
}
|
|
265
|
+
return buffer.subarray(0, offset).toString("utf8");
|
|
266
|
+
} finally {
|
|
267
|
+
closeSync(fd);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
210
271
|
function readJsonObjectOrBackup(filePath) {
|
|
211
272
|
try {
|
|
212
|
-
const parsed = JSON.parse(
|
|
273
|
+
const parsed = JSON.parse(readBoundedUtf8(filePath, MAX_STATE_JSON_BYTES, "state JSON"));
|
|
213
274
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("JSON root must be an object");
|
|
214
275
|
return parsed;
|
|
215
276
|
} catch {
|
|
@@ -286,7 +347,7 @@ function assertSafeStateRootForRemoval(root) {
|
|
|
286
347
|
}
|
|
287
348
|
|
|
288
349
|
function assertValidStateMarker(marker, options = {}) {
|
|
289
|
-
const content =
|
|
350
|
+
const content = readBoundedUtf8(marker, MAX_MARKER_BYTES, "state marker");
|
|
290
351
|
try {
|
|
291
352
|
const value = JSON.parse(content);
|
|
292
353
|
if (value?.app === appName && value?.schema === 1) return;
|
|
@@ -315,7 +376,7 @@ function stateRootMatchesRecordedWorkspace(root) {
|
|
|
315
376
|
if (!entry.isDirectory()) continue;
|
|
316
377
|
const stateFile = path.join(profiles, entry.name, "state.json");
|
|
317
378
|
if (!existsSync(stateFile)) continue;
|
|
318
|
-
const value = JSON.parse(
|
|
379
|
+
const value = JSON.parse(readBoundedUtf8(stateFile, MAX_STATE_JSON_BYTES, "state JSON"));
|
|
319
380
|
if (typeof value?.workspace?.path !== "string") continue;
|
|
320
381
|
try { if (realpathSync(value.workspace.path) === root) return true; } catch {}
|
|
321
382
|
}
|
|
@@ -327,7 +388,7 @@ function isRecognizableLegacyStateRoot(root) {
|
|
|
327
388
|
const config = path.join(root, "config.json");
|
|
328
389
|
try {
|
|
329
390
|
if (existsSync(config)) {
|
|
330
|
-
const value = JSON.parse(
|
|
391
|
+
const value = JSON.parse(readBoundedUtf8(config, MAX_STATE_JSON_BYTES, "config JSON"));
|
|
331
392
|
if (
|
|
332
393
|
value &&
|
|
333
394
|
typeof value.selectedWorkspace === "string" &&
|
|
@@ -343,7 +404,7 @@ function isRecognizableLegacyStateRoot(root) {
|
|
|
343
404
|
if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
|
|
344
405
|
const stateFile = path.join(profiles, entry.name, "state.json");
|
|
345
406
|
if (!existsSync(stateFile)) continue;
|
|
346
|
-
const value = JSON.parse(
|
|
407
|
+
const value = JSON.parse(readBoundedUtf8(stateFile, MAX_STATE_JSON_BYTES, "state JSON"));
|
|
347
408
|
if (value?.workspace?.hash === entry.name && typeof value?.workspace?.path === "string") return true;
|
|
348
409
|
}
|
|
349
410
|
} catch {}
|
|
@@ -354,11 +415,13 @@ function atomicWriteJson(filePath, value) {
|
|
|
354
415
|
const dir = path.dirname(filePath);
|
|
355
416
|
ensureOwnerOnlyDir(dir);
|
|
356
417
|
cleanupStaleAtomicTemps(dir, path.basename(filePath));
|
|
418
|
+
const serialized = `${JSON.stringify(value, null, 2)}\n`;
|
|
419
|
+
if (Buffer.byteLength(serialized) > MAX_STATE_JSON_BYTES) throw new Error(`state JSON exceeds ${MAX_STATE_JSON_BYTES} bytes`);
|
|
357
420
|
const tempPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
358
421
|
let fd;
|
|
359
422
|
try {
|
|
360
423
|
fd = openSync(tempPath, "wx", 0o600);
|
|
361
|
-
writeFileSync(fd,
|
|
424
|
+
writeFileSync(fd, serialized, "utf8");
|
|
362
425
|
fsyncSync(fd);
|
|
363
426
|
closeSync(fd);
|
|
364
427
|
fd = undefined;
|
|
@@ -412,11 +475,11 @@ export function ensureWorkerSecrets(state, options = {}) {
|
|
|
412
475
|
if (!state.worker.name || options.workerName) state.worker.name = options.workerName || defaultWorkerName(state.workspace.hash);
|
|
413
476
|
}
|
|
414
477
|
|
|
415
|
-
|
|
478
|
+
function defaultWorkerName(hash) {
|
|
416
479
|
return `mbm-${String(hash || "default").slice(0, 12)}`;
|
|
417
480
|
}
|
|
418
481
|
|
|
419
|
-
|
|
482
|
+
function randomToken(prefix) {
|
|
420
483
|
return `${prefix}_${randomBytes(32).toString("base64url")}`;
|
|
421
484
|
}
|
|
422
485
|
|
|
@@ -424,9 +487,6 @@ export function sha256(value) {
|
|
|
424
487
|
return createHash("sha256").update(String(value)).digest("hex");
|
|
425
488
|
}
|
|
426
489
|
|
|
427
|
-
export function fileSha256(filePath) {
|
|
428
|
-
return createHash("sha256").update(readFileSync(filePath)).digest("hex");
|
|
429
|
-
}
|
|
430
490
|
|
|
431
491
|
export function ensureOwnerOnlyDir(dir) {
|
|
432
492
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -434,6 +494,9 @@ export function ensureOwnerOnlyDir(dir) {
|
|
|
434
494
|
}
|
|
435
495
|
|
|
436
496
|
export function ownerOnlyFile(filePath) {
|
|
497
|
+
const info = lstatSync(filePath);
|
|
498
|
+
if (info.isSymbolicLink()) throw new Error(`owner-only file must not be a symbolic link: ${filePath}`);
|
|
499
|
+
if (!info.isFile()) throw new Error(`owner-only path is not a regular file: ${filePath}`);
|
|
437
500
|
try { chmodSync(filePath, 0o600); } catch {}
|
|
438
501
|
}
|
|
439
502
|
|
|
@@ -442,6 +505,11 @@ export function redactState(state) {
|
|
|
442
505
|
if (clone.worker?.oauthPassword) clone.worker.oauthPassword = "<redacted>";
|
|
443
506
|
if (clone.worker?.daemonSecret) clone.worker.daemonSecret = "<redacted>";
|
|
444
507
|
if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = "<redacted>";
|
|
508
|
+
if (clone.resources && typeof clone.resources === "object") {
|
|
509
|
+
for (const value of Object.values(clone.resources)) {
|
|
510
|
+
if (value && typeof value === "object" && value.path) value.path = "<local-resource-path>";
|
|
511
|
+
}
|
|
512
|
+
}
|
|
445
513
|
return clone;
|
|
446
514
|
}
|
|
447
515
|
|
package/src/local/stdio.mjs
CHANGED
|
@@ -2,11 +2,12 @@ import readline from "node:readline";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { LocalDaemon } from "./daemon.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { classifyOperationalError, createLogger } from "./log.mjs";
|
|
6
6
|
import {
|
|
7
7
|
MCP_INSTRUCTIONS,
|
|
8
8
|
MCP_PROTOCOL_VERSION,
|
|
9
9
|
MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
10
|
+
SERVER_NAME,
|
|
10
11
|
rpcError,
|
|
11
12
|
rpcResult,
|
|
12
13
|
toolResult,
|
|
@@ -14,11 +15,12 @@ import {
|
|
|
14
15
|
} from "./tools.mjs";
|
|
15
16
|
|
|
16
17
|
const MAX_LINE_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
const SLOW_TOOL_CALL_MS = 30_000;
|
|
17
19
|
const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version);
|
|
18
20
|
|
|
19
|
-
export async function runStdioServer({ workspace, policy,
|
|
20
|
-
const logger =
|
|
21
|
-
const runtime = new LocalDaemon({ workspace, policy, logger });
|
|
21
|
+
export async function runStdioServer({ workspace, policy, logLevel = "info", jobRoot = "", resources = {}, resourceStatePath = "" }) {
|
|
22
|
+
const logger = createLogger({ component: "stdio", level: logLevel, stderrOnly: true, color: false });
|
|
23
|
+
const runtime = new LocalDaemon({ workspace, policy, logger, jobRoot, resources, resourceStatePath });
|
|
22
24
|
const pending = new Map();
|
|
23
25
|
let negotiatedVersion = MCP_PROTOCOL_VERSION;
|
|
24
26
|
let initialized = false;
|
|
@@ -40,7 +42,7 @@ export async function runStdioServer({ workspace, policy, verbose = false }) {
|
|
|
40
42
|
return;
|
|
41
43
|
}
|
|
42
44
|
void handleLine(line).catch((error) => {
|
|
43
|
-
logger.error("stdio request handler failed", {
|
|
45
|
+
logger.error("stdio request handler failed", { error_class: classifyOperationalError(error) });
|
|
44
46
|
});
|
|
45
47
|
});
|
|
46
48
|
|
|
@@ -74,7 +76,7 @@ export async function runStdioServer({ workspace, policy, verbose = false }) {
|
|
|
74
76
|
protocolVersion: negotiatedVersion,
|
|
75
77
|
capabilities: { tools: { listChanged: false }, logging: {} },
|
|
76
78
|
serverInfo: {
|
|
77
|
-
name:
|
|
79
|
+
name: SERVER_NAME,
|
|
78
80
|
title: "Machine Bridge MCP",
|
|
79
81
|
version: PACKAGE_VERSION,
|
|
80
82
|
description: "Workspace-scoped local coding tools over MCP stdio or authenticated remote relay.",
|
|
@@ -128,11 +130,15 @@ export async function runStdioServer({ workspace, policy, verbose = false }) {
|
|
|
128
130
|
try {
|
|
129
131
|
const result = await runtime.executeTool(name, args, { callId });
|
|
130
132
|
send(rpcResult(message.id, toolResult(result)));
|
|
131
|
-
|
|
133
|
+
const durationMs = Date.now() - started;
|
|
134
|
+
if (durationMs >= SLOW_TOOL_CALL_MS) logger.info("slow tool call completed", { tool: name, duration_ms: durationMs });
|
|
135
|
+
else logger.debug("tool call completed", { call_id: callId.slice(0, 20), tool: name, duration_ms: durationMs });
|
|
132
136
|
} catch (error) {
|
|
133
137
|
const safeError = runtime.safeErrorMessage(error);
|
|
134
138
|
send(rpcResult(message.id, toolResult({ error: safeError }, true)));
|
|
135
|
-
|
|
139
|
+
const durationMs = Date.now() - started;
|
|
140
|
+
logger.warn("tool call failed", { tool: name, duration_ms: durationMs, error_class: classifyOperationalError(error) });
|
|
141
|
+
logger.debug("tool call failure correlation", { call_id: callId.slice(0, 20) });
|
|
136
142
|
} finally {
|
|
137
143
|
if (key) pending.delete(key);
|
|
138
144
|
runtime.finishCall(callId);
|
|
@@ -162,33 +168,3 @@ function jsonRpcIdKey(value) {
|
|
|
162
168
|
function asObject(value) {
|
|
163
169
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
164
170
|
}
|
|
165
|
-
|
|
166
|
-
function classifyError(error) {
|
|
167
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
168
|
-
if (/cancel/i.test(message)) return "cancelled";
|
|
169
|
-
if (/timed out/i.test(message)) return "timeout";
|
|
170
|
-
if (/outside the configured workspace/i.test(message)) return "path_boundary";
|
|
171
|
-
if (/disabled|requires .* mode/i.test(message)) return "policy_denied";
|
|
172
|
-
if (/not found|ENOENT/i.test(message)) return "not_found";
|
|
173
|
-
if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
|
|
174
|
-
if (/maximum|exceeds|max_bytes|too many/i.test(message)) return "limit_exceeded";
|
|
175
|
-
if (/invalid|must|requires|ambiguous|mismatch/i.test(message)) return "invalid_request";
|
|
176
|
-
return "execution_failed";
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function errorMessage(error) {
|
|
180
|
-
return sanitizeLogText(error instanceof Error ? error.message : String(error)).slice(0, 4096) || "tool call failed";
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function stderrLogger(verbose) {
|
|
184
|
-
const write = (level, message, fields) => {
|
|
185
|
-
if (level === "debug" && !verbose) return;
|
|
186
|
-
process.stderr.write(`[${level}] stdio: ${sanitizeLogText(message)}${formatFields(fields)}\n`);
|
|
187
|
-
};
|
|
188
|
-
return {
|
|
189
|
-
info(message, fields) { write("info", message, fields); },
|
|
190
|
-
warn(message, fields) { write("warn", message, fields); },
|
|
191
|
-
error(message, fields) { write("error", message, fields); },
|
|
192
|
-
debug(message, fields) { write("debug", message, fields); },
|
|
193
|
-
};
|
|
194
|
-
}
|
package/src/local/tools.mjs
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
import catalog from "../shared/tool-catalog.json" with { type: "json" };
|
|
2
|
+
import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
|
|
2
3
|
|
|
3
|
-
export const
|
|
4
|
-
export const
|
|
4
|
+
export const SERVER_NAME = String(serverMetadata.name);
|
|
5
|
+
export const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
6
|
+
export const MCP_SUPPORTED_PROTOCOL_VERSIONS = Object.freeze(serverMetadata.supportedProtocolVersions.map((value) => String(value)));
|
|
5
7
|
|
|
6
|
-
export const MCP_INSTRUCTIONS =
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
export const MCP_INSTRUCTIONS = Object.freeze(serverMetadata.instructions.map((value) => String(value))).join("\n");
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_POLICY_PROFILE = "full";
|
|
12
|
+
export const DEFAULT_POLICY_REVISION = 2;
|
|
13
|
+
|
|
14
|
+
export const POLICY_PROFILES = Object.freeze({
|
|
15
|
+
review: Object.freeze({ profile: "review", allowWrite: false, execMode: "off", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
|
|
16
|
+
edit: Object.freeze({ profile: "edit", allowWrite: true, execMode: "off", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
|
|
17
|
+
agent: Object.freeze({ profile: "agent", allowWrite: true, execMode: "direct", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
|
|
18
|
+
full: Object.freeze({ profile: "full", allowWrite: true, execMode: "shell", unrestrictedPaths: true, minimalEnv: false, exposeAbsolutePaths: true }),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const POLICY_ORIGINS = new Set(["default", "explicit", "custom", "migrated", "legacy-preserved"]);
|
|
22
|
+
|
|
23
|
+
export function policyProfile(name, origin = "explicit") {
|
|
24
|
+
const profile = String(name || "").trim().toLowerCase();
|
|
25
|
+
if (!POLICY_PROFILES[profile]) throw new Error(`unknown policy profile: ${profile}`);
|
|
26
|
+
return normalizePolicy({ ...POLICY_PROFILES[profile], origin, revision: DEFAULT_POLICY_REVISION });
|
|
27
|
+
}
|
|
14
28
|
|
|
15
29
|
export function normalizePolicy(policy = {}) {
|
|
16
30
|
const execMode = ["off", "direct", "shell"].includes(policy.execMode)
|
|
@@ -18,8 +32,12 @@ export function normalizePolicy(policy = {}) {
|
|
|
18
32
|
: policy.allowExec === true
|
|
19
33
|
? "shell"
|
|
20
34
|
: "off";
|
|
35
|
+
const origin = POLICY_ORIGINS.has(policy.origin) ? policy.origin : "custom";
|
|
36
|
+
const revision = Number.isInteger(policy.revision) && policy.revision > 0 ? policy.revision : DEFAULT_POLICY_REVISION;
|
|
21
37
|
return {
|
|
22
38
|
profile: typeof policy.profile === "string" && policy.profile ? policy.profile : "custom",
|
|
39
|
+
origin,
|
|
40
|
+
revision,
|
|
23
41
|
allowWrite: policy.allowWrite === true,
|
|
24
42
|
allowExec: execMode !== "off",
|
|
25
43
|
execMode,
|
|
@@ -44,9 +62,6 @@ export function allToolNames() {
|
|
|
44
62
|
return catalog.map((tool) => tool.name);
|
|
45
63
|
}
|
|
46
64
|
|
|
47
|
-
export function findTool(name) {
|
|
48
|
-
return catalog.find((tool) => tool.name === name) || null;
|
|
49
|
-
}
|
|
50
65
|
|
|
51
66
|
export function toolResult(value, isError = false) {
|
|
52
67
|
const special = specialMcpResult(value);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "machine-bridge-mcp",
|
|
3
|
+
"protocolVersion": "2025-11-25",
|
|
4
|
+
"supportedProtocolVersions": [
|
|
5
|
+
"2025-11-25",
|
|
6
|
+
"2025-06-18",
|
|
7
|
+
"2025-03-26"
|
|
8
|
+
],
|
|
9
|
+
"instructions": [
|
|
10
|
+
"You are connected to a local workspace through machine-bridge-mcp.",
|
|
11
|
+
"Remote mode uses a Cloudflare relay; stdio mode runs on the local machine. File and command operations execute on the user's local runtime, not in the Worker.",
|
|
12
|
+
"Filesystem scope and path display follow the active local policy; the default full profile is unrestricted.",
|
|
13
|
+
"Filename sensitivity is not classified by this server; the MCP host, connector gateway, local OS, or endpoint-security software may enforce additional independent rules.",
|
|
14
|
+
"Use diagnose_runtime to distinguish a request that reached the daemon from local filesystem, process-spawn, shell, managed-job-storage, and resource failures. A tool call blocked before any response cannot be diagnosed by the server.",
|
|
15
|
+
"Never request or return secret-file contents when a local resource alias can be used. Resources are registered only through the local machine-mcp CLI and may be injected by path, stdin, or environment without entering MCP arguments.",
|
|
16
|
+
"If execution-class tools are unavailable but write access remains, use stage_job to persist a reviewed plan without running it; the operator can launch it locally with machine-mcp job approve JOB_ID.",
|
|
17
|
+
"For multi-step, remote, long-running, or cleanup-sensitive work, prefer one start_job call with argv steps, job-scoped temporary_files, and idempotent finally_steps. The detached runner continues after MCP disconnects; inspect it with read_job or the local machine-mcp job CLI.",
|
|
18
|
+
"Prefer sending remote shell programs through a process stdin rather than creating remote helper files. If temporary files are necessary, use {{temp:name}} or explicit finally_steps.",
|
|
19
|
+
"Managed-job resource redaction is defense in depth, not a guarantee against transformed or partial secret output. Use capture_output=discard for steps that may echo credentials.",
|
|
20
|
+
"Do not use shell encoding, renaming, or alternate tools to bypass host or platform safety policy.",
|
|
21
|
+
"run_process avoids shell parsing but is not an OS sandbox. exec_command is exposed only in shell mode and has the local user's authority.",
|
|
22
|
+
"Inspect before editing, prefer read_file line ranges, edit_file, or apply_patch over whole-file replacement, and report commands that were run."
|
|
23
|
+
]
|
|
24
|
+
}
|