machine-bridge-mcp 0.6.0 → 0.7.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.
@@ -0,0 +1,37 @@
1
+ import { renameSync } from "node:fs";
2
+
3
+ const TRANSIENT_REPLACE_ERRORS = new Set(["EACCES", "EBUSY", "EPERM", "ENOTEMPTY"]);
4
+ const WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
5
+
6
+ export function replaceFileSync(source, target, options = {}) {
7
+ const rename = typeof options.rename === "function" ? options.rename : renameSync;
8
+ const attempts = clampInteger(options.attempts, 8, 1, 32);
9
+ const baseDelayMs = clampInteger(options.baseDelayMs, 15, 0, 1000);
10
+ let lastError;
11
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
12
+ try {
13
+ rename(source, target);
14
+ return { attempts: attempt };
15
+ } catch (error) {
16
+ lastError = error;
17
+ if (!isTransientReplaceError(error) || attempt === attempts) throw error;
18
+ sleepSync(Math.min(baseDelayMs * attempt, 250));
19
+ }
20
+ }
21
+ throw lastError || new Error("atomic file replacement failed");
22
+ }
23
+
24
+ export function isTransientReplaceError(error) {
25
+ return TRANSIENT_REPLACE_ERRORS.has(String(error?.code || ""));
26
+ }
27
+
28
+ function sleepSync(milliseconds) {
29
+ if (milliseconds <= 0) return;
30
+ Atomics.wait(WAIT_BUFFER, 0, 0, milliseconds);
31
+ }
32
+
33
+ function clampInteger(value, fallback, minimum, maximum) {
34
+ const parsed = Number.parseInt(String(value ?? ""), 10);
35
+ const number = Number.isFinite(parsed) ? parsed : fallback;
36
+ return Math.min(Math.max(number, minimum), maximum);
37
+ }
package/src/local/cli.mjs CHANGED
@@ -5,10 +5,12 @@ import process from "node:process";
5
5
  import readline from "node:readline/promises";
6
6
  import { LocalDaemon } from "./daemon.mjs";
7
7
  import { runStdioServer } from "./stdio.mjs";
8
- import { DEFAULT_POLICY_PROFILE, DEFAULT_POLICY_REVISION, POLICY_PROFILES, normalizePolicy, policyProfile } from "./tools.mjs";
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";
10
10
  import { activeManagedJobs, inspectResourceFile, loadManagedJobPlan, ManagedJobManager, publicResourceRegistry, validateResourceName } from "./managed-jobs.mjs";
11
11
  import { runWrangler } from "./shell.mjs";
12
+ import { generateRegisteredSshKey } from "./resource-operations.mjs";
13
+ import { runFullAccessTest } from "./full-access-test.mjs";
12
14
  import {
13
15
  acquireDaemonLock,
14
16
  acquireStartupLock,
@@ -38,7 +40,7 @@ const BOOLEAN_OPTIONS = new Set([
38
40
  "help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
39
41
  "daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials",
40
42
  "printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
41
- "yes", "keepWorker", "allowInsecurePermissions",
43
+ "yes", "keepWorker", "allowInsecurePermissions", "showPaths",
42
44
  ]);
43
45
  const VALUE_OPTIONS = new Set([
44
46
  "workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel",
@@ -60,6 +62,7 @@ export async function main(argv = process.argv.slice(2)) {
60
62
  case "client-config": return clientConfigCommand(args);
61
63
  case "status": return statusCommand(args);
62
64
  case "doctor": return doctorCommand(args);
65
+ case "full-test": return fullTestCommand(args);
63
66
  case "workspace": return workspaceCommand(args);
64
67
  case "service":
65
68
  case "autostart": return serviceCommand(args);
@@ -84,11 +87,12 @@ const COMMAND_OPTIONS = {
84
87
  "client-config": new Set(["workspace", "stateDir", "profile", "client", "json"]),
85
88
  status: new Set(["workspace", "stateDir"]),
86
89
  doctor: new Set(["workspace", "stateDir"]),
90
+ "full-test": new Set(["workspace", "stateDir", "json"]),
87
91
  "rotate-secrets": new Set(["workspace", "stateDir", "workerName", "noPrintCredentials", "printMcpCredentials", "printCredentials", "quiet"]),
88
92
  workspace: new Set(["workspace", "stateDir"]),
89
93
  service: new Set(["workspace", "stateDir", "quiet"]),
90
94
  autostart: new Set(["workspace", "stateDir", "quiet"]),
91
- resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "json"]),
95
+ resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "showPaths", "json"]),
92
96
  job: new Set(["workspace", "stateDir", "json", "yes"]),
93
97
  uninstall: new Set(["stateDir", "keepWorker", "yes"]),
94
98
  };
