machine-bridge-mcp 1.2.10 → 2.0.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 (73) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +11 -6
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +23 -11
  6. package/docs/AUDIT.md +37 -3
  7. package/docs/CLIENTS.md +2 -2
  8. package/docs/ENGINEERING.md +2 -2
  9. package/docs/GETTING_STARTED.md +1 -1
  10. package/docs/LOCAL_AUTHORIZATION.md +111 -0
  11. package/docs/LOGGING.md +7 -5
  12. package/docs/MULTI_ACCOUNT.md +5 -5
  13. package/docs/OPERATIONS.md +33 -7
  14. package/docs/OVERVIEW.md +12 -8
  15. package/docs/PROJECT_STANDARDS.md +1 -1
  16. package/docs/RELEASING.md +4 -4
  17. package/docs/TESTING.md +9 -2
  18. package/docs/THREAT_MODEL.md +7 -6
  19. package/docs/TOOL_REFERENCE.md +4 -4
  20. package/docs/UPGRADING.md +10 -6
  21. package/package.json +8 -2
  22. package/scripts/check-plan.mjs +5 -0
  23. package/scripts/check-runner.mjs +75 -0
  24. package/scripts/coverage-check.mjs +1 -1
  25. package/scripts/github-backlog.mjs +116 -0
  26. package/scripts/github-push.mjs +4 -0
  27. package/scripts/local-release-acceptance.mjs +2 -2
  28. package/scripts/release-acceptance.mjs +36 -17
  29. package/scripts/run-checks.mjs +11 -21
  30. package/src/local/account-admin.mjs +28 -3
  31. package/src/local/bounded-output.mjs +20 -3
  32. package/src/local/cli-approval.mjs +117 -0
  33. package/src/local/cli-options.mjs +8 -2
  34. package/src/local/cli.mjs +13 -2
  35. package/src/local/device-identity.mjs +108 -0
  36. package/src/local/errors.mjs +5 -1
  37. package/src/local/execution-limits.mjs +6 -1
  38. package/src/local/operation-authorization.mjs +366 -0
  39. package/src/local/operation-risk.mjs +221 -0
  40. package/src/local/operation-state-lock.mjs +92 -0
  41. package/src/local/process-execution.mjs +94 -14
  42. package/src/local/process-output-stream.mjs +82 -0
  43. package/src/local/process-result-projection.mjs +25 -0
  44. package/src/local/process-sessions.mjs +37 -51
  45. package/src/local/relay-connection.mjs +12 -5
  46. package/src/local/runtime-relay.mjs +13 -5
  47. package/src/local/runtime.mjs +14 -7
  48. package/src/local/secure-file.mjs +24 -4
  49. package/src/local/state.mjs +9 -3
  50. package/src/local/tool-executor.mjs +4 -2
  51. package/src/local/tools.mjs +4 -9
  52. package/src/local/worker-deployment.mjs +4 -3
  53. package/src/local/worker-secret-file.mjs +2 -1
  54. package/src/shared/admin-auth.d.mts +10 -0
  55. package/src/shared/admin-auth.mjs +36 -0
  56. package/src/shared/daemon-auth.d.mts +20 -0
  57. package/src/shared/daemon-auth.mjs +55 -0
  58. package/src/shared/result-projection.d.mts +6 -0
  59. package/src/shared/result-projection.json +4 -0
  60. package/src/shared/result-projection.mjs +50 -0
  61. package/src/shared/server-metadata.json +1 -0
  62. package/src/shared/tool-catalog.json +4 -4
  63. package/src/worker/account-admin.ts +73 -4
  64. package/src/worker/daemon-auth.ts +205 -0
  65. package/src/worker/daemon-sockets.ts +15 -2
  66. package/src/worker/errors.ts +18 -4
  67. package/src/worker/index.ts +59 -10
  68. package/src/worker/mcp-jsonrpc.ts +4 -2
  69. package/src/worker/nonce-store.ts +79 -0
  70. package/src/worker/oauth-controller.ts +11 -6
  71. package/src/worker/oauth-refresh-families.ts +172 -0
  72. package/src/worker/oauth-state.ts +48 -3
  73. package/src/worker/oauth-tokens.ts +49 -40
@@ -7,6 +7,7 @@ import { terminateProcessTree, terminateProcessTreeWithEscalation } from "./proc
7
7
  import { createToolAuthorizer } from "./policy.mjs";
8
8
  import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
9
9
  import { clampInteger } from "./numbers.mjs";
10
+ import { ProcessOutputStream } from "./process-output-stream.mjs";
10
11
  import {
11
12
  MAX_PROCESS_SESSIONS, MAX_PROCESS_SESSION_OUTPUT_BYTES, MAX_PROCESS_SESSION_STDIN_BYTES, PROCESS_SESSION_RETENTION_MS,
12
13
  } from "./execution-limits.mjs";
@@ -40,11 +41,37 @@ export class ProcessSessionManager {
40
41
  for (const session of this.sessions.values()) notifySessionWaiters(session);
41
42
  }
42
43
 
