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/CHANGELOG.md +94 -0
- package/README.md +159 -201
- package/SECURITY.md +134 -0
- package/docs/ARCHITECTURE.md +169 -0
- package/docs/CLIENTS.md +72 -0
- package/docs/OPERATIONS.md +81 -0
- package/docs/RELEASING.md +62 -0
- package/docs/TESTING.md +51 -0
- package/docs/examples/github-actions-ci.yml +53 -0
- package/package.json +34 -13
- package/src/local/cli.mjs +391 -299
- package/src/local/daemon.mjs +783 -195
- package/src/local/log.mjs +33 -11
- package/src/local/patch.mjs +140 -0
- package/src/local/process-sessions.mjs +352 -0
- package/src/local/service.mjs +46 -19
- package/src/local/shell.mjs +90 -19
- package/src/local/state.mjs +240 -51
- package/src/local/stdio.mjs +194 -0
- package/src/local/tools.mjs +96 -0
- package/src/shared/tool-catalog.json +638 -0
- package/src/worker/index.ts +778 -549
- package/tsconfig.json +4 -2
- package/wrangler.jsonc +2 -2
- package/src/local/api-server.mjs +0 -368
- package/src/local/self-test.mjs +0 -256
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 {
|
|
8
|
-
import {
|
|
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 "
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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});
|
|
200
|
-
if (
|
|
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
|
-
|
|
203
|
-
|
|
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:
|
|
290
|
+
includeCredentials: Boolean(args.printMcpCredentials || args.printCredentials),
|
|
234
291
|
quiet: Boolean(args.quiet),
|
|
235
292
|
});
|
|
236
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
280
|
-
|
|
339
|
+
keepProcessAlive({ daemon, lock, logger });
|
|
340
|
+
} catch (error) {
|
|
341
|
+
try { daemon?.stop?.(); } catch {}
|
|
342
|
+
lock.release();
|
|
343
|
+
throw error;
|
|
281
344
|
}
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
288
|
-
const
|
|
289
|
-
|
|
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
|
|
341
|
-
|
|
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
|
|
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)
|
|
387
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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"},
|
|
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
|
|
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]) >=
|
|
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.
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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],
|
|
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
|
|
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,
|
|
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
|
-
|
|
727
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 <
|
|
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
|
|
793
|
-
|
|
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
|
-
--
|
|
816
|
-
--
|
|
817
|
-
--
|
|
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
|
|
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
|
|
836
|
-
|
|
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}`);
|