machine-bridge-mcp 1.2.11 → 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 (50) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/README.md +10 -5
  3. package/browser-extension/manifest.json +2 -2
  4. package/docs/ARCHITECTURE.md +18 -10
  5. package/docs/AUDIT.md +25 -3
  6. package/docs/CLIENTS.md +2 -2
  7. package/docs/ENGINEERING.md +2 -2
  8. package/docs/GETTING_STARTED.md +1 -1
  9. package/docs/LOCAL_AUTHORIZATION.md +111 -0
  10. package/docs/LOGGING.md +4 -4
  11. package/docs/MULTI_ACCOUNT.md +5 -5
  12. package/docs/OPERATIONS.md +30 -5
  13. package/docs/OVERVIEW.md +12 -8
  14. package/docs/PROJECT_STANDARDS.md +1 -1
  15. package/docs/RELEASING.md +3 -3
  16. package/docs/TESTING.md +5 -2
  17. package/docs/THREAT_MODEL.md +7 -6
  18. package/docs/UPGRADING.md +10 -6
  19. package/package.json +4 -2
  20. package/scripts/check-plan.mjs +2 -0
  21. package/scripts/local-release-acceptance.mjs +2 -2
  22. package/scripts/release-acceptance.mjs +7 -4
  23. package/src/local/account-admin.mjs +28 -3
  24. package/src/local/cli-approval.mjs +117 -0
  25. package/src/local/cli-options.mjs +8 -2
  26. package/src/local/cli.mjs +13 -2
  27. package/src/local/device-identity.mjs +108 -0
  28. package/src/local/operation-authorization.mjs +366 -0
  29. package/src/local/operation-risk.mjs +221 -0
  30. package/src/local/operation-state-lock.mjs +92 -0
  31. package/src/local/relay-connection.mjs +12 -5
  32. package/src/local/runtime-relay.mjs +13 -5
  33. package/src/local/runtime.mjs +13 -7
  34. package/src/local/state.mjs +9 -3
  35. package/src/local/tool-executor.mjs +4 -2
  36. package/src/local/worker-deployment.mjs +4 -3
  37. package/src/local/worker-secret-file.mjs +2 -1
  38. package/src/shared/admin-auth.d.mts +10 -0
  39. package/src/shared/admin-auth.mjs +36 -0
  40. package/src/shared/daemon-auth.d.mts +20 -0
  41. package/src/shared/daemon-auth.mjs +55 -0
  42. package/src/worker/account-admin.ts +73 -4
  43. package/src/worker/daemon-auth.ts +205 -0
  44. package/src/worker/daemon-sockets.ts +15 -2
  45. package/src/worker/index.ts +59 -10
  46. package/src/worker/nonce-store.ts +79 -0
  47. package/src/worker/oauth-controller.ts +11 -6
  48. package/src/worker/oauth-refresh-families.ts +172 -0
  49. package/src/worker/oauth-state.ts +48 -3
  50. package/src/worker/oauth-tokens.ts +49 -40
