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
@@ -28,6 +28,7 @@ import { CapabilityObserver } from "./capability-observer.mjs";
28
28
  import { readBoundedRegularFileSync } from "./secure-file.mjs";
29
29
  import { clampInteger } from "./numbers.mjs";
30
30
  import { isPlainRecord } from "./records.mjs";
31
+ import { AccountAccessGate, normalizeAccountRole } from "./account-access.mjs";
31
32
 
32
33
  const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
33
34
  const MAX_CONCURRENT_TOOL_CALLS = 16;
@@ -100,6 +101,7 @@ export class LocalRuntime {
100
101
  this.workspaceCanonicalPromise = null;
101
102
  this.policy = normalizePolicy(policy);
102
103
  this.policyGate = new PolicyGate(this.policy);
104
+ this.accountAccessGate = new AccountAccessGate();
103
105
  this.logger = logger;
104
106
  this.onSuperseded = typeof onSuperseded === "function" ? onSuperseded : null;
105
107
  this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
@@ -210,6 +212,7 @@ export class LocalRuntime {
210
212
  (args, context) => handler(this, args, context),
211
213
  ])),
212
214
  policyGate: this.policyGate,
215
+ accountAccessGate: this.accountAccessGate,
213
216
  callRegistry: this.callRegistry,
214
217
  observability: this.observability,
215
218
  logger: this.logger,
@@ -372,6 +375,7 @@ export class LocalRuntime {
372
375
  callId: envelope.id,
373
376
  origin: "relay",
374
377
  timeoutMs: envelope.timeoutMs,
378
+ authorization: envelope.authorization,
375
379
  });
376
380
  this.send({ type: "tool_result", id: envelope.id, ok: true, result });
