machine-bridge-mcp 0.5.0 → 0.6.2
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 +68 -0
- package/README.md +113 -8
- package/SECURITY.md +42 -6
- package/docs/ARCHITECTURE.md +50 -9
- package/docs/CLIENTS.md +24 -3
- package/docs/LOGGING.md +12 -19
- package/docs/MANAGED_JOBS.md +282 -0
- package/docs/OPERATIONS.md +79 -10
- package/docs/TESTING.md +11 -2
- package/package.json +13 -6
- package/src/local/atomic-fs.mjs +37 -0
- package/src/local/cli.mjs +254 -7
- package/src/local/daemon.mjs +168 -6
- package/src/local/full-access-test.mjs +206 -0
- package/src/local/job-runner.mjs +471 -0
- package/src/local/managed-jobs.mjs +855 -0
- package/src/local/resource-operations.mjs +66 -0
- package/src/local/ssh-key.mjs +125 -0
- package/src/local/state.mjs +56 -12
- package/src/local/stdio.mjs +4 -6
- package/src/local/tools.mjs +37 -3
- package/src/shared/server-metadata.json +10 -1
- package/src/shared/tool-catalog.json +531 -0
- package/src/worker/index.ts +7 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { inspectResourceFile, validateResourceName } from "./managed-jobs.mjs";
|
|
5
|
+
import { generateSshKeyPair } from "./ssh-key.mjs";
|
|
6
|
+
import { acquireStartupLock, loadState, saveState } from "./state.mjs";
|
|
7
|
+
|
|
8
|
+
export async function generateRegisteredSshKey({ workspace, stateDir, name: rawName, targetPath, comment = "" }) {
|
|
9
|
+
const name = validateResourceName(rawName);
|
|
10
|
+
if (typeof targetPath !== "string" || !targetPath.trim()) throw new Error("SSH private key target path is required");
|
|
11
|
+
const target = resolve(targetPath);
|
|
12
|
+
const state = loadState(workspace, { stateDir });
|
|
13
|
+
const lock = acquireStartupLock(state);
|
|
14
|
+
if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
|
|
15
|
+
let key = null;
|
|
16
|
+
try {
|
|
17
|
+
state.resources ||= {};
|
|
18
|
+
const existing = state.resources[name];
|
|
19
|
+
if (existing?.path && !samePathIdentity(existing.path, target)) {
|
|
20
|
+
throw new Error(`local resource ${name} is already registered to a different file; remove it first`);
|
|
21
|
+
}
|
|
22
|
+
if (!existing && Object.keys(state.resources).length >= 64) throw new Error("local resource registry limit reached (64)");
|
|
23
|
+
key = await generateSshKeyPair({
|
|
24
|
+
privateKeyPath: target,
|
|
25
|
+
type: "ed25519",
|
|
26
|
+
comment: comment || `machine-mcp:${name}`,
|
|
27
|
+
});
|
|
28
|
+
const inspected = inspectResourceFile(key.privateKeyPath);
|
|
29
|
+
state.resources[name] = inspected;
|
|
30
|
+
try {
|
|
31
|
+
saveState(state);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (key.created) {
|
|
34
|
+
await rm(key.privateKeyPath, { force: true }).catch(() => {});
|
|
35
|
+
await rm(key.publicKeyPath, { force: true }).catch(() => {});
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
name,
|
|
41
|
+
created: key.created,
|
|
42
|
+
registered: true,
|
|
43
|
+
privateKeyPath: key.privateKeyPath,
|
|
44
|
+
publicKeyPath: key.publicKeyPath,
|
|
45
|
+
fingerprint: key.fingerprint,
|
|
46
|
+
keyType: key.publicKeyType,
|
|
47
|
+
privateMode: key.privateMode,
|
|
48
|
+
publicMode: key.publicMode,
|
|
49
|
+
privateKeyContentExposed: false,
|
|
50
|
+
availableToNewJobsImmediately: true,
|
|
51
|
+
};
|
|
52
|
+
} finally {
|
|
53
|
+
lock.release();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function samePathIdentity(left, right) {
|
|
58
|
+
const a = canonicalIfExisting(left);
|
|
59
|
+
const b = canonicalIfExisting(right);
|
|
60
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function canonicalIfExisting(value) {
|
|
64
|
+
const absolute = resolve(String(value));
|
|
65
|
+
try { return realpathSync.native ? realpathSync.native(absolute) : realpathSync(absolute); } catch { return absolute; }
|
|
66
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { chmod, copyFile, link, lstat, mkdir, readFile, rm, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, resolve } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { run } from "./shell.mjs";
|
|
6
|
+
|
|
7
|
+
const KEY_TYPES = new Set(["ed25519", "rsa"]);
|
|
8
|
+
|
|
9
|
+
export async function generateSshKeyPair(options = {}) {
|
|
10
|
+
const privateKeyPath = resolve(String(options.privateKeyPath || ""));
|
|
11
|
+
if (!privateKeyPath || privateKeyPath === resolve(".")) throw new Error("private key path is required");
|
|
12
|
+
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
13
|
+
const type = String(options.type || "ed25519").toLowerCase();
|
|
14
|
+
if (!KEY_TYPES.has(type)) throw new Error("SSH key type must be ed25519 or rsa");
|
|
15
|
+
const comment = boundedComment(options.comment || `machine-mcp:${basename(privateKeyPath)}`);
|
|
16
|
+
const parent = dirname(privateKeyPath);
|
|
17
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
18
|
+
if (process.platform !== "win32") await chmod(parent, 0o700);
|
|
19
|
+
|
|
20
|
+
const privateInfo = await safeLstat(privateKeyPath);
|
|
21
|
+
const publicInfo = await safeLstat(publicKeyPath);
|
|
22
|
+
if (privateInfo?.isSymbolicLink() || publicInfo?.isSymbolicLink()) throw new Error("SSH key path must not be a symbolic link");
|
|
23
|
+
if (privateInfo || publicInfo) {
|
|
24
|
+
if (!privateInfo?.isFile() || !publicInfo?.isFile()) throw new Error("SSH key pair is incomplete or not a pair of regular files");
|
|
25
|
+
await secureKeyModes(privateKeyPath, publicKeyPath);
|
|
26
|
+
return inspectSshKeyPair(privateKeyPath, publicKeyPath, false);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const suffix = `${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
30
|
+
const tempPrivate = resolve(parent, `.${basename(privateKeyPath)}.mbm-${suffix}`);
|
|
31
|
+
const tempPublic = `${tempPrivate}.pub`;
|
|
32
|
+
try {
|
|
33
|
+
const args = ["-q", "-t", type];
|
|
34
|
+
if (type === "rsa") args.push("-b", String(normalizeRsaBits(options.bits)));
|
|
35
|
+
args.push("-N", "", "-f", tempPrivate, "-C", comment);
|
|
36
|
+
const generated = await run("ssh-keygen", args, { capture: true, timeoutMs: 30_000, maxOutputBytes: 64 * 1024 });
|
|
37
|
+
if (generated.code !== 0) throw new Error("ssh-keygen failed");
|
|
38
|
+
await secureKeyModes(tempPrivate, tempPublic);
|
|
39
|
+
await installNoReplace(tempPrivate, privateKeyPath);
|
|
40
|
+
try {
|
|
41
|
+
await installNoReplace(tempPublic, publicKeyPath);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
await rm(privateKeyPath, { force: true });
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
await secureKeyModes(privateKeyPath, publicKeyPath);
|
|
47
|
+
return inspectSshKeyPair(privateKeyPath, publicKeyPath, true);
|
|
48
|
+
} finally {
|
|
49
|
+
await rm(tempPrivate, { force: true });
|
|
50
|
+
await rm(tempPublic, { force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function inspectSshKeyPair(privateKeyPath, publicKeyPath = `${privateKeyPath}.pub`, created = false) {
|
|
55
|
+
const privateInfo = await stat(privateKeyPath);
|
|
56
|
+
const publicInfo = await stat(publicKeyPath);
|
|
57
|
+
if (!privateInfo.isFile() || !publicInfo.isFile()) throw new Error("SSH key pair is not composed of regular files");
|
|
58
|
+
const derived = await run("ssh-keygen", ["-y", "-P", "", "-f", privateKeyPath], {
|
|
59
|
+
capture: true,
|
|
60
|
+
allowFailure: true,
|
|
61
|
+
timeoutMs: 15_000,
|
|
62
|
+
maxOutputBytes: 64 * 1024,
|
|
63
|
+
});
|
|
64
|
+
if (derived.code !== 0) throw new Error("SSH private key cannot be used non-interactively or is invalid");
|
|
65
|
+
const publicLine = (await readFile(publicKeyPath, "utf8")).trim();
|
|
66
|
+
if (!/^(ssh-ed25519|ssh-rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(publicLine)) throw new Error("generated SSH public key is invalid");
|
|
67
|
+
const expectedFields = publicLine.split(/\s+/).slice(0, 2).join(" ");
|
|
68
|
+
const derivedFields = derived.stdout.trim().split(/\s+/).slice(0, 2).join(" ");
|
|
69
|
+
if (expectedFields !== derivedFields) throw new Error("SSH public key does not match the private key");
|
|
70
|
+
const fingerprint = await run("ssh-keygen", ["-lf", publicKeyPath, "-E", "sha256"], {
|
|
71
|
+
capture: true,
|
|
72
|
+
timeoutMs: 15_000,
|
|
73
|
+
maxOutputBytes: 64 * 1024,
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
created,
|
|
77
|
+
privateKeyPath: resolve(privateKeyPath),
|
|
78
|
+
publicKeyPath: resolve(publicKeyPath),
|
|
79
|
+
privateMode: process.platform === "win32" ? null : `0${(privateInfo.mode & 0o777).toString(8)}`,
|
|
80
|
+
publicMode: process.platform === "win32" ? null : `0${(publicInfo.mode & 0o777).toString(8)}`,
|
|
81
|
+
fingerprint: fingerprint.stdout.trim(),
|
|
82
|
+
publicKeyType: publicLine.split(/\s+/, 1)[0],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function installNoReplace(source, target) {
|
|
87
|
+
try {
|
|
88
|
+
await link(source, target);
|
|
89
|
+
await unlink(source);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error?.code === "EXDEV") {
|
|
92
|
+
await copyFile(source, target, fsConstants.COPYFILE_EXCL);
|
|
93
|
+
await unlink(source);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (error?.code === "EEXIST") throw new Error(`refusing to replace existing SSH key file: ${target}`);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function secureKeyModes(privateKeyPath, publicKeyPath) {
|
|
102
|
+
if (process.platform === "win32") return;
|
|
103
|
+
await chmod(privateKeyPath, 0o600);
|
|
104
|
+
await chmod(publicKeyPath, 0o644);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function safeLstat(path) {
|
|
108
|
+
try { return await lstat(path); } catch (error) {
|
|
109
|
+
if (error?.code === "ENOENT") return null;
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function boundedComment(value) {
|
|
115
|
+
const comment = String(value || "").replace(/[\r\n\0]/g, " ").trim();
|
|
116
|
+
if (!comment) return "machine-mcp";
|
|
117
|
+
if (Buffer.byteLength(comment) > 256) throw new Error("SSH key comment exceeds 256 bytes");
|
|
118
|
+
return comment;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeRsaBits(value) {
|
|
122
|
+
const bits = Number.parseInt(String(value || "3072"), 10);
|
|
123
|
+
if (![2048, 3072, 4096].includes(bits)) throw new Error("RSA bits must be 2048, 3072, or 4096");
|
|
124
|
+
return bits;
|
|
125
|
+
}
|
package/src/local/state.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync,
|
|
2
|
+
import { closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, 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
6
|
import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
|
|
7
|
+
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
7
8
|
|
|
8
9
|
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
9
10
|
export const appName = String(serverMetadata.name);
|
|
@@ -20,7 +21,7 @@ export function expandHome(input = "") {
|
|
|
20
21
|
|
|
21
22
|
export function resolveWorkspace(input = process.cwd()) {
|
|
22
23
|
const resolved = path.resolve(expandHome(input));
|
|
23
|
-
return realpathSync(resolved);
|
|
24
|
+
return realpathSync.native ? realpathSync.native(resolved) : realpathSync(resolved);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export function defaultStateRoot() {
|
|
@@ -52,9 +53,10 @@ export function saveGlobalConfig(config, stateRoot = defaultStateRoot()) {
|
|
|
52
53
|
|
|
53
54
|
export function setSelectedWorkspace(workspace, stateRoot = defaultStateRoot()) {
|
|
54
55
|
const root = expandHome(stateRoot);
|
|
56
|
+
const canonicalWorkspace = resolveWorkspace(workspace);
|
|
55
57
|
const config = loadGlobalConfig(root);
|
|
56
|
-
config.selectedWorkspace =
|
|
57
|
-
config.selectedWorkspaceHash = workspaceHash(
|
|
58
|
+
config.selectedWorkspace = canonicalWorkspace;
|
|
59
|
+
config.selectedWorkspaceHash = workspaceHash(canonicalWorkspace);
|
|
58
60
|
saveGlobalConfig(config, root);
|
|
59
61
|
return config;
|
|
60
62
|
}
|
|
@@ -79,7 +81,9 @@ export function removeStateRoot(stateRoot = defaultStateRoot()) {
|
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
export function workspaceHash(workspace) {
|
|
82
|
-
|
|
84
|
+
const canonical = resolveWorkspace(workspace);
|
|
85
|
+
const identity = process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
86
|
+
return createHash("sha256").update(identity).digest("hex").slice(0, 24);
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
function profileDirForWorkspace(workspace, stateRoot = defaultStateRoot()) {
|
|
@@ -87,9 +91,43 @@ function profileDirForWorkspace(workspace, stateRoot = defaultStateRoot()) {
|
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
|
|
94
|
+
function matchingLegacyProfileDir(canonicalWorkspace, stateRoot) {
|
|
95
|
+
const profilesRoot = path.join(stateRoot, "profiles");
|
|
96
|
+
if (!existsSync(profilesRoot)) return "";
|
|
97
|
+
const matches = [];
|
|
98
|
+
for (const entry of readdirSync(profilesRoot, { withFileTypes: true })) {
|
|
99
|
+
if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
|
|
100
|
+
const profileDir = path.join(profilesRoot, entry.name);
|
|
101
|
+
const stateFile = path.join(profileDir, "state.json");
|
|
102
|
+
if (!existsSync(stateFile)) continue;
|
|
103
|
+
try {
|
|
104
|
+
const value = JSON.parse(readBoundedUtf8(stateFile, MAX_STATE_JSON_BYTES, "state JSON"));
|
|
105
|
+
const storedWorkspace = value?.workspace?.path;
|
|
106
|
+
if (typeof storedWorkspace !== "string") continue;
|
|
107
|
+
if (sameWorkspaceIdentity(storedWorkspace, canonicalWorkspace)) matches.push(profileDir);
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
if (matches.length > 1) throw new Error("multiple Machine Bridge profiles refer to the same canonical workspace; remove or merge the duplicate state profiles");
|
|
111
|
+
return matches[0] || "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sameWorkspaceIdentity(left, right) {
|
|
115
|
+
try {
|
|
116
|
+
const a = resolveWorkspace(left);
|
|
117
|
+
const b = resolveWorkspace(right);
|
|
118
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
90
124
|
export function loadState(workspace, options = {}) {
|
|
125
|
+
const canonicalWorkspace = resolveWorkspace(workspace);
|
|
91
126
|
const stateRoot = ensureStateRoot(options.stateDir ? expandHome(options.stateDir) : defaultStateRoot());
|
|
92
|
-
const
|
|
127
|
+
const canonicalProfileDir = profileDirForWorkspace(canonicalWorkspace, stateRoot);
|
|
128
|
+
const profileDir = existsSync(canonicalProfileDir)
|
|
129
|
+
? canonicalProfileDir
|
|
130
|
+
: matchingLegacyProfileDir(canonicalWorkspace, stateRoot) || canonicalProfileDir;
|
|
93
131
|
const statePath = path.join(profileDir, "state.json");
|
|
94
132
|
ensureOwnerOnlyDir(profileDir);
|
|
95
133
|
let state = {};
|
|
@@ -97,15 +135,16 @@ export function loadState(workspace, options = {}) {
|
|
|
97
135
|
ownerOnlyFile(statePath);
|
|
98
136
|
state = readJsonObjectOrBackup(statePath);
|
|
99
137
|
}
|
|
100
|
-
state.schemaVersion =
|
|
138
|
+
state.schemaVersion = 5;
|
|
101
139
|
state.workspace = {
|
|
102
|
-
path:
|
|
103
|
-
hash:
|
|
140
|
+
path: canonicalWorkspace,
|
|
141
|
+
hash: path.basename(profileDir),
|
|
104
142
|
updatedAt: new Date().toISOString(),
|
|
105
143
|
};
|
|
106
144
|
state.paths = { stateRoot, profileDir, statePath };
|
|
107
145
|
state.worker ||= {};
|
|
108
146
|
state.policy ||= {};
|
|
147
|
+
state.resources ||= {};
|
|
109
148
|
delete state.localApi;
|
|
110
149
|
return state;
|
|
111
150
|
}
|
|
@@ -236,9 +275,9 @@ function readJsonObjectOrBackup(filePath) {
|
|
|
236
275
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("JSON root must be an object");
|
|
237
276
|
return parsed;
|
|
238
277
|
} catch {
|
|
239
|
-
const backupPath = `${filePath}.corrupt-${Date.now()}`;
|
|
278
|
+
const backupPath = `${filePath}.corrupt-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
240
279
|
try {
|
|
241
|
-
|
|
280
|
+
replaceFileSync(filePath, backupPath);
|
|
242
281
|
ownerOnlyFile(backupPath);
|
|
243
282
|
pruneBackups(filePath, 3);
|
|
244
283
|
} catch {}
|
|
@@ -387,7 +426,7 @@ function atomicWriteJson(filePath, value) {
|
|
|
387
426
|
fsyncSync(fd);
|
|
388
427
|
closeSync(fd);
|
|
389
428
|
fd = undefined;
|
|
390
|
-
|
|
429
|
+
replaceFileSync(tempPath, filePath);
|
|
391
430
|
ownerOnlyFile(filePath);
|
|
392
431
|
} catch (error) {
|
|
393
432
|
if (fd !== undefined) {
|
|
@@ -467,6 +506,11 @@ export function redactState(state) {
|
|
|
467
506
|
if (clone.worker?.oauthPassword) clone.worker.oauthPassword = "<redacted>";
|
|
468
507
|
if (clone.worker?.daemonSecret) clone.worker.daemonSecret = "<redacted>";
|
|
469
508
|
if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = "<redacted>";
|
|
509
|
+
if (clone.resources && typeof clone.resources === "object") {
|
|
510
|
+
for (const value of Object.values(clone.resources)) {
|
|
511
|
+
if (value && typeof value === "object" && value.path) value.path = "<local-resource-path>";
|
|
512
|
+
}
|
|
513
|
+
}
|
|
470
514
|
return clone;
|
|
471
515
|
}
|
|
472
516
|
|
package/src/local/stdio.mjs
CHANGED
|
@@ -18,9 +18,9 @@ const MAX_LINE_BYTES = 8 * 1024 * 1024;
|
|
|
18
18
|
const SLOW_TOOL_CALL_MS = 30_000;
|
|
19
19
|
const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version);
|
|
20
20
|
|
|
21
|
-
export async function runStdioServer({ workspace, policy, logLevel = "info" }) {
|
|
21
|
+
export async function runStdioServer({ workspace, policy, logLevel = "info", jobRoot = "", resources = {}, resourceStatePath = "" }) {
|
|
22
22
|
const logger = createLogger({ component: "stdio", level: logLevel, stderrOnly: true, color: false });
|
|
23
|
-
const runtime = new LocalDaemon({ workspace, policy, logger });
|
|
23
|
+
const runtime = new LocalDaemon({ workspace, policy, logger, jobRoot, resources, resourceStatePath });
|
|
24
24
|
const pending = new Map();
|
|
25
25
|
let negotiatedVersion = MCP_PROTOCOL_VERSION;
|
|
26
26
|
let initialized = false;
|
|
@@ -131,14 +131,12 @@ export async function runStdioServer({ workspace, policy, logLevel = "info" }) {
|
|
|
131
131
|
const result = await runtime.executeTool(name, args, { callId });
|
|
132
132
|
send(rpcResult(message.id, toolResult(result)));
|
|
133
133
|
const durationMs = Date.now() - started;
|
|
134
|
-
|
|
135
|
-
else logger.debug("tool call completed", { call_id: callId.slice(0, 20), tool: name, duration_ms: durationMs });
|
|
134
|
+
logger.debug(durationMs >= SLOW_TOOL_CALL_MS ? "slow tool call completed" : "tool call completed", { call_id: callId.slice(0, 20), tool: name, duration_ms: durationMs });
|
|
136
135
|
} catch (error) {
|
|
137
136
|
const safeError = runtime.safeErrorMessage(error);
|
|
138
137
|
send(rpcResult(message.id, toolResult({ error: safeError }, true)));
|
|
139
138
|
const durationMs = Date.now() - started;
|
|
140
|
-
logger.
|
|
141
|
-
logger.debug("tool call failure correlation", { call_id: callId.slice(0, 20) });
|
|
139
|
+
logger.debug("tool call failed", { call_id: callId.slice(0, 20), tool: name, duration_ms: durationMs, error_class: classifyOperationalError(error) });
|
|
142
140
|
} finally {
|
|
143
141
|
if (key) pending.delete(key);
|
|
144
142
|
runtime.finishCall(callId);
|
package/src/local/tools.mjs
CHANGED
|
@@ -9,7 +9,7 @@ export const MCP_INSTRUCTIONS = Object.freeze(serverMetadata.instructions.map((v
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
export const DEFAULT_POLICY_PROFILE = "full";
|
|
12
|
-
export const DEFAULT_POLICY_REVISION =
|
|
12
|
+
export const DEFAULT_POLICY_REVISION = 3;
|
|
13
13
|
|
|
14
14
|
export const POLICY_PROFILES = Object.freeze({
|
|
15
15
|
review: Object.freeze({ profile: "review", allowWrite: false, execMode: "off", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
|
|
@@ -34,8 +34,9 @@ export function normalizePolicy(policy = {}) {
|
|
|
34
34
|
: "off";
|
|
35
35
|
const origin = POLICY_ORIGINS.has(policy.origin) ? policy.origin : "custom";
|
|
36
36
|
const revision = Number.isInteger(policy.revision) && policy.revision > 0 ? policy.revision : DEFAULT_POLICY_REVISION;
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const requestedProfile = typeof policy.profile === "string" && policy.profile ? policy.profile : "custom";
|
|
38
|
+
const normalized = {
|
|
39
|
+
profile: requestedProfile,
|
|
39
40
|
origin,
|
|
40
41
|
revision,
|
|
41
42
|
allowWrite: policy.allowWrite === true,
|
|
@@ -45,6 +46,38 @@ export function normalizePolicy(policy = {}) {
|
|
|
45
46
|
minimalEnv: policy.minimalEnv !== false,
|
|
46
47
|
exposeAbsolutePaths: policy.exposeAbsolutePaths === true,
|
|
47
48
|
};
|
|
49
|
+
if (POLICY_PROFILES[requestedProfile]) {
|
|
50
|
+
const canonical = POLICY_PROFILES[requestedProfile];
|
|
51
|
+
normalized.allowWrite = canonical.allowWrite;
|
|
52
|
+
normalized.allowExec = canonical.execMode !== "off";
|
|
53
|
+
normalized.execMode = canonical.execMode;
|
|
54
|
+
normalized.unrestrictedPaths = canonical.unrestrictedPaths;
|
|
55
|
+
normalized.minimalEnv = canonical.minimalEnv;
|
|
56
|
+
normalized.exposeAbsolutePaths = canonical.exposeAbsolutePaths;
|
|
57
|
+
}
|
|
58
|
+
return normalized;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isCanonicalFullPolicy(policy = {}) {
|
|
62
|
+
return policyCapabilitiesEqual(normalizePolicy(policy), POLICY_PROFILES.full);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function assertCanonicalFullPolicy(policy = {}) {
|
|
66
|
+
const normalized = normalizePolicy(policy);
|
|
67
|
+
if (!isCanonicalFullPolicy(normalized) || normalized.profile !== "full") {
|
|
68
|
+
throw new Error("full profile invariant failed: full must enable writes, shell execution, unrestricted paths, full parent environment, and absolute paths");
|
|
69
|
+
}
|
|
70
|
+
const exposed = toolsForPolicy(normalized);
|
|
71
|
+
if (exposed.length !== catalog.length) throw new Error("full profile invariant failed: complete tool catalog is not exposed");
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function policyCapabilitiesEqual(left, right) {
|
|
76
|
+
return left.allowWrite === right.allowWrite
|
|
77
|
+
&& left.execMode === right.execMode
|
|
78
|
+
&& left.unrestrictedPaths === right.unrestrictedPaths
|
|
79
|
+
&& left.minimalEnv === right.minimalEnv
|
|
80
|
+
&& left.exposeAbsolutePaths === right.exposeAbsolutePaths;
|
|
48
81
|
}
|
|
49
82
|
|
|
50
83
|
export function toolsForPolicy(policy = {}) {
|
|
@@ -92,6 +125,7 @@ function isAvailable(availability, policy) {
|
|
|
92
125
|
if (availability === "write") return policy.allowWrite;
|
|
93
126
|
if (availability === "direct-exec") return policy.execMode === "direct" || policy.execMode === "shell";
|
|
94
127
|
if (availability === "shell-exec") return policy.execMode === "shell";
|
|
128
|
+
if (availability === "full") return policy.profile === "full" && isCanonicalFullPolicy(policy);
|
|
95
129
|
return false;
|
|
96
130
|
}
|
|
97
131
|
|
|
@@ -10,8 +10,17 @@
|
|
|
10
10
|
"You are connected to a local workspace through machine-bridge-mcp.",
|
|
11
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
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 may enforce additional independent rules.",
|
|
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
|
+
"A named full profile is canonical: it always enables writes, shell execution, unrestricted paths, the full parent environment, absolute paths, and the complete tool catalog. Any explicit narrowing is stored as custom.",
|
|
15
|
+
"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.",
|
|
16
|
+
"Never request or return secret-file contents when a local resource alias can be used. Resources are registered through the local machine-mcp CLI; under canonical full policy, generate_ssh_key_resource can generate and register an Ed25519 key without returning private content and may be injected by path, stdin, or environment without entering MCP arguments.",
|
|
17
|
+
"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.",
|
|
18
|
+
"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.",
|
|
19
|
+
"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.",
|
|
20
|
+
"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.",
|
|
21
|
+
"Do not use shell encoding, renaming, or alternate tools to bypass host or platform safety policy.",
|
|
14
22
|
"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.",
|
|
23
|
+
"Per-tool success, failure, cancellation, and timing events are debug-only; default logs contain lifecycle and infrastructure events, not a tool-call transcript.",
|
|
15
24
|
"Inspect before editing, prefer read_file line ranges, edit_file, or apply_patch over whole-file replacement, and report commands that were run."
|
|
16
25
|
]
|
|
17
26
|
}
|