@@ -0,0 +1,221 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+
4
+ export const OPERATION_APPROVAL_SCOPES = Object.freeze([
5
+ "shell",
6
+ "external-write",
7
+ "sensitive-write",
8
+ "external-read",
9
+ "sensitive-read",
10
+ "browser-session",
11
+ "data-export",
12
+ "persistent-job",
13
+ "application-control",
14
+ "credential-operation",
15
+ "full",
16
+ ]);
17
+
18
+ const SHELL_TOOLS = new Set(["exec_command", "run_process", "start_process", "run_local_command", "read_process", "write_process", "kill_process"]);
19
+ const PERSISTENT_TOOLS = new Set(["stage_job", "start_job", "list_jobs", "read_job", "cancel_job"]);
20
+ const APPLICATION_CONTROL_TOOLS = new Set(["open_local_application", "inspect_local_application", "operate_local_application"]);
21
+ const CREDENTIAL_TOOLS = new Set(["generate_ssh_key_resource"]);
22
+ const BROWSER_PROFILE_TOOLS = new Set([
23
+ "pair_browser_extension", "browser_list_tabs", "browser_manage_tabs", "browser_get_source",
24
+ "browser_inspect_page", "browser_wait", "browser_action", "browser_fill_form", "browser_screenshot",
25
+ ]);
26
+ const AUTOMATIC_TOOLS = new Set([
27
+ "server_info", "project_overview", "list_local_applications", "browser_status", "list_roots",
28
+ "diagnose_runtime", "list_local_resources",
29
+ ]);
30
+ const FILE_WRITE_TOOLS = new Set(["write_file", "edit_file"]);
31
+ const FILE_READ_TOOLS = new Set([
32
+ "session_bootstrap", "resolve_task_capabilities", "agent_context", "list_local_skills", "load_local_skill", "list_local_commands",
33
+ "list_dir", "list_files", "read_file", "view_image", "search_text",
34
+ "git_status", "git_diff", "git_log", "git_show",
35
+ ]);
36
+ const SENSITIVE_SEGMENTS = new Set([
37
+ ".ssh", ".aws", ".azure", ".gnupg", ".kube", ".docker", ".npmrc", ".pypirc",
38
+ "keychains", "cookies", "login data", "credentials", "secrets",
39
+ ]);
40
+
41
+ export function reviewedOperationToolNames() {
42
+ return new Set([
43
+ ...AUTOMATIC_TOOLS,
44
+ ...SHELL_TOOLS,
45
+ ...PERSISTENT_TOOLS,
46
+ ...APPLICATION_CONTROL_TOOLS,
47
+ ...CREDENTIAL_TOOLS,
48
+ ...BROWSER_PROFILE_TOOLS,
49
+ ...FILE_WRITE_TOOLS,
50
+ ...FILE_READ_TOOLS,
51
+ "browser_upload_files",
52
+ "apply_patch",
53
+ ]);
54
+ }
55
+
56
+ export async function classifyOperation(tool, args = {}, options = {}) {
57
+ const name = String(tool || "");
58
+ if (SHELL_TOOLS.has(name)) return requirement("shell", "remote shell or process control", name, args);
59
+ if (PERSISTENT_TOOLS.has(name)) return requirement("persistent-job", "persistent managed job", name, args);
60
+ if (name === "operate_local_application" && args.value_resource) {
61
+ return requirement(["application-control", "data-export"], "desktop application control using protected local data", name, {
62
+ application: args.application,
63
+ action: args.action,
64
+ value_resource: args.value_resource,
65
+ });
66
+ }
67
+ if (APPLICATION_CONTROL_TOOLS.has(name)) return requirement("application-control", "desktop application control", name, args);
68
+ if (CREDENTIAL_TOOLS.has(name)) return requirement("credential-operation", "credential or key operation", name, args);
69
+ if (name === "browser_upload_files") {
70
+ return requirement(["browser-session", "data-export"], "existing browser profile file upload", name, args);
71
+ }
72
+ if (name === "browser_action" && args.value_resource) {
73
+ return requirement(["browser-session", "data-export"], "existing browser profile input from protected local data", name, {
74
+ action: args.action,
75
+ tab_id: args.tab_id,
76
+ value_resource: args.value_resource,
77
+ });
78
+ }
79
+ if (name === "browser_fill_form" && Array.isArray(args.fields) && args.fields.some((field) => field?.value_resource || field?.sensitive === true)) {
80
+ return requirement(["browser-session", "data-export"], "existing browser profile form containing protected local data", name, {
81
+ tab_id: args.tab_id,
82
+ resource_fields: args.fields.filter((field) => field?.value_resource || field?.sensitive === true).length,
83
+ submit: args.submit === true,
84
+ });
85
+ }
86
+ if (BROWSER_PROFILE_TOOLS.has(name)) {
87
+ return requirement("browser-session", "access to the existing browser profile", name, {
88
+ action: args.action,
89
+ tab_id: args.tab_id,
90
+ frame_id: args.frame_id,
91
+ submit: args.submit === true,
92
+ });
93
+ }
94
+ if (FILE_WRITE_TOOLS.has(name)) {
95
+ const target = await canonicalWritePath(args.path, options);
96
+ const scopes = pathWriteScopes(options.workspace, target);
97
+ if (scopes.length) return requirement(scopes, pathWriteCategory(scopes), name, { path: target });
98
+ }
99
+ if (name === "apply_patch") {
100
+ const paths = patchPaths(args.patch);
101
+ const targets = [];
102
+ for (const candidate of paths) targets.push(await canonicalWritePath(candidate, options));
103
+ const scopes = normalizeScopes(targets.flatMap((target) => pathWriteScopes(options.workspace, target)));
104
+ if (scopes.length) return requirement(scopes, pathWriteCategory(scopes, "patch"), name, { paths: targets });
105
+ }
106
+ if (FILE_READ_TOOLS.has(name) && args.path !== undefined) {
107
+ const target = await canonicalExistingPath(args.path, options);
108
+ const scopes = [];
109
+ if (!isWithin(options.workspace, target)) scopes.push("external-read");
110
+ if (isSensitivePath(target)) scopes.push("sensitive-read");
111
+ if (scopes.length) return requirement(scopes, pathReadCategory(scopes), name, { path: target });
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function requirement(scopes, category, tool, target) {
117
+ const normalized = normalizeScopes(Array.isArray(scopes) ? scopes : [scopes]);
118
+ if (!normalized.length) throw new Error("operation approval requirement is missing a scope");
119
+ return {
120
+ scope: normalized[0],
121
+ scopes: normalized,
122
+ category,
123
+ targetHash: createHash("sha256").update(JSON.stringify({ tool, target: redactTarget(target) })).digest("hex"),
124
+ };
125
+ }
126
+
127
+ function normalizeScopes(scopes) {
128
+ const requested = new Set(scopes.map((scope) => String(scope || "")));
129
+ return OPERATION_APPROVAL_SCOPES.filter((scope) => requested.has(scope));
130
+ }
131
+
132
+ function pathWriteScopes(workspace, target) {
133
+ const scopes = [];
134
+ if (!isWithin(workspace, target)) scopes.push("external-write");
135
+ if (isSensitiveWritePath(target)) scopes.push("sensitive-write");
136
+ return scopes;
137
+ }
138
+
139
+ function pathWriteCategory(scopes, subject = "write") {
140
+ const external = scopes.includes("external-write");
141
+ const sensitive = scopes.includes("sensitive-write");
142
+ if (external && sensitive) return `${subject} outside the selected workspace to a credential or persistence-sensitive path`;
143
+ if (sensitive) return `${subject} to a credential or persistence-sensitive path`;
144
+ return `${subject} outside the selected workspace`;
145
+ }
146
+
147
+ function pathReadCategory(scopes) {
148
+ const external = scopes.includes("external-read");
149
+ const sensitive = scopes.includes("sensitive-read");
150
+ if (external && sensitive) return "read outside the selected workspace from a credential-sensitive location";
151
+ if (sensitive) return "read from a credential-sensitive location";
152
+ return "read outside the selected workspace";
153
+ }
154
+
155
+ function redactTarget(value) {
156
+ if (!value || typeof value !== "object") return String(value || "");
157
+ const out = {};
158
+ for (const [key, item] of Object.entries(value)) {
159
+ if (["content", "command", "stdin", "value", "old_text", "new_text", "patch"].includes(key)) {
160
+ out[key] = `<sha256:${createHash("sha256").update(String(item || "")).digest("hex")}>`;
161
+ } else out[key] = item;
162
+ }
163
+ return out;
164
+ }
165
+
166
+ async function canonicalExistingPath(value, options) {
167
+ if (typeof options.resolveExistingPath === "function") return options.resolveExistingPath(value);
168
+ return path.resolve(options.workspace, String(value || "."));
169
+ }
170
+
171
+ async function canonicalWritePath(value, options) {
172
+ if (typeof options.resolveWritePath === "function") return options.resolveWritePath(value);
173
+ return path.resolve(options.workspace, String(value || "."));
174
+ }
175
+
176
+ function isWithin(workspace, target) {
177
+ const relative = path.relative(path.resolve(workspace), path.resolve(target));
178
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
179
+ }
180
+
181
+ function isSensitivePath(target) {
182
+ const lower = path.resolve(target).toLowerCase();
183
+ const segments = lower.split(/[\\/]+/);
184
+ if (segments.some((segment) => SENSITIVE_SEGMENTS.has(segment))) return true;
185
+ const base = path.basename(lower);
186
+ return isSensitiveEnvironmentFile(base) || /(?:token|secret|credential|private[-_]?key)/.test(base);
187
+ }
188
+
189
+ function isSensitiveWritePath(target) {
190
+ const lower = path.resolve(target).toLowerCase();
191
+ const normalized = lower.split(path.sep).join("/");
192
+ const base = path.basename(lower);
193
+ if (isSensitivePath(lower)) return true;
194
+ if ([".zshrc", ".bashrc", ".bash_profile", ".profile", ".gitconfig", ".netrc", ".curlrc", "authorized_keys", "sshd_config", "sudoers"].includes(base)) return true;
195
+ return normalized.includes("/.git/hooks/")
196
+ || normalized.endsWith("/.git/config")
197
+ || normalized.endsWith("/.config/git/config")
198
+ || normalized.includes("/library/launchagents/")
199
+ || normalized.includes("/library/launchdaemons/")
200
+ || normalized.includes("/.config/autostart/")
201
+ || normalized.includes("/.config/systemd/user/")
202
+ || normalized.includes("/start menu/programs/startup/")
203
+ || normalized.endsWith("/.config/fish/config.fish");
204
+ }
205
+
206
+ function isSensitiveEnvironmentFile(base) {
207
+ if (base === ".env") return true;
208
+ if (!base.startsWith(".env.")) return false;
209
+ return ![".env.example", ".env.sample", ".env.template", ".env.defaults"].includes(base);
210
+ }
211
+
212
+ function patchPaths(patch) {
213
+ const paths = [];
214
+ for (const line of String(patch || "").split(/\r?\n/)) {
215
+ const match = /^(?:\*\*\* (?:Add|Update|Delete) File:|\*\*\* Move to:|\+\+\+|---)\s+(.+)$/.exec(line);
216
+ if (!match) continue;
217
+ const value = match[1].replace(/^[ab]\//, "").trim();
218
+ if (value && value !== "/dev/null") paths.push(value);
219
+ }
220
+ return paths;
221
+ }
@@ -0,0 +1,92 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { createExclusiveFileSync, removeOwnedJsonFileSync } from "./exclusive-file.mjs";
5
+ import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
6
+ import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
7
+ import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
8
+
9
+ const LOCK_PURPOSE = "operation-authorization";
10
+ const LOCK_FILE = "operation-authorization.lock";
11
+ const MAX_LOCK_BYTES = 8 * 1024;
12
+ const DEFAULT_WAIT_MS = 5_000;
13
+ const DEFAULT_POLL_MS = 25;
14
+
15
+ export async function withOperationStateLock(root, callback, options = {}) {
16
+ if (typeof callback !== "function") throw new TypeError("operation-state lock requires a callback");
17
+ const requestedRoot = String(root || "").trim();
18
+ if (!requestedRoot) throw new Error("operation-state lock root is missing");
19
+ const directory = path.resolve(requestedRoot);
20
+ ensureOwnerOnlyDirectorySync(directory);
21
+ const lockPath = path.join(directory, LOCK_FILE);
22
+ const timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_WAIT_MS);
23
+ const pollMs = positiveInteger(options.pollMs, DEFAULT_POLL_MS);
24
+ const deadline = createMonotonicDeadline(timeoutMs);
25
+ const owner = lockOwner();
26
+
27
+ while (true) {
28
+ try {
29
+ createExclusiveFileSync(lockPath, `${JSON.stringify(owner, null, 2)}\n`, { mode: 0o600 });
30
+ break;
31
+ } catch (error) {
32
+ if (error?.code !== "EEXIST") throw error;
33
+ const existing = readLockOwner(lockPath);
34
+ if (!existing) throw new Error("operation authorization lock is malformed; inspect the owner-only state directory");
35
+ if (existing.purpose !== LOCK_PURPOSE) throw new Error("operation authorization lock contains mismatched purpose metadata");
36
+ const identity = inspectProcessInstance(existing, { maxAgeMs: Number.POSITIVE_INFINITY });
37
+ if (identity.reclaimable) {
38
+ if (removeOwnedJsonFileSync(lockPath, { token: existing.token, purpose: LOCK_PURPOSE }, { maxBytes: MAX_LOCK_BYTES })) continue;
39
+ }
40
+ if (deadline.expired()) {
41
+ const pid = Number.isInteger(existing.pid) ? `pid ${existing.pid}` : "unknown process";
42
+ throw new Error(`operation authorization state is busy (${pid}); retry after the current approval operation finishes`);
43
+ }
44
+ await delay(Math.min(pollMs, Math.max(1, deadline.remainingMs())));
45
+ }
46
+ }
47
+
48
+ let result;
49
+ let callbackError;
50
+ try {
51
+ result = await callback();
52
+ } catch (error) {
53
+ callbackError = error;
54
+ }
55
+ const released = removeOwnedJsonFileSync(lockPath, { token: owner.token, purpose: LOCK_PURPOSE }, { maxBytes: MAX_LOCK_BYTES });
56
+ if (!released) throw new Error("operation authorization lock changed before release; approval state may require inspection");
57
+ if (callbackError) throw callbackError;
58
+ return result;
59
+ }
60
+
61
+ function lockOwner() {
62
+ return {
63
+ pid: process.pid,
64
+ token: randomBytes(16).toString("hex"),
65
+ purpose: LOCK_PURPOSE,
66
+ startedAt: new Date().toISOString(),
67
+ processStartedAt: new Date(currentProcessStartTimeMs()).toISOString(),
68
+ entryScript: String(process.argv[1] || "").slice(0, 4096),
69
+ };
70
+ }
71
+
72
+ function readLockOwner(file) {
73
+ if (!existsSync(file)) return null;
74
+ let parsed;
75
+ try { parsed = JSON.parse(readBoundedRegularFileSync(file, MAX_LOCK_BYTES, "operation authorization lock").toString("utf8")); } catch { return null; }
76
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
77
+ if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
78
+ if (!/^[a-f0-9]{32}$/.test(String(parsed.token || ""))) return null;
79
+ if (parsed.purpose !== LOCK_PURPOSE) return null;
80
+ if (!Number.isFinite(Date.parse(String(parsed.startedAt || "")))) return null;
81
+ if (!Number.isFinite(Date.parse(String(parsed.processStartedAt || "")))) return null;
82
+ return parsed;
83
+ }
84
+
85
+ function positiveInteger(value, fallback) {
86
+ const number = Number(value);
87
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
88
+ }
89
+
90
+ function delay(milliseconds) {
91
+ return new Promise((resolvePromise) => { setTimeout(resolvePromise, milliseconds); });
92
+ }
@@ -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;
@@ -155,6 +155,12 @@ export class LocalRuntime {
155
155
  readResourceText,
156
156
  throwIfCancelled: (context) => this.throwIfCancelled(context),
157
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
+ });
158
164
  this.browserBridgeManager = new BrowserBridgeManager({
159
165
  policy: this.policy,
160
166
  authorizeTool: (tool) => this.policyGate.assert(tool),
@@ -169,6 +175,7 @@ export class LocalRuntime {
169
175
  handlers: bindRuntimeToolHandlers(this),
170
176
  policyGate: this.policyGate,
171
177
  accountAccessGate: this.accountAccessGate,
178
+ operationAuthorizer: this.operationAuthorizer,
172
179
  callRegistry: this.callRegistry,
173
180
  observability: this.observability,
174
181
  logger: this.logger,
@@ -176,7 +183,7 @@ export class LocalRuntime {
176
183
  slowMs: SLOW_TOOL_CALL_MS,
177
184
  });
178
185
  this.relay = createRuntimeRelayConnection(this, {
179
- workerUrl: remoteWorkerUrl, secret: remoteSecret, expectedVersion: expectedRelayVersion, onFatal,
186
+ workerUrl: remoteWorkerUrl, deviceIdentity, expectedVersion: expectedRelayVersion, onFatal,
180
187
  });
181
188
  this.relayCallRecovery = new RelayCallRecovery({
182
189
  logger: this.logger,
@@ -210,7 +217,7 @@ export class LocalRuntime {
210
217
  }
211
218
 
212
219
  async start() {
213
- 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");
214
221
  if (!this.lifecycle.beginStart()) return;
215
222
  if (this.policy.profile === "full") {
216
223
  void this.browserBridgeManager.ensureStarted().catch((error) => {
@@ -601,8 +608,8 @@ export class LocalRuntime {
601
608
 
602
609
  async resolveWritePath(inputPath = ".") {
603
610
  const candidate = this.resolvePath(inputPath);
604
- if (this.policy.unrestrictedPaths) return candidate;
605
611
  const candidateInfo = await lstat(candidate).catch(() => null);
612
+ if (candidateInfo?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
606
613
  let ancestor = candidate;
607
614
  while (!(await lstat(ancestor).catch(() => null))) {
608
615
  const parent = dirname(ancestor);
@@ -610,8 +617,7 @@ export class LocalRuntime {
610
617
  ancestor = parent;
611
618
  }
612
619
  const [workspace, canonicalAncestor] = await Promise.all([this.canonicalWorkspace(), realpath(ancestor)]);
613
- assertContainedPath(workspace, canonicalAncestor);
614
- if (candidateInfo?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
620
+ if (!this.policy.unrestrictedPaths) assertContainedPath(workspace, canonicalAncestor);
615
621
  const suffix = relative(ancestor, candidate);
616
622
  return suffix ? resolve(canonicalAncestor, suffix) : canonicalAncestor;
617
623
  }
@@ -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
  };
@@ -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
+ }