377
381
  } catch (error) {
@@ -388,12 +392,21 @@ export class LocalRuntime {
388
392
  return this.callRegistry.cancel(callId, reason);
389
393
  }
390
394
 
395
+ handleRelayDisconnect() {
396
+ const cancelled = this.callRegistry.cancelOrigin("relay", "remote relay disconnected");
397
+ this.terminateActiveProcesses("SIGTERM", true);
398
+ if (cancelled > 0) {
399
+ this.logger.event?.("debug", "relay.calls.cancelled_on_disconnect", { cancelled_calls: cancelled });
400
+ }
401
+ }
402
+
391
403
  async executeTool(tool, args, context = {}) {
392
404
  this.lifecycle.assertOperational();
393
405
  return this.toolExecutor.execute(tool, args, {
394
406
  callId: context.callId,
395
407
  origin: context.origin || "local",
396
408
  timeoutMs: context.timeoutMs,
409
+ authorization: context.authorization,
397
410
  context,
398
411
  });
399
412
  }
@@ -778,7 +791,7 @@ function createRelayConnection(runtime, { workerUrl, secret, expectedVersion, on
778
791
  protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
779
792
  }),
780
793
  onMessage: (data) => handleRelayData(runtime, data),
781
- onDisconnect: () => runtime.terminateActiveProcesses("SIGTERM", true),
794
+ onDisconnect: () => runtime.handleRelayDisconnect(),
782
795
  onSuperseded: () => {
783
796
  runtime.terminateActiveProcesses("SIGKILL");
784
797
  runtime.processSessionManager.clear();
@@ -805,16 +818,28 @@ function normalizeRelayToolCall(message) {
805
818
  const id = typeof message.id === "string" && message.id.length <= 256 ? message.id : "";
806
819
  const tool = typeof message.tool === "string" && message.tool.length <= 128 ? message.tool : "";
807
820
  const argumentsValue = message.arguments === undefined ? {} : message.arguments;
808
- if (!id || !tool || !isPlainRecord(argumentsValue)) return { ok: false, id };
821
+ const authorization = normalizeRelayAuthorization(message.authorization);
822
+ if (!id || !tool || !isPlainRecord(argumentsValue) || !authorization) return { ok: false, id };
809
823
  return {
810
824
  ok: true,
811
825
  id,
812
826
  tool,
813
827
  arguments: argumentsValue,
828
+ authorization,
814
829
  timeoutMs: clampInteger(message.timeout_ms, 60_000, 1000, 610_000),
815
830
  };
816
831
  }
817
832
 
833
+ function normalizeRelayAuthorization(value) {
834
+ if (!isPlainRecord(value)) return null;
835
+ const accountId = typeof value.account_id === "string" && /^acct_[A-Za-z0-9_-]{20,96}$/.test(value.account_id) ? value.account_id : "";
836
+ const accountVersion = Number(value.account_version);
837
+ let role;
838
+ try { role = normalizeAccountRole(value.role); } catch { return null; }
839
+ if (!accountId || !Number.isInteger(accountVersion) || accountVersion < 1) return null;
840
+ return Object.freeze({ account_id: accountId, account_version: accountVersion, role });
841
+ }
842
+
818
843
 
819
844
 
820
845
  function collectToolPathCandidates(error, toolArgs, workspace) {
@@ -72,13 +72,10 @@ export function trimAutostartLogs(stateRoot, options = {}) {
72
72
  const root = expandHome(stateRoot);
73
73
  const maxBytes = Number.isFinite(Number(options.maxBytes)) ? Math.max(1024, Number(options.maxBytes)) : 2 * 1024 * 1024;
74
74
  const keepBytes = Math.min(maxBytes, Number.isFinite(Number(options.keepBytes)) ? Math.max(1024, Number(options.keepBytes)) : 1024 * 1024);
75
- const schemaVersion = String(Number.isInteger(Number(options.schemaVersion)) && Number(options.schemaVersion) > 0
76
- ? Number(options.schemaVersion)
77
- : AUTOSTART_LOG_SCHEMA_VERSION);
75
+ const schemaVersion = String(AUTOSTART_LOG_SCHEMA_VERSION);
78
76
  const logs = path.join(root, "logs");
79
77
  const schemaFile = path.join(logs, ".log-schema");
80
- const migrate = readLogSchema(schemaFile) !== schemaVersion;
81
- let migrationComplete = true;
78
+ const reset = readLogSchema(schemaFile) !== schemaVersion;
82
79
 
83
80
  for (const name of ["daemon.out.log", "daemon.err.log"]) {
84
81
  const file = path.join(logs, name);
@@ -86,20 +83,12 @@ export function trimAutostartLogs(stateRoot, options = {}) {
86
83
  try {
87
84
  if (!existsSync(file)) continue;
88
85
  const before = lstatSync(file);
89
- if (before.isSymbolicLink() || !before.isFile()) {
90
- if (migrate) migrationComplete = false;
91
- continue;
92
- }
86
+ if (before.isSymbolicLink() || !before.isFile()) continue;
93
87
  const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
94
88
  fd = openSync(file, Number(fsConstants.O_RDWR) | noFollow);
95
89
  const info = fstatSync(fd);
96
- if (!info.isFile()) {
97
- if (migrate) migrationComplete = false;
98
- continue;
99
- }
100
- if (migrate && info.size > 0) {
101
- const legacy = readLogTail(fd, info.size, maxBytes);
102
- if (legacy.length) writePrivateServiceFile(path.join(logs, legacyLogName(name)), legacy);
90
+ if (!info.isFile()) continue;
91
+ if (reset) {
103
92
  ftruncateSync(fd, 0);
104
93
  } else if (info.size > maxBytes) {
105
94
  const tail = readLogTail(fd, info.size, keepBytes);
@@ -108,16 +97,13 @@ export function trimAutostartLogs(stateRoot, options = {}) {
108
97
  }
109
98
  try { chmodSync(file, 0o600); } catch {}
110
99
  } catch {
111
- if (migrate) migrationComplete = false;
112
- // Operational log maintenance is best effort and must not stop startup.
100
+ // Log maintenance is best effort and must not stop daemon startup.
113
101
  } finally {
114
102
  if (fd !== undefined) try { closeSync(fd); } catch {}
115
103
  }
116
104
  }
117
105
 
118
- if (migrate && migrationComplete) {
119
- try { writePrivateServiceFile(schemaFile, `${schemaVersion}\n`); } catch {}
120
- }
106
+ try { writePrivateServiceFile(schemaFile, `${schemaVersion}\n`); } catch {}
121
107
  }
122
108
 
123
109
  function readLogSchema(file) {
@@ -131,10 +117,6 @@ function readLogTail(fd, size, limit) {
131
117
  return lineSafeTail(buffer);
132
118
  }
133
119
 
134
- function legacyLogName(name) {
135
- return name.endsWith(".log") ? `${name.slice(0, -4)}.legacy.log` : `${name}.legacy`;
136
- }
137
-
138
120
  function lineSafeTail(buffer) {
139
121
  if (!Buffer.isBuffer(buffer) || buffer.length === 0) return Buffer.alloc(0);
140
122
  let text = buffer.toString("utf8");
@@ -224,7 +206,6 @@ export function daemonArgs(spec) {
224
206
  "--daemon-only",
225
207
  "--workspace", spec.workspace,
226
208
  "--state-dir", spec.stateRoot,
227
- "--no-print-credentials",
228
209
  "--log-level", "warn",
229
210
  "--log-format", "json",
230
211
  ];
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { packageRoot } from "./state.mjs";
5
+ import { BoundedOutput } from "./bounded-output.mjs";
5
6
 
6
7
  export function run(command, args = [], options = {}) {
7
8
  return new Promise((resolve, reject) => {
@@ -15,10 +16,8 @@ export function run(command, args = [], options = {}) {
15
16
  detached: process.platform !== "win32",
16
17
  windowsHide: true,
17
18
  });
18
- let stdout = "";
19
- let stderr = "";
20
- let stdoutTruncated = 0;
21
- let stderrTruncated = 0;
19
+ const stdout = new BoundedOutput(maxOutputBytes);
20
+ const stderr = new BoundedOutput(maxOutputBytes);
22
21
  let settled = false;
23
22
  let timedOut = false;
24
23
  let timer = null;
@@ -40,28 +39,18 @@ export function run(command, args = [], options = {}) {
40
39
  callback();
41
40
  };
42
41
  if (capture) {
43
- child.stdout?.on("data", chunk => {
44
- const next = appendLimited(stdout, chunk, maxOutputBytes);
45
- stdout = next.value;
46
- stdoutTruncated += next.truncated;
47
- });
48
- child.stderr?.on("data", chunk => {
49
- const next = appendLimited(stderr, chunk, maxOutputBytes);
50
- stderr = next.value;
51
- stderrTruncated += next.truncated;
52
- });
42
+ child.stdout?.on("data", chunk => stdout.append(chunk));
43
+ child.stderr?.on("data", chunk => stderr.append(chunk));
53
44
  }
54
45
  child.on("error", error => finish(() => {
55
- const result = { code: 127, stdout: finalizeOutput(stdout, stdoutTruncated), stderr: finalizeOutput(error.message || stderr, stderrTruncated) };
46
+ const result = capturedResult(127, stdout, stderr, error.message);
56
47
  if (options.allowFailure) resolve(result);
57
48
  else reject(error);
58
49
  }));
59
50
  child.on("close", code => finish(() => {
60
51
  const timeoutMessage = timedOut ? `command timed out after ${timeoutMs}ms` : "";
61
52
  const result = {
62
- code: timedOut ? 124 : code,
63
- stdout: finalizeOutput(stdout, stdoutTruncated),
64
- stderr: finalizeOutput([stderr, timeoutMessage].filter(Boolean).join("\n"), stderrTruncated),
53
+ ...capturedResult(timedOut ? 124 : code, stdout, stderr, timeoutMessage),
65
54
  };
66
55
  if ((!timedOut && code === 0) || options.allowFailure) resolve(result);
67
56
  else {
@@ -92,16 +81,15 @@ function terminateCommandTree(child, force) {
92
81
  }
93
82
  }
94
83
 
95
- function appendLimited(current, chunk, max) {
96
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
97
- const budget = Math.max(0, max - Buffer.byteLength(current));
98
- if (buffer.length <= budget) return { value: current + buffer.toString("utf8"), truncated: 0 };
99
- const slice = buffer.subarray(0, budget).toString("utf8");
100
- return { value: current + slice, truncated: buffer.length - Buffer.byteLength(slice) };
101
- }
102
-
103
- function finalizeOutput(value, truncated) {
104
- return truncated > 0 ? `${value}\n\n[truncated ${truncated} bytes]` : value;
84
+ function capturedResult(code, stdout, stderr, extraStderr = "") {
85
+ const stderrText = [stderr.text(), extraStderr].filter(Boolean).join("\n");
86
+ return {
87
+ code,
88
+ stdout: stdout.text(),
89
+ stderr: stderrText,
90
+ stdout_truncated_bytes: stdout.truncatedBytes,
91
+ stderr_truncated_bytes: stderr.truncatedBytes,
92
+ };
105
93
  }
106
94
 
107
95
  function findWranglerCommand() {
@@ -122,7 +110,7 @@ export function workspaceShellCommand(command) {
122
110
  if (process.platform === "win32") {
123
111
  return { cmd: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command] };
124
112
  }
125
- const shell = process.env.MBM_EXEC_SHELL || (existsSync("/bin/zsh") ? "/bin/zsh" : existsSync("/bin/bash") ? "/bin/bash" : "/bin/sh");
113
+ const shell = existsSync("/bin/zsh") ? "/bin/zsh" : existsSync("/bin/bash") ? "/bin/bash" : "/bin/sh";
126
114
  const base = path.basename(shell);
127
115
  if (base === "zsh") return { cmd: shell, args: ["-f", "-c", command] };
128
116
  if (base === "bash") return { cmd: shell, args: ["--noprofile", "--norc", "-c", command] };
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
3
3
  import { activeManagedJobs } from "./managed-jobs.mjs";
4
4
  import { inspectProcessInstance } from "./process-identity.mjs";
5
5
  import { readBoundedRegularFileSync } from "./secure-file.mjs";
6
- import { expandHome, readDaemonLockOwner, resolveWorkspace } from "./state.mjs";
6
+ import { STATE_SCHEMA_VERSION, expandHome, readDaemonLockOwner, resolveWorkspace } from "./state.mjs";
7
7
 
8
8
  const PROFILE_NAME = /^[a-f0-9]{24}$/;
9
9
  const WORKER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
@@ -66,7 +66,7 @@ export function knownProfileStates(stateRoot) {
66
66
  const workspace = resolveWorkspace(candidate);
67
67
  if (seen.has(workspace)) break;
68
68
  states.push({
69
- schemaVersion: 5,
69
+ schemaVersion: STATE_SCHEMA_VERSION,
70
70
  workspace: { path: workspace, hash: entry.name },
71
71
  paths: { stateRoot: canonicalStateRoot, profileDir, statePath },
72
72
  });
@@ -11,6 +11,10 @@ import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-ide
11
11
  export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
12
12
  export const appName = String(serverMetadata.name);
13
13
  const STATE_MARKER = ".machine-bridge-mcp-state";
14
+ const STATE_MARKER_SCHEMA = 2;
15
+ export const STATE_SCHEMA_VERSION = 6;
16
+ const GLOBAL_CONFIG_SCHEMA = 1;
17
+ const CORRUPT_RECOVERY = Symbol("corrupt-recovery");
14
18
  const MAX_STATE_JSON_BYTES = 2 * 1024 * 1024;
15
19
  const MAX_LOCK_BYTES = 64 * 1024;
16
20
  const MAX_MARKER_BYTES = 4096;
@@ -47,9 +51,14 @@ export function loadGlobalConfig(stateRoot = defaultStateRoot()) {
47
51
  const root = path.resolve(expandHome(stateRoot));
48
52
  assertNoForeignMaintenance(root);
49
53
  const file = configPath(root);
50
- if (!existsSync(file)) return {};
54
+ if (!existsSync(file)) return { schemaVersion: GLOBAL_CONFIG_SCHEMA };
51
55
  ownerOnlyFile(file);
52
- return readJsonObjectOrBackup(file);
56
+ const config = readJsonObjectOrBackup(file);
57
+ if (config[CORRUPT_RECOVERY]) return { schemaVersion: GLOBAL_CONFIG_SCHEMA };
58
+ if (config.schemaVersion !== GLOBAL_CONFIG_SCHEMA) {
59
+ throw new Error("global configuration schema is obsolete; remove the state root and initialize the current version");
60
+ }
61
+ return config;
53
62
  }
54
63
 
55
64
  export function saveGlobalConfig(config, stateRoot = defaultStateRoot()) {
@@ -58,7 +67,7 @@ export function saveGlobalConfig(config, stateRoot = defaultStateRoot()) {
58
67
  const root = ensureStateRoot(requestedRoot);
59
68
  assertNoForeignMaintenance(root);
60
69
  const file = configPath(root);
61
- atomicWriteJson(file, { ...config, updatedAt: new Date().toISOString() });
70
+ atomicWriteJson(file, { ...config, schemaVersion: GLOBAL_CONFIG_SCHEMA, updatedAt: new Date().toISOString() });
62
71
  }
63
72
 
64
73
  export function setSelectedWorkspace(workspace, stateRoot = defaultStateRoot()) {
@@ -127,26 +136,6 @@ function assertStateRootSeparatedFromWorkspace(stateRoot, workspace) {
127
136
  }
128
137
  }
129
138
 
130
- function matchingLegacyProfileDir(canonicalWorkspace, stateRoot) {
131
- const profilesRoot = path.join(stateRoot, "profiles");
132
- if (!existsSync(profilesRoot)) return "";
133
- const matches = [];
134
- for (const entry of readdirSync(profilesRoot, { withFileTypes: true })) {
135
- if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
136
- const profileDir = path.join(profilesRoot, entry.name);
137
- const stateFile = path.join(profileDir, "state.json");
138
- if (!existsSync(stateFile)) continue;
139
- try {
140
- const value = JSON.parse(readBoundedUtf8(stateFile, MAX_STATE_JSON_BYTES, "state JSON"));
141
- const storedWorkspace = value?.workspace?.path;
142
- if (typeof storedWorkspace !== "string") continue;
143
- if (sameWorkspaceIdentity(storedWorkspace, canonicalWorkspace)) matches.push(profileDir);
144
- } catch {}
145
- }
146
- if (matches.length > 1) throw new Error("multiple Machine Bridge profiles refer to the same canonical workspace; remove or merge the duplicate state profiles");
147
- return matches[0] || "";
148
- }
149
-
150
139
  function sameWorkspaceIdentity(left, right) {
151
140
  try {
152
141
  const a = resolveWorkspace(left);
@@ -164,18 +153,18 @@ export function loadState(workspace, options = {}) {
164
153
  assertNoForeignMaintenance(requestedStateRoot);
165
154
  const stateRoot = ensureStateRoot(requestedStateRoot);
166
155
  assertNoForeignMaintenance(stateRoot);
167
- const canonicalProfileDir = profileDirForWorkspace(canonicalWorkspace, stateRoot);
168
- const profileDir = existsSync(canonicalProfileDir)
169
- ? canonicalProfileDir
170
- : matchingLegacyProfileDir(canonicalWorkspace, stateRoot) || canonicalProfileDir;
156
+ const profileDir = profileDirForWorkspace(canonicalWorkspace, stateRoot);
171
157
  const statePath = path.join(profileDir, "state.json");
172
158
  ensureOwnerOnlyDir(profileDir);
173
159
  let state = {};
174
160
  if (existsSync(statePath)) {
175
161
  ownerOnlyFile(statePath);
176
162
  state = readJsonObjectOrBackup(statePath);
163
+ if (!state[CORRUPT_RECOVERY] && state.schemaVersion !== STATE_SCHEMA_VERSION) {
164
+ throw new Error("workspace state schema is obsolete; remove the state root and initialize the current version");
165
+ }
177
166
  }
178
- state.schemaVersion = 5;
167
+ state.schemaVersion = STATE_SCHEMA_VERSION;
179
168
  state.workspace = {
180
169
  path: canonicalWorkspace,
181
170
  hash: path.basename(profileDir),
@@ -185,7 +174,6 @@ export function loadState(workspace, options = {}) {
185
174
  state.worker ||= {};
186
175
  state.policy ||= {};
187
176
  state.resources ||= {};
188
- delete state.localApi;
189
177
  return state;
190
178
  }
191
179
 
@@ -450,7 +438,9 @@ function readJsonObjectOrBackup(filePath) {
450
438
  replaceFileSync(filePath, backupPath);
451
439
  ownerOnlyFile(backupPath);
452
440
  pruneBackups(filePath, 3);
453
- return {};
441
+ const recovered = {};
442
+ Object.defineProperty(recovered, CORRUPT_RECOVERY, { value: true });
443
+ return recovered;
454
444
  }
455
445
  }
456
446
 
@@ -466,23 +456,10 @@ function ensureStateRoot(inputRoot) {
466
456
  const marker = path.join(root, STATE_MARKER);
467
457
  if (!existsSync(marker)) {
468
458
  const entries = readdirSync(root);
469
- if (entries.length) {
470
- if (!hasOnlyStateEntries(entries)) {
471
- throw new Error(`state root must be a dedicated directory; unexpected entries found in ${root}`);
472
- }
473
- if (!isRecognizableLegacyStateRoot(root)) {
474
- throw new Error(`state root is non-empty but does not contain recognizable Machine Bridge state: ${root}`);
475
- }
476
- }
477
- try {
478
- createExclusiveFileSync(marker, `${JSON.stringify({ app: appName, schema: 1 })}\n`, { mode: 0o600 });
479
- } catch (error) {
480
- if (error?.code !== "EEXIST") throw error;
481
- assertValidStateMarker(marker, { migrateLegacy: true });
482
- }
483
- } else {
484
- assertValidStateMarker(marker, { migrateLegacy: true });
459
+ if (entries.length) throw new Error(`state root must be empty before initializing the current schema: ${root}`);
460
+ createExclusiveFileSync(marker, `${JSON.stringify({ app: appName, schema: STATE_MARKER_SCHEMA })}\n`, { mode: 0o600 });
485
461
  }
462
+ assertValidStateMarker(marker);
486
463
  ensureOwnerOnlyDir(root);
487
464
  ownerOnlyFile(marker);
488
465
  cleanupStaleAtomicTemps(root);
@@ -512,21 +489,16 @@ function assertSafeStateRootForRemoval(root) {
512
489
  assertValidStateMarker(marker);
513
490
  return canonical;
514
491
  }
515
- if (isRecognizableLegacyStateRoot(canonical)) return canonical;
516
492
  throw new Error(`refusing to remove unrecognized state root without ${STATE_MARKER}: ${canonical}`);
517
493
  }
518
494
 
519
- function assertValidStateMarker(marker, options = {}) {
495
+ function assertValidStateMarker(marker) {
520
496
  const content = readBoundedUtf8(marker, MAX_MARKER_BYTES, "state marker");
521
- try {
522
- const value = JSON.parse(content);
523
- if (value?.app === appName && value?.schema === 1) return;
524
- } catch {}
525
- if (content.trim() === `${appName} state root`) {
526
- if (options.migrateLegacy) atomicWriteJson(marker, { app: appName, schema: 1 });
527
- return;
497
+ let value;
498
+ try { value = JSON.parse(content); } catch { throw new Error(`invalid state root marker: ${marker}`); }
499
+ if (value?.app !== appName || value?.schema !== STATE_MARKER_SCHEMA) {
500
+ throw new Error("state root schema is obsolete; remove it and initialize the current version");
528
501
  }
529
- throw new Error(`invalid state root marker: ${marker}`);
530
502
  }
531
503
 
532
504
  function hasOnlyStateEntries(entries) {
@@ -566,33 +538,6 @@ function stateRootMatchesRecordedWorkspace(root) {
566
538
  return false;
567
539
  }
568
540
 
569
- function isRecognizableLegacyStateRoot(root) {
570
- const config = path.join(root, "config.json");
571
- try {
572
- if (existsSync(config)) {
573
- const value = JSON.parse(readBoundedUtf8(config, MAX_STATE_JSON_BYTES, "config JSON"));
574
- if (
575
- value &&
576
- typeof value.selectedWorkspace === "string" &&
577
- typeof value.selectedWorkspaceHash === "string" &&
578
- workspaceHash(value.selectedWorkspace) === value.selectedWorkspaceHash
579
- ) return true;
580
- }
581
- } catch {}
582
- const profiles = path.join(root, "profiles");
583
- if (!existsSync(profiles)) return false;
584
- try {
585
- for (const entry of readdirSync(profiles, { withFileTypes: true })) {
586
- if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
587
- const stateFile = path.join(profiles, entry.name, "state.json");
588
- if (!existsSync(stateFile)) continue;
589
- const value = JSON.parse(readBoundedUtf8(stateFile, MAX_STATE_JSON_BYTES, "state JSON"));
590
- if (value?.workspace?.hash === entry.name && typeof value?.workspace?.path === "string") return true;
591
- }
592
- } catch {}
593
- return false;
594
- }
595
-
596
541
  function atomicWriteJson(filePath, value) {
597
542
  const dir = path.dirname(filePath);
598
543
  ensureOwnerOnlyDir(dir);
@@ -636,7 +581,7 @@ function pruneBackups(filePath, keep) {
636
581
 
637
582
  export function ensureWorkerSecrets(state, options = {}) {
638
583
  state.worker ||= {};
639
- if (!state.worker.oauthPassword || options.rotateSecrets) state.worker.oauthPassword = randomToken("mcp_password");
584
+ if (!state.worker.accountAdminSecret || options.rotateSecrets) state.worker.accountAdminSecret = randomToken("account_admin");
640
585
  if (!state.worker.daemonSecret || options.rotateSecrets) state.worker.daemonSecret = randomToken("daemon_secret");
641
586
  if (!state.worker.oauthTokenVersion || options.rotateSecrets) state.worker.oauthTokenVersion = randomToken("token_version");
642
587
  if (!state.worker.name || options.workerName) state.worker.name = options.workerName || defaultWorkerName(state.workspace.hash);
@@ -669,7 +614,7 @@ export function ownerOnlyFile(filePath) {
669
614
 
670
615
  export function redactState(state) {
671
616
  const clone = redactHomeInValue(JSON.parse(JSON.stringify(state)));
672
- if (clone.worker?.oauthPassword) clone.worker.oauthPassword = "<redacted>";
617
+ if (clone.worker?.accountAdminSecret) clone.worker.accountAdminSecret = "<redacted>";
673
618
  if (clone.worker?.daemonSecret) clone.worker.daemonSecret = "<redacted>";
674
619
  if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = "<redacted>";
675
620
  if (clone.resources && typeof clone.resources === "object") {
@@ -5,6 +5,7 @@ export class ToolExecutor {
5
5
  this.handlers = options.handlers || {};
6
6
  this.policyGate = options.policyGate;
7
7
  this.callRegistry = options.callRegistry;
8
+ this.accountAccessGate = options.accountAccessGate;
8
9
  this.observability = options.observability;
9
10
  this.logger = options.logger || console;
10
11
  this.safeMessage = typeof options.safeMessage === "function" ? options.safeMessage : (error) => String(error?.message || error || "operation failed");
@@ -12,7 +13,7 @@ export class ToolExecutor {
12
13
  this.pipeline = composeMiddleware([
13
14
  lifecycleMiddleware(this.callRegistry),
14
15
  observabilityMiddleware(this.observability, this.logger, this.safeMessage, this.slowMs),
15
- authorizeMiddleware(this.policyGate),
16
+ authorizeMiddleware(this.policyGate, this.accountAccessGate),
16
17
  ], invokeHandler(this.handlers));
17
18
  }
18
19
 
@@ -25,9 +26,14 @@ export function composeMiddleware(middleware, terminal) {
25
26
  return middleware.reduceRight((next, current) => (operation) => current(operation, next), terminal);
26
27
  }
27
28
 
28
- function authorizeMiddleware(policyGate) {
29
+ function authorizeMiddleware(policyGate, accountAccessGate) {
29
30
  return async (operation, next) => {
30
31
  policyGate.assert(operation.tool);
32
+ if (operation.context.origin === "relay") {
33
+ const role = operation.request.authorization?.role;
34
+ if (!role) throw new Error("relay tool call is missing an account role");
35
+ accountAccessGate.assert(role, operation.tool);
36
+ }
31
37
  return next(operation);
32
38
  };
33
39
  }
@@ -0,0 +1,23 @@
1
+ {
2
+ "revision": 1,
3
+ "defaultRole": "reviewer",
4
+ "ownerRole": "owner",
5
+ "roles": {
6
+ "reviewer": {
7
+ "profile": "review",
8
+ "description": "Read-only access to the selected workspace."
9
+ },
10
+ "editor": {
11
+ "profile": "edit",
12
+ "description": "Read and deterministic file mutation without process execution."
13
+ },
14
+ "operator": {
15
+ "profile": "agent",
16
+ "description": "Workspace-confined file mutation and direct process execution."
17
+ },
18
+ "owner": {
19
+ "profile": "full",
20
+ "description": "Complete bridge authority, including shell, browser, applications, unrestricted paths, and account administration."
21
+ }
22
+ }
23
+ }
@@ -1,7 +1,11 @@
1
1
  {
2
- "revision": 4,
2
+ "revision": 5,
3
3
  "defaultProfile": "full",
4
- "origins": ["default", "explicit", "custom", "migrated", "legacy-preserved"],
4
+ "origins": [
5
+ "default",
6
+ "explicit",
7
+ "custom"
8
+ ],
5
9
  "profiles": {
6
10
  "review": {
7
11
  "profile": "review",
@@ -38,14 +42,33 @@
38
42
  },
39
43
  "availability": {
40
44
  "always": {},
41
- "write": { "allowWrite": true },
42
- "direct-exec": { "execModes": ["direct", "shell"] },
43
- "write+direct-exec": { "allowWrite": true, "execModes": ["direct", "shell"] },
44
- "shell-exec": { "execModes": ["shell"] },
45
+ "write": {
46
+ "allowWrite": true
47
+ },
48
+ "direct-exec": {
49
+ "execModes": [
50
+ "direct",
51
+ "shell"
52
+ ]
53
+ },
54
+ "write+direct-exec": {
55
+ "allowWrite": true,
56
+ "execModes": [
57
+ "direct",
58
+ "shell"
59
+ ]
60
+ },
61
+ "shell-exec": {
62
+ "execModes": [
63
+ "shell"
64
+ ]
65
+ },
45
66
  "full": {
46
67
  "profile": "full",
47
68
  "allowWrite": true,
48
- "execModes": ["shell"],
69
+ "execModes": [
70
+ "shell"
71
+ ],
49
72
  "unrestrictedPaths": true,
50
73
  "minimalEnv": false,
51
74
  "exposeAbsolutePaths": true
@@ -0,0 +1,46 @@
1
+ import accessContract from "../shared/access-contract.json";
2
+ import policyContract from "../shared/policy-contract.json";
3
+ import toolCatalog from "../shared/tool-catalog.json";
4
+ import { policyAllowsAvailability, type DaemonPolicy } from "./policy";
5
+
6
+ export type AccountRole = keyof typeof accessContract.roles;
7
+
8
+ const roles = accessContract.roles as Record<string, { profile: string }>;
9
+ const profiles = policyContract.profiles as unknown as Record<string, DaemonPolicy>;
10
+ const toolAvailability = new Map((toolCatalog as Array<{ name: string; availability: string }>).map((tool) => [tool.name, tool.availability]));
11
+
12
+ export const ACCOUNT_ACCESS_REVISION = Number(accessContract.revision);
13
+ export const ACCOUNT_ROLES = Object.freeze(Object.keys(roles));
14
+ export const OWNER_ACCOUNT_ROLE = String(accessContract.ownerRole) as AccountRole;
15
+
16
+ export function normalizeAccountRole(value: unknown): AccountRole | null {
17
+ const role = String(value ?? "").trim().toLowerCase();
18
+ return role in roles ? role as AccountRole : null;
19
+ }
20
+
21
+ export function accountRolePolicy(role: AccountRole): DaemonPolicy {
22
+ const profileName = roles[role]?.profile;
23
+ const profile = profiles[profileName];
24
+ if (!profile) throw new Error(`account role references an unknown policy profile: ${role}`);
25
+ return {
26
+ profile: String(profile.profile),
27
+ origin: "explicit",
28
+ revision: Number(policyContract.revision),
29
+ allowWrite: profile.allowWrite === true,
30
+ allowExec: String(profile.execMode) !== "off",
31
+ execMode: String(profile.execMode) as DaemonPolicy["execMode"],
32
+ unrestrictedPaths: profile.unrestrictedPaths === true,
33
+ minimalEnv: profile.minimalEnv !== false,
34
+ exposeAbsolutePaths: profile.exposeAbsolutePaths === true,
35
+ };
36
+ }
37
+
38
+ export function accountRoleAllowsTool(role: AccountRole, toolName: string): boolean {
39
+ if (toolName === "server_info") return true;
40
+ const availability = toolAvailability.get(toolName);
41
+ return Boolean(availability && policyAllowsAvailability(accountRolePolicy(role), availability));
42
+ }
43
+
44
+ export function accountRoleToolNames(role: AccountRole, daemonTools: Iterable<string>): Set<string> {
45
+ return new Set([...daemonTools].filter((name) => accountRoleAllowsTool(role, name)));
46
+ }