machine-bridge-mcp 0.7.1 → 0.8.1
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 +41 -0
- package/CONTRIBUTING.md +8 -0
- package/README.md +5 -5
- package/docs/ARCHITECTURE.md +8 -6
- package/docs/ENGINEERING.md +160 -0
- package/docs/LOGGING.md +65 -28
- package/docs/OPERATIONS.md +7 -1
- package/docs/PRIVACY.md +6 -0
- package/docs/TESTING.md +9 -3
- package/package.json +8 -4
- package/src/local/cli.mjs +339 -280
- package/src/local/full-access-test.mjs +2 -2
- package/src/local/job-runner.mjs +3 -18
- package/src/local/log.mjs +2 -0
- package/src/local/managed-jobs.mjs +57 -77
- package/src/local/relay-connection.mjs +517 -0
- package/src/local/{daemon.mjs → runtime.mjs} +145 -188
- package/src/local/secure-file.mjs +27 -0
- package/src/local/service.mjs +43 -7
- package/src/local/ssh-key.mjs +37 -19
- package/src/local/stdio.mjs +86 -70
- package/src/worker/index.ts +11 -4
package/src/local/cli.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSy
|
|
|
3
3
|
import path, { join, resolve } from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import readline from "node:readline/promises";
|
|
6
|
-
import {
|
|
6
|
+
import { LocalRuntime } from "./runtime.mjs";
|
|
7
7
|
import { runStdioServer } from "./stdio.mjs";
|
|
8
8
|
import { assertCanonicalFullPolicy, DEFAULT_POLICY_PROFILE, DEFAULT_POLICY_REVISION, POLICY_PROFILES, normalizePolicy, policyProfile, toolsForPolicy } from "./tools.mjs";
|
|
9
9
|
import { classifyOperationalError, createLogger, normalizeLogLevel, sanitizeLogText } from "./log.mjs";
|
|
@@ -46,6 +46,22 @@ const VALUE_OPTIONS = new Set([
|
|
|
46
46
|
"workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel",
|
|
47
47
|
]);
|
|
48
48
|
|
|
49
|
+
const COMMAND_HANDLERS = Object.freeze({
|
|
50
|
+
start: startCommand,
|
|
51
|
+
stdio: stdioCommand,
|
|
52
|
+
"client-config": clientConfigCommand,
|
|
53
|
+
status: statusCommand,
|
|
54
|
+
doctor: doctorCommand,
|
|
55
|
+
"full-test": fullTestCommand,
|
|
56
|
+
workspace: workspaceCommand,
|
|
57
|
+
service: serviceCommand,
|
|
58
|
+
autostart: serviceCommand,
|
|
59
|
+
"rotate-secrets": rotateSecretsCommand,
|
|
60
|
+
resource: resourceCommand,
|
|
61
|
+
job: jobCommand,
|
|
62
|
+
uninstall: uninstallCommand,
|
|
63
|
+
});
|
|
64
|
+
|
|
49
65
|
export async function main(argv = process.argv.slice(2)) {
|
|
50
66
|
const [command, rest] = normalizeCommand(argv);
|
|
51
67
|
const args = parseArgs(rest);
|
|
@@ -55,26 +71,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
55
71
|
validateCommandOptions(command, args);
|
|
56
72
|
validatePositionals(command, args);
|
|
57
73
|
validateLoggingOptions(args);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
case "status": return statusCommand(args);
|
|
64
|
-
case "doctor": return doctorCommand(args);
|
|
65
|
-
case "full-test": return fullTestCommand(args);
|
|
66
|
-
case "workspace": return workspaceCommand(args);
|
|
67
|
-
case "service":
|
|
68
|
-
case "autostart": return serviceCommand(args);
|
|
69
|
-
case "rotate-secrets": return rotateSecretsCommand(args);
|
|
70
|
-
case "resource": return resourceCommand(args);
|
|
71
|
-
case "job": return jobCommand(args);
|
|
72
|
-
case "uninstall": return uninstallCommand(args);
|
|
73
|
-
default:
|
|
74
|
-
console.error(`Unknown command: ${command}`);
|
|
75
|
-
usage();
|
|
76
|
-
process.exitCode = 2;
|
|
77
|
-
}
|
|
74
|
+
const handler = COMMAND_HANDLERS[command];
|
|
75
|
+
if (handler) return handler(args);
|
|
76
|
+
console.error(`Unknown command: ${command}`);
|
|
77
|
+
usage();
|
|
78
|
+
process.exitCode = 2;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
const COMMAND_OPTIONS = {
|
|
@@ -125,44 +126,55 @@ function toKebab(value) {
|
|
|
125
126
|
return String(value).replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`);
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
129
|
+
const SINGLE_WORKSPACE_POSITIONAL_COMMANDS = new Set(["start", "stdio", "status", "doctor", "full-test", "rotate-secrets"]);
|
|
130
|
+
const STATIC_POSITIONAL_RULES = Object.freeze({
|
|
131
|
+
"client-config": Object.freeze({ max: 1, tooMany: "client-config accepts at most one positional client name" }),
|
|
132
|
+
uninstall: Object.freeze({ max: 0, tooMany: "uninstall does not accept positional arguments" }),
|
|
133
|
+
});
|
|
134
|
+
const RESOURCE_POSITIONAL_LIMITS = Object.freeze({ add: 3, "generate-ssh-key": 3, remove: 2, check: 2 });
|
|
135
|
+
const JOB_POSITIONAL_LIMITS = Object.freeze({ read: 2, inspect: 2, cancel: 2, approve: 2, submit: 2 });
|
|
136
|
+
const ACTION_POSITIONAL_RULES = Object.freeze({
|
|
137
|
+
workspace(args) {
|
|
136
138
|
const action = String(args._[0] || "show");
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
if (command === "service" || command === "autostart") {
|
|
139
|
+
return { max: action === "set" || action === "select" ? 2 : 1, tooMany: `workspace ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
|
|
140
|
+
},
|
|
141
|
+
service(args) {
|
|
143
142
|
const action = String(args._[0] || "status");
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (count > 1) throw new Error("client-config accepts at most one positional client name");
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
if (command === "resource") {
|
|
143
|
+
return { max: action === "install" ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
|
|
144
|
+
},
|
|
145
|
+
autostart(args) {
|
|
146
|
+
return ACTION_POSITIONAL_RULES.service(args);
|
|
147
|
+
},
|
|
148
|
+
resource(args) {
|
|
154
149
|
const action = String(args._[0] || "list");
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
if (command === "job") {
|
|
150
|
+
return { max: RESOURCE_POSITIONAL_LIMITS[action] ?? 1, tooMany: `resource ${action} received too many positional arguments` };
|
|
151
|
+
},
|
|
152
|
+
job(args) {
|
|
160
153
|
const action = String(args._[0] || "list");
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
154
|
+
return { max: JOB_POSITIONAL_LIMITS[action] ?? 1, tooMany: `job ${action} received too many positional arguments` };
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
export function validatePositionals(command, args) {
|
|
159
|
+
const count = args._.length;
|
|
160
|
+
const rule = positionalRule(command, args);
|
|
161
|
+
if (!rule) return;
|
|
162
|
+
if (count > rule.max) throw new Error(rule.tooMany);
|
|
163
|
+
if (args.workspace && Number.isInteger(rule.workspaceConflictAfter) && count > rule.workspaceConflictAfter) {
|
|
164
|
+
throw new Error("workspace path was provided both positionally and with --workspace");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function positionalRule(command, args) {
|
|
169
|
+
if (SINGLE_WORKSPACE_POSITIONAL_COMMANDS.has(command)) {
|
|
170
|
+
return {
|
|
171
|
+
max: 1,
|
|
172
|
+
tooMany: `${command} accepts at most one positional workspace path`,
|
|
173
|
+
workspaceConflictAfter: 0,
|
|
174
|
+
};
|
|
164
175
|
}
|
|
165
|
-
if (command
|
|
176
|
+
if (STATIC_POSITIONAL_RULES[command]) return STATIC_POSITIONAL_RULES[command];
|
|
177
|
+
return ACTION_POSITIONAL_RULES[command]?.(args) || null;
|
|
166
178
|
}
|
|
167
179
|
|
|
168
180
|
function normalizeCommand(argv) {
|
|
@@ -300,138 +312,173 @@ async function startCommand(args) {
|
|
|
300
312
|
}
|
|
301
313
|
|
|
302
314
|
try {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
// Stop an installed service before acquiring the runtime lock. If a
|
|
308
|
-
// foreground daemon owns the lock, no new policy or secret state is saved.
|
|
309
|
-
await stopAutostartBestEffort(logger);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const lock = acquireDaemonLock(state);
|
|
313
|
-
if (!lock.acquired) {
|
|
314
|
-
const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
|
|
315
|
-
logger.warn(`local daemon already running for this workspace (${pid}); requested changes were not applied`);
|
|
316
|
-
if (args.json) printStartJson(state, {
|
|
317
|
-
showCredentials: Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials),
|
|
318
|
-
requestedChangesApplied: false,
|
|
319
|
-
notice: "local daemon already running; requested changes were not applied",
|
|
320
|
-
});
|
|
321
|
-
else printMcpConnection(state, {
|
|
322
|
-
noPrintCredentials: Boolean(args.noPrintCredentials),
|
|
323
|
-
includeCredentials: Boolean(args.printMcpCredentials || args.printCredentials),
|
|
324
|
-
quiet: Boolean(args.quiet),
|
|
325
|
-
verbose: Boolean(args.verbose),
|
|
326
|
-
});
|
|
315
|
+
await prepareStartMode(args, state, logger);
|
|
316
|
+
const daemonLock = acquireDaemonLock(state);
|
|
317
|
+
if (!daemonLock.acquired) {
|
|
318
|
+
reportExistingDaemon(args, state, daemonLock.owner, logger);
|
|
327
319
|
return;
|
|
328
320
|
}
|
|
329
|
-
|
|
330
|
-
let daemon = null;
|
|
331
|
-
try {
|
|
332
|
-
const previousMcpServerUrl = state.worker?.mcpServerUrl || "";
|
|
333
|
-
const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
|
|
334
|
-
const workerName = validateWorkerName(args.workerName);
|
|
335
|
-
ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName });
|
|
336
|
-
const previousPolicyOrigin = state.policy?.origin;
|
|
337
|
-
state.policy = resolvePolicy(args, state.policy);
|
|
338
|
-
const policyMigrated = !previousPolicyOrigin && state.policy.origin === "migrated";
|
|
339
|
-
state.policy.updatedAt = new Date().toISOString();
|
|
340
|
-
saveState(state);
|
|
341
|
-
|
|
342
|
-
if (!args.daemonOnly) await ensureWorker(state, args);
|
|
343
|
-
else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
|
|
344
|
-
|
|
345
|
-
const mcpConnectionChanged = previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl;
|
|
346
|
-
const shouldPrintMcpCredentials = Boolean(args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
|
|
347
|
-
|
|
348
|
-
if (!args.daemonOnly && !args.noAutostart) {
|
|
349
|
-
await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], logger });
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
daemon = new LocalDaemon({
|
|
353
|
-
workerUrl: state.worker.url,
|
|
354
|
-
secret: state.worker.daemonSecret,
|
|
355
|
-
workspace,
|
|
356
|
-
policy: state.policy,
|
|
357
|
-
logger: createLogger({ level: args.json ? "error" : effectiveLogLevel(args), component: "daemon" }),
|
|
358
|
-
jobRoot: join(state.paths.profileDir, "jobs"),
|
|
359
|
-
resources: state.resources,
|
|
360
|
-
resourceStatePath: state.paths.statePath,
|
|
361
|
-
onSuperseded: () => {
|
|
362
|
-
logger.warn("this daemon was replaced by a newer authenticated instance; exiting without reconnecting");
|
|
363
|
-
lock.release();
|
|
364
|
-
process.exit(0);
|
|
365
|
-
},
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
const waitForConnect = daemon.start();
|
|
369
|
-
await waitForConnectWithNotice(waitForConnect, 20_000, logger);
|
|
370
|
-
if (args.json) printStartJson(state, {
|
|
371
|
-
showCredentials: Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials),
|
|
372
|
-
notice: policyMigrated ? "legacy implicit policy migrated to full access" : "",
|
|
373
|
-
});
|
|
374
|
-
else {
|
|
375
|
-
printMcpConnection(state, {
|
|
376
|
-
noPrintCredentials: Boolean(args.noPrintCredentials),
|
|
377
|
-
includeCredentials: shouldPrintMcpCredentials,
|
|
378
|
-
quiet: Boolean(args.quiet),
|
|
379
|
-
verbose: Boolean(args.verbose),
|
|
380
|
-
policyMigrated,
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
keepProcessAlive({ daemon, lock, logger });
|
|
384
|
-
} catch (error) {
|
|
385
|
-
try { daemon?.stop?.(); } catch {}
|
|
386
|
-
lock.release();
|
|
387
|
-
throw error;
|
|
388
|
-
}
|
|
321
|
+
await startRemoteRuntime({ args, workspace, state, daemonLock, logger });
|
|
389
322
|
} finally {
|
|
390
323
|
startupLock.release();
|
|
391
324
|
}
|
|
392
325
|
}
|
|
393
326
|
|
|
327
|
+
async function prepareStartMode(args, state, logger) {
|
|
328
|
+
if (args.daemonOnly) {
|
|
329
|
+
const { trimAutostartLogs } = await import("./service.mjs");
|
|
330
|
+
trimAutostartLogs(state.paths.stateRoot);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Stop an installed service before acquiring the runtime lock. If a
|
|
334
|
+
// foreground daemon owns the lock, no new policy or secret state is saved.
|
|
335
|
+
await stopAutostartBestEffort(logger);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function reportExistingDaemon(args, state, owner, logger) {
|
|
339
|
+
const pid = owner?.pid ? `pid ${owner.pid}` : "unknown pid";
|
|
340
|
+
logger.warn(`local daemon already running for this workspace (${pid}); requested changes were not applied`);
|
|
341
|
+
if (args.json) {
|
|
342
|
+
printStartJson(state, {
|
|
343
|
+
showCredentials: Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials),
|
|
344
|
+
requestedChangesApplied: false,
|
|
345
|
+
notice: "local daemon already running; requested changes were not applied",
|
|
346
|
+
});
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
printMcpConnection(state, {
|
|
350
|
+
noPrintCredentials: Boolean(args.noPrintCredentials),
|
|
351
|
+
includeCredentials: Boolean(args.printMcpCredentials || args.printCredentials),
|
|
352
|
+
quiet: Boolean(args.quiet),
|
|
353
|
+
verbose: Boolean(args.verbose),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function startRemoteRuntime({ args, workspace, state, daemonLock, logger }) {
|
|
358
|
+
let runtime = null;
|
|
359
|
+
try {
|
|
360
|
+
const readiness = await prepareRemoteState({ args, workspace, state, logger });
|
|
361
|
+
runtime = createRemoteRuntime({ args, workspace, state, daemonLock, logger });
|
|
362
|
+
await runtime.start();
|
|
363
|
+
reportRemoteReady(args, state, readiness);
|
|
364
|
+
keepProcessAlive({ daemon: runtime, lock: daemonLock, logger });
|
|
365
|
+
} catch (error) {
|
|
366
|
+
try { runtime?.stop?.(); } catch {}
|
|
367
|
+
daemonLock.release();
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function prepareRemoteState({ args, workspace, state, logger }) {
|
|
373
|
+
const previousMcpServerUrl = state.worker?.mcpServerUrl || "";
|
|
374
|
+
const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
|
|
375
|
+
const workerName = validateWorkerName(args.workerName);
|
|
376
|
+
ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName });
|
|
377
|
+
const previousPolicyOrigin = state.policy?.origin;
|
|
378
|
+
state.policy = resolvePolicy(args, state.policy);
|
|
379
|
+
const policyMigrated = !previousPolicyOrigin && state.policy.origin === "migrated";
|
|
380
|
+
state.policy.updatedAt = new Date().toISOString();
|
|
381
|
+
saveState(state);
|
|
382
|
+
|
|
383
|
+
if (!args.daemonOnly) await ensureWorker(state, args);
|
|
384
|
+
else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
|
|
385
|
+
|
|
386
|
+
if (!args.daemonOnly && !args.noAutostart) {
|
|
387
|
+
await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], logger });
|
|
388
|
+
}
|
|
389
|
+
const mcpConnectionChanged = Boolean(previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl);
|
|
390
|
+
return {
|
|
391
|
+
policyMigrated,
|
|
392
|
+
shouldPrintMcpCredentials: Boolean(args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function createRemoteRuntime({ args, workspace, state, daemonLock, logger }) {
|
|
397
|
+
return new LocalRuntime({
|
|
398
|
+
workerUrl: state.worker.url,
|
|
399
|
+
secret: state.worker.daemonSecret,
|
|
400
|
+
expectedRelayVersion: currentPackageVersion(),
|
|
401
|
+
workspace,
|
|
402
|
+
policy: state.policy,
|
|
403
|
+
logger: createLogger({ level: args.json ? "error" : effectiveLogLevel(args), component: "daemon" }),
|
|
404
|
+
jobRoot: join(state.paths.profileDir, "jobs"),
|
|
405
|
+
resources: state.resources,
|
|
406
|
+
resourceStatePath: state.paths.statePath,
|
|
407
|
+
onSuperseded: () => {
|
|
408
|
+
daemonLock.release();
|
|
409
|
+
process.exit(0);
|
|
410
|
+
},
|
|
411
|
+
onFatal: () => {
|
|
412
|
+
daemonLock.release();
|
|
413
|
+
process.exit(1);
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function reportRemoteReady(args, state, readiness) {
|
|
419
|
+
if (args.json) {
|
|
420
|
+
printStartJson(state, {
|
|
421
|
+
showCredentials: Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials),
|
|
422
|
+
notice: readiness.policyMigrated ? "legacy implicit policy migrated to full access" : "",
|
|
423
|
+
});
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
printMcpConnection(state, {
|
|
427
|
+
noPrintCredentials: Boolean(args.noPrintCredentials),
|
|
428
|
+
includeCredentials: readiness.shouldPrintMcpCredentials,
|
|
429
|
+
quiet: Boolean(args.quiet),
|
|
430
|
+
verbose: Boolean(args.verbose),
|
|
431
|
+
policyMigrated: readiness.policyMigrated,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
394
435
|
export function resolvePolicy(args = {}, stored = {}) {
|
|
395
436
|
const hasStored = stored && typeof stored === "object" && (
|
|
396
437
|
typeof stored.allowWrite === "boolean" || typeof stored.allowExec === "boolean" || typeof stored.execMode === "string"
|
|
397
438
|
);
|
|
398
|
-
const explicitKeys = ["profile",
|
|
439
|
+
const explicitKeys = ["profile", ...POLICY_OVERRIDE_KEYS];
|
|
399
440
|
const hasExplicit = explicitKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key));
|
|
400
|
-
|
|
441
|
+
const base = selectPolicyBase(args, stored, hasStored);
|
|
442
|
+
if (!hasExplicit) return normalizePolicy(base);
|
|
443
|
+
applyPolicyOverrides(base, args);
|
|
444
|
+
if (args.profile === undefined || POLICY_OVERRIDE_KEYS.some((key) => Object.prototype.hasOwnProperty.call(args, key))) {
|
|
445
|
+
base.profile = "custom";
|
|
446
|
+
base.origin = "custom";
|
|
447
|
+
base.revision = DEFAULT_POLICY_REVISION;
|
|
448
|
+
}
|
|
449
|
+
return normalizePolicy(base);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const POLICY_OVERRIDE_KEYS = Object.freeze(["execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"]);
|
|
401
453
|
|
|
454
|
+
function selectPolicyBase(args, stored, hasStored) {
|
|
402
455
|
if (args.profile !== undefined) {
|
|
403
456
|
const profile = String(args.profile).trim().toLowerCase();
|
|
404
457
|
if (!POLICY_PROFILES[profile]) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
|
|
405
|
-
|
|
406
|
-
} else if (hasStored) {
|
|
407
|
-
base = migrateLegacyPolicy(stored);
|
|
408
|
-
} else {
|
|
409
|
-
base = policyProfile(DEFAULT_POLICY_PROFILE, "default");
|
|
458
|
+
return policyProfile(profile, "explicit");
|
|
410
459
|
}
|
|
460
|
+
if (hasStored) return migrateLegacyPolicy(stored);
|
|
461
|
+
return policyProfile(DEFAULT_POLICY_PROFILE, "default");
|
|
462
|
+
}
|
|
411
463
|
|
|
412
|
-
|
|
464
|
+
function applyPolicyOverrides(policy, args) {
|
|
413
465
|
if (args.execMode !== undefined) {
|
|
414
466
|
const execMode = String(args.execMode).trim().toLowerCase();
|
|
415
467
|
if (!["off", "direct", "shell"].includes(execMode)) throw new Error("--exec-mode must be off, direct, or shell");
|
|
416
|
-
|
|
417
|
-
}
|
|
418
|
-
if (args.noWrite === true) base.allowWrite = false;
|
|
419
|
-
if (args.noWrite === false) base.allowWrite = true;
|
|
420
|
-
if (args.noExec === true) base.execMode = "off";
|
|
421
|
-
if (args.noExec === false && base.execMode === "off") base.execMode = "direct";
|
|
422
|
-
if (args.fullEnv === true) base.minimalEnv = false;
|
|
423
|
-
if (args.fullEnv === false) base.minimalEnv = true;
|
|
424
|
-
if (args.unrestrictedPaths === true) base.unrestrictedPaths = true;
|
|
425
|
-
if (args.unrestrictedPaths === false) base.unrestrictedPaths = false;
|
|
426
|
-
if (args.absolutePaths === true) base.exposeAbsolutePaths = true;
|
|
427
|
-
if (args.absolutePaths === false) base.exposeAbsolutePaths = false;
|
|
428
|
-
const overrideKeys = ["execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"];
|
|
429
|
-
if (args.profile === undefined || overrideKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key))) {
|
|
430
|
-
base.profile = "custom";
|
|
431
|
-
base.origin = "custom";
|
|
432
|
-
base.revision = DEFAULT_POLICY_REVISION;
|
|
468
|
+
policy.execMode = execMode;
|
|
433
469
|
}
|
|
434
|
-
|
|
470
|
+
applyBooleanOverride(args, "noWrite", (enabled) => { policy.allowWrite = !enabled; });
|
|
471
|
+
applyBooleanOverride(args, "noExec", (enabled) => {
|
|
472
|
+
if (enabled) policy.execMode = "off";
|
|
473
|
+
else if (policy.execMode === "off") policy.execMode = "direct";
|
|
474
|
+
});
|
|
475
|
+
applyBooleanOverride(args, "fullEnv", (enabled) => { policy.minimalEnv = !enabled; });
|
|
476
|
+
applyBooleanOverride(args, "unrestrictedPaths", (enabled) => { policy.unrestrictedPaths = enabled; });
|
|
477
|
+
applyBooleanOverride(args, "absolutePaths", (enabled) => { policy.exposeAbsolutePaths = enabled; });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function applyBooleanOverride(args, key, apply) {
|
|
481
|
+
if (typeof args[key] === "boolean") apply(args[key]);
|
|
435
482
|
}
|
|
436
483
|
|
|
437
484
|
function migrateLegacyPolicy(stored = {}) {
|
|
@@ -471,137 +518,147 @@ async function stdioCommand(args) {
|
|
|
471
518
|
});
|
|
472
519
|
}
|
|
473
520
|
|
|
521
|
+
const RESOURCE_ACTION_HANDLERS = Object.freeze({
|
|
522
|
+
list: resourceListAction,
|
|
523
|
+
add: resourceAddAction,
|
|
524
|
+
"generate-ssh-key": resourceGenerateSshKeyAction,
|
|
525
|
+
remove: resourceRemoveAction,
|
|
526
|
+
check: resourceCheckAction,
|
|
527
|
+
});
|
|
528
|
+
|
|
474
529
|
async function resourceCommand(args) {
|
|
475
530
|
const action = String(args._[0] || "list").toLowerCase();
|
|
531
|
+
const handler = RESOURCE_ACTION_HANDLERS[action];
|
|
532
|
+
if (!handler) throw new Error(`Unknown resource action: ${action}`);
|
|
476
533
|
const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
477
534
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
478
535
|
state.resources ||= {};
|
|
536
|
+
return handler({ args, workspace, state });
|
|
537
|
+
}
|
|
479
538
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
539
|
+
function resourceListAction({ args, workspace, state }) {
|
|
540
|
+
const includePaths = args.showPaths === true;
|
|
541
|
+
const resources = publicResourceRegistry(state.resources, { includePaths });
|
|
542
|
+
if (args.json) {
|
|
543
|
+
console.log(JSON.stringify({
|
|
484
544
|
workspace: includePaths ? workspace : "<local-workspace>",
|
|
485
545
|
paths_exposed: includePaths,
|
|
486
546
|
resources,
|
|
487
547
|
}, null, 2));
|
|
488
|
-
else if (!Object.keys(resources).length) console.log("No local resources registered.");
|
|
489
|
-
else for (const [name, value] of Object.entries(resources)) {
|
|
490
|
-
const fields = [name, value.mode || "n/a", `${value.size ?? "n/a"} bytes`];
|
|
491
|
-
if (includePaths) fields.splice(1, 0, value.path);
|
|
492
|
-
console.log(fields.join("\t"));
|
|
493
|
-
}
|
|
494
548
|
return;
|
|
495
549
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
const name = validateResourceName(args._[1]);
|
|
499
|
-
const inputPath = args._[2];
|
|
500
|
-
if (!inputPath) throw new Error("resource add requires NAME and FILE_PATH");
|
|
501
|
-
const lock = acquireStartupLock(state);
|
|
502
|
-
if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
|
|
503
|
-
try {
|
|
504
|
-
const latest = loadState(workspace, { stateDir: args.stateDir });
|
|
505
|
-
latest.resources ||= {};
|
|
506
|
-
const inspected = inspectResourceFile(expandHome(inputPath), { allowInsecurePermissions: args.allowInsecurePermissions === true });
|
|
507
|
-
if (!Object.prototype.hasOwnProperty.call(latest.resources, name) && Object.keys(latest.resources).length >= 64) {
|
|
508
|
-
throw new Error("local resource registry limit reached (64)");
|
|
509
|
-
}
|
|
510
|
-
latest.resources[name] = inspected;
|
|
511
|
-
saveState(latest);
|
|
512
|
-
const result = publicResourceInspection(name, inspected, {
|
|
513
|
-
includePath: args.showPaths === true,
|
|
514
|
-
available_to_new_jobs_immediately: true,
|
|
515
|
-
});
|
|
516
|
-
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
517
|
-
else {
|
|
518
|
-
console.log(`Registered local resource: ${name}`);
|
|
519
|
-
if (args.showPaths === true) console.log(`Path: ${inspected.path}`);
|
|
520
|
-
console.log(`Mode: ${inspected.mode || "n/a"}; size: ${inspected.size} bytes`);
|
|
521
|
-
console.log("The resource is available to newly submitted managed jobs immediately.");
|
|
522
|
-
}
|
|
523
|
-
} finally {
|
|
524
|
-
lock.release();
|
|
525
|
-
}
|
|
550
|
+
if (!Object.keys(resources).length) {
|
|
551
|
+
console.log("No local resources registered.");
|
|
526
552
|
return;
|
|
527
553
|
}
|
|
554
|
+
for (const [name, value] of Object.entries(resources)) {
|
|
555
|
+
const fields = [name, value.mode || "n/a", `${value.size ?? "n/a"} bytes`];
|
|
556
|
+
if (includePaths) fields.splice(1, 0, value.path);
|
|
557
|
+
console.log(fields.join(" "));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
528
560
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
561
|
+
function resourceAddAction({ args, workspace, state }) {
|
|
562
|
+
const name = validateResourceName(args._[1]);
|
|
563
|
+
const inputPath = args._[2];
|
|
564
|
+
if (!inputPath) throw new Error("resource add requires NAME and FILE_PATH");
|
|
565
|
+
const lock = acquireStartupLock(state);
|
|
566
|
+
if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
|
|
567
|
+
try {
|
|
568
|
+
const latest = loadState(workspace, { stateDir: args.stateDir });
|
|
569
|
+
latest.resources ||= {};
|
|
570
|
+
const inspected = inspectResourceFile(expandHome(inputPath), { allowInsecurePermissions: args.allowInsecurePermissions === true });
|
|
571
|
+
if (!Object.prototype.hasOwnProperty.call(latest.resources, name) && Object.keys(latest.resources).length >= 64) {
|
|
572
|
+
throw new Error("local resource registry limit reached (64)");
|
|
573
|
+
}
|
|
574
|
+
latest.resources[name] = inspected;
|
|
575
|
+
saveState(latest);
|
|
576
|
+
const result = publicResourceInspection(name, inspected, {
|
|
577
|
+
includePath: args.showPaths === true,
|
|
578
|
+
available_to_new_jobs_immediately: true,
|
|
540
579
|
});
|
|
541
|
-
const includePaths = args.showPaths === true;
|
|
542
|
-
const result = {
|
|
543
|
-
name: key.name,
|
|
544
|
-
created: key.created,
|
|
545
|
-
fingerprint: key.fingerprint,
|
|
546
|
-
key_type: key.keyType,
|
|
547
|
-
private_mode: key.privateMode,
|
|
548
|
-
public_mode: key.publicMode,
|
|
549
|
-
private_key_content_exposed: key.privateKeyContentExposed,
|
|
550
|
-
registered: key.registered,
|
|
551
|
-
available_to_new_jobs_immediately: key.availableToNewJobsImmediately,
|
|
552
|
-
paths_exposed: includePaths,
|
|
553
|
-
...(includePaths ? { private_key_path: key.privateKeyPath, public_key_path: key.publicKeyPath } : {}),
|
|
554
|
-
};
|
|
555
580
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
556
581
|
else {
|
|
557
|
-
console.log(
|
|
558
|
-
if (
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}
|
|
562
|
-
console.log(`Fingerprint: ${key.fingerprint}`);
|
|
563
|
-
console.log("Private key content was not printed or sent through MCP.");
|
|
582
|
+
console.log(`Registered local resource: ${name}`);
|
|
583
|
+
if (args.showPaths === true) console.log(`Path: ${inspected.path}`);
|
|
584
|
+
console.log(`Mode: ${inspected.mode || "n/a"}; size: ${inspected.size} bytes`);
|
|
585
|
+
console.log("The resource is available to newly submitted managed jobs immediately.");
|
|
564
586
|
}
|
|
565
|
-
|
|
587
|
+
} finally {
|
|
588
|
+
lock.release();
|
|
566
589
|
}
|
|
590
|
+
}
|
|
567
591
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
592
|
+
async function resourceGenerateSshKeyAction({ args, workspace }) {
|
|
593
|
+
const name = validateResourceName(args._[1]);
|
|
594
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
595
|
+
if (!home) throw new Error("HOME or USERPROFILE is required to choose a default SSH key path");
|
|
596
|
+
const requestedPath = args._[2] ? expandHome(args._[2]) : join(home, ".ssh", `machine-mcp-${name}-ed25519`);
|
|
597
|
+
const key = await generateRegisteredSshKey({
|
|
598
|
+
workspace,
|
|
599
|
+
stateDir: args.stateDir,
|
|
600
|
+
name,
|
|
601
|
+
targetPath: requestedPath,
|
|
602
|
+
comment: `machine-mcp:${name}`,
|
|
603
|
+
});
|
|
604
|
+
const includePaths = args.showPaths === true;
|
|
605
|
+
const result = {
|
|
606
|
+
name: key.name,
|
|
607
|
+
created: key.created,
|
|
608
|
+
fingerprint: key.fingerprint,
|
|
609
|
+
key_type: key.keyType,
|
|
610
|
+
private_mode: key.privateMode,
|
|
611
|
+
public_mode: key.publicMode,
|
|
612
|
+
private_key_content_exposed: key.privateKeyContentExposed,
|
|
613
|
+
registered: key.registered,
|
|
614
|
+
available_to_new_jobs_immediately: key.availableToNewJobsImmediately,
|
|
615
|
+
paths_exposed: includePaths,
|
|
616
|
+
...(includePaths ? { private_key_path: key.privateKeyPath, public_key_path: key.publicKeyPath } : {}),
|
|
617
|
+
};
|
|
618
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
619
|
+
else {
|
|
620
|
+
console.log(`${key.created ? "Generated and registered" : "Reused and registered"} SSH key resource: ${name}`);
|
|
621
|
+
if (includePaths) {
|
|
622
|
+
console.log(`Private key: ${key.privateKeyPath}`);
|
|
623
|
+
console.log(`Public key: ${key.publicKeyPath}`);
|
|
586
624
|
}
|
|
587
|
-
|
|
625
|
+
console.log(`Fingerprint: ${key.fingerprint}`);
|
|
626
|
+
console.log("Private key content was not printed or sent through MCP.");
|
|
588
627
|
}
|
|
628
|
+
}
|
|
589
629
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const
|
|
630
|
+
function resourceRemoveAction({ args, workspace, state }) {
|
|
631
|
+
const name = validateResourceName(args._[1]);
|
|
632
|
+
const lock = acquireStartupLock(state);
|
|
633
|
+
if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
|
|
634
|
+
try {
|
|
635
|
+
const latest = loadState(workspace, { stateDir: args.stateDir });
|
|
636
|
+
latest.resources ||= {};
|
|
637
|
+
const existed = Object.prototype.hasOwnProperty.call(latest.resources, name);
|
|
638
|
+
delete latest.resources[name];
|
|
639
|
+
saveState(latest);
|
|
640
|
+
const result = { name, removed: existed, affects_new_jobs_immediately: true };
|
|
596
641
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
597
642
|
else {
|
|
598
|
-
|
|
599
|
-
console.log(
|
|
643
|
+
console.log(existed ? `Removed local resource: ${name}` : `Local resource was not registered: ${name}`);
|
|
644
|
+
console.log("The change applies to newly submitted managed jobs immediately.");
|
|
600
645
|
}
|
|
601
|
-
|
|
646
|
+
} finally {
|
|
647
|
+
lock.release();
|
|
602
648
|
}
|
|
649
|
+
}
|
|
603
650
|
|
|
604
|
-
|
|
651
|
+
function resourceCheckAction({ args, state }) {
|
|
652
|
+
const name = validateResourceName(args._[1]);
|
|
653
|
+
const resource = state.resources[name];
|
|
654
|
+
if (!resource) throw new Error(`local resource is not registered: ${name}`);
|
|
655
|
+
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
|
|
656
|
+
const result = publicResourceInspection(name, inspected, { includePath: args.showPaths === true });
|
|
657
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
658
|
+
else {
|
|
659
|
+
const pathDetail = args.showPaths === true ? ` at ${inspected.path}` : "";
|
|
660
|
+
console.log(`${name}: available${pathDetail} (${inspected.mode || "n/a"}, ${inspected.size} bytes)`);
|
|
661
|
+
}
|
|
605
662
|
}
|
|
606
663
|
|
|
607
664
|
function publicResourceInspection(name, inspected, { includePath = false, ...extra } = {}) {
|
|
@@ -700,7 +757,8 @@ async function ensureWorker(state, args) {
|
|
|
700
757
|
logger.success("Worker unchanged and healthy", { url: state.worker.url });
|
|
701
758
|
return state.worker;
|
|
702
759
|
}
|
|
703
|
-
logger.warn("Worker
|
|
760
|
+
logger.warn("Worker is not healthy at the expected version; redeploying automatically", { reason: workerHealthUserReason(health.error) });
|
|
761
|
+
logger.debug("Worker health check detail", { health_error: health.error });
|
|
704
762
|
}
|
|
705
763
|
|
|
706
764
|
logger.info("Checking Cloudflare Wrangler login");
|
|
@@ -824,6 +882,18 @@ async function workerHealth(workerUrl, expectedVersion = currentPackageVersion()
|
|
|
824
882
|
}
|
|
825
883
|
}
|
|
826
884
|
|
|
885
|
+
export function workerHealthUserReason(value) {
|
|
886
|
+
const reason = String(value || "");
|
|
887
|
+
if (reason.startsWith("version_mismatch:")) return "deployed version does not match the local package";
|
|
888
|
+
if (/^HTTP \d+$/.test(reason)) return "health endpoint returned an HTTP error";
|
|
889
|
+
if (reason === "unexpected_health_response") return "health endpoint returned an unexpected response";
|
|
890
|
+
if (reason === "timeout") return "health check timed out";
|
|
891
|
+
if (reason === "tls_error") return "TLS validation failed";
|
|
892
|
+
if (reason === "network_error") return "network request failed";
|
|
893
|
+
if (reason === "missing_worker_url") return "Worker URL is missing";
|
|
894
|
+
return "health check failed";
|
|
895
|
+
}
|
|
896
|
+
|
|
827
897
|
function workerHealthError(error) {
|
|
828
898
|
const message = String(error?.message || error || "");
|
|
829
899
|
if (/timeout|aborted/i.test(message)) return "timeout";
|
|
@@ -849,17 +919,6 @@ function extractWorkerUrl(text = "") {
|
|
|
849
919
|
return anyHttps.find(match => /workers\.dev|\/healthz|\/mcp/.test(match[0]))?.[0]?.replace(/[),.]+$/, "") || "";
|
|
850
920
|
}
|
|
851
921
|
|
|
852
|
-
async function waitForConnectWithNotice(promise, timeoutMs, logger) {
|
|
853
|
-
let timeout;
|
|
854
|
-
const timed = new Promise(resolvePromise => {
|
|
855
|
-
timeout = setTimeout(() => resolvePromise("timeout"), timeoutMs);
|
|
856
|
-
});
|
|
857
|
-
const result = await Promise.race([promise.then(() => "connected"), timed]);
|
|
858
|
-
clearTimeout(timeout);
|
|
859
|
-
if (result === "timeout") logger.warn("Still connecting; the process will keep retrying");
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
|
|
863
922
|
function printStartJson(state, { showCredentials = false, requestedChangesApplied = true, notice = "" } = {}) {
|
|
864
923
|
createLogger({ component: "ready" }).json({
|
|
865
924
|
mcp: {
|
|
@@ -967,7 +1026,7 @@ async function doctorCommand(args) {
|
|
|
967
1026
|
}
|
|
968
1027
|
const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
|
|
969
1028
|
checks.push({ name: "worker-health", ok: health.ok, detail: health.ok ? state.worker.url : health.error });
|
|
970
|
-
const diagnosticRuntime = new
|
|
1029
|
+
const diagnosticRuntime = new LocalRuntime({
|
|
971
1030
|
workspace,
|
|
972
1031
|
policy: state.policy,
|
|
973
1032
|
logger: createLogger({ level: "error", component: "doctor" }),
|
|
@@ -1264,7 +1323,7 @@ function usage() {
|
|
|
1264
1323
|
console.log(`machine-bridge-mcp
|
|
1265
1324
|
|
|
1266
1325
|
Usage:
|
|
1267
|
-
npm install -g --allow-scripts=esbuild,workerd,sharp machine-bridge-mcp@latest && machine-mcp
|
|
1326
|
+
npm install -g --omit=optional --allow-scripts=esbuild,workerd,sharp,fsevents machine-bridge-mcp@latest && machine-mcp
|
|
1268
1327
|
npx machine-bridge-mcp@latest # no global install; autostart may rely on npm cache
|
|
1269
1328
|
./mbm # from source checkout
|
|
1270
1329
|
.\\mbm.cmd # from source checkout on Windows cmd
|