machine-bridge-mcp 0.2.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/local/cli.mjs CHANGED
@@ -4,15 +4,17 @@ import path, { resolve } from "node:path";
4
4
  import process from "node:process";
5
5
  import readline from "node:readline/promises";
6
6
  import { LocalDaemon } from "./daemon.mjs";
7
- import { startLocalApiServer, DEFAULT_API_HOST, DEFAULT_API_PORT, DEFAULT_API_MODEL } from "./api-server.mjs";
8
- import { createLogger, redactSecret } from "./log.mjs";
7
+ import { runStdioServer } from "./stdio.mjs";
8
+ import { normalizePolicy } from "./tools.mjs";
9
+ import { createLogger, sanitizeLogText } from "./log.mjs";
9
10
  import { runWrangler } from "./shell.mjs";
10
11
  import {
11
12
  acquireDaemonLock,
13
+ acquireStartupLock,
12
14
  appName,
15
+ daemonLockPathForState,
13
16
  defaultStateRoot,
14
17
  ensureOwnerOnlyDir,
15
- ensureLocalApiKey,
16
18
  ensureWorkerSecrets,
17
19
  expandHome,
18
20
  loadGlobalConfig,
@@ -20,8 +22,10 @@ import {
20
22
  ownerOnlyFile,
21
23
  packageRoot,
22
24
  previewSecret,
25
+ readDaemonLockOwner,
23
26
  redactState,
24
27
  removeStateRoot,
28
+ validateStateRootForRemoval,
25
29
  resolveWorkspace,
26
30
  saveGlobalConfig,
27
31
  saveState,
@@ -29,16 +33,29 @@ import {
29
33
  setSelectedWorkspace,
30
34
  } from "./state.mjs";
31
35
 
36
+ const BOOLEAN_OPTIONS = new Set([
37
+ "help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
38
+ "daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials",
39
+ "printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
40
+ "yes", "keepWorker", "noApi", "api", "rotateApiKey",
41
+ ]);
42
+ const VALUE_OPTIONS = new Set([
43
+ "workspace", "stateDir", "workerName", "profile", "execMode", "client", "apiPort", "apiHost", "apiKey", "apiModel", "port",
44
+ ]);
45
+
32
46
  export async function main(argv = process.argv.slice(2)) {
33
47
  const [command, rest] = normalizeCommand(argv);
34
48
  const args = parseArgs(rest);
35
- if (command === "api" && args.help) return apiUsage();
36
49
  if (args.help || command === "help") return usage();
37
50
  if (args.version || command === "version") return version();
51
+ if (command === "api") throw removedLocalApiError();
52
+ validateCommandOptions(command, args);
53
+ validatePositionals(command, args);
38
54
 
39
55
  switch (command) {
40
56
  case "start": return startCommand(args);
41
- case "api": return apiCommand(args);
57
+ case "stdio": return stdioCommand(args);
58
+ case "client-config": return clientConfigCommand(args);
42
59
  case "status": return statusCommand(args);
43
60
  case "doctor": return doctorCommand(args);
44
61
  case "workspace": return workspaceCommand(args);
@@ -53,28 +70,108 @@ export async function main(argv = process.argv.slice(2)) {
53
70
  }
54
71
  }
55
72
 
73
+ const COMMAND_OPTIONS = {
74
+ start: new Set([
75
+ "workspace", "stateDir", "workerName", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
76
+ "daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials", "printCredentials",
77
+ "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
78
+ "noApi", "api", "apiPort", "apiHost", "apiKey", "rotateApiKey", "apiModel", "port",
79
+ ]),
80
+ stdio: new Set(["workspace", "stateDir", "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths", "verbose"]),
81
+ "client-config": new Set(["workspace", "stateDir", "profile", "client", "json"]),
82
+ status: new Set(["workspace", "stateDir"]),
83
+ doctor: new Set(["workspace", "stateDir"]),
84
+ "rotate-secrets": new Set(["workspace", "stateDir", "workerName", "noPrintCredentials", "quiet"]),
85
+ workspace: new Set(["workspace", "stateDir"]),
86
+ service: new Set(["workspace", "stateDir", "quiet"]),
87
+ autostart: new Set(["workspace", "stateDir", "quiet"]),
88
+ uninstall: new Set(["stateDir", "keepWorker", "yes"]),
89
+ };
90
+
91
+ export function validateCommandOptions(command, args) {
92
+ const allowed = COMMAND_OPTIONS[command];
93
+ if (!allowed) return;
94
+ for (const key of Object.keys(args)) {
95
+ if (key === "_" || key === "help" || key === "version") continue;
96
+ if (!allowed.has(key)) throw new Error(`Option --${toKebab(key)} is not valid for ${command}`);
97
+ }
98
+ }
99
+
100
+ function toKebab(value) {
101
+ return String(value).replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`);
102
+ }
103
+
104
+ export function validatePositionals(command, args) {
105
+ const count = args._.length;
106
+ const conflict = Boolean(args.workspace) && count > (command === "workspace" || command === "service" || command === "autostart" ? 1 : 0);
107
+ if (conflict) throw new Error("workspace path was provided both positionally and with --workspace");
108
+ if (["start", "stdio", "status", "doctor", "rotate-secrets"].includes(command)) {
109
+ if (count > 1) throw new Error(`${command} accepts at most one positional workspace path`);
110
+ if (args.workspace && count) throw new Error("workspace path was provided both positionally and with --workspace");
111
+ return;
112
+ }
113
+ if (command === "workspace") {
114
+ const action = String(args._[0] || "show");
115
+ const max = action === "set" || action === "select" ? 2 : 1;
116
+ if (count > max) throw new Error(`workspace ${action} received too many positional arguments`);
117
+ if (args.workspace && count > 1) throw new Error("workspace path was provided both positionally and with --workspace");
118
+ return;
119
+ }
120
+ if (command === "service" || command === "autostart") {
121
+ const action = String(args._[0] || "status");
122
+ const max = action === "install" ? 2 : 1;
123
+ if (count > max) throw new Error(`service ${action} received too many positional arguments`);
124
+ if (args.workspace && count > 1) throw new Error("workspace path was provided both positionally and with --workspace");
125
+ return;
126
+ }
127
+ if (command === "client-config") {
128
+ if (count > 1) throw new Error("client-config accepts at most one positional client name");
129
+ return;
130
+ }
131
+ if (command === "uninstall" && count) throw new Error("uninstall does not accept positional arguments");
132
+ }
133
+
56
134
  function normalizeCommand(argv) {
57
135
  if (!argv.length || argv[0].startsWith("--")) return ["start", argv];
58
136
  return [argv[0], argv.slice(1)];
59
137
  }
60
138
 
61
- function parseArgs(argv) {
139
+ export function parseArgs(argv) {
62
140
  const out = { _: [] };
141
+ let positionalOnly = false;
63
142
  for (let i = 0; i < argv.length; i += 1) {
64
143
  const raw = argv[i];
65
- if (!raw.startsWith("--")) {
144
+ if (positionalOnly || raw === "-" || !raw.startsWith("--")) {
66
145
  out._.push(raw);
67
146
  continue;
68
147
  }
148
+ if (raw === "--") {
149
+ positionalOnly = true;
150
+ continue;
151
+ }
69
152
  const eq = raw.indexOf("=");
70
- const key = raw.slice(2, eq >= 0 ? eq : undefined);
71
- let value = eq >= 0 ? raw.slice(eq + 1) : true;
72
- if (eq < 0 && argv[i + 1] && !argv[i + 1].startsWith("--")) value = argv[++i];
73
- out[toCamel(key)] = value;
153
+ const rawKey = raw.slice(2, eq >= 0 ? eq : undefined);
154
+ if (!rawKey) throw new Error("invalid empty option");
155
+ const key = toCamel(rawKey);
156
+ if (!BOOLEAN_OPTIONS.has(key) && !VALUE_OPTIONS.has(key)) throw new Error(`Unknown option: --${rawKey}`);
157
+ if (Object.prototype.hasOwnProperty.call(out, key)) throw new Error(`Duplicate option: --${rawKey}`);
158
+ if (BOOLEAN_OPTIONS.has(key)) {
159
+ out[key] = eq >= 0 ? parseBooleanOption(raw.slice(eq + 1), rawKey) : true;
160
+ continue;
161
+ }
162
+ const value = eq >= 0 ? raw.slice(eq + 1) : argv[++i];
163
+ if (value === undefined || value === "" || value.startsWith("--")) throw new Error(`Option --${rawKey} requires a value`);
164
+ out[key] = value;
74
165
  }
75
166
  return out;
76
167
  }
77
168
 
169
+ function parseBooleanOption(value, key) {
170
+ if (value === "true" || value === "1") return true;
171
+ if (value === "false" || value === "0") return false;
172
+ throw new Error(`Option --${key} expects true or false when using =`);
173
+ }
174
+
78
175
  function toCamel(key) {
79
176
  return key.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
80
177
  }
@@ -159,198 +256,192 @@ async function confirm(prompt, assumeYes = false) {
159
256
 
160
257
  async function startCommand(args) {
161
258
  assertNodeVersion();
259
+ assertNoRemovedLocalApiOptions(args);
162
260
  const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "cli" });
163
261
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
164
262
  const state = loadState(workspace, { stateDir: args.stateDir });
165
- const previousMcpServerUrl = state.worker?.mcpServerUrl || "";
166
- const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
167
- const apiEnabled = args.noApi ? false : true;
168
-
169
- ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName: args.workerName && String(args.workerName) });
170
- if (apiEnabled) configureLocalApiState(state, args);
171
- state.policy = {
172
- allowWrite: args.noWrite ? false : true,
173
- allowExec: args.noExec ? false : true,
174
- unrestrictedPaths: true,
175
- minimalEnv: args.fullEnv ? false : true,
176
- apiEnabled,
177
- updatedAt: new Date().toISOString(),
178
- };
179
- saveState(state);
180
-
181
- if (!args.daemonOnly) await ensureWorker(state, args);
182
- else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
183
-
184
- const mcpConnectionChanged = previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl;
185
- const shouldPrintMcpCredentials = Boolean(args.json || args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
186
-
187
- if (!args.daemonOnly && !args.noAutostart) {
188
- await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], policy: state.policy });
263
+ const startupLock = acquireStartupLock(state);
264
+ if (!startupLock.acquired) {
265
+ const pid = startupLock.owner?.pid ? `pid ${startupLock.owner.pid}` : "unknown pid";
266
+ throw new Error(`another startup/deployment operation is already running for this workspace (${pid})`);
189
267
  }
190
268
 
191
- if (!args.daemonOnly) await stopAutostartBestEffort(Boolean(args.quiet));
269
+ try {
270
+ if (args.daemonOnly) {
271
+ const { trimAutostartLogs } = await import("./service.mjs");
272
+ trimAutostartLogs(state.paths.stateRoot);
273
+ } else {
274
+ // Stop an installed service before acquiring the runtime lock. If a
275
+ // foreground daemon owns the lock, no new policy or secret state is saved.
276
+ await stopAutostartBestEffort(Boolean(args.quiet));
277
+ }
192
278
 
193
- const lock = acquireDaemonLock(state);
194
- let daemon = null;
195
- let apiServer = null;
196
- if (!lock.acquired) {
197
- if (!args.quiet) {
279
+ const lock = acquireDaemonLock(state);
280
+ if (!lock.acquired) {
198
281
  const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
199
- logger.warn(`local daemon already running for this workspace (${pid}); not starting a duplicate`);
200
- if (!args.json) printMcpConnection(state, {
282
+ logger.warn(`local daemon already running for this workspace (${pid}); requested changes were not applied`);
283
+ if (args.json) printStartJson(state, {
201
284
  noPrintCredentials: Boolean(args.noPrintCredentials),
202
- includeCredentials: shouldPrintMcpCredentials,
203
- quiet: Boolean(args.quiet),
285
+ requestedChangesApplied: false,
286
+ notice: "local daemon already running; requested changes were not applied",
204
287
  });
205
- }
206
- if (!apiEnabled) {
207
- if (args.json) printStartJson(state, null, { noPrintCredentials: Boolean(args.noPrintCredentials) });
208
- return;
209
- }
210
- apiServer = await startOptionalApiServer(state, args, logger);
211
- if (args.json) printStartJson(state, apiServer, { noPrintCredentials: Boolean(args.noPrintCredentials) });
212
- else if (apiServer) printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials), quiet: Boolean(args.quiet) });
213
- if (apiServer && !apiServer.alreadyRunning) keepProcessAlive({ apiServer, logger });
214
- return;
215
- }
216
-
217
- try {
218
- daemon = new LocalDaemon({
219
- workerUrl: state.worker.url,
220
- secret: state.worker.daemonSecret,
221
- workspace,
222
- policy: state.policy,
223
- logger: createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "daemon" }),
224
- });
225
-
226
- const waitForConnect = daemon.start();
227
- await waitForConnectWithNotice(waitForConnect, 20_000, Boolean(args.quiet || args.json));
228
- if (apiEnabled) apiServer = await startOptionalApiServer(state, args, logger);
229
- if (args.json) printStartJson(state, apiServer, { noPrintCredentials: Boolean(args.noPrintCredentials) });
230
- else {
231
- printMcpConnection(state, {
288
+ else printMcpConnection(state, {
232
289
  noPrintCredentials: Boolean(args.noPrintCredentials),
233
- includeCredentials: shouldPrintMcpCredentials,
290
+ includeCredentials: Boolean(args.printMcpCredentials || args.printCredentials),
234
291
  quiet: Boolean(args.quiet),
235
292
  });
236
- if (apiServer) printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials), quiet: Boolean(args.quiet) });
293
+ return;
237
294
  }
238
- keepProcessAlive({ daemon, lock, apiServer: apiServer?.alreadyRunning ? null : apiServer, logger });
239
- } catch (error) {
240
- try { daemon?.stop?.(); } catch {}
241
- lock.release();
242
- if (apiServer && !apiServer.alreadyRunning) await apiServer.close().catch(() => {});
243
- throw error;
244
- }
245
- }
246
-
247
- async function apiCommand(args) {
248
- assertNodeVersion();
249
- const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
250
- const state = loadState(workspace, { stateDir: args.stateDir });
251
- configureLocalApiState(state, args);
252
- saveState(state);
253
- const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "api" });
254
- const apiServer = await startConfiguredApiServer(state, args, logger);
255
- if (args.json) printApiJson(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
256
- else printApiConnection(apiServer, state, { noPrintCredentials: Boolean(args.noPrintCredentials), quiet: Boolean(args.quiet) });
257
- keepProcessAlive({ apiServer, logger });
258
- }
259
295
 
296
+ let daemon = null;
297
+ try {
298
+ const previousMcpServerUrl = state.worker?.mcpServerUrl || "";
299
+ const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
300
+ const workerName = validateWorkerName(args.workerName);
301
+ ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName });
302
+ state.policy = resolvePolicy(args, state.policy);
303
+ state.policy.updatedAt = new Date().toISOString();
304
+ saveState(state);
305
+
306
+ if (!args.daemonOnly) await ensureWorker(state, args);
307
+ else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
308
+
309
+ const mcpConnectionChanged = previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl;
310
+ const shouldPrintMcpCredentials = Boolean(args.json || args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
311
+
312
+ if (!args.daemonOnly && !args.noAutostart) {
313
+ await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1] });
314
+ }
260
315
 
261
- async function startConfiguredApiServer(state, args, logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "api" })) {
262
- const apiOptions = apiOptionsFromArgs(state, args);
263
- return startLocalApiServer({ ...apiOptions, logger });
264
- }
316
+ daemon = new LocalDaemon({
317
+ workerUrl: state.worker.url,
318
+ secret: state.worker.daemonSecret,
319
+ workspace,
320
+ policy: state.policy,
321
+ logger: createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "daemon" }),
322
+ onSuperseded: () => {
323
+ logger.warn("this daemon was replaced by a newer authenticated instance; exiting without reconnecting");
324
+ lock.release();
325
+ process.exit(0);
326
+ },
327
+ });
265
328
 
266
- async function startOptionalApiServer(state, args, parentLogger) {
267
- const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "api" });
268
- const apiOptions = apiOptionsFromArgs(state, args);
269
- try {
270
- return await startLocalApiServer({ ...apiOptions, logger });
271
- } catch (error) {
272
- if (error?.code === "EADDRINUSE") {
273
- const baseUrl = apiBaseUrl(apiOptions.host, apiOptions.port);
274
- const health = await probeLocalApiHealth(baseUrl, state.localApi?.apiKey);
275
- if (health.ok) {
276
- logger.success("local OpenAI-compatible API already running", { baseUrl });
277
- return { ...health, baseUrl, host: apiOptions.host, port: Number(apiOptions.port), alreadyRunning: true, close() { return Promise.resolve(); } };
329
+ const waitForConnect = daemon.start();
330
+ await waitForConnectWithNotice(waitForConnect, 20_000, Boolean(args.quiet || args.json));
331
+ if (args.json) printStartJson(state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
332
+ else {
333
+ printMcpConnection(state, {
334
+ noPrintCredentials: Boolean(args.noPrintCredentials),
335
+ includeCredentials: shouldPrintMcpCredentials,
336
+ quiet: Boolean(args.quiet),
337
+ });
278
338
  }
279
- parentLogger.warn(error.message);
280
- return null;
339
+ keepProcessAlive({ daemon, lock, logger });
340
+ } catch (error) {
341
+ try { daemon?.stop?.(); } catch {}
342
+ lock.release();
343
+ throw error;
281
344
  }
282
- parentLogger.warn(`Local API skipped: ${error.message}`);
283
- return null;
345
+ } finally {
346
+ startupLock.release();
347
+ }
348
+ }
349
+
350
+ const POLICY_PROFILES = Object.freeze({
351
+ review: Object.freeze({ profile: "review", allowWrite: false, execMode: "off", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
352
+ edit: Object.freeze({ profile: "edit", allowWrite: true, execMode: "off", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
353
+ agent: Object.freeze({ profile: "agent", allowWrite: true, execMode: "direct", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
354
+ full: Object.freeze({ profile: "full", allowWrite: true, execMode: "shell", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
355
+ });
356
+
357
+ export function resolvePolicy(args = {}, stored = {}) {
358
+ const hasStored = stored && typeof stored === "object" && (
359
+ typeof stored.allowWrite === "boolean" || typeof stored.allowExec === "boolean" || typeof stored.execMode === "string"
360
+ );
361
+ const explicitKeys = ["profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"];
362
+ const hasExplicit = explicitKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key));
363
+ let base;
364
+ if (args.profile !== undefined) {
365
+ const profile = String(args.profile).trim().toLowerCase();
366
+ if (!POLICY_PROFILES[profile]) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
367
+ base = { ...POLICY_PROFILES[profile] };
368
+ } else if (hasStored) {
369
+ base = normalizePolicy(stored);
370
+ } else {
371
+ base = { ...POLICY_PROFILES.review };
372
+ }
373
+
374
+ if (!hasExplicit) return normalizePolicy(base);
375
+ if (args.execMode !== undefined) {
376
+ const execMode = String(args.execMode).trim().toLowerCase();
377
+ if (!["off", "direct", "shell"].includes(execMode)) throw new Error("--exec-mode must be off, direct, or shell");
378
+ base.execMode = execMode;
379
+ }
380
+ if (args.noWrite === true) base.allowWrite = false;
381
+ if (args.noWrite === false) base.allowWrite = true;
382
+ if (args.noExec === true) base.execMode = "off";
383
+ if (args.noExec === false && base.execMode === "off") base.execMode = "direct";
384
+ if (args.fullEnv === true) base.minimalEnv = false;
385
+ if (args.fullEnv === false) base.minimalEnv = true;
386
+ if (args.unrestrictedPaths === true) base.unrestrictedPaths = true;
387
+ if (args.unrestrictedPaths === false) base.unrestrictedPaths = false;
388
+ if (args.absolutePaths === true) base.exposeAbsolutePaths = true;
389
+ if (args.absolutePaths === false) base.exposeAbsolutePaths = false;
390
+ const overrideKeys = ["execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"];
391
+ if (args.profile === undefined || overrideKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key))) base.profile = "custom";
392
+ return normalizePolicy(base);
393
+ }
394
+
395
+ async function stdioCommand(args) {
396
+ assertNodeVersion();
397
+ const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
398
+ const state = loadState(workspace, { stateDir: args.stateDir });
399
+ const policy = resolvePolicy(args, state.policy);
400
+ await runStdioServer({ workspace, policy, verbose: Boolean(args.verbose) });
401
+ }
402
+
403
+ async function clientConfigCommand(args) {
404
+ const workspaceArgs = { ...args, _: [] };
405
+ const workspace = await chooseWorkspace(workspaceArgs, { promptOnFirstRun: false, save: false, allowPositional: false });
406
+ const requested = String(args.client || args._[0] || "all").trim().toLowerCase();
407
+ const profile = String(args.profile || "agent").trim().toLowerCase();
408
+ if (!POLICY_PROFILES[profile]) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
409
+ if (!["all", "claude", "cursor", "codex", "generic"].includes(requested)) throw new Error("client must be all, claude, cursor, codex, or generic");
410
+ const command = process.execPath;
411
+ const argsList = [resolve(process.argv[1]), "stdio", "--workspace", workspace, "--profile", profile];
412
+ const jsonConfig = { mcpServers: { "machine-bridge": { command, args: argsList } } };
413
+ const codex = `[mcp_servers.machine_bridge]\ncommand = ${JSON.stringify(command)}\nargs = ${JSON.stringify(argsList)}\n`;
414
+ if (args.json) {
415
+ console.log(JSON.stringify({ workspace, profile, claude: jsonConfig, cursor: jsonConfig, generic: jsonConfig, codex_toml: codex }, null, 2));
416
+ return;
417
+ }
418
+ if (["all", "claude", "cursor", "generic"].includes(requested)) {
419
+ console.log(`${requested === "all" ? "Claude Desktop / Cursor / generic stdio" : requested}:`);
420
+ console.log(JSON.stringify(jsonConfig, null, 2));
421
+ }
422
+ if (["all", "codex"].includes(requested)) {
423
+ if (requested === "all") console.log("");
424
+ console.log("Codex CLI:");
425
+ console.log(codex.trimEnd());
284
426
  }
285
427
  }
286
428
 
287
- function apiBaseUrl(host, port) {
288
- const textHost = String(host || DEFAULT_API_HOST);
289
- const urlHost = textHost.includes(":") && !textHost.startsWith("[") ? `[${textHost}]` : textHost;
290
- return `http://${urlHost}:${port || DEFAULT_API_PORT}/v1`;
291
- }
292
-
293
- async function probeLocalApiHealth(baseUrl, expectedApiKey = "") {
294
- try {
295
- const healthUrl = `${String(baseUrl).replace(/\/v1$/, "")}/health`;
296
- const response = await fetch(healthUrl, { signal: AbortSignal.timeout(750) });
297
- if (!response.ok) return { ok: false };
298
- const body = await response.json().catch(() => null);
299
- if (body?.service !== "machine-bridge-mcp-local-api") return { ok: false };
300
- if (body.api_key_sha256 && expectedApiKey && body.api_key_sha256 !== sha256String(expectedApiKey)) return { ok: false, reason: "api_key_mismatch" };
301
- return { ok: true };
302
- } catch {
303
- return { ok: false };
304
- }
305
- }
306
-
307
- function apiOptionsFromArgs(state, args = {}) {
308
- const explicitPort = explicitArg(args.apiPort) ?? explicitArg(args.port);
309
- const envPort = valueFromArgsEnv(undefined, "MBM_API_PORT", "PORT");
310
- return {
311
- host: valueFromArgsEnv(args.apiHost, "MBM_API_HOST") || state.localApi?.host || DEFAULT_API_HOST,
312
- port: explicitPort ?? envPort ?? state.localApi?.port ?? DEFAULT_API_PORT,
313
- apiKey: valueFromArgsEnv(args.apiKey, "MBM_API_KEY") || state.localApi?.apiKey || ensureLocalApiKey(state, { rotateApiKey: Boolean(args.rotateApiKey) }),
314
- model: valueFromArgsEnv(args.apiModel, "MBM_API_MODEL") || state.localApi?.model || DEFAULT_API_MODEL,
315
- workerUrl: state.worker?.url || "",
316
- daemonSecret: state.worker?.daemonSecret || "",
317
- };
318
- }
319
-
320
- function configureLocalApiState(state, args = {}) {
321
- state.localApi ||= {};
322
- ensureLocalApiKey(state, { apiKey: explicitArg(args.apiKey), rotateApiKey: Boolean(args.rotateApiKey) });
323
- const port = explicitArg(args.apiPort) ?? explicitArg(args.port);
324
- if (port !== undefined && String(port) !== "0") state.localApi.port = String(port);
325
- const host = explicitArg(args.apiHost);
326
- if (host !== undefined) state.localApi.host = String(host);
327
- const model = explicitArg(args.apiModel);
328
- if (model !== undefined) state.localApi.model = String(model);
329
- state.localApi.updatedAt = new Date().toISOString();
330
- }
331
-
332
- function explicitArg(value) {
333
- return value !== undefined && value !== null && value !== true ? String(value) : undefined;
334
- }
335
-
336
- function sha256String(value) {
337
- return createHash("sha256").update(String(value)).digest("hex");
429
+ function assertNoRemovedLocalApiOptions(args) {
430
+ const removed = ["api", "noApi", "apiPort", "apiHost", "apiKey", "rotateApiKey", "apiModel", "port"].filter((key) => args[key] !== undefined);
431
+ if (removed.length) throw removedLocalApiError();
338
432
  }
339
433
 
340
- function valueFromArgsEnv(argValue, ...envNames) {
341
- if (argValue !== undefined && argValue !== null && argValue !== true) return String(argValue);
342
- for (const name of envNames) {
343
- if (process.env[name]) return process.env[name];
344
- }
345
- return undefined;
434
+ function removedLocalApiError() {
435
+ return new Error("Local /v1 API support has been removed. Use the printed Remote MCP Server URL/password with an MCP client instead.");
346
436
  }
347
437
 
348
438
  async function ensureWorker(state, args) {
349
439
  const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "worker" });
350
440
  const desiredHash = workerDeployHash(state);
441
+ const expectedVersion = currentPackageVersion();
351
442
  const complete = state.worker.url && state.worker.mcpServerUrl && state.worker.oauthPassword && state.worker.daemonSecret && state.worker.oauthTokenVersion && state.worker.name;
352
443
  if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
353
- const health = await workerHealth(state.worker.url);
444
+ const health = await workerHealth(state.worker.url, expectedVersion);
354
445
  if (health.ok) {
355
446
  logger.success("Worker unchanged and healthy", { url: state.worker.url });
356
447
  return state.worker;
@@ -378,13 +469,17 @@ async function ensureWorker(state, args) {
378
469
  if (!workerUrl) throw new Error("Worker deployed but URL could not be detected. Re-run with --worker-name or inspect Wrangler output.");
379
470
  state.worker.url = workerUrl.replace(/\/+$/, "");
380
471
  state.worker.mcpServerUrl = `${state.worker.url}/mcp`;
381
- state.worker.deployHash = desiredHash;
472
+ delete state.worker.deployHash;
382
473
  state.worker.updatedAt = new Date().toISOString();
383
474
  saveState(state);
384
475
 
385
- const health = await retryHealth(state.worker.url, 8);
386
- if (!health.ok) logger.warn("Worker deployed but health check did not pass yet", { error: health.error });
387
- else logger.success("Worker ready", { url: state.worker.url });
476
+ const health = await retryHealth(state.worker.url, expectedVersion, 8);
477
+ if (!health.ok) {
478
+ throw new Error(`Worker deployment did not become healthy at the expected version: ${health.error}`);
479
+ }
480
+ state.worker.deployHash = desiredHash;
481
+ saveState(state);
482
+ logger.success("Worker ready", { url: state.worker.url, version: health.version });
388
483
  return state.worker;
389
484
  }
390
485
 
@@ -432,16 +527,12 @@ function workerDeployHash(state) {
432
527
  }
433
528
 
434
529
  function workerHashContent(file) {
435
- const content = readFileSync(file, "utf8");
436
- if (path.relative(packageRoot, file).replaceAll("\\", "/") === "src/worker/index.ts") {
437
- return content.replace(/const SERVER_VERSION = "[^"]+";/, 'const SERVER_VERSION = "<ignored-for-deploy-hash>";');
438
- }
439
- return content;
530
+ return readFileSync(file, "utf8");
440
531
  }
441
532
 
442
533
  function workerDeployHashFiles() {
443
534
  const files = [];
444
- for (const item of ["src/worker", "wrangler.jsonc", "tsconfig.json"]) {
535
+ for (const item of ["src/worker", "src/shared", "wrangler.jsonc", "tsconfig.json"]) {
445
536
  collectHashFiles(resolve(packageRoot, item), files);
446
537
  }
447
538
  return files.sort();
@@ -461,23 +552,24 @@ function collectHashFiles(target, out) {
461
552
  }
462
553
  }
463
554
 
464
- async function workerHealth(workerUrl) {
555
+ async function workerHealth(workerUrl, expectedVersion = currentPackageVersion()) {
465
556
  if (!workerUrl) return { ok: false, error: "missing_worker_url" };
466
557
  try {
467
558
  const response = await fetch(`${String(workerUrl).replace(/\/+$/, "")}/healthz`, { signal: AbortSignal.timeout(5000) });
468
559
  if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
469
560
  const body = await response.json().catch(() => null);
470
561
  if (body?.ok !== true || body?.server !== appName) return { ok: false, error: "unexpected_health_response" };
471
- return { ok: true };
562
+ if (body?.version !== expectedVersion) return { ok: false, error: `version_mismatch:${body?.version || "unknown"}!=${expectedVersion}` };
563
+ return { ok: true, version: body.version };
472
564
  } catch (error) {
473
565
  return { ok: false, error: error.message };
474
566
  }
475
567
  }
476
568
 
477
- async function retryHealth(workerUrl, attempts) {
569
+ async function retryHealth(workerUrl, expectedVersion, attempts) {
478
570
  let last = { ok: false, error: "not_checked" };
479
571
  for (let i = 0; i < attempts; i += 1) {
480
- last = await workerHealth(workerUrl);
572
+ last = await workerHealth(workerUrl, expectedVersion);
481
573
  if (last.ok) return last;
482
574
  await sleep(1000 + i * 500);
483
575
  }
@@ -502,10 +594,8 @@ async function waitForConnectWithNotice(promise, timeoutMs, quiet = false) {
502
594
  }
503
595
 
504
596
 
505
- function printStartJson(state, apiServer, { noPrintCredentials = false } = {}) {
597
+ function printStartJson(state, { noPrintCredentials = false, requestedChangesApplied = true, notice = "" } = {}) {
506
598
  const mcpPassword = noPrintCredentials ? previewSecret(state.worker.oauthPassword) : state.worker.oauthPassword;
507
- const runtimeApiKey = apiServer?.apiKey || state.localApi?.apiKey || "";
508
- const apiKey = runtimeApiKey ? (noPrintCredentials ? previewSecret(runtimeApiKey) : runtimeApiKey) : null;
509
599
  createLogger({ component: "ready" }).json({
510
600
  mcp: {
511
601
  server_url: state.worker.mcpServerUrl,
@@ -513,34 +603,11 @@ function printStartJson(state, apiServer, { noPrintCredentials = false } = {}) {
513
603
  worker_url: state.worker.url,
514
604
  worker_name: state.worker.name,
515
605
  },
516
- local_api: apiServer ? {
517
- base_url: apiServer.baseUrl,
518
- api_key: apiKey,
519
- already_running: Boolean(apiServer.alreadyRunning),
520
- backend: "chatgpt-mcp",
521
- model: state.localApi?.model || DEFAULT_API_MODEL,
522
- mcp_bridge_configured: Boolean(state.worker?.url && state.worker?.daemonSecret),
523
- client_type: "OpenAI-compatible",
524
- } : null,
525
606
  workspace: state.workspace.path,
526
607
  state_path: state.paths.statePath,
527
608
  policy: state.policy,
528
- });
529
- }
530
-
531
- function printApiJson(apiServer, state, { noPrintCredentials = false } = {}) {
532
- const runtimeApiKey = apiServer?.apiKey || state.localApi?.apiKey || "";
533
- createLogger({ component: "ready" }).json({
534
- local_api: {
535
- base_url: apiServer.baseUrl,
536
- api_key: noPrintCredentials ? previewSecret(runtimeApiKey) : runtimeApiKey,
537
- backend: "chatgpt-mcp",
538
- model: apiServer?.model || state.localApi?.model || DEFAULT_API_MODEL,
539
- mcp_bridge_configured: Boolean(state.worker?.url && state.worker?.daemonSecret),
540
- client_type: "OpenAI-compatible",
541
- },
542
- workspace: state.workspace.path,
543
- state_path: state.paths.statePath,
609
+ requested_changes_applied: requestedChangesApplied,
610
+ ...(notice ? { notice } : {}),
544
611
  });
545
612
  }
546
613
 
@@ -570,32 +637,17 @@ function printMcpConnection(state, { json = false, noPrintCredentials = false, i
570
637
  logger.plain(" Use --print-mcp-credentials only when a ChatGPT app needs to reconnect.");
571
638
  }
572
639
  logger.plain(` Workspace cwd: ${payload.workspace}`);
573
- logger.plain(` Policy: write=${payload.policy.allowWrite ? "on" : "off"}, exec=${payload.policy.allowExec ? "on" : "off"}, local_api=${payload.policy.apiEnabled === false ? "off" : "on"}, unrestricted_paths=${payload.policy.unrestrictedPaths ? "on" : "off"}`);
640
+ logger.plain(` Policy: profile=${payload.policy.profile || "custom"}, write=${payload.policy.allowWrite ? "on" : "off"}, exec_mode=${payload.policy.execMode || (payload.policy.allowExec ? "shell" : "off")}, unrestricted_paths=${payload.policy.unrestrictedPaths ? "on" : "off"}, absolute_paths=${payload.policy.exposeAbsolutePaths ? "on" : "off"}`);
574
641
  logger.plain(` State: ${payload.state_path}`);
575
642
  }
576
643
 
577
- function printApiConnection(apiServer, state, { noPrintCredentials = false, quiet = false } = {}) {
578
- const logger = createLogger({ component: "ready", quiet });
579
- const runtimeApiKey = apiServer?.apiKey || state.localApi?.apiKey || "";
580
- logger.success("Local ChatGPT MCP-backed OpenAI-compatible API is running");
581
- logger.plain(` API Base URL: ${apiServer.baseUrl}`);
582
- logger.plain(` API key: ${noPrintCredentials ? `${redactSecret(runtimeApiKey)} (redacted)` : runtimeApiKey}`);
583
- logger.plain(" Client type: OpenAI-compatible");
584
- logger.plain(` Model: ${apiServer?.model || state.localApi?.model || DEFAULT_API_MODEL}`);
585
- logger.plain(" Backend: ChatGPT MCP sampling via the connected ChatGPT app");
586
- if (state.worker?.mcpServerUrl) logger.plain(` MCP Server URL: ${state.worker.mcpServerUrl}`);
587
- else logger.plain(" MCP bridge: not deployed yet; run `machine-mcp` before using generation routes.");
588
- }
589
-
590
-
591
- function keepProcessAlive({ daemon = null, lock = null, apiServer = null, logger = createLogger({ component: "cli" }) } = {}) {
644
+ function keepProcessAlive({ daemon = null, lock = null, logger = createLogger({ component: "cli" }) } = {}) {
592
645
  let stopping = false;
593
646
  const stop = async () => {
594
647
  if (stopping) return;
595
648
  stopping = true;
596
649
  logger.info("stopping local services");
597
650
  try { daemon?.stop?.(); } catch {}
598
- try { await apiServer?.close?.(); } catch {}
599
651
  try { lock?.release?.(); } catch {}
600
652
  process.exit(0);
601
653
  };
@@ -616,11 +668,11 @@ async function statusCommand(args) {
616
668
  async function doctorCommand(args) {
617
669
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
618
670
  const checks = [];
619
- checks.push({ name: "node", ok: Number(process.versions.node.split(".")[0]) >= 20, detail: process.version });
671
+ checks.push({ name: "node", ok: Number(process.versions.node.split(".")[0]) >= 22, detail: process.version });
620
672
  const wrangler = await runWrangler(["--version"], { capture: true, allowFailure: true });
621
673
  checks.push({ name: "wrangler", ok: wrangler.code === 0, detail: (wrangler.stdout || wrangler.stderr).trim() });
622
674
  const whoami = await runWrangler(["whoami"], { capture: true, allowFailure: true });
623
- checks.push({ name: "cloudflare-login", ok: whoami.code === 0, detail: sanitizeLines(whoami.stdout || whoami.stderr) });
675
+ checks.push({ name: "cloudflare-login", ok: whoami.code === 0, detail: whoami.code === 0 ? "authenticated" : sanitizeLines(whoami.stderr || whoami.stdout) });
624
676
  const state = loadState(workspace, { stateDir: args.stateDir });
625
677
  const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
626
678
  checks.push({ name: "worker-health", ok: health.ok, detail: health.ok ? state.worker.url : health.error });
@@ -630,11 +682,26 @@ async function doctorCommand(args) {
630
682
  async function rotateSecretsCommand(args) {
631
683
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
632
684
  const state = loadState(workspace, { stateDir: args.stateDir });
633
- ensureWorkerSecrets(state, { rotateSecrets: true, workerName: args.workerName && String(args.workerName) });
634
- saveState(state);
635
- console.log(`Rotated MCP password: ${state.worker.oauthPassword}`);
636
- console.log(`Rotated daemon secret: ${previewSecret(state.worker.daemonSecret)}`);
637
- console.log("Run start to redeploy the Worker with the new secrets and revoke old OAuth access tokens.");
685
+ const startupLock = acquireStartupLock(state);
686
+ if (!startupLock.acquired) {
687
+ const pid = startupLock.owner?.pid ? `pid ${startupLock.owner.pid}` : "unknown pid";
688
+ throw new Error(`another startup/deployment operation is already running for this workspace (${pid})`);
689
+ }
690
+ try {
691
+ await stopAutostartBestEffort(Boolean(args.quiet));
692
+ await sleep(500);
693
+ const daemonOwner = readDaemonLockOwner(daemonLockPathForState(state));
694
+ if (daemonOwner?.pid && isPidAlive(daemonOwner.pid)) {
695
+ throw new Error(`refusing to rotate secrets while the daemon is active (pid ${daemonOwner.pid}); stop the foreground daemon and retry`);
696
+ }
697
+ ensureWorkerSecrets(state, { rotateSecrets: true, workerName: validateWorkerName(args.workerName) });
698
+ saveState(state);
699
+ console.log(`Rotated MCP password: ${args.noPrintCredentials ? "<redacted>" : state.worker.oauthPassword}`);
700
+ console.log(`Rotated daemon secret: ${args.noPrintCredentials ? "<redacted>" : previewSecret(state.worker.daemonSecret)}`);
701
+ console.log("Run start to redeploy the Worker with the new secrets and revoke old OAuth access tokens.");
702
+ } finally {
703
+ startupLock.release();
704
+ }
638
705
  }
639
706
 
640
707
  async function serviceCommand(args) {
@@ -653,7 +720,7 @@ async function serviceCommand(args) {
653
720
  if (!state.worker?.url) {
654
721
  throw new Error("No deployed Worker is recorded for this workspace. Run `machine-mcp` once before `machine-mcp service install`.");
655
722
  }
656
- const result = await installAutostart({ workspace, stateRoot, entryScript: process.argv[1], policy: state.policy, logger: structuredLogger(Boolean(args.quiet)) });
723
+ const result = await installAutostart({ workspace, stateRoot, entryScript: process.argv[1], logger: structuredLogger(Boolean(args.quiet)) });
657
724
  console.log(JSON.stringify(result, null, 2));
658
725
  return;
659
726
  }
@@ -675,10 +742,10 @@ async function serviceCommand(args) {
675
742
  throw new Error(`Unknown service action: ${action}`);
676
743
  }
677
744
 
678
- async function installAutostartBestEffort({ workspace, stateRoot, entryScript, policy }) {
745
+ async function installAutostartBestEffort({ workspace, stateRoot, entryScript }) {
679
746
  try {
680
747
  const { installAutostart } = await import("./service.mjs");
681
- const result = await installAutostart({ workspace, stateRoot, entryScript, policy, logger: structuredLogger(false) });
748
+ const result = await installAutostart({ workspace, stateRoot, entryScript, logger: structuredLogger(false) });
682
749
  if (result?.ok) console.log("Autostart installed for future logins. Use `machine-mcp service status` to inspect or `machine-mcp service uninstall` to remove.");
683
750
  else console.warn("Autostart installation returned a warning; run `machine-mcp service status` for details.");
684
751
  } catch (error) {
@@ -698,6 +765,7 @@ async function stopAutostartBestEffort(quiet = false) {
698
765
  async function uninstallCommand(args) {
699
766
  const stateRoot = stateRootFromArgs(args);
700
767
  const deleteRemote = !args.keepWorker;
768
+ validateStateRootForRemoval(stateRoot);
701
769
  const action = deleteRemote
702
770
  ? `delete deployed Worker(s), remove autostart entries, and remove local state at ${stateRoot}`
703
771
  : `remove autostart entries and local state at ${stateRoot} while keeping deployed Worker(s)`;
@@ -706,8 +774,15 @@ async function uninstallCommand(args) {
706
774
  console.log("Uninstall cancelled. Re-run with `machine-mcp uninstall --yes` to skip confirmation.");
707
775
  return;
708
776
  }
777
+ const autostartRemoved = await removeAutostartBestEffort(stateRoot);
778
+ if (!autostartRemoved) throw new Error("autostart removal failed; state and Worker were kept so the uninstall can be retried safely");
779
+ await sleep(500);
780
+ const activeLocks = activeStateLocks(stateRoot);
781
+ if (activeLocks.length) {
782
+ const detail = activeLocks.map((item) => `${item.kind}:${item.pid || "unknown"}`).join(", ");
783
+ throw new Error(`refusing to uninstall while Machine Bridge processes are active (${detail}); stop foreground sessions and retry`);
784
+ }
709
785
  if (deleteRemote) await deleteKnownWorkers(stateRoot);
710
- await removeAutostartBestEffort(stateRoot);
711
786
  removeStateRoot(stateRoot);
712
787
  console.log("Removed local autostart entries and state.");
713
788
  if (deleteRemote) console.log("Requested deletion for known deployed Worker(s).");
@@ -721,10 +796,18 @@ async function deleteKnownWorkers(stateRoot) {
721
796
  console.log("No deployed Worker name found in local state.");
722
797
  return;
723
798
  }
799
+ const failures = [];
724
800
  for (const name of names) {
725
801
  const result = await runWrangler(["delete", name, "--force"], { capture: true, allowFailure: true });
726
- if (result.code === 0) console.log(`Deleted Worker: ${name}`);
727
- else console.warn(`Failed to delete Worker ${name}: ${(result.stderr || result.stdout || "unknown error").trim()}`);
802
+ const detail = (result.stderr || result.stdout || "unknown error").trim();
803
+ if (result.code === 0 || /not found|does not exist|could not find/i.test(detail)) {
804
+ console.log(`Deleted Worker: ${name}`);
805
+ } else {
806
+ failures.push({ name, detail: sanitizeLines(detail) || "unknown error" });
807
+ }
808
+ }
809
+ if (failures.length) {
810
+ throw new Error(`failed to delete Worker(s): ${failures.map((item) => `${item.name} (${item.detail})`).join(", ")}; local state was kept for retry`);
728
811
  }
729
812
  }
730
813
 
@@ -738,7 +821,8 @@ function knownWorkerNames(stateRoot) {
738
821
  if (!existsSync(stateFile)) continue;
739
822
  try {
740
823
  const state = JSON.parse(readFileSync(stateFile, "utf8"));
741
- if (state?.worker?.name) names.add(String(state.worker.name));
824
+ const name = String(state?.worker?.name || "");
825
+ if (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) names.add(name);
742
826
  } catch {}
743
827
  }
744
828
  return [...names];
@@ -746,10 +830,44 @@ function knownWorkerNames(stateRoot) {
746
830
 
747
831
  async function removeAutostartBestEffort(stateRoot) {
748
832
  try {
749
- const { uninstallAutostart } = await import("./service.mjs");
750
- await uninstallAutostart({ stateRoot, logger: structuredLogger(false) });
833
+ const { stopAutostart, uninstallAutostart } = await import("./service.mjs");
834
+ await stopAutostart({ logger: structuredLogger(true) }).catch(() => {});
835
+ const result = await uninstallAutostart({ stateRoot, logger: structuredLogger(false) });
836
+ if (result?.ok === false) {
837
+ console.warn("Autostart removal reported failure.");
838
+ return false;
839
+ }
840
+ return true;
751
841
  } catch (error) {
752
842
  console.warn(`Autostart removal skipped or failed: ${error.message}`);
843
+ return false;
844
+ }
845
+ }
846
+
847
+ function activeStateLocks(stateRoot) {
848
+ const profiles = resolve(expandHome(stateRoot), "profiles");
849
+ if (!existsSync(profiles)) return [];
850
+ const active = [];
851
+ for (const profile of readdirSync(profiles, { withFileTypes: true })) {
852
+ if (!profile.isDirectory()) continue;
853
+ for (const [kind, name] of [["daemon", "daemon.lock"], ["startup", "startup.lock"]]) {
854
+ const lockPath = resolve(profiles, profile.name, name);
855
+ if (!existsSync(lockPath)) continue;
856
+ const owner = readDaemonLockOwner(lockPath);
857
+ if (owner?.pid && isPidAlive(owner.pid)) active.push({ kind, pid: owner.pid, path: lockPath });
858
+ }
859
+ }
860
+ return active;
861
+ }
862
+
863
+ function isPidAlive(pid) {
864
+ const parsed = Number(pid);
865
+ if (!Number.isInteger(parsed) || parsed <= 0) return false;
866
+ try {
867
+ process.kill(parsed, 0);
868
+ return true;
869
+ } catch (error) {
870
+ return error?.code === "EPERM";
753
871
  }
754
872
  }
755
873
 
@@ -760,7 +878,7 @@ function structuredLogger(quiet) {
760
878
  function sanitizeLines(text) {
761
879
  const home = process.env.HOME || process.env.USERPROFILE || "";
762
880
  const homePattern = home ? new RegExp(escapeRegExp(home), "g") : null;
763
- let value = String(text || "")
881
+ let value = sanitizeLogText(text)
764
882
  .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "<email>");
765
883
  if (homePattern) value = value.replace(homePattern, "~");
766
884
  return value
@@ -774,9 +892,18 @@ function escapeRegExp(value) {
774
892
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
775
893
  }
776
894
 
895
+ function validateWorkerName(value) {
896
+ if (value === undefined || value === null || value === false) return undefined;
897
+ const name = String(value).trim();
898
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) {
899
+ throw new Error("--worker-name must be 1-63 lowercase letters, digits, or hyphens, and cannot start or end with a hyphen");
900
+ }
901
+ return name;
902
+ }
903
+
777
904
  function assertNodeVersion() {
778
905
  const major = Number(process.versions.node.split(".")[0]);
779
- if (major < 20) throw new Error(`Node.js >=20 is required; current ${process.version}`);
906
+ if (major < 22) throw new Error(`Node.js >=22 is required; current ${process.version}`);
780
907
  }
781
908
 
782
909
  function usage() {
@@ -789,8 +916,9 @@ Usage:
789
916
  .\\mbm.cmd # from source checkout on Windows cmd
790
917
 
791
918
  Commands:
792
- start Deploy/update Worker, install autostart, start daemon and local API
793
- api Start local ChatGPT MCP-backed OpenAI-compatible API only
919
+ start Deploy/update Worker, install autostart, start remote daemon
920
+ stdio Run a local MCP stdio server for Claude, Cursor, Codex, and compatible clients
921
+ client-config Print stdio client configuration snippets
794
922
  workspace show Show remembered workspace
795
923
  workspace set Re-select workspace; prompts with current/default path
796
924
  service status Show autostart status
@@ -812,19 +940,15 @@ Start options:
812
940
  --no-autostart Do not install login autostart during start
813
941
  --no-print-credentials Redact credentials in console output
814
942
  --print-mcp-credentials Print MCP URL/password again for reconnecting ChatGPT apps
815
- --no-write Disable write_file (default: write enabled)
816
- --no-exec Disable exec_command (default: exec enabled)
817
- --full-env Pass full parent environment to exec_command (default: minimal env)
943
+ --profile NAME Policy profile: review (default for new workspaces), edit, agent, or full
944
+ --exec-mode MODE Command mode: off, direct argv, or full shell
945
+ --no-write Disable write_file, edit_file, and apply_patch
946
+ --no-exec Disable run_process and exec_command
947
+ --full-env Pass the full parent environment to local commands
948
+ --unrestricted-paths Allow filesystem tools outside the workspace
949
+ --absolute-paths Return absolute local paths in tool results (off by default)
818
950
  --state-dir DIR Override state root
819
- --json Print MCP and local API connection details as JSON
820
- --no-api Do not start the local OpenAI-compatible API
821
- --api Deprecated no-op; local API starts by default
822
- --api-port PORT Local API port (default: 8765)
823
- --port PORT Alias for --api-port on the api command
824
- --api-host HOST Local API host (default: 127.0.0.1)
825
- --api-key KEY Set or replace local API key in state
826
- --rotate-api-key Rotate local API key
827
- --api-model MODEL Local model id advertised by /v1/models (default: chatgpt-mcp)
951
+ --json Print MCP connection details as JSON
828
952
 
829
953
  Uninstall options:
830
954
  --keep-worker Do not delete deployed Worker(s) during uninstall
@@ -832,43 +956,11 @@ Uninstall options:
832
956
  `);
833
957
  }
834
958
 
835
- function apiUsage() {
836
- console.log(`machine-bridge-mcp local ChatGPT MCP-backed API
837
-
838
- Usage:
839
- machine-mcp
840
- machine-mcp --api-port 8766
841
- machine-mcp api # local API only
842
- machine-mcp api --api-port 8766
843
-
844
- Client settings:
845
- API Base URL: http://127.0.0.1:<port>/v1
846
- API key: printed on startup unless --no-print-credentials is set
847
- Model: chatgpt-mcp by default
848
- Backend: connected ChatGPT app through the Remote MCP bridge
849
-
850
- Options:
851
- --workspace PATH Use and remember this workspace path
852
- --api-host HOST Local API host (default: 127.0.0.1)
853
- --api-port PORT Local API port (default: 8765)
854
- --port PORT Alias for --api-port
855
- --api-key KEY Set or replace local API key in state
856
- --rotate-api-key Rotate local API key
857
- --api-model MODEL Local model id advertised by /v1/models
858
- --no-print-credentials Redact the local API key in console output
859
- --no-api Only valid for start; disables the default local API
860
- --state-dir DIR Override state root
861
-
862
- Important:
863
- Generation routes require a ChatGPT app connected to the printed MCP Server URL.
864
- The local API sends sampling/createMessage requests through that MCP connection.
865
-
866
- Environment:
867
- MBM_API_HOST, MBM_API_PORT, MBM_API_KEY, MBM_API_MODEL
868
- `);
959
+ function currentPackageVersion() {
960
+ const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
961
+ return String(pkg.version);
869
962
  }
870
963
 
871
-
872
964
  function version() {
873
965
  const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
874
966
  console.log(`${pkg.name} ${pkg.version}`);