machine-bridge-mcp 0.17.1 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +12 -13
  3. package/SECURITY.md +13 -13
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +5 -5
  6. package/docs/AUDIT.md +3 -3
  7. package/docs/CLIENTS.md +2 -2
  8. package/docs/GETTING_STARTED.md +13 -9
  9. package/docs/LOGGING.md +2 -2
  10. package/docs/MANAGED_JOBS.md +1 -1
  11. package/docs/MULTI_ACCOUNT.md +75 -358
  12. package/docs/OPERATIONS.md +4 -4
  13. package/docs/POLICY_REFERENCE.md +1 -1
  14. package/docs/PRIVACY.md +1 -1
  15. package/docs/PROJECT_STANDARDS.md +1 -1
  16. package/docs/TESTING.md +4 -4
  17. package/package.json +4 -3
  18. package/src/local/account-access.mjs +37 -0
  19. package/src/local/account-admin.mjs +105 -0
  20. package/src/local/bounded-output.mjs +50 -0
  21. package/src/local/call-registry.mjs +10 -0
  22. package/src/local/cli-account-admin.mjs +79 -0
  23. package/src/local/cli-options.mjs +9 -4
  24. package/src/local/cli-policy.mjs +7 -28
  25. package/src/local/cli.mjs +48 -78
  26. package/src/local/daemon-process.mjs +3 -2
  27. package/src/local/errors.mjs +0 -14
  28. package/src/local/log.mjs +1 -1
  29. package/src/local/managed-jobs.mjs +1 -3
  30. package/src/local/process-execution.mjs +90 -60
  31. package/src/local/relay-connection.mjs +11 -3
  32. package/src/local/runtime.mjs +27 -2
  33. package/src/local/service.mjs +7 -26
  34. package/src/local/shell.mjs +17 -29
  35. package/src/local/state-inventory.mjs +2 -2
  36. package/src/local/state.mjs +30 -85
  37. package/src/local/tool-executor.mjs +8 -2
  38. package/src/shared/access-contract.json +23 -0
  39. package/src/shared/policy-contract.json +30 -7
  40. package/src/worker/access.ts +46 -0
  41. package/src/worker/account-admin.ts +103 -0
  42. package/src/worker/index.ts +75 -48
  43. package/src/worker/oauth-state.ts +184 -2
  44. 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 legacy/orphan daemons that launchd or
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
- if (!args.daemonOnly) await ensureWorker(state, args);
271
- else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
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
- const mcpConnectionChanged = Boolean(previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl);
277
- return {
278
- policyMigrated,
279
- shouldPrintMcpCredentials: Boolean(args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged),
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
- policyMigrated: readiness.policyMigrated,
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.oauthPassword && state.worker.daemonSecret && state.worker.oauthTokenVersion && state.worker.name;
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
- MCP_OAUTH_PASSWORD: state.worker.oauthPassword,
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-v1");
448
+ hash.update("mbm-worker-deploy-v2");
457
449
  hash.update(String(state.worker.name || ""));
458
- hash.update(String(state.worker.oauthPassword || ""));
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, { showCredentials = false, requestedChangesApplied = true, notice = "" } = {}) {
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
- const payload = {
570
- mcp_server_url: state.worker.mcpServerUrl,
571
- mcp_connection_password: state.worker.oauthPassword,
572
- workspace: state.workspace.path,
573
- state_path: state.paths.statePath,
574
- policy: state.policy,
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.success("Remote MCP bridge is ready");
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
- if (policyMigrated) {
586
- logger.warn("Legacy implicit policy migrated to full access; use --profile agent, edit, or review to narrow it.");
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
- const showMcpPassword = Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials);
717
- console.log(`Rotated MCP connection password: ${showMcpPassword ? state.worker.oauthPassword : previewSecret(state.worker.oauthPassword)}`);
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 MCP password and daemon secret in local state
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 connection details as JSON; credentials stay redacted unless explicitly requested
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: owner.mode === "service" ? "service_identity_unavailable" : "legacy_identity_unavailable" };
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 : "legacy";
202
+ return owner?.mode === "service" || owner?.mode === "foreground" ? owner.mode : "invalid";
202
203
  }
203
204
 
204
205
  function boundedPositiveInt(value, fallback) {
@@ -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(?:mcp_password|daemon_secret|token_version|mcp_at|mcp_code)_[A-Za-z0-9_-]+\b/g;
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 text = readBoundedFile(join(dir, "runner.pid"), 1024).toString("utf8").trim();
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 child = spawn(cmd, args, {
71
- cwd,
72
- env: executionEnv(this.workspace, { fullEnv: this.policy.minimalEnv === false, runtimeDir: this.runtimeDir }),
73
- detached: process.platform !== "win32",
74
- windowsHide: true,
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.on("error", () => {});
79
- child.stdin.end(String(stdin));
91
+ child.stdin?.on?.("error", () => {});
92
+ child.stdin?.end?.(String(stdin));
80
93
  }
81
- let stdout = "";
82
- let stderr = "";
83
- let stdoutTruncated = 0;
84
- let stderrTruncated = 0;
85
- let timedOut = false;
94
+
86
95
  let settled = false;
87
- let killTimer = null;
88
- const timer = setTimeout(() => {
89
- timedOut = true;
90
- killTimer = terminateProcessTreeWithEscalation(child);
91
- }, timeoutMs);
92
- timer.unref?.();
93
- const cleanup = () => {
94
- clearTimeout(timer);
95
- if (killTimer && !timedOut) clearTimeout(killTimer);
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
- const finish = (callback) => {
99
- if (settled) return;
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
- child.stdout.on("data", (chunk) => {
105
- const next = appendLimited(stdout, chunk, maxOutputBytes);
106
- stdout = next.value;
107
- stdoutTruncated += next.truncated;
108
- });
109
- child.stderr.on("data", (chunk) => {
110
- const next = appendLimited(stderr, chunk, maxOutputBytes);
111
- stderr = next.value;
112
- stderrTruncated += next.truncated;
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
- child.on("error", (error) => finish(() => {
115
- if (allowFailure) resolvePromise({ code: 127, stdout: finalizeOutput(stdout, stdoutTruncated), stderr: boundedErrorMessage(error) });
116
- else rejectPromise(error);
117
- }));
118
- child.on("close", (code) => finish(() => {
119
- const result = { code, stdout: finalizeOutput(stdout, stdoutTruncated), stderr: finalizeOutput(stderr, stderrTruncated) };
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
- if (code === 0 || allowFailure) resolvePromise(result);
126
- else rejectPromise(new BridgeError("execution_failed", stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
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 = classifyOperationalError(error);
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 = classifyOperationalError(error);
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 = classifyOperationalError(error);
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;