@@ -123,7 +127,7 @@ function toKebab(value) {
123
127
 
124
128
  export function validatePositionals(command, args) {
125
129
  const count = args._.length;
126
- if (["start", "stdio", "status", "doctor", "rotate-secrets"].includes(command)) {
130
+ if (["start", "stdio", "status", "doctor", "full-test", "rotate-secrets"].includes(command)) {
127
131
  if (count > 1) throw new Error(`${command} accepts at most one positional workspace path`);
128
132
  if (args.workspace && count) throw new Error("workspace path was provided both positionally and with --workspace");
129
133
  return;
@@ -148,7 +152,7 @@ export function validatePositionals(command, args) {
148
152
  }
149
153
  if (command === "resource") {
150
154
  const action = String(args._[0] || "list");
151
- const max = action === "add" ? 3 : action === "remove" || action === "check" ? 2 : 1;
155
+ const max = action === "add" || action === "generate-ssh-key" ? 3 : action === "remove" || action === "check" ? 2 : 1;
152
156
  if (count > max) throw new Error(`resource ${action} received too many positional arguments`);
153
157
  return;
154
158
  }
@@ -474,10 +478,19 @@ async function resourceCommand(args) {
474
478
  state.resources ||= {};
475
479
 
476
480
  if (action === "list") {
477
- const resources = publicResourceRegistry(state.resources);
478
- if (args.json) console.log(JSON.stringify({ workspace, resources }, null, 2));
481
+ const includePaths = args.showPaths === true;
482
+ const resources = publicResourceRegistry(state.resources, { includePaths });
483
+ if (args.json) console.log(JSON.stringify({
484
+ workspace: includePaths ? workspace : "<local-workspace>",
485
+ paths_exposed: includePaths,
486
+ resources,
487
+ }, null, 2));
479
488
  else if (!Object.keys(resources).length) console.log("No local resources registered.");
480
- else for (const [name, value] of Object.entries(resources)) console.log(`${name} ${value.path} ${value.mode || "n/a"} ${value.size ?? "n/a"} bytes`);
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
+ }
481
494
  return;
482
495
  }
483
496
 
@@ -496,11 +509,14 @@ async function resourceCommand(args) {
496
509
  }
497
510
  latest.resources[name] = inspected;
498
511
  saveState(latest);
499
- const result = { name, ...inspected, contents_exposed: false, available_to_new_jobs_immediately: true };
512
+ const result = publicResourceInspection(name, inspected, {
513
+ includePath: args.showPaths === true,
514
+ available_to_new_jobs_immediately: true,
515
+ });
500
516
  if (args.json) console.log(JSON.stringify(result, null, 2));
501
517
  else {
502
518
  console.log(`Registered local resource: ${name}`);
503
- console.log(`Path: ${inspected.path}`);
519
+ if (args.showPaths === true) console.log(`Path: ${inspected.path}`);
504
520
  console.log(`Mode: ${inspected.mode || "n/a"}; size: ${inspected.size} bytes`);
505
521
  console.log("The resource is available to newly submitted managed jobs immediately.");
506
522
  }
@@ -510,6 +526,45 @@ async function resourceCommand(args) {
510
526
  return;
511
527
  }
512
528
 
529
+ if (action === "generate-ssh-key") {
530
+ const name = validateResourceName(args._[1]);
531
+ const home = process.env.HOME || process.env.USERPROFILE;
532
+ if (!home) throw new Error("HOME or USERPROFILE is required to choose a default SSH key path");
533
+ const requestedPath = args._[2] ? expandHome(args._[2]) : join(home, ".ssh", `machine-mcp-${name}-ed25519`);
534
+ const key = await generateRegisteredSshKey({
535
+ workspace,
536
+ stateDir: args.stateDir,
537
+ name,
538
+ targetPath: requestedPath,
539
+ comment: `machine-mcp:${name}`,
540
+ });
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
+ if (args.json) console.log(JSON.stringify(result, null, 2));
556
+ else {
557
+ console.log(`${key.created ? "Generated and registered" : "Reused and registered"} SSH key resource: ${name}`);
558
+ if (includePaths) {
559
+ console.log(`Private key: ${key.privateKeyPath}`);
560
+ console.log(`Public key: ${key.publicKeyPath}`);
561
+ }
562
+ console.log(`Fingerprint: ${key.fingerprint}`);
563
+ console.log("Private key content was not printed or sent through MCP.");
564
+ }
565
+ return;
566
+ }
567
+
513
568
  if (action === "remove") {
514
569
  const name = validateResourceName(args._[1]);
515
570
  const lock = acquireStartupLock(state);
@@ -537,15 +592,33 @@ async function resourceCommand(args) {
537
592
  const resource = state.resources[name];
538
593
  if (!resource) throw new Error(`local resource is not registered: ${name}`);
539
594
  const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
540
- const result = { name, ...inspected, contents_exposed: false };
595
+ const result = publicResourceInspection(name, inspected, { includePath: args.showPaths === true });
541
596
  if (args.json) console.log(JSON.stringify(result, null, 2));
542
- else console.log(`${name}: available (${inspected.mode || "n/a"}, ${inspected.size} bytes)`);
597
+ else {
598
+ const pathDetail = args.showPaths === true ? ` at ${inspected.path}` : "";
599
+ console.log(`${name}: available${pathDetail} (${inspected.mode || "n/a"}, ${inspected.size} bytes)`);
600
+ }
543
601
  return;
544
602
  }
545
603
 
546
604
  throw new Error(`Unknown resource action: ${action}`);
547
605
  }
548
606
 
607
+ function publicResourceInspection(name, inspected, { includePath = false, ...extra } = {}) {
608
+ return {
609
+ name,
610
+ kind: inspected.kind,
611
+ size: inspected.size ?? null,
612
+ mode: inspected.mode ?? null,
613
+ updated_at: inspected.updatedAt ?? null,
614
+ allow_insecure_permissions: inspected.allowInsecurePermissions === true,
615
+ ...extra,
616
+ paths_exposed: includePath,
617
+ contents_exposed: false,
618
+ ...(includePath ? { path: inspected.path } : {}),
619
+ };
620
+ }
621
+
549
622
  async function jobCommand(args) {
550
623
  const action = String(args._[0] || "list").toLowerCase();
551
624
  const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
@@ -875,7 +948,7 @@ async function statusCommand(args) {
875
948
  async function doctorCommand(args) {
876
949
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
877
950
  const checks = [];
878
- checks.push({ name: "node", ok: Number(process.versions.node.split(".")[0]) >= 22, detail: process.version });
951
+ checks.push({ name: "node", ok: isSupportedNodeVersion(), detail: process.version });
879
952
  const wrangler = await runWrangler(["--version"], { capture: true, allowFailure: true });
880
953
  checks.push({ name: "wrangler", ok: wrangler.code === 0, detail: (wrangler.stdout || wrangler.stderr).trim() });
881
954
  const whoami = await runWrangler(["whoami"], { capture: true, allowFailure: true });
@@ -884,6 +957,14 @@ async function doctorCommand(args) {
884
957
  const storedPolicyOrigin = state.policy?.origin;
885
958
  state.policy = resolvePolicy({}, state.policy);
886
959
  checks.push({ name: "policy", ok: true, detail: formatPolicySummary(state.policy) });
960
+ if (state.policy.profile === "full") {
961
+ try {
962
+ assertCanonicalFullPolicy(state.policy);
963
+ checks.push({ name: "full-policy-contract", ok: true, detail: `${toolsForPolicy(state.policy).length} tools exposed` });
964
+ } catch (error) {
965
+ checks.push({ name: "full-policy-contract", ok: false, detail: sanitizeLines(error?.message || error) });
966
+ }
967
+ }
887
968
  const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
888
969
  checks.push({ name: "worker-health", ok: health.ok, detail: health.ok ? state.worker.url : health.error });
889
970
  const diagnosticRuntime = new LocalDaemon({
@@ -917,6 +998,15 @@ async function doctorCommand(args) {
917
998
  }, null, 2));
918
999
  }
919
1000
 
1001
+ async function fullTestCommand(args) {
1002
+ const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
1003
+ const state = loadState(workspace, { stateDir: args.stateDir });
1004
+ const policy = resolvePolicy({}, state.policy);
1005
+ const result = await runFullAccessTest({ workspace, policy });
1006
+ console.log(JSON.stringify(result, null, 2));
1007
+ if (!result.ok) process.exitCode = 1;
1008
+ }
1009
+
920
1010
  async function rotateSecretsCommand(args) {
921
1011
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
922
1012
  const state = loadState(workspace, { stateDir: args.stateDir });
@@ -1161,9 +1251,13 @@ function validateWorkerName(value) {
1161
1251
  return name;
1162
1252
  }
1163
1253
 
1254
+ export function isSupportedNodeVersion(version = process.versions.node) {
1255
+ const major = Number(String(version || "").split(".")[0]);
1256
+ return Number.isInteger(major) && major >= 26;
1257
+ }
1258
+
1164
1259
  function assertNodeVersion() {
1165
- const major = Number(process.versions.node.split(".")[0]);
1166
- if (major < 22) throw new Error(`Node.js >=22 is required; current ${process.version}`);
1260
+ if (!isSupportedNodeVersion()) throw new Error(`Node.js >=26 is required; current ${process.version}`);
1167
1261
  }
1168
1262
 
1169
1263
  function usage() {
@@ -1188,7 +1282,10 @@ Commands:
1188
1282
  service uninstall Remove only the autostart entry
1189
1283
  status Print redacted local profile state and Worker health
1190
1284
  doctor Check Node, Wrangler, Cloudflare login, Worker health
1285
+ full-test Run real local full-profile capability tests in a temporary sandbox
1191
1286
  rotate-secrets Rotate MCP password and daemon secret in local state
1287
+ resource generate-ssh-key NAME [PATH]
1288
+ Generate/reuse an Ed25519 key locally and register its private file by alias
1192
1289
  uninstall Delete known Worker(s), remove autostart and local state
1193
1290
 
1194
1291
  Start options:
@@ -1214,6 +1311,7 @@ Start options:
1214
1311
  --quiet Alias for --log-level error
1215
1312
  --allow-insecure-permissions
1216
1313
  Permit resource registration when a file is group/other-readable
1314
+ --show-paths Include local absolute paths in resource command output
1217
1315
 
1218
1316
  Uninstall options:
1219
1317
  --keep-worker Do not delete deployed Worker(s) during uninstall
@@ -9,9 +9,11 @@ import { applyUpdateHunks, parsePatchEnvelope } from "./patch.mjs";
9
9
  import { executionEnv, workspaceShellCommand } from "./shell.mjs";
10
10
  import { MAX_COMMAND_BYTES, ProcessSessionManager, terminateProcessTree, validateArgv } from "./process-sessions.mjs";
11
11
  export { MAX_COMMAND_BYTES } from "./process-sessions.mjs";
12
- import { MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, SERVER_NAME, toolNamesForPolicy } from "./tools.mjs";
12
+ import { allToolNames, assertCanonicalFullPolicy, isCanonicalFullPolicy, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, POLICY_PROFILES, SERVER_NAME, toolNamesForPolicy } from "./tools.mjs";
13
13
  import { classifyOperationalError } from "./log.mjs";
14
14
  import { ManagedJobManager } from "./managed-jobs.mjs";
15
+ import { generateRegisteredSshKey } from "./resource-operations.mjs";
16
+ import { expandHome } from "./state.mjs";
15
17
 
16
18
  export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
17
19
  const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
@@ -33,6 +35,7 @@ export class LocalDaemon {
33
35
  this.policy = normalizePolicy(policy);
34
36
  this.logger = logger;
35
37
  this.onSuperseded = typeof onSuperseded === "function" ? onSuperseded : null;
38
+ this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
36
39
  this.closed = false;
37
40
  this.ws = null;
38
41
  this.heartbeat = null;
@@ -83,15 +86,31 @@ export class LocalDaemon {
83
86
  protocol_version: MCP_PROTOCOL_VERSION,
84
87
  supported_protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
85
88
  workspace: this.displayPath(this.workspace),
86
- workspace_name: basename(this.workspace),
89
+ workspace_name: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace",
87
90
  policy: this.policy,
91
+ policy_contract: {
92
+ named_profile_is_canonical: this.policy.profile === "custom" || policyMatchesNamedProfile(this.policy),
93
+ full_catalog_complete: this.policy.profile === "full" ? isCanonicalFullPolicy(this.policy) && this.tools().length + 1 === allToolNames().length : null,
94
+ machine_bridge_internal_denials_under_full: this.policy.profile === "full" && isCanonicalFullPolicy(this.policy) ? false : null,
95
+ },
88
96
  enforcement: {
89
97
  filesystem_scope: this.policy.unrestrictedPaths ? "local-user-accessible" : "workspace",
90
98
  sensitive_filename_filter: false,
91
99
  operating_system_permissions_apply: true,
92
100
  host_policy_is_independent: true,
93
101
  },
102
+ tool_delivery: {
103
+ full_profile_scope: "local-daemon-and-relay-advertisement",
104
+ daemon_advertised_tool_count: this.tools().length + 1,
105
+ host_exposed_tools_known_to_server: false,
106
+ host_may_expose_subset: true,
107
+ },
94
108
  tools: ["server_info", ...this.tools()],
109
+ observability: {
110
+ per_tool_events: "debug-only",
111
+ default_logs_include_tool_failures: false,
112
+ tool_arguments_or_results_logged: false,
113
+ },
95
114
  runtime: {
96
115
  environment: this.policy.minimalEnv ? "isolated-minimal" : "full-parent",
97
116
  runtime_dir: this.policy.exposeAbsolutePaths ? this.runtimeDir : "<private-runtime-dir>",
@@ -131,7 +150,7 @@ export class LocalDaemon {
131
150
  if (this.closed) return;
132
151
  const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
133
152
  this.logger.debug?.("connecting to remote relay", { endpoint: redactUrl(wsUrl) });
134
- const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret } });
153
+ const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret }, maxPayload: MAX_WS_MESSAGE_BYTES });
135
154
  this.ws = socket;
136
155
 
137
156
  socket.on("open", () => {
@@ -251,14 +270,12 @@ export class LocalDaemon {
251
270
  if (this.cancelledCalls.has(id)) throw new Error("tool call cancelled");
252
271
  this.send({ type: "tool_result", id, ok: true, result });
253
272
  const durationMs = Date.now() - started;
254
- if (durationMs >= SLOW_TOOL_CALL_MS) this.logger.info?.("slow tool call completed", { tool, duration_ms: durationMs });
255
- else this.logger.debug?.("tool call completed", { call_id: shortCallId(id), tool, duration_ms: durationMs });
273
+ this.logger.debug?.(durationMs >= SLOW_TOOL_CALL_MS ? "slow tool call completed" : "tool call completed", { call_id: shortCallId(id), tool, duration_ms: durationMs });
256
274
  } catch (error) {
257
- const safeError = this.safeErrorMessage(error);
275
+ const safeError = this.safeErrorMessage(error, argumentsValue);
258
276
  this.send({ type: "tool_result", id, ok: false, error: { message: safeError } });
259
277
  const durationMs = Date.now() - started;
260
- this.logger.warn?.("tool call failed", { tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
261
- this.logger.debug?.("tool call failure correlation", { call_id: shortCallId(id) });
278
+ this.logger.debug?.("tool call failed", { call_id: shortCallId(id), tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
262
279
  } finally {
263
280
  clearTimeout(deadline);
264
281
  this.activeToolCalls -= 1;
@@ -306,6 +323,7 @@ export class LocalDaemon {
306
323
  case "git_show": return this.gitShow(args, context);
307
324
  case "diagnose_runtime": return this.diagnoseRuntime(context);
308
325
  case "list_local_resources": return this.managedJobManager.listResources();
326
+ case "generate_ssh_key_resource": return this.generateSshKeyResource(args, context);
309
327
  case "stage_job": return this.managedJobManager.stage(args);
310
328
  case "start_job": return this.managedJobManager.start(args);
311
329
  case "list_jobs": return this.managedJobManager.list(args);
@@ -327,7 +345,7 @@ export class LocalDaemon {
327
345
  const git = await this.runProcess("git", ["-c", "core.fsmonitor=false", "-C", this.workspace, "rev-parse", "--show-toplevel"], 10_000, true, 512 * 1024, context);
328
346
  return {
329
347
  workspace: this.displayPath(this.workspace),
330
- workspaceName: basename(this.workspace),
348
+ workspaceName: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace",
331
349
  gitRoot: git.code === 0 ? this.displayPath(git.stdout.trim()) : "",
332
350
  policy: this.policy,
333
351
  tools: ["server_info", ...this.tools()],
@@ -336,7 +354,7 @@ export class LocalDaemon {
336
354
  }
337
355
 
338
356
  listRoots() {
339
- const roots = [{ name: basename(this.workspace), path: this.displayPath(this.workspace), default: true }];
357
+ const roots = [{ name: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace", path: this.displayPath(this.workspace), default: true }];
340
358
  if (this.policy.unrestrictedPaths) {
341
359
  const home = process.env.HOME || process.env.USERPROFILE;
342
360
  if (home && home !== this.workspace) roots.push({ name: "home", path: this.displayPath(resolve(home)), default: false });
@@ -729,6 +747,41 @@ export class LocalDaemon {
729
747
  };
730
748
  }
731
749
 
750
+ async generateSshKeyResource(args = {}, context = {}) {
751
+ this.throwIfCancelled(context);
752
+ assertCanonicalFullPolicy(this.policy);
753
+ if (!this.resourceStatePath) throw new Error("local resource state is unavailable in this runtime");
754
+ const home = process.env.HOME || process.env.USERPROFILE;
755
+ if (!home) throw new Error("HOME or USERPROFILE is required to choose a default SSH key path");
756
+ const target = args.path
757
+ ? resolve(expandHome(String(args.path)))
758
+ : resolve(home, ".ssh", `machine-mcp-${args.name}-ed25519`);
759
+ const key = await generateRegisteredSshKey({
760
+ workspace: this.workspace,
761
+ stateDir: stateRootFromProfileStatePath(this.resourceStatePath),
762
+ name: args.name,
763
+ targetPath: target,
764
+ comment: args.comment || `machine-mcp:${args.name}`,
765
+ });
766
+ const exposePaths = args.expose_paths === true;
767
+ return {
768
+ name: key.name,
769
+ created: key.created,
770
+ registered: key.registered,
771
+ fingerprint: key.fingerprint,
772
+ key_type: key.keyType,
773
+ private_mode: key.privateMode,
774
+ public_mode: key.publicMode,
775
+ private_key_content_exposed: key.privateKeyContentExposed,
776
+ available_to_new_jobs_immediately: key.availableToNewJobsImmediately,
777
+ paths_exposed: exposePaths,
778
+ ...(exposePaths ? {
779
+ private_key_path: resolve(key.privateKeyPath),
780
+ public_key_path: resolve(key.publicKeyPath),
781
+ } : {}),
782
+ };
783
+ }
784
+
732
785
  async runDirectProcess(args, context = {}) {
733
786
  if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") throw new Error("run_process is disabled by daemon policy");
734
787
  const argv = validateArgv(args.argv);
@@ -788,7 +841,7 @@ export class LocalDaemon {
788
841
  timer.unref?.();
789
842
  const cleanup = () => {
790
843
  clearTimeout(timer);
791
- if (killTimer && !timedOut) clearTimeout(killTimer);
844
+ if (killTimer) clearTimeout(killTimer);
792
845
  this.activeProcesses.delete(child);
793
846
  if (context.callId) {
794
847
  const set = this.callProcesses.get(context.callId);
@@ -901,19 +954,25 @@ export class LocalDaemon {
901
954
 
902
955
  displayPath(fullPath) {
903
956
  const absolute = resolve(fullPath);
904
- if (this.policy.exposeAbsolutePaths || this.policy.unrestrictedPaths) return absolute;
905
- assertContainedPath(this.workspace, absolute);
957
+ if (this.policy.exposeAbsolutePaths) return absolute;
906
958
  const shown = relative(this.workspace, absolute);
907
- return shown ? shown.split(sep).join("/") : ".";
959
+ const insideWorkspace = shown === "" || (!shown.startsWith(`..${sep}`) && shown !== ".." && !isAbsolute(shown));
960
+ if (insideWorkspace) return shown ? shown.split(sep).join("/") : ".";
961
+ return `<external-path:${sha256(absolute).slice(0, 12)}>`;
908
962
  }
909
963
 
910
- safeErrorMessage(error) {
964
+ safeErrorMessage(error, toolArgs = {}) {
911
965
  let message = boundedErrorMessage(error);
912
966
  if (!this.policy.exposeAbsolutePaths) {
913
967
  for (const prefix of equivalentPathPrefixes(this.workspace, this.workspaceInput)) message = replacePathPrefix(message, prefix, ".");
914
968
  for (const prefix of equivalentPathPrefixes(this.runtimeDir)) message = replacePathPrefix(message, prefix, "<runtime>");
915
969
  const home = process.env.HOME || process.env.USERPROFILE;
916
970
  if (home) message = replacePathPrefix(message, resolve(home), "<home>");
971
+ for (const candidate of collectToolPathCandidates(error, toolArgs, this.workspaceInput)) {
972
+ const absolute = isAbsolute(candidate) ? resolve(candidate) : resolve(this.workspaceInput, candidate);
973
+ const replacement = this.displayPath(absolute);
974
+ for (const prefix of equivalentPathPrefixes(candidate, absolute)) message = replacePathPrefix(message, prefix, replacement);
975
+ }
917
976
  }
918
977
  return message;
919
978
  }
@@ -931,6 +990,25 @@ export class LocalDaemon {
931
990
  }
932
991
  }
933
992
 
993
+ function policyMatchesNamedProfile(policy) {
994
+ const named = POLICY_PROFILES[policy.profile];
995
+ if (!named) return false;
996
+ return policy.allowWrite === named.allowWrite
997
+ && policy.execMode === named.execMode
998
+ && policy.unrestrictedPaths === named.unrestrictedPaths
999
+ && policy.minimalEnv === named.minimalEnv
1000
+ && policy.exposeAbsolutePaths === named.exposeAbsolutePaths;
1001
+ }
1002
+
1003
+ function stateRootFromProfileStatePath(statePath) {
1004
+ const absolute = resolve(statePath);
1005
+ if (basename(absolute) !== "state.json") throw new Error("local resource state path is invalid");
1006
+ const profileDir = dirname(absolute);
1007
+ const profilesDir = dirname(profileDir);
1008
+ if (basename(profilesDir) !== "profiles") throw new Error("local resource state path is outside the expected profile layout");
1009
+ return dirname(profilesDir);
1010
+ }
1011
+
934
1012
  function normalizeWorkerUrl(value) {
935
1013
  let url;
936
1014
  try { url = new URL(String(value || "")); } catch { throw new Error("invalid Worker URL"); }
@@ -1140,6 +1218,27 @@ function isPlainRecord(value) {
1140
1218
  }
1141
1219
 
1142
1220
 
1221
+ function collectToolPathCandidates(error, toolArgs, workspace) {
1222
+ const candidates = new Set();
1223
+ for (const value of [error?.path, error?.dest]) if (typeof value === "string" && value) candidates.add(value);
1224
+ const visit = (value, key = "", depth = 0) => {
1225
+ if (depth > 5 || value === null || value === undefined) return;
1226
+ if (typeof value === "string") {
1227
+ if (/(?:^|[_-])(?:path|cwd|workspace|root|directory|dir)(?:$|[_-])/i.test(key) && value && !value.includes("\0")) candidates.add(value);
1228
+ return;
1229
+ }
1230
+ if (Array.isArray(value)) {
1231
+ for (const item of value.slice(0, 64)) visit(item, key, depth + 1);
1232
+ return;
1233
+ }
1234
+ if (typeof value !== "object") return;
1235
+ for (const [childKey, child] of Object.entries(value).slice(0, 128)) visit(child, childKey, depth + 1);
1236
+ };
1237
+ visit(toolArgs);
1238
+ candidates.delete(workspace);
1239
+ return [...candidates].sort((left, right) => right.length - left.length);
1240
+ }
1241
+
1143
1242
  function equivalentPathPrefixes(...values) {
1144
1243
  const prefixes = new Set(values.filter(Boolean).map((value) => String(value)));
1145
1244
  for (const value of [...prefixes]) {