machine-bridge-mcp 0.17.0 → 0.18.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 +33 -0
- package/README.md +21 -12
- package/SECURITY.md +17 -9
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +8 -6
- package/docs/AUDIT.md +3 -3
- package/docs/CLIENTS.md +3 -1
- package/docs/GETTING_STARTED.md +404 -0
- package/docs/LOGGING.md +2 -2
- package/docs/MANAGED_JOBS.md +1 -1
- package/docs/MULTI_ACCOUNT.md +123 -0
- package/docs/OPERATIONS.md +4 -4
- package/docs/POLICY_REFERENCE.md +1 -1
- package/docs/PRIVACY.md +1 -1
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/TESTING.md +4 -4
- package/package.json +4 -3
- package/src/local/account-access.mjs +37 -0
- package/src/local/account-admin.mjs +105 -0
- package/src/local/bounded-output.mjs +50 -0
- package/src/local/call-registry.mjs +10 -0
- package/src/local/cli-account-admin.mjs +79 -0
- package/src/local/cli-options.mjs +9 -4
- package/src/local/cli-policy.mjs +7 -28
- package/src/local/cli.mjs +48 -78
- package/src/local/daemon-process.mjs +3 -2
- package/src/local/errors.mjs +0 -14
- package/src/local/log.mjs +1 -1
- package/src/local/managed-jobs.mjs +1 -3
- package/src/local/process-execution.mjs +90 -60
- package/src/local/relay-connection.mjs +11 -3
- package/src/local/runtime.mjs +27 -2
- package/src/local/service.mjs +7 -26
- package/src/local/shell.mjs +17 -29
- package/src/local/state-inventory.mjs +2 -2
- package/src/local/state.mjs +30 -85
- package/src/local/tool-executor.mjs +8 -2
- package/src/shared/access-contract.json +23 -0
- package/src/shared/policy-contract.json +30 -7
- package/src/worker/access.ts +46 -0
- package/src/worker/account-admin.ts +103 -0
- package/src/worker/index.ts +75 -48
- package/src/worker/oauth-state.ts +184 -2
- package/src/worker/tool-catalog.ts +13 -0
package/src/local/cli.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import { assertCanonicalFullPolicy, POLICY_PROFILES, toolsForPolicy } from "./to
|
|
|
10
10
|
import { resolvePolicy } from "./cli-policy.mjs";
|
|
11
11
|
import { effectiveLogFormat, effectiveLogLevel, normalizeCommand, parseArgs, validateCommandOptions, validateLoggingOptions, validatePositionals } from "./cli-options.mjs";
|
|
12
12
|
import { createLocalAdminCommands } from "./cli-local-admin.mjs";
|
|
13
|
+
import { generateAccountPassword } from "./account-admin.mjs";
|
|
14
|
+
import { accountAdminClient, createAccountCommand } from "./cli-account-admin.mjs";
|
|
13
15
|
export { resolvePolicy } from "./cli-policy.mjs";
|
|
14
16
|
export { parseArgs, validateCommandOptions, validateLoggingOptions, validatePositionals } from "./cli-options.mjs";
|
|
15
17
|
import { classifyOperationalError, createLogger, normalizeLogLevel, sanitizeLogText } from "./log.mjs";
|
|
@@ -32,7 +34,6 @@ import {
|
|
|
32
34
|
loadState,
|
|
33
35
|
ownerOnlyFile,
|
|
34
36
|
packageRoot,
|
|
35
|
-
previewSecret,
|
|
36
37
|
readDaemonLockOwner,
|
|
37
38
|
redactState,
|
|
38
39
|
removeStateRoot,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
} from "./state.mjs";
|
|
46
47
|
|
|
47
48
|
const localAdminCommands = createLocalAdminCommands({ chooseWorkspace, confirm });
|
|
49
|
+
const accountCommand = createAccountCommand({ chooseWorkspace, confirm });
|
|
48
50
|
|
|
49
51
|
const COMMAND_HANDLERS = Object.freeze({
|
|
50
52
|
start: startCommand,
|
|
@@ -58,6 +60,7 @@ const COMMAND_HANDLERS = Object.freeze({
|
|
|
58
60
|
autostart: serviceCommand,
|
|
59
61
|
"rotate-secrets": rotateSecretsCommand,
|
|
60
62
|
resource: localAdminCommands.resourceCommand,
|
|
63
|
+
account: accountCommand,
|
|
61
64
|
browser: localAdminCommands.browserCommand,
|
|
62
65
|
job: localAdminCommands.jobCommand,
|
|
63
66
|
uninstall: uninstallCommand,
|
|
@@ -68,7 +71,6 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
68
71
|
const args = parseArgs(rest);
|
|
69
72
|
if (args.help || command === "help") return usage();
|
|
70
73
|
if (args.version || command === "version") return version();
|
|
71
|
-
if (command === "api") throw removedLocalApiError();
|
|
72
74
|
validateCommandOptions(command, args);
|
|
73
75
|
validatePositionals(command, args);
|
|
74
76
|
validateLoggingOptions(args);
|
|
@@ -192,7 +194,7 @@ async function prepareStartMode(args, state, logger) {
|
|
|
192
194
|
}
|
|
193
195
|
// A normal foreground start first asks the platform service manager to
|
|
194
196
|
// unload the job, then independently reclaims a verified daemon-only
|
|
195
|
-
// process. The second step handles
|
|
197
|
+
// process. The second step handles orphaned service daemons that launchd or
|
|
196
198
|
// another service manager no longer tracks.
|
|
197
199
|
return stopAutostartBestEffort(logger);
|
|
198
200
|
}
|
|
@@ -208,11 +210,7 @@ function reportExistingDaemon(args, state, owner, logger) {
|
|
|
208
210
|
const notice = `${mode} daemon already running for this workspace (${pid}${version}); it was not restarted and requested changes were not applied`;
|
|
209
211
|
logger.warn(notice);
|
|
210
212
|
if (args.json) {
|
|
211
|
-
printStartJson(state, {
|
|
212
|
-
showCredentials: Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials),
|
|
213
|
-
requestedChangesApplied: false,
|
|
214
|
-
notice,
|
|
215
|
-
});
|
|
213
|
+
printStartJson(state, { requestedChangesApplied: false, notice });
|
|
216
214
|
return;
|
|
217
215
|
}
|
|
218
216
|
if (owner?.mode === "foreground") {
|
|
@@ -236,8 +234,6 @@ function isIdempotentDaemonOnlyStart(args) {
|
|
|
236
234
|
|| args.fullEnv
|
|
237
235
|
|| args.unrestrictedPaths
|
|
238
236
|
|| args.absolutePaths
|
|
239
|
-
|| args.printMcpCredentials
|
|
240
|
-
|| args.printCredentials
|
|
241
237
|
);
|
|
242
238
|
}
|
|
243
239
|
|
|
@@ -257,27 +253,33 @@ async function startRemoteRuntime({ args, workspace, state, daemonLock, logger }
|
|
|
257
253
|
}
|
|
258
254
|
|
|
259
255
|
async function prepareRemoteState({ args, workspace, state, logger }) {
|
|
260
|
-
const previousMcpServerUrl = state.worker?.mcpServerUrl || "";
|
|
261
|
-
const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
|
|
262
256
|
const workerName = validateWorkerName(args.workerName);
|
|
263
257
|
ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName });
|
|
264
|
-
const previousPolicyOrigin = state.policy?.origin;
|
|
265
258
|
state.policy = resolvePolicy(args, state.policy);
|
|
266
|
-
const policyMigrated = !previousPolicyOrigin && state.policy.origin === "migrated";
|
|
267
259
|
state.policy.updatedAt = new Date().toISOString();
|
|
268
260
|
saveState(state);
|
|
269
261
|
|
|
270
|
-
|
|
271
|
-
|
|
262
|
+
let initialOwner = null;
|
|
263
|
+
if (!args.daemonOnly) {
|
|
264
|
+
await ensureWorker(state, args);
|
|
265
|
+
initialOwner = await ensureInitialOwnerAccount(state);
|
|
266
|
+
} else if (!state.worker.url) {
|
|
267
|
+
throw new Error("--daemon-only requires an existing Worker URL; run start once without --daemon-only");
|
|
268
|
+
}
|
|
272
269
|
|
|
273
270
|
if (!args.daemonOnly && !args.noAutostart) {
|
|
274
271
|
await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], logger });
|
|
275
272
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
273
|
+
return { initialOwner };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function ensureInitialOwnerAccount(state) {
|
|
277
|
+
const client = accountAdminClient(state);
|
|
278
|
+
const existing = await client.list();
|
|
279
|
+
if (existing.accounts.length > 0) return null;
|
|
280
|
+
const password = generateAccountPassword();
|
|
281
|
+
const created = await client.create({ name: "owner", role: "owner", password, displayName: "Bridge Owner" });
|
|
282
|
+
return { ...created.account, password };
|
|
281
283
|
}
|
|
282
284
|
|
|
283
285
|
function createRemoteRuntime({ args, workspace, state, daemonLock, logger }) {
|
|
@@ -305,18 +307,13 @@ function createRemoteRuntime({ args, workspace, state, daemonLock, logger }) {
|
|
|
305
307
|
|
|
306
308
|
function reportRemoteReady(args, state, readiness) {
|
|
307
309
|
if (args.json) {
|
|
308
|
-
printStartJson(state, {
|
|
309
|
-
showCredentials: Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials),
|
|
310
|
-
notice: readiness.policyMigrated ? "legacy implicit policy migrated to full access" : "",
|
|
311
|
-
});
|
|
310
|
+
printStartJson(state, { initialOwner: readiness.initialOwner });
|
|
312
311
|
return;
|
|
313
312
|
}
|
|
314
313
|
printMcpConnection(state, {
|
|
315
|
-
noPrintCredentials: Boolean(args.noPrintCredentials),
|
|
316
|
-
includeCredentials: readiness.shouldPrintMcpCredentials,
|
|
317
314
|
quiet: Boolean(args.quiet),
|
|
318
315
|
verbose: Boolean(args.verbose),
|
|
319
|
-
|
|
316
|
+
initialOwner: readiness.initialOwner,
|
|
320
317
|
});
|
|
321
318
|
}
|
|
322
319
|
|
|
@@ -363,16 +360,11 @@ async function clientConfigCommand(args) {
|
|
|
363
360
|
}
|
|
364
361
|
}
|
|
365
362
|
|
|
366
|
-
function removedLocalApiError() {
|
|
367
|
-
return new Error("Local /v1 API support has been removed. Use the printed Remote MCP Server URL/password with an MCP client instead.");
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
|
|
371
363
|
async function ensureWorker(state, args) {
|
|
372
364
|
const logger = createLogger({ level: args.json ? "error" : effectiveLogLevel(args), format: effectiveLogFormat(args), component: "worker" });
|
|
373
365
|
const desiredHash = workerDeployHash(state);
|
|
374
366
|
const expectedVersion = currentPackageVersion();
|
|
375
|
-
const complete = state.worker.url && state.worker.mcpServerUrl && state.worker.
|
|
367
|
+
const complete = state.worker.url && state.worker.mcpServerUrl && state.worker.accountAdminSecret && state.worker.daemonSecret && state.worker.oauthTokenVersion && state.worker.name;
|
|
376
368
|
if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
|
|
377
369
|
const health = await workerHealth(state.worker.url, expectedVersion);
|
|
378
370
|
if (health.ok) {
|
|
@@ -423,7 +415,7 @@ async function withSecretsFile(state, callback) {
|
|
|
423
415
|
cleanupStaleSecretFiles(dir);
|
|
424
416
|
const tempPath = resolve(dir, `worker-secrets-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}.json`);
|
|
425
417
|
const payload = {
|
|
426
|
-
|
|
418
|
+
ACCOUNT_ADMIN_SECRET: state.worker.accountAdminSecret,
|
|
427
419
|
DAEMON_SHARED_SECRET: state.worker.daemonSecret,
|
|
428
420
|
OAUTH_TOKEN_VERSION: state.worker.oauthTokenVersion,
|
|
429
421
|
};
|
|
@@ -453,9 +445,9 @@ export function cleanupStaleSecretFiles(dir) {
|
|
|
453
445
|
|
|
454
446
|
function workerDeployHash(state) {
|
|
455
447
|
const hash = createHash("sha256");
|
|
456
|
-
hash.update("mbm-worker-deploy-
|
|
448
|
+
hash.update("mbm-worker-deploy-v2");
|
|
457
449
|
hash.update(String(state.worker.name || ""));
|
|
458
|
-
hash.update(String(state.worker.
|
|
450
|
+
hash.update(String(state.worker.accountAdminSecret || ""));
|
|
459
451
|
hash.update(String(state.worker.daemonSecret || ""));
|
|
460
452
|
hash.update(String(state.worker.oauthTokenVersion || ""));
|
|
461
453
|
for (const file of workerDeployHashFiles()) {
|
|
@@ -542,14 +534,14 @@ function extractWorkerUrl(text = "") {
|
|
|
542
534
|
return anyHttps.find(match => /workers\.dev|\/healthz|\/mcp/.test(match[0]))?.[0]?.replace(/[),.]+$/, "") || "";
|
|
543
535
|
}
|
|
544
536
|
|
|
545
|
-
function printStartJson(state, {
|
|
537
|
+
function printStartJson(state, { requestedChangesApplied = true, notice = "", initialOwner = null } = {}) {
|
|
546
538
|
createLogger({ component: "ready" }).json({
|
|
547
539
|
mcp: {
|
|
548
540
|
server_url: state.worker.mcpServerUrl,
|
|
549
|
-
connection_password: showCredentials ? state.worker.oauthPassword : previewSecret(state.worker.oauthPassword),
|
|
550
541
|
worker_url: state.worker.url,
|
|
551
542
|
worker_name: state.worker.name,
|
|
552
543
|
},
|
|
544
|
+
...(initialOwner ? { initial_owner: initialOwner } : {}),
|
|
553
545
|
workspace: state.workspace.path,
|
|
554
546
|
state_path: state.paths.statePath,
|
|
555
547
|
policy: state.policy,
|
|
@@ -558,36 +550,20 @@ function printStartJson(state, { showCredentials = false, requestedChangesApplie
|
|
|
558
550
|
});
|
|
559
551
|
}
|
|
560
552
|
|
|
561
|
-
function printMcpConnection(state, {
|
|
562
|
-
noPrintCredentials = false,
|
|
563
|
-
includeCredentials = false,
|
|
564
|
-
quiet = false,
|
|
565
|
-
verbose = false,
|
|
566
|
-
policyMigrated = false,
|
|
567
|
-
} = {}) {
|
|
553
|
+
function printMcpConnection(state, { quiet = false, verbose = false, initialOwner = null } = {}) {
|
|
568
554
|
const logger = createLogger({ component: "ready", quiet, level: quiet ? "error" : verbose ? "debug" : "info" });
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
};
|
|
576
|
-
if (includeCredentials) {
|
|
577
|
-
logger.success("Remote MCP bridge is ready; save these connection details if your ChatGPT app needs to reconnect");
|
|
578
|
-
logger.plain(` MCP Server URL: ${payload.mcp_server_url}`);
|
|
579
|
-
if (!noPrintCredentials) logger.plain(` MCP connection password: ${payload.mcp_connection_password}`);
|
|
580
|
-
else logger.plain(` MCP connection password: ${previewSecret(payload.mcp_connection_password)} (redacted)`);
|
|
555
|
+
logger.success("Remote MCP bridge is ready");
|
|
556
|
+
logger.plain(` MCP Server URL: ${state.worker.mcpServerUrl}`);
|
|
557
|
+
if (initialOwner) {
|
|
558
|
+
logger.warn("Initial owner account created; save the password now because it is not stored locally or shown again.");
|
|
559
|
+
logger.plain(` Account: ${initialOwner.name}`);
|
|
560
|
+
logger.plain(` Password: ${initialOwner.password}`);
|
|
581
561
|
} else {
|
|
582
|
-
logger.
|
|
583
|
-
logger.safePlain(" Connection credentials unchanged; use --print-mcp-credentials only when reconnecting a client.");
|
|
562
|
+
logger.safePlain(" Use `machine-mcp account` to manage account access.");
|
|
584
563
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
588
|
-
logger.plain(` Workspace: ${payload.workspace}`);
|
|
589
|
-
logger.safePlain(` Policy: ${formatPolicySummary(payload.policy)}`);
|
|
590
|
-
if (verbose) logger.plain(` State: ${payload.state_path}`);
|
|
564
|
+
logger.plain(` Workspace: ${state.workspace.path}`);
|
|
565
|
+
logger.safePlain(` Policy: ${formatPolicySummary(state.policy)}`);
|
|
566
|
+
if (verbose) logger.plain(` State: ${state.paths.statePath}`);
|
|
591
567
|
}
|
|
592
568
|
|
|
593
569
|
function formatPolicySummary(policy = {}) {
|
|
@@ -616,12 +592,10 @@ function keepProcessAlive({ daemon = null, lock = null, logger = createLogger({
|
|
|
616
592
|
async function statusCommand(args) {
|
|
617
593
|
const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
|
|
618
594
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
619
|
-
const storedPolicyOrigin = state.policy?.origin;
|
|
620
595
|
state.policy = resolvePolicy({}, state.policy);
|
|
621
596
|
const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
|
|
622
597
|
const payload = {
|
|
623
598
|
...redactState(state),
|
|
624
|
-
policyMigrationPending: !storedPolicyOrigin && state.policy.origin === "migrated",
|
|
625
599
|
workerHealth: health,
|
|
626
600
|
};
|
|
627
601
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -640,7 +614,6 @@ async function doctorCommand(args) {
|
|
|
640
614
|
const whoami = await runWrangler(["whoami"], { capture: true, allowFailure: true });
|
|
641
615
|
checks.push({ name: "cloudflare-login", ok: whoami.code === 0, detail: whoami.code === 0 ? "authenticated" : sanitizeLines(whoami.stderr || whoami.stdout) });
|
|
642
616
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
643
|
-
const storedPolicyOrigin = state.policy?.origin;
|
|
644
617
|
state.policy = resolvePolicy({}, state.policy);
|
|
645
618
|
checks.push({ name: "policy", ok: true, detail: formatPolicySummary(state.policy) });
|
|
646
619
|
if (state.policy.profile === "full") {
|
|
@@ -679,7 +652,6 @@ async function doctorCommand(args) {
|
|
|
679
652
|
ok: checks.every(check => check.ok),
|
|
680
653
|
checks,
|
|
681
654
|
runtimeDiagnostics,
|
|
682
|
-
policyMigrationPending: !storedPolicyOrigin && state.policy.origin === "migrated",
|
|
683
655
|
state: redactState(state),
|
|
684
656
|
}, null, 2));
|
|
685
657
|
}
|
|
@@ -713,10 +685,8 @@ async function rotateSecretsCommand(args) {
|
|
|
713
685
|
}
|
|
714
686
|
ensureWorkerSecrets(state, { rotateSecrets: true, workerName: validateWorkerName(args.workerName) });
|
|
715
687
|
saveState(state);
|
|
716
|
-
|
|
717
|
-
console.log(
|
|
718
|
-
console.log(`Rotated daemon secret: ${previewSecret(state.worker.daemonSecret)}`);
|
|
719
|
-
console.log("Run `machine-mcp --print-mcp-credentials` to redeploy and display the new client connection credentials when needed.");
|
|
688
|
+
console.log("Rotated account administration, daemon, and global token-version secrets.");
|
|
689
|
+
console.log("All account access tokens are invalid. Run machine-mcp to redeploy, then reconnect clients.");
|
|
720
690
|
} finally {
|
|
721
691
|
startupLock.release();
|
|
722
692
|
}
|
|
@@ -1008,7 +978,9 @@ Commands:
|
|
|
1008
978
|
status Print redacted local profile state and Worker health
|
|
1009
979
|
doctor Check Node, Wrangler, Cloudflare login, Worker health
|
|
1010
980
|
full-test Run real local full-profile capability tests in a temporary sandbox
|
|
1011
|
-
rotate-secrets Rotate
|
|
981
|
+
rotate-secrets Rotate account-admin, daemon, and global token-version secrets
|
|
982
|
+
account list|add|role|enable|disable|rotate-password|remove
|
|
983
|
+
Manage isolated remote accounts and targeted revocation
|
|
1012
984
|
resource generate-ssh-key NAME [PATH]
|
|
1013
985
|
Generate/reuse an Ed25519 key locally and register its private file by alias
|
|
1014
986
|
browser status Show browser-extension bridge and connection status
|
|
@@ -1023,8 +995,6 @@ Start options:
|
|
|
1023
995
|
--rotate-secrets Rotate secrets before deploying
|
|
1024
996
|
--daemon-only Skip deploy and only connect daemon from existing state
|
|
1025
997
|
--no-autostart Do not install login autostart during start
|
|
1026
|
-
--no-print-credentials Redact credentials in console output
|
|
1027
|
-
--print-mcp-credentials Print MCP URL/password again for reconnecting ChatGPT apps
|
|
1028
998
|
--profile NAME Policy profile: full (default), agent, edit, or review
|
|
1029
999
|
--exec-mode MODE Command mode: off, direct argv, or full shell
|
|
1030
1000
|
--no-write Disable write_file, edit_file, and apply_patch
|
|
@@ -1033,7 +1003,7 @@ Start options:
|
|
|
1033
1003
|
--unrestricted-paths Allow filesystem tools outside the workspace
|
|
1034
1004
|
--absolute-paths Return absolute local paths (enabled by the full profile)
|
|
1035
1005
|
--state-dir DIR Override state root
|
|
1036
|
-
--json Print
|
|
1006
|
+
--json Print machine-readable output; secrets are never included
|
|
1037
1007
|
--log-level LEVEL error, warn, info (default), or debug
|
|
1038
1008
|
--log-format FORMAT text (default) or newline-delimited json
|
|
1039
1009
|
--verbose Alias for --log-level debug; includes per-tool success/correlation logs
|
|
@@ -153,10 +153,11 @@ function inspectWorkspaceDaemonOwner(state, owner) {
|
|
|
153
153
|
if (!processIdentity.current) return { verified_service_daemon: false, reason: processIdentity.reason };
|
|
154
154
|
if (!sameCanonicalPath(owner.workspace, state.workspace.path)) return { verified_service_daemon: false, reason: "workspace_mismatch" };
|
|
155
155
|
if (owner.mode === "foreground") return { verified_service_daemon: false, reason: "foreground_daemon" };
|
|
156
|
+
if (owner.mode !== "service") return { verified_service_daemon: false, reason: "invalid_daemon_mode" };
|
|
156
157
|
|
|
157
158
|
const command = processCommandLine(pid);
|
|
158
159
|
if (!command) {
|
|
159
|
-
return { verified_service_daemon: false, reason:
|
|
160
|
+
return { verified_service_daemon: false, reason: "service_identity_unavailable" };
|
|
160
161
|
}
|
|
161
162
|
const argv = splitProcessCommandLine(command);
|
|
162
163
|
const entryName = path.basename(String(owner.entryScript || "machine-mcp"));
|
|
@@ -198,7 +199,7 @@ function publicDaemonOwner(owner) {
|
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
function publicDaemonMode(owner) {
|
|
201
|
-
return owner?.mode === "service" || owner?.mode === "foreground" ? owner.mode : "
|
|
202
|
+
return owner?.mode === "service" || owner?.mode === "foreground" ? owner.mode : "invalid";
|
|
202
203
|
}
|
|
203
204
|
|
|
204
205
|
function boundedPositiveInt(value, fallback) {
|
package/src/local/errors.mjs
CHANGED
|
@@ -49,20 +49,6 @@ export function errorCode(error, fallback = "execution_failed") {
|
|
|
49
49
|
if (["ECONNRESET", "ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "EAI_AGAIN"].includes(nodeCode)) return "network_error";
|
|
50
50
|
if (["EEXIST", "ENOTEMPTY", "EBUSY"].includes(nodeCode)) return "conflict";
|
|
51
51
|
|
|
52
|
-
// Legacy errors are classified once at the boundary. New code should throw BridgeError directly.
|
|
53
|
-
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
54
|
-
if (/cancel/i.test(message)) return "cancelled";
|
|
55
|
-
if (/timed out/i.test(message)) return "timeout";
|
|
56
|
-
if (/unauthorized|authentication|\b401\b/i.test(message)) return "authentication_failed";
|
|
57
|
-
if (/forbidden|authorization|\b403\b/i.test(message)) return "authorization_denied";
|
|
58
|
-
if (/outside the configured workspace/i.test(message)) return "path_boundary";
|
|
59
|
-
if (/disabled|requires .* mode|requires the canonical .* profile/i.test(message)) return "policy_denied";
|
|
60
|
-
if (/not found|ENOENT/i.test(message)) return "not_found";
|
|
61
|
-
if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
|
|
62
|
-
if (/maximum|exceeds|max_bytes|too many|limit reached/i.test(message)) return "limit_exceeded";
|
|
63
|
-
if (/integrity|hash mismatch|changed during/i.test(message)) return "integrity_error";
|
|
64
|
-
if (/protocol|unexpected .* message|invalid .* envelope/i.test(message)) return "protocol_error";
|
|
65
|
-
if (/invalid|must|requires|ambiguous|mismatch/i.test(message)) return "invalid_request";
|
|
66
52
|
return normalizeErrorCode(fallback);
|
|
67
53
|
}
|
|
68
54
|
|
package/src/local/log.mjs
CHANGED
|
@@ -18,7 +18,7 @@ const MAX_LOG_OBJECT_KEYS = 48;
|
|
|
18
18
|
const LEVEL_RANK = Object.freeze({ debug: 10, info: 20, success: 20, warn: 30, error: 40 });
|
|
19
19
|
const SENSITIVE_KEY = /(authorization|cookie|password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)/i;
|
|
20
20
|
const LOCAL_PATH_KEY = /(path|paths|cwd|workspace|directory|(?:^|[_-])dir(?:$|[_-])|root|home)/i;
|
|
21
|
-
const SECRET_VALUE = /\b(?:
|
|
21
|
+
const SECRET_VALUE = /\b(?:account_admin|account_password|daemon_secret|token_version|mcp_at|mcp_code)_[A-Za-z0-9_-]+\b/g;
|
|
22
22
|
const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi;
|
|
23
23
|
const EMAIL_VALUE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
24
24
|
const AWS_ACCESS_KEY = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g;
|
|
@@ -674,9 +674,7 @@ function launchRunner(dir, recover = false, recoveryToken = "") {
|
|
|
674
674
|
|
|
675
675
|
function readRunnerOwner(dir, fallback = {}) {
|
|
676
676
|
try {
|
|
677
|
-
const
|
|
678
|
-
if (/^\d+$/.test(text)) return { pid: Number(text), ...fallback };
|
|
679
|
-
const parsed = JSON.parse(text);
|
|
677
|
+
const parsed = JSON.parse(readBoundedFile(join(dir, "runner.pid"), 1024).toString("utf8"));
|
|
680
678
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ...fallback };
|
|
681
679
|
return { ...fallback, ...parsed };
|
|
682
680
|
} catch {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { stat } from "node:fs/promises";
|
|
3
|
+
import { BoundedOutput } from "./bounded-output.mjs";
|
|
3
4
|
import { executionEnv, workspaceShellCommand } from "./shell.mjs";
|
|
4
5
|
import { MAX_COMMAND_BYTES, terminateProcessTreeWithEscalation, validateArgv } from "./process-sessions.mjs";
|
|
5
6
|
import { BridgeError } from "./errors.mjs";
|
|
@@ -9,7 +10,7 @@ const MAX_STDIN_BYTES = 1024 * 1024;
|
|
|
9
10
|
const DEFAULT_OUTPUT_BYTES = 512 * 1024;
|
|
10
11
|
|
|
11
12
|
export class ProcessExecutionService {
|
|
12
|
-
constructor({ workspace, policy, policyGate, runtimeDir, processTracker, resolveExistingPath, resolveLocalCommand, displayPath, throwIfCancelled }) {
|
|
13
|
+
constructor({ workspace, policy, policyGate, runtimeDir, processTracker, resolveExistingPath, resolveLocalCommand, displayPath, throwIfCancelled, spawnProcess = spawn, terminateProcess = terminateProcessTreeWithEscalation }) {
|
|
13
14
|
this.workspace = workspace;
|
|
14
15
|
this.policy = policy;
|
|
15
16
|
this.policyGate = policyGate;
|
|
@@ -19,6 +20,8 @@ export class ProcessExecutionService {
|
|
|
19
20
|
this.resolveLocalCommand = resolveLocalCommand;
|
|
20
21
|
this.displayPath = displayPath;
|
|
21
22
|
this.throwIfCancelled = throwIfCancelled;
|
|
23
|
+
this.spawnProcess = spawnProcess;
|
|
24
|
+
this.terminateProcess = terminateProcess;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
async runDirect(args, context = {}) {
|
|
@@ -66,83 +69,110 @@ export class ProcessExecutionService {
|
|
|
66
69
|
if (stdin !== null && Buffer.byteLength(String(stdin)) > MAX_STDIN_BYTES) {
|
|
67
70
|
throw new BridgeError("limit_exceeded", "process stdin exceeds 1 MiB");
|
|
68
71
|
}
|
|
72
|
+
|
|
69
73
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const stdout = new BoundedOutput(maxOutputBytes);
|
|
75
|
+
const stderr = new BoundedOutput(maxOutputBytes);
|
|
76
|
+
let child;
|
|
77
|
+
try {
|
|
78
|
+
child = this.spawnProcess(cmd, args, {
|
|
79
|
+
cwd,
|
|
80
|
+
env: executionEnv(this.workspace, { fullEnv: this.policy.minimalEnv === false, runtimeDir: this.runtimeDir }),
|
|
81
|
+
detached: process.platform !== "win32",
|
|
82
|
+
windowsHide: true,
|
|
83
|
+
});
|
|
84
|
+
} catch (error) {
|
|
85
|
+
rejectPromise(error);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
76
89
|
this.processTracker.track(child, context.callId);
|
|
77
90
|
if (stdin !== null) {
|
|
78
|
-
child.stdin
|
|
79
|
-
child.stdin
|
|
91
|
+
child.stdin?.on?.("error", () => {});
|
|
92
|
+
child.stdin?.end?.(String(stdin));
|
|
80
93
|
}
|
|
81
|
-
|
|
82
|
-
let stderr = "";
|
|
83
|
-
let stdoutTruncated = 0;
|
|
84
|
-
let stderrTruncated = 0;
|
|
85
|
-
let timedOut = false;
|
|
94
|
+
|
|
86
95
|
let settled = false;
|
|
87
|
-
let
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
let processClosed = false;
|
|
97
|
+
let terminationTimer = null;
|
|
98
|
+
let timeoutTimer = null;
|
|
99
|
+
const signal = context.signal;
|
|
100
|
+
|
|
101
|
+
const cleanupAfterClose = () => {
|
|
102
|
+
if (processClosed) return;
|
|
103
|
+
processClosed = true;
|
|
104
|
+
clearTimeout(timeoutTimer);
|
|
105
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
96
106
|
this.processTracker.untrack(child);
|
|
97
107
|
};
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
|
|
109
|
+
const settle = (callback) => {
|
|
110
|
+
if (settled) return false;
|
|
100
111
|
settled = true;
|
|
101
|
-
cleanup();
|
|
102
112
|
callback();
|
|
113
|
+
return true;
|
|
103
114
|
};
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
|
|
116
|
+
const terminate = () => {
|
|
117
|
+
if (terminationTimer || processClosed) return;
|
|
118
|
+
terminationTimer = this.terminateProcess(child);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const rejectCancelled = () => {
|
|
122
|
+
terminate();
|
|
123
|
+
const reason = signal?.reason;
|
|
124
|
+
settle(() => rejectPromise(reason instanceof Error ? reason : new BridgeError("cancelled", "tool call cancelled")));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const onAbort = () => rejectCancelled();
|
|
128
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
129
|
+
|
|
130
|
+
timeoutTimer = setTimeout(() => {
|
|
131
|
+
terminate();
|
|
132
|
+
settle(() => rejectPromise(new BridgeError("timeout", `command timed out after ${timeoutMs}ms`, { retryable: true })));
|
|
133
|
+
}, timeoutMs);
|
|
134
|
+
timeoutTimer.unref?.();
|
|
135
|
+
|
|
136
|
+
child.stdout?.on?.("data", (chunk) => stdout.append(chunk));
|
|
137
|
+
child.stderr?.on?.("data", (chunk) => stderr.append(chunk));
|
|
138
|
+
|
|
139
|
+
child.on("error", (error) => {
|
|
140
|
+
cleanupAfterClose();
|
|
141
|
+
settle(() => {
|
|
142
|
+
if (allowFailure) resolvePromise(processResult(127, stdout, error.message || stderr.text()));
|
|
143
|
+
else rejectPromise(error);
|
|
144
|
+
});
|
|
113
145
|
});
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
try { this.throwIfCancelled(context); } catch (error) { rejectPromise(error); return; }
|
|
121
|
-
if (timedOut) {
|
|
122
|
-
rejectPromise(new BridgeError("timeout", `command timed out after ${timeoutMs}ms`, { retryable: true }));
|
|
146
|
+
|
|
147
|
+
child.on("close", (code) => {
|
|
148
|
+
cleanupAfterClose();
|
|
149
|
+
if (settled) return;
|
|
150
|
+
try { this.throwIfCancelled(context); } catch (error) {
|
|
151
|
+
settle(() => rejectPromise(error));
|
|
123
152
|
return;
|
|
124
153
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
154
|
+
const result = processResult(code, stdout, stderr);
|
|
155
|
+
if (code === 0 || allowFailure) settle(() => resolvePromise(result));
|
|
156
|
+
else settle(() => rejectPromise(new BridgeError("execution_failed", result.stderr.trim() || result.stdout.trim() || `${cmd} exited ${code}`)));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (signal?.aborted) rejectCancelled();
|
|
128
160
|
});
|
|
129
161
|
}
|
|
130
162
|
}
|
|
131
163
|
|
|
164
|
+
function processResult(code, stdout, stderr) {
|
|
165
|
+
const stderrBuffer = stderr instanceof BoundedOutput ? stderr : null;
|
|
166
|
+
return {
|
|
167
|
+
code,
|
|
168
|
+
stdout: stdout instanceof BoundedOutput ? stdout.text() : String(stdout || ""),
|
|
169
|
+
stderr: stderrBuffer ? stderrBuffer.text() : boundedErrorMessage(stderr),
|
|
170
|
+
stdout_truncated_bytes: stdout instanceof BoundedOutput ? stdout.truncatedBytes : 0,
|
|
171
|
+
stderr_truncated_bytes: stderrBuffer ? stderrBuffer.truncatedBytes : 0,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
132
175
|
export function boundedErrorMessage(error) {
|
|
133
176
|
const message = error instanceof Error ? error.message : String(error);
|
|
134
177
|
return message.replace(/[\r\n]+/g, " ").slice(0, 4096) || "tool call failed";
|
|
135
178
|
}
|
|
136
|
-
|
|
137
|
-
function appendLimited(current, chunk, maximum) {
|
|
138
|
-
const text = String(chunk || "");
|
|
139
|
-
const budget = Math.max(0, maximum - Buffer.byteLength(current));
|
|
140
|
-
const textBytes = Buffer.byteLength(text);
|
|
141
|
-
if (textBytes <= budget) return { value: current + text, truncated: 0 };
|
|
142
|
-
const slice = Buffer.from(text).subarray(0, budget).toString();
|
|
143
|
-
return { value: current + slice, truncated: textBytes - Buffer.byteLength(slice) };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function finalizeOutput(value, truncated) {
|
|
147
|
-
return truncated > 0 ? `${value}\n\n[truncated ${truncated} bytes]` : value;
|
|
148
|
-
}
|
|
@@ -209,7 +209,7 @@ export class RelayConnection {
|
|
|
209
209
|
this.failPermanently("relay_proxy_configuration");
|
|
210
210
|
return;
|
|
211
211
|
}
|
|
212
|
-
this.lastTransportErrorClass =
|
|
212
|
+
this.lastTransportErrorClass = classifyRelayTransportError(error);
|
|
213
213
|
this.logger.debug?.("remote relay connection could not be created", { error_class: this.lastTransportErrorClass });
|
|
214
214
|
this.scheduleReconnect("connection_interrupted");
|
|
215
215
|
return;
|
|
@@ -306,7 +306,7 @@ export class RelayConnection {
|
|
|
306
306
|
|
|
307
307
|
socket.on("error", (error) => {
|
|
308
308
|
if (this.socket !== socket || this.closed) return;
|
|
309
|
-
this.lastTransportErrorClass =
|
|
309
|
+
this.lastTransportErrorClass = classifyRelayTransportError(error);
|
|
310
310
|
this.logger.debug?.("remote relay transport error", { error_class: this.lastTransportErrorClass });
|
|
311
311
|
if (this.lastTransportErrorClass === "authentication_failed") {
|
|
312
312
|
this.failPermanently("relay_authentication_failed");
|
|
@@ -373,7 +373,7 @@ export class RelayConnection {
|
|
|
373
373
|
socket.send(JSON.stringify(value));
|
|
374
374
|
return true;
|
|
375
375
|
} catch (error) {
|
|
376
|
-
this.lastTransportErrorClass =
|
|
376
|
+
this.lastTransportErrorClass = classifyRelayTransportError(error);
|
|
377
377
|
this.pendingCloseCategory = "relay_transport_error";
|
|
378
378
|
this.logger.debug?.("remote relay send failed", { error_class: this.lastTransportErrorClass });
|
|
379
379
|
terminateSocket(socket);
|
|
@@ -585,6 +585,14 @@ function redactUrl(value) {
|
|
|
585
585
|
}
|
|
586
586
|
}
|
|
587
587
|
|
|
588
|
+
function classifyRelayTransportError(error) {
|
|
589
|
+
const directStatus = Number(error?.statusCode ?? error?.response?.statusCode);
|
|
590
|
+
if (directStatus === 401 || directStatus === 403) return "authentication_failed";
|
|
591
|
+
const match = /^Unexpected server response: (\d{3})$/.exec(String(error?.message || ""));
|
|
592
|
+
if (match && [401, 403].includes(Number(match[1]))) return "authentication_failed";
|
|
593
|
+
return classifyOperationalError(error);
|
|
594
|
+
}
|
|
595
|
+
|
|
588
596
|
function boundedPositiveInteger(value, fallback) {
|
|
589
597
|
const number = Number(value);
|
|
590
598
|
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|