44
+ retainCompletedOutput({ command, cwd, stdout, stderr, exitCode, startedAt, closedAt = Date.now() }) {
45
+ this.prune();
46
+ this.evictExitedForCapacity();
47
+ if (this.sessions.size >= MAX_PROCESS_SESSIONS) return null;
48
+ const completedAt = Number.isFinite(Number(closedAt)) ? Number(closedAt) : Date.now();
49
+ const session = {
50
+ id: `proc_${randomBytes(24).toString("base64url")}`,
51
+ child: null,
52
+ argv0: basename(String(command || "process")),
53
+ cwd,
54
+ stdout,
55
+ stderr,
56
+ startedAt: Number.isFinite(Number(startedAt)) ? Number(startedAt) : completedAt,
57
+ lastActivity: completedAt,
58
+ closedAt: completedAt,
59
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
60
+ signal: null,
61
+ stdinClosed: true,
62
+ waiters: new Set(),
63
+ terminationTimer: null,
64
+ };
65
+ this.sessions.set(session.id, session);
66
+ return this.summary(session);
67
+ }
68
+
43
69
  async start(args, context = {}) {
44
70
  this.authorizeTool("start_process");
45
71
  const argv = validateArgv(args.argv);
46
72
  const cwd = await this.resolveCwd(args.cwd || ".");
47
73
  this.prune();
74
+ this.evictExitedForCapacity();
48
75
  if (this.sessions.size >= MAX_PROCESS_SESSIONS) throw new Error(`process session limit reached (${MAX_PROCESS_SESSIONS})`);
49
76
  this.throwIfCancelled(context);
50
77
 
@@ -59,8 +86,8 @@ export class ProcessSessionManager {
59
86
  child,
60
87
  argv0: basename(argv[0]),
61
88
  cwd,
62
- stdout: createSessionStream(),
63
- stderr: createSessionStream(),
89
+ stdout: new ProcessOutputStream(MAX_PROCESS_SESSION_OUTPUT_BYTES),
90
+ stderr: new ProcessOutputStream(MAX_PROCESS_SESSION_OUTPUT_BYTES),
64
91
  startedAt: Date.now(),
65
92
  lastActivity: Date.now(),
66
93
  closedAt: null,
@@ -74,12 +101,12 @@ export class ProcessSessionManager {
74
101
  this.trackChild(child, context.callId);
75
102
 
76
103
  child.stdout.on("data", (chunk) => {
77
- appendSessionStream(session.stdout, chunk);
104
+ session.stdout.append(chunk);
78
105
  session.lastActivity = Date.now();
79
106
  notifySessionWaiters(session);
80
107
  });
81
108
  child.stderr.on("data", (chunk) => {
82
- appendSessionStream(session.stderr, chunk);
109
+ session.stderr.append(chunk);
83
110
  session.lastActivity = Date.now();
84
111
  notifySessionWaiters(session);
85
112
  });
@@ -94,7 +121,7 @@ export class ProcessSessionManager {
94
121
  });
95
122
 
96
123
  child.on("error", (error) => {
97
- appendSessionStream(session.stderr, Buffer.from(`${boundedErrorMessage(error)}\n`));
124
+ session.stderr.append(Buffer.from(`${boundedErrorMessage(error)}\n`));
98
125
  session.lastActivity = Date.now();
99
126
  notifySessionWaiters(session);
100
127
  });
@@ -132,8 +159,8 @@ export class ProcessSessionManager {
132
159
  session.lastActivity = Date.now();
133
160
  return {
134
161
  ...this.summary(session),
135
- stdout: readSessionStream(session.stdout, stdoutOffset, maxBytes),
136
- stderr: readSessionStream(session.stderr, stderrOffset, maxBytes),
162
+ stdout: session.stdout.read(stdoutOffset, maxBytes),
163
+ stderr: session.stderr.read(stderrOffset, maxBytes),
137
164
  };
138
165
  }
139
166
 
@@ -207,6 +234,9 @@ export class ProcessSessionManager {
207
234
  for (const [id, session] of this.sessions) {
208
235
  if (session.closedAt !== null && session.lastActivity < cutoff) this.sessions.delete(id);
209
236
  }
237
+ }
238
+
239
+ evictExitedForCapacity() {
210
240
  if (this.sessions.size < MAX_PROCESS_SESSIONS) return;
211
241
  const exited = [...this.sessions.values()]
212
242
  .filter((session) => session.closedAt !== null)
@@ -240,50 +270,6 @@ function waitForSpawn(child) {
240
270
  });
241
271
  }
242
272
 
243
- function createSessionStream() {
244
- return { buffer: Buffer.alloc(0), baseOffset: 0, totalBytes: 0 };
245
- }
246
-
247
- function appendSessionStream(stream, chunk) {
248
- const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
249
- stream.totalBytes += input.length;
250
- let combined = stream.buffer.length ? Buffer.concat([stream.buffer, input]) : Buffer.from(input);
251
- if (combined.length > MAX_PROCESS_SESSION_OUTPUT_BYTES) {
252
- const dropped = combined.length - MAX_PROCESS_SESSION_OUTPUT_BYTES;
253
- combined = combined.subarray(dropped);
254
- stream.baseOffset += dropped;
255
- }
256
- stream.buffer = combined;
257
- }
258
-
259
- function readSessionStream(stream, requestedOffset, maxBytes) {
260
- const clampedOffset = Math.min(requestedOffset, stream.totalBytes);
261
- const effectiveOffset = Math.max(clampedOffset, stream.baseOffset);
262
- const start = effectiveOffset - stream.baseOffset;
263
- const slice = stream.buffer.subarray(start, Math.min(stream.buffer.length, start + maxBytes));
264
- let data;
265
- let dataBase64;
266
- let encoding = "utf8";
267
- try {
268
- data = new TextDecoder("utf-8", { fatal: true }).decode(slice);
269
- } catch {
270
- data = new TextDecoder("utf-8").decode(slice);
271
- dataBase64 = slice.toString("base64");
272
- encoding = "base64";
273
- }
274
- return {
275
- data,
276
- ...(dataBase64 ? { data_base64: dataBase64 } : {}),
277
- encoding,
278
- requested_offset: requestedOffset,
279
- start_offset: effectiveOffset,
280
- next_offset: effectiveOffset + slice.length,
281
- total_offset: stream.totalBytes,
282
- truncated_before: requestedOffset < stream.baseOffset,
283
- truncated_after: effectiveOffset + slice.length < stream.totalBytes,
284
- };
285
- }
286
-
287
273
  function sessionHasOutputAfter(session, stdoutOffset, stderrOffset) {
288
274
  return session.stdout.totalBytes > stdoutOffset || session.stderr.totalBytes > stderrOffset;
289
275
  }
@@ -23,10 +23,9 @@ const DEFAULT_SCHEDULER = Object.freeze({
23
23
  export class RelayConnection {
24
24
  constructor(options = {}) {
25
25
  this.workerUrl = normalizeWorkerUrl(options.workerUrl);
26
- if (typeof options.secret !== "string" || options.secret.length < 16) throw new Error("daemon secret is missing or too short");
27
- this.secret = options.secret;
28
26
  this.logger = options.logger || console;
29
27
  this.helloMessage = typeof options.helloMessage === "function" ? options.helloMessage : () => ({ type: "hello" });
28
+ this.connectionHeaders = typeof options.connectionHeaders === "function" ? options.connectionHeaders : () => ({});
30
29
  this.expectedServer = String(options.expectedServer || "");
31
30
  this.expectedVersion = String(options.expectedVersion || "");
32
31
  this.onMessage = typeof options.onMessage === "function" ? options.onMessage : () => {};
@@ -164,6 +163,13 @@ export class RelayConnection {
164
163
  return false;
165
164
  }
166
165
  this.logger.debug?.("remote relay welcome received");
166
+ Promise.resolve(this.helloMessage(message)).then((hello) => {
167
+ if (this.socket !== socket || this.closed || this.authenticated) return;
168
+ if (!this.sendOnSocket(socket, hello)) this.failPermanently("relay_authentication_failed");
169
+ }).catch((error) => {
170
+ this.logger.debug?.("could not create daemon authentication proof", { error_class: classifyOperationalError(error) });
171
+ this.failPermanently("relay_authentication_failed");
172
+ });
167
173
  return true;
168
174
  }
169
175
 
@@ -273,9 +279,11 @@ export class RelayConnection {
273
279
  let socket;
274
280
  try {
275
281
  const proxy = this.proxyAgentForUrl(wsUrl);
282
+ const headers = this.connectionHeaders();
283
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) throw new Error("relay connection headers are invalid");
276
284
  this.networkRoute = proxy?.agent ? "proxy" : "direct";
277
285
  socket = new this.WebSocketClass(wsUrl, {
278
- headers: { "X-Bridge-Token": this.secret },
286
+ headers,
279
287
  maxPayload: this.maxPayload,
280
288
  ...(proxy?.agent ? { agent: proxy.agent } : {}),
281
289
  });
@@ -308,8 +316,7 @@ export class RelayConnection {
308
316
  return;
309
317
  }
310
318
  this.lastInboundAt = this.now();
311
- this.logger.debug?.("remote relay transport opened; awaiting authentication acknowledgement");
312
- if (!this.sendOnSocket(socket, this.helloMessage())) return;
319
+ this.logger.debug?.("remote relay transport opened; awaiting device challenge");
313
320
  this.clearTimer("handshakeTimer", "clearTimeout");
314
321
  this.handshakeTimer = this.scheduler.setTimeout(() => {
315
322
  if (this.socket !== socket || this.closed || this.ready) return;
@@ -1,5 +1,6 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { RelayConnection } from "./relay-connection.mjs";
3
+ import { createDaemonAuthentication, createDaemonPreflightHeaders } from "./device-identity.mjs";
3
4
  import { MCP_SUPPORTED_PROTOCOL_VERSIONS, SERVER_NAME } from "./tools.mjs";
4
5
  import { normalizeAccountRole } from "./account-access.mjs";
5
6
  import { clampInteger } from "./numbers.mjs";
@@ -7,21 +8,27 @@ import { isPlainRecord } from "./records.mjs";
7
8
 
8
9
  export const MAX_RELAY_MESSAGE_BYTES = 8 * 1024 * 1024;
9
10
 
10
- export function createRuntimeRelayConnection(runtime, { workerUrl, secret, expectedVersion, onFatal }) {
11
+ export function createRuntimeRelayConnection(runtime, { workerUrl, deviceIdentity, expectedVersion, onFatal }) {
11
12
  if (!workerUrl) return null;
12
13
  return new RelayConnection({
13
14
  workerUrl,
14
- secret,
15
15
  logger: runtime.logger,
16
16
  maxPayload: MAX_RELAY_MESSAGE_BYTES,
17
17
  expectedServer: SERVER_NAME,
18
18
  expectedVersion: String(expectedVersion || ""),
19
- helloMessage: () => ({
19
+ connectionHeaders: () => createDaemonPreflightHeaders(
20
+ deviceIdentity,
21
+ workerUrl,
22
+ SERVER_NAME,
23
+ String(expectedVersion || ""),
24
+ ),
25
+ helloMessage: async (welcome) => ({
20
26
  type: "hello",
21
27
  instance_id: runtime.relayInstanceId,
22
28
  tools: runtime.tools(),
23
29
  policy: runtime.policy,
24
30
  protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
31
+ authentication: await createDaemonAuthentication(deviceIdentity, welcome, runtime.relayInstanceId),
25
32
  }),
26
33
  onMessage: (data, relayContext) => handleRelayData(runtime, data, relayContext),
27
34
  onDisconnect: () => runtime.handleRelayDisconnect(),
@@ -82,8 +89,9 @@ function normalizeRelayAuthorization(value) {
82
89
  if (!isPlainRecord(value)) return null;
83
90
  const accountId = typeof value.account_id === "string" && /^acct_[A-Za-z0-9_-]{20,96}$/.test(value.account_id) ? value.account_id : "";
84
91
  const accountVersion = Number(value.account_version);
92
+ const clientId = typeof value.client_id === "string" && /^mcp_client_[A-Za-z0-9_-]{43}$/.test(value.client_id) ? value.client_id : "";
85
93
  let role;
86
94
  try { role = normalizeAccountRole(value.role); } catch { return null; }
87
- if (!accountId || !Number.isInteger(accountVersion) || accountVersion < 1) return null;
88
- return Object.freeze({ account_id: accountId, account_version: accountVersion, role });
95
+ if (!accountId || !clientId || !Number.isInteger(accountVersion) || accountVersion < 1) return null;
96
+ return Object.freeze({ account_id: accountId, account_version: accountVersion, client_id: clientId, role });
89
97
  }
@@ -31,6 +31,7 @@ import { AccountAccessGate } from "./account-access.mjs";
31
31
  import { buildProjectOverview, buildRuntimeInfo } from "./runtime-reporting.mjs";
32
32
  import { diagnoseRuntime as runRuntimeDiagnostics } from "./runtime-diagnostics.mjs";
33
33
  import { bindRuntimeToolHandlers, runtimeToolHandlerNames as registeredRuntimeToolHandlerNames } from "./runtime-tool-handlers.mjs";
34
+ import { OperationAuthorizer } from "./operation-authorization.mjs";
34
35
  import { createRuntimeRelayConnection, normalizeRelayResumeCalls, normalizeRelayToolCall } from "./runtime-relay.mjs";
35
36
  import { RelayCallRecovery } from "./relay-call-recovery.mjs";
36
37
  import { assertContainedPath, createRuntimeDir, redactRuntimeErrorMessage, stateRootFromProfileStatePath } from "./runtime-paths.mjs";
@@ -46,9 +47,8 @@ export function runtimeToolHandlerNames() {
46
47
  }
47
48
 
48
49
  export class LocalRuntime {
49
- constructor({ workerUrl = "", secret = "", expectedRelayVersion = "", workspace, policy, logger = console, onSuperseded = null, onFatal = null, jobRoot = "", resources = {}, resourceStatePath = "", browserStateRoot = "", agentHome = process.env.HOME || process.env.USERPROFILE || "", codexHome = process.env.CODEX_HOME || "", recoverJobs = true, applicationAutomation = {} }) {
50
+ constructor({ workerUrl = "", deviceIdentity = null, expectedRelayVersion = "", workspace, policy, logger = console, onSuperseded = null, onFatal = null, jobRoot = "", approvalRoot = "", resources = {}, resourceStatePath = "", browserStateRoot = "", agentHome = process.env.HOME || process.env.USERPROFILE || "", codexHome = process.env.CODEX_HOME || "", recoverJobs = true, applicationAutomation = {} }) {
50
51
  const remoteWorkerUrl = workerUrl ? String(workerUrl) : "";
51
- const remoteSecret = secret || "";
52
52
  this.workspaceInput = resolve(workspace || process.cwd());
53
53
  this.workspace = realpathSync.native ? realpathSync.native(this.workspaceInput) : realpathSync(this.workspaceInput);
54
54
  this.workspaceCanonicalPromise = null;
@@ -135,6 +135,7 @@ export class LocalRuntime {
135
135
  resolveLocalCommand: (args, context) => this.agentContextManager.resolveLocalCommand(args, context),
136
136
  displayPath: (value) => this.displayPath(value),
137
137
  throwIfCancelled: (context) => this.throwIfCancelled(context),
138
+ retainCompletedOutput: (value) => this.processSessionManager.retainCompletedOutput(value),
138
139
  });
139
140
  this.gitService = new GitService({
140
141
  resolveExistingPath: (value) => this.resolveExistingPath(value),
@@ -154,6 +155,12 @@ export class LocalRuntime {
154
155
  readResourceText,
155
156
  throwIfCancelled: (context) => this.throwIfCancelled(context),
156
157
  });
158
+ this.operationAuthorizer = new OperationAuthorizer({
159
+ workspace: this.workspace,
160
+ root: approvalRoot,
161
+ resolveExistingPath: (value) => this.resolveExistingPath(value),
162
+ resolveWritePath: (value) => this.resolveWritePath(value),
163
+ });
157
164
  this.browserBridgeManager = new BrowserBridgeManager({
158
165
  policy: this.policy,
159
166
  authorizeTool: (tool) => this.policyGate.assert(tool),
@@ -168,6 +175,7 @@ export class LocalRuntime {
168
175
  handlers: bindRuntimeToolHandlers(this),
169
176
  policyGate: this.policyGate,
170
177
  accountAccessGate: this.accountAccessGate,
178
+ operationAuthorizer: this.operationAuthorizer,
171
179
  callRegistry: this.callRegistry,
172
180
  observability: this.observability,
173
181
  logger: this.logger,
@@ -175,7 +183,7 @@ export class LocalRuntime {
175
183
  slowMs: SLOW_TOOL_CALL_MS,
176
184
  });
177
185
  this.relay = createRuntimeRelayConnection(this, {
178
- workerUrl: remoteWorkerUrl, secret: remoteSecret, expectedVersion: expectedRelayVersion, onFatal,
186
+ workerUrl: remoteWorkerUrl, deviceIdentity, expectedVersion: expectedRelayVersion, onFatal,
179
187
  });
180
188
  this.relayCallRecovery = new RelayCallRecovery({
181
189
  logger: this.logger,
@@ -209,7 +217,7 @@ export class LocalRuntime {
209
217
  }
210
218
 
211
219
  async start() {
212
- if (!this.relay) throw new Error("remote daemon start requires a Worker URL and daemon secret");
220
+ if (!this.relay) throw new Error("remote daemon start requires a Worker URL and device identity");
213
221
  if (!this.lifecycle.beginStart()) return;
214
222
  if (this.policy.profile === "full") {
215
223
  void this.browserBridgeManager.ensureStarted().catch((error) => {
@@ -600,8 +608,8 @@ export class LocalRuntime {
600
608
 
601
609
  async resolveWritePath(inputPath = ".") {
602
610
  const candidate = this.resolvePath(inputPath);
603
- if (this.policy.unrestrictedPaths) return candidate;
604
611
  const candidateInfo = await lstat(candidate).catch(() => null);
612
+ if (candidateInfo?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
605
613
  let ancestor = candidate;
606
614
  while (!(await lstat(ancestor).catch(() => null))) {
607
615
  const parent = dirname(ancestor);
@@ -609,8 +617,7 @@ export class LocalRuntime {
609
617
  ancestor = parent;
610
618
  }
611
619
  const [workspace, canonicalAncestor] = await Promise.all([this.canonicalWorkspace(), realpath(ancestor)]);
612
- assertContainedPath(workspace, canonicalAncestor);
613
- if (candidateInfo?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
620
+ if (!this.policy.unrestrictedPaths) assertContainedPath(workspace, canonicalAncestor);
614
621
  const suffix = relative(ancestor, candidate);
615
622
  return suffix ? resolve(canonicalAncestor, suffix) : canonicalAncestor;
616
623
  }
@@ -16,6 +16,11 @@ export function openRegularFileSync(file, flags, options = {}) {
16
16
  try {
17
17
  const info = fstatSync(fd);
18
18
  if (!info.isFile()) throw new Error(`${label} is not a regular file`);
19
+ if (options.verifyPathIdentity === true) {
20
+ const pathInfo = lstatSync(file);
21
+ if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) throw new Error(`${label} must be a regular file and not a symbolic link`);
22
+ if (!sameFileIdentity(info, pathInfo)) throw new Error(`${label} identity changed while opening`);
23
+ }
19
24
  if (Number.isInteger(options.chmod)) setDescriptorMode(fd, options.chmod);
20
25
  return { fd, info };
21
26
  } catch (error) {
@@ -37,15 +42,19 @@ export function chmodRegularFileSync(file, mode, label = "path") {
37
42
  return withRegularFileSync(file, fsConstants.O_RDONLY, { label, chmod: mode }, () => undefined);
38
43
  }
39
44
 
40
- export function readBoundedRegularFileSync(file, maxBytes, label = "path") {
41
- return readBoundedRegularFileWithInfoSync(file, maxBytes, label).buffer;
45
+ export function readBoundedRegularFileSync(file, maxBytes, label = "path", options = {}) {
46
+ return readBoundedRegularFileWithInfoSync(file, maxBytes, label, options).buffer;
42
47
  }
43
48
 
44
- export function readBoundedRegularFileWithInfoSync(file, maxBytes, label = "path") {
49
+ export function readBoundedRegularFileWithInfoSync(file, maxBytes, label = "path", options = {}) {
45
50
  const limit = Number(maxBytes);
46
51
  if (!Number.isSafeInteger(limit) || limit < 0) throw new Error("maximum file size must be a non-negative safe integer");
47
- return withRegularFileSync(file, fsConstants.O_RDONLY, { label }, (fd, info) => {
52
+ return withRegularFileSync(file, fsConstants.O_RDONLY, {
53
+ label,
54
+ verifyPathIdentity: options.verifyPathIdentity === true,
55
+ }, (fd, info) => {
48
56
  if (info.size > limit) throw new Error(`file exceeds ${limit} bytes`);
57
+ options.afterOpen?.({ fd, info });
49
58
  const buffer = Buffer.alloc(info.size);
50
59
  let offset = 0;
51
60
  while (offset < buffer.length) {
@@ -104,3 +113,14 @@ function setDescriptorMode(fd, mode) {
104
113
  if (process.platform !== "win32") throw error;
105
114
  }
106
115
  }
116
+
117
+ function sameFileIdentity(left, right) {
118
+ const leftDevice = Number(left.dev);
119
+ const rightDevice = Number(right.dev);
120
+ const leftInode = Number(left.ino);
121
+ const rightInode = Number(right.ino);
122
+ if ([leftDevice, rightDevice, leftInode, rightInode].every((value) => Number.isSafeInteger(value) && value >= 0)) {
123
+ return leftDevice === rightDevice && leftInode === rightInode;
124
+ }
125
+ return true;
126
+ }
@@ -7,6 +7,7 @@ import serverMetadata from "../shared/server-metadata.json" with { type: "json"
7
7
  import { replaceFileSync } from "./atomic-fs.mjs";
8
8
  import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
9
9
  import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
10
+ import { createDeviceIdentity, validateDeviceIdentity } from "./device-identity.mjs";
10
11
  import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
11
12
  import { chmodRegularFileSync, ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
12
13
 
@@ -591,9 +592,14 @@ function pruneBackups(filePath, keep) {
591
592
 
592
593
  export function ensureWorkerSecrets(state, options = {}) {
593
594
  state.worker ||= {};
595
+ const enrollingDeviceIdentity = !state.worker.deviceIdentity;
594
596
  if (!state.worker.accountAdminSecret || options.rotateSecrets) state.worker.accountAdminSecret = randomToken("account_admin");
595
- if (!state.worker.daemonSecret || options.rotateSecrets) state.worker.daemonSecret = randomToken("daemon_secret");
596
- if (!state.worker.oauthTokenVersion || options.rotateSecrets) state.worker.oauthTokenVersion = randomToken("token_version");
597
+ if (enrollingDeviceIdentity || options.rotateSecrets) state.worker.deviceIdentity = createDeviceIdentity();
598
+ else validateDeviceIdentity(state.worker.deviceIdentity);
599
+ delete state.worker.daemonSecret;
600
+ if (!state.worker.oauthTokenVersion || enrollingDeviceIdentity || options.rotateSecrets) {
601
+ state.worker.oauthTokenVersion = randomToken("token_version");
602
+ }
597
603
 
598
604
  const requestedName = options.workerName || "";
599
605
  if (!state.worker.name) {
@@ -635,7 +641,7 @@ export function ownerOnlyFile(filePath) {
635
641
  export function redactState(state) {
636
642
  const clone = redactHomeInValue(JSON.parse(JSON.stringify(state)));
637
643
  if (clone.worker?.accountAdminSecret) clone.worker.accountAdminSecret = "<redacted>";
638
- if (clone.worker?.daemonSecret) clone.worker.daemonSecret = "<redacted>";
644
+ if (clone.worker?.deviceIdentity?.privateJwk?.d) clone.worker.deviceIdentity.privateJwk.d = "<redacted>";
639
645
  if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = "<redacted>";
640
646
  if (clone.resources && typeof clone.resources === "object") {
641
647
  for (const value of Object.values(clone.resources)) {
@@ -7,6 +7,7 @@ export class ToolExecutor {
7
7
  this.policyGate = options.policyGate;
8
8
  this.callRegistry = options.callRegistry;
9
9
  this.accountAccessGate = options.accountAccessGate;
10
+ this.operationAuthorizer = options.operationAuthorizer;
10
11
  this.observability = options.observability;
11
12
  this.logger = options.logger || console;
12
13
  this.safeMessage = typeof options.safeMessage === "function" ? options.safeMessage : (error) => String(error?.message || error || "operation failed");
@@ -14,7 +15,7 @@ export class ToolExecutor {
14
15
  this.pipeline = composeMiddleware([
15
16
  lifecycleMiddleware(this.callRegistry),
16
17
  observabilityMiddleware(this.observability, this.logger, this.safeMessage, this.slowMs),
17
- authorizeMiddleware(this.policyGate, this.accountAccessGate),
18
+ authorizeMiddleware(this.policyGate, this.accountAccessGate, this.operationAuthorizer),
18
19
  ], invokeHandler(this.handlers));
19
20
  }
20
21
 
@@ -27,13 +28,14 @@ export function composeMiddleware(middleware, terminal) {
27
28
  return middleware.reduceRight((next, current) => (operation) => current(operation, next), terminal);
28
29
  }
29
30
 
30
- function authorizeMiddleware(policyGate, accountAccessGate) {
31
+ function authorizeMiddleware(policyGate, accountAccessGate, operationAuthorizer) {
31
32
  return async (operation, next) => {
32
33
  policyGate.assert(operation.tool);
33
34
  if (operation.context.origin === "relay") {
34
35
  const role = operation.request.authorization?.role;
35
36
  if (!role) throw new Error("relay tool call is missing an account role");
36
37
  accountAccessGate.assert(role, operation.tool);
38
+ await operationAuthorizer?.authorize(operation);
37
39
  }
38
40
  return next(operation);
39
41
  };
@@ -1,4 +1,5 @@
1
1
  import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
2
+ import { projectMcpResult } from "../shared/result-projection.mjs";
2
3
  export {
3
4
  DEFAULT_POLICY_PROFILE,
4
5
  DEFAULT_POLICY_REVISION,
@@ -28,10 +29,9 @@ export const MCP_INSTRUCTIONS = Object.freeze(serverMetadata.instructions.map((v
28
29
  export function toolResult(value, isError = false) {
29
30
  const special = specialMcpResult(value);
30
31
  if (special) return { ...special, isError };
31
- const structuredContent = toStructuredContent(value);
32
- const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
33
- const result = { content: [{ type: "text", text }], isError };
34
- if (structuredContent) result.structuredContent = structuredContent;
32
+ const projection = projectMcpResult(value);
33
+ const result = { content: [{ type: "text", text: projection.text }], isError };
34
+ if (projection.structuredContent) result.structuredContent = projection.structuredContent;
35
35
  return result;
36
36
  }
37
37
 
@@ -55,8 +55,3 @@ function specialMcpResult(value) {
55
55
  }
56
56
  return result;
57
57
  }
58
-
59
- function toStructuredContent(value) {
60
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
61
- return value;
62
- }
@@ -4,6 +4,7 @@ import path, { resolve } from "node:path";
4
4
  import { runWrangler } from "./shell.mjs";
5
5
  import { packageRoot, saveState } from "./state.mjs";
6
6
  import { withWorkerSecretsFile } from "./worker-secret-file.mjs";
7
+ import { publicDeviceJwkJson } from "./device-identity.mjs";
7
8
  import {
8
9
  normalizeWorkerOrigin,
9
10
  retryWorkerHealth,
@@ -81,11 +82,11 @@ export function workerDeploymentFingerprint(state, options = {}) {
81
82
  const root = resolve(options.packageRoot || packageRoot);
82
83
  const keyMaterial = [
83
84
  String(state.worker.accountAdminSecret || ""),
84
- String(state.worker.daemonSecret || ""),
85
+ publicDeviceJwkJson(state.worker.deviceIdentity),
85
86
  String(state.worker.oauthTokenVersion || ""),
86
87
  ].join("\0");
87
88
  const fingerprint = createHmac("sha256", keyMaterial);
88
- fingerprint.update("mbm-worker-deploy-v3");
89
+ fingerprint.update("mbm-worker-deploy-v4");
89
90
  fingerprint.update(String(state.worker.name || ""));
90
91
  for (const file of workerDeployHashFiles(root)) {
91
92
  fingerprint.update(path.relative(root, file));
@@ -118,7 +119,7 @@ export function workerUrlMatchesName(workerUrl, workerName) {
118
119
  }
119
120
 
120
121
  function hasCompleteWorkerState(worker = {}) {
121
- return Boolean(worker.url && worker.mcpServerUrl && worker.accountAdminSecret && worker.daemonSecret && worker.oauthTokenVersion && worker.name);
122
+ return Boolean(worker.url && worker.mcpServerUrl && worker.accountAdminSecret && worker.deviceIdentity && worker.oauthTokenVersion && worker.name);
122
123
  }
123
124
 
124
125
  function workerDeployHashFiles(root) {
@@ -3,6 +3,7 @@ import { lstatSync, readdirSync, unlinkSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { createExclusiveFileSync } from "./exclusive-file.mjs";
5
5
  import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
6
+ import { publicDeviceJwkJson } from "./device-identity.mjs";
6
7
  import { chmodRegularFileSync, ensureOwnerOnlyDirectorySync } from "./secure-file.mjs";
7
8
 
8
9
  const SECRET_FILE_PATTERN = /^worker-secrets-(\d+)-(\d+)(?:-p(\d+))?(?:-([a-f0-9]+))?\.json$/;
@@ -17,7 +18,7 @@ export async function withWorkerSecretsFile(state, callback, options = {}) {
17
18
  const tempPath = resolve(dir, `worker-secrets-${process.pid}-${createdAt}-p${processStartedAt}-${random}.json`);
18
19
  const payload = {
19
20
  ACCOUNT_ADMIN_SECRET: state.worker.accountAdminSecret,
20
- DAEMON_SHARED_SECRET: state.worker.daemonSecret,
21
+ DAEMON_DEVICE_PUBLIC_KEY: publicDeviceJwkJson(state.worker.deviceIdentity),
21
22
  OAUTH_TOKEN_VERSION: state.worker.oauthTokenVersion,
22
23
  };
23
24
 
@@ -0,0 +1,10 @@
1
+ export const ADMIN_AUTH_SCHEME: "hmac-sha256-v1";
2
+ export const ADMIN_AUTH_TTL_SECONDS: number;
3
+ export function adminAuthTranscript(input: {
4
+ origin: unknown;
5
+ method: unknown;
6
+ pathname: unknown;
7
+ bodyHash: unknown;
8
+ issuedAt: unknown;
9
+ nonce: unknown;
10
+ }): string;
@@ -0,0 +1,36 @@
1
+ export const ADMIN_AUTH_SCHEME = "hmac-sha256-v1";
2
+ export const ADMIN_AUTH_TTL_SECONDS = 5 * 60;
3
+
4
+ export function adminAuthTranscript(input = {}) {
5
+ const origin = requiredOrigin(input.origin);
6
+ const method = requiredToken(input.method, "method", /^[A-Z]{3,10}$/);
7
+ const pathname = requiredToken(input.pathname, "pathname", /^\/[A-Za-z0-9/_-]{1,255}$/);
8
+ const bodyHash = requiredToken(input.bodyHash, "body hash", /^[a-f0-9]{64}$/);
9
+ const issuedAt = requiredInteger(input.issuedAt, "issued at");
10
+ const nonce = requiredToken(input.nonce, "nonce", /^[A-Za-z0-9_-]{32,128}$/);
11
+ return [ADMIN_AUTH_SCHEME, origin, method, pathname, bodyHash, issuedAt, nonce].join("\0");
12
+ }
13
+
14
+
15
+ function requiredOrigin(value) {
16
+ let url;
17
+ try { url = new URL(String(value || "")); } catch { throw new Error("admin authentication origin is invalid"); }
18
+ const secure = url.protocol === "https:";
19
+ const loopback = url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
20
+ if ((!secure && !loopback) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
21
+ throw new Error("admin authentication origin is invalid");
22
+ }
23
+ return url.origin;
24
+ }
25
+
26
+ function requiredToken(value, label, pattern) {
27
+ const text = String(value || "");
28
+ if (!pattern.test(text)) throw new Error(`admin authentication ${label} is invalid`);
29
+ return text;
30
+ }
31
+
32
+ function requiredInteger(value, label) {
33
+ const number = Number(value);
34
+ if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`admin authentication ${label} is invalid`);
35
+ return String(number);
36
+ }
@@ -0,0 +1,20 @@
1
+ export const DAEMON_AUTH_SCHEME: "device-signature-v1";
2
+ export const DAEMON_PREFLIGHT_SCHEME: "device-preflight-v1";
3
+ export const DAEMON_AUTH_CHALLENGE_TTL_SECONDS: number;
4
+ export const DAEMON_PREFLIGHT_TTL_SECONDS: number;
5
+ export function daemonAuthTranscript(input: {
6
+ challenge: unknown;
7
+ workerOrigin: unknown;
8
+ server: unknown;
9
+ version: unknown;
10
+ instanceId: unknown;
11
+ issuedAt: unknown;
12
+ }): string;
13
+
14
+ export function daemonPreflightTranscript(input: {
15
+ workerOrigin: unknown;
16
+ server: unknown;
17
+ version: unknown;
18
+ nonce: unknown;
19
+ issuedAt: unknown;
20
+ }): string;
@@ -0,0 +1,55 @@
1
+ export const DAEMON_AUTH_SCHEME = "device-signature-v1";
2
+ export const DAEMON_PREFLIGHT_SCHEME = "device-preflight-v1";
3
+ export const DAEMON_AUTH_CHALLENGE_TTL_SECONDS = 30;
4
+ export const DAEMON_PREFLIGHT_TTL_SECONDS = 5 * 60;
5
+
6
+
7
+ export function daemonPreflightTranscript(input = {}) {
8
+ const values = [
9
+ DAEMON_PREFLIGHT_SCHEME,
10
+ requiredOrigin(input.workerOrigin),
11
+ requiredText(input.server, "server", 1, 128),
12
+ requiredText(input.version, "version", 1, 64),
13
+ requiredText(input.nonce, "preflight nonce", 24, 128),
14
+ requiredInteger(input.issuedAt, "preflight issued at"),
15
+ ];
16
+ return values.join("\0");
17
+ }
18
+
19
+ export function daemonAuthTranscript(input = {}) {
20
+ const values = [
21
+ DAEMON_AUTH_SCHEME,
22
+ requiredText(input.challenge, "challenge", 16, 256),
23
+ requiredOrigin(input.workerOrigin),
24
+ requiredText(input.server, "server", 1, 128),
25
+ requiredText(input.version, "version", 1, 64),
26
+ requiredText(input.instanceId, "instance id", 16, 128),
27
+ requiredInteger(input.issuedAt, "issued at"),
28
+ ];
29
+ return values.join("\0");
30
+ }
31
+
32
+ function requiredText(value, label, minimum, maximum) {
33
+ const text = String(value || "");
34
+ if (text.length < minimum || text.length > maximum || /[\0\r\n]/.test(text)) {
35
+ throw new Error(`daemon authentication ${label} is invalid`);
36
+ }
37
+ return text;
38
+ }
39
+
40
+ function requiredOrigin(value) {
41
+ let url;
42
+ try { url = new URL(String(value || "")); } catch { throw new Error("daemon authentication Worker origin is invalid"); }
43
+ const secure = url.protocol === "https:";
44
+ const loopback = url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
45
+ if ((!secure && !loopback) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
46
+ throw new Error("daemon authentication Worker origin is invalid");
47
+ }
48
+ return url.origin;
49
+ }
50
+
51
+ function requiredInteger(value, label) {
52
+ const number = Number(value);
53
+ if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`daemon authentication ${label} is invalid`);
54
+ return String(number);
55
+ }