machine-bridge-mcp 2.0.0 → 3.0.0-beta.9

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 (143) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/CONTRIBUTING.md +20 -25
  3. package/README.md +21 -14
  4. package/SECURITY.md +147 -123
  5. package/browser-extension/manifest.json +3 -2
  6. package/browser-extension/service-worker.js +31 -12
  7. package/docs/ARCHITECTURE.md +27 -13
  8. package/docs/AUDIT.md +85 -2
  9. package/docs/CLIENTS.md +2 -2
  10. package/docs/ENGINEERING.md +9 -3
  11. package/docs/GETTING_STARTED.md +10 -9
  12. package/docs/LOCAL_AUTHORIZATION.md +115 -72
  13. package/docs/LOGGING.md +12 -3
  14. package/docs/MANAGED_JOBS.md +24 -29
  15. package/docs/MULTI_ACCOUNT.md +98 -37
  16. package/docs/OPERATIONS.md +33 -22
  17. package/docs/OVERVIEW.md +7 -7
  18. package/docs/PROJECT_STANDARDS.md +9 -5
  19. package/docs/RELEASING.md +171 -64
  20. package/docs/TESTING.md +19 -7
  21. package/docs/THREAT_MODEL.md +138 -67
  22. package/docs/TOOL_REFERENCE.md +2 -2
  23. package/docs/UPGRADING.md +102 -18
  24. package/native/macos/MachineBridgeTrustBroker.swift +233 -0
  25. package/package.json +36 -5
  26. package/scripts/candidate-runtime-store.mjs +38 -0
  27. package/scripts/check-plan.mjs +19 -0
  28. package/scripts/check-runner.mjs +4 -1
  29. package/scripts/commit-message-check.mjs +5 -2
  30. package/scripts/coverage-check.mjs +30 -2
  31. package/scripts/github-push.mjs +11 -0
  32. package/scripts/github-release.mjs +38 -7
  33. package/scripts/install-published-prerelease.mjs +167 -0
  34. package/scripts/local-release-acceptance.mjs +23 -12
  35. package/scripts/npm-publication-policy.mjs +16 -0
  36. package/scripts/prerelease-activation.mjs +79 -0
  37. package/scripts/privacy-check.mjs +7 -5
  38. package/scripts/promotion-digest.mjs +121 -0
  39. package/scripts/publish-npm.mjs +51 -0
  40. package/scripts/published-release.mjs +80 -0
  41. package/scripts/release-acceptance.mjs +30 -1
  42. package/scripts/release-candidate-manifest.mjs +40 -0
  43. package/scripts/release-channel.mjs +106 -0
  44. package/scripts/release-impact-check.mjs +12 -22
  45. package/scripts/release-soak.mjs +237 -0
  46. package/scripts/start-release-candidate.mjs +113 -7
  47. package/src/local/account-access.mjs +6 -0
  48. package/src/local/account-admin.mjs +34 -16
  49. package/src/local/agent-context.mjs +45 -42
  50. package/src/local/app-automation.mjs +9 -4
  51. package/src/local/authority-context.mjs +106 -0
  52. package/src/local/browser-bridge-http.mjs +4 -1
  53. package/src/local/browser-bridge.mjs +14 -9
  54. package/src/local/browser-broker-routes.mjs +4 -0
  55. package/src/local/browser-broker-server.mjs +5 -4
  56. package/src/local/browser-extension-identity.mjs +50 -0
  57. package/src/local/browser-extension-protocol.mjs +9 -4
  58. package/src/local/browser-operation-service.mjs +7 -0
  59. package/src/local/browser-pairing-http.mjs +36 -0
  60. package/src/local/browser-pairing-store.mjs +56 -47
  61. package/src/local/cli-account-admin.mjs +41 -7
  62. package/src/local/cli-activate.mjs +90 -0
  63. package/src/local/cli-approval.mjs +14 -58
  64. package/src/local/cli-local-admin.mjs +5 -15
  65. package/src/local/cli-options.mjs +11 -7
  66. package/src/local/cli-service.mjs +35 -5
  67. package/src/local/cli.mjs +112 -58
  68. package/src/local/daemon-process.mjs +6 -0
  69. package/src/local/delegated-process-sandbox.mjs +147 -0
  70. package/src/local/device-identity.mjs +157 -48
  71. package/src/local/device-root-provider.mjs +87 -0
  72. package/src/local/errors.mjs +25 -3
  73. package/src/local/git-service.mjs +16 -15
  74. package/src/local/job-runner.mjs +35 -27
  75. package/src/local/log.mjs +14 -1
  76. package/src/local/macos-trust-broker.mjs +320 -0
  77. package/src/local/managed-job-lock.mjs +18 -0
  78. package/src/local/managed-job-projection.mjs +9 -1
  79. package/src/local/managed-job-runner.mjs +13 -1
  80. package/src/local/managed-job-storage.mjs +2 -1
  81. package/src/local/managed-job-terminal.mjs +138 -0
  82. package/src/local/managed-jobs.mjs +88 -32
  83. package/src/local/operation-authorization.mjs +75 -239
  84. package/src/local/operation-risk.mjs +28 -0
  85. package/src/local/operation-state-lock.mjs +7 -86
  86. package/src/local/owner-state-lock.mjs +104 -0
  87. package/src/local/policy.mjs +19 -0
  88. package/src/local/process-execution.mjs +21 -9
  89. package/src/local/process-sessions.mjs +27 -17
  90. package/src/local/process-tracker.mjs +30 -6
  91. package/src/local/process-tree-ownership.mjs +59 -0
  92. package/src/local/process-tree.mjs +22 -18
  93. package/src/local/relay-connection.mjs +47 -35
  94. package/src/local/relay-diagnostics.mjs +65 -0
  95. package/src/local/remote-configuration.mjs +48 -0
  96. package/src/local/runtime-activation.mjs +141 -0
  97. package/src/local/runtime-diagnostics.mjs +21 -0
  98. package/src/local/runtime-relay.mjs +9 -8
  99. package/src/local/runtime-reporting.mjs +8 -3
  100. package/src/local/runtime-tool-handlers.mjs +8 -8
  101. package/src/local/runtime.mjs +73 -39
  102. package/src/local/secure-file.mjs +5 -1
  103. package/src/local/security-audit-log.mjs +204 -0
  104. package/src/local/service-convergence.mjs +17 -1
  105. package/src/local/service-ownership.mjs +17 -0
  106. package/src/local/service-restart-handoff.mjs +50 -0
  107. package/src/local/service-restart-scheduler.mjs +49 -0
  108. package/src/local/service-status.mjs +47 -0
  109. package/src/local/service.mjs +142 -43
  110. package/src/local/state-inventory.mjs +21 -3
  111. package/src/local/state.mjs +155 -17
  112. package/src/local/stdio.mjs +5 -0
  113. package/src/local/tool-executor.mjs +39 -6
  114. package/src/local/tool-result-boundary.mjs +51 -0
  115. package/src/local/tools.mjs +1 -0
  116. package/src/local/trusted-executable.mjs +53 -0
  117. package/src/local/trusted-git-executable.mjs +34 -0
  118. package/src/local/trusted-github-cli.mjs +24 -0
  119. package/src/local/windows-service.mjs +20 -0
  120. package/src/local/worker-deployment.mjs +3 -4
  121. package/src/local/worker-secret-file.mjs +2 -2
  122. package/src/local/workspace-file-service.mjs +34 -31
  123. package/src/shared/admin-auth.d.mts +2 -1
  124. package/src/shared/admin-auth.mjs +3 -3
  125. package/src/shared/device-session-auth.d.mts +20 -0
  126. package/src/shared/device-session-auth.mjs +51 -0
  127. package/src/shared/server-metadata.json +1 -1
  128. package/src/shared/tool-catalog.json +2 -2
  129. package/src/worker/account-admin.ts +23 -22
  130. package/src/worker/authority.ts +9 -2
  131. package/src/worker/daemon-auth.ts +45 -67
  132. package/src/worker/daemon-sockets.ts +12 -2
  133. package/src/worker/device-session-verifier.ts +129 -0
  134. package/src/worker/dpop.ts +151 -0
  135. package/src/worker/http.ts +47 -16
  136. package/src/worker/index.ts +53 -16
  137. package/src/worker/nonce-store.ts +1 -5
  138. package/src/worker/oauth-client-admin.ts +45 -0
  139. package/src/worker/oauth-controller.ts +55 -6
  140. package/src/worker/oauth-refresh-families.ts +21 -7
  141. package/src/worker/oauth-state.ts +6 -0
  142. package/src/worker/oauth-tokens.ts +28 -8
  143. package/src/worker/websocket-protocol.ts +8 -2
@@ -12,10 +12,11 @@ const MAX_WALK_ENTRIES = 200_000;
12
12
  const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
13
13
 
14
14
  export class WorkspaceFileService {
15
- constructor({ workspace, policy, policyGate, resolveExistingPath, resolveWritePath, displayPath, throwIfCancelled, withMutationLock }) {
15
+ constructor({ workspace, policy, policyGate, policyForContext = null, resolveExistingPath, resolveWritePath, displayPath, throwIfCancelled, withMutationLock }) {
16
16
  this.workspace = workspace;
17
17
  this.policy = policy;
18
18
  this.policyGate = policyGate;
19
+ this.policyForContext = typeof policyForContext === "function" ? policyForContext : () => this.policy;
19
20
  this.resolveExistingPath = resolveExistingPath;
20
21
  this.resolveWritePath = resolveWritePath;
21
22
  this.displayPath = displayPath;
@@ -23,18 +24,19 @@ export class WorkspaceFileService {
23
24
  this.withMutationLock = withMutationLock;
24
25
  }
25
26
 
26
- listRoots() {
27
- const roots = [{ name: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace", path: this.displayPath(this.workspace), default: true }];
28
- if (this.policy.unrestrictedPaths) {
27
+ listRoots(context = {}) {
28
+ const policy = this.policyForContext(context);
29
+ const roots = [{ name: policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace", path: this.displayPath(this.workspace, context), default: true }];
30
+ if (policy.unrestrictedPaths) {
29
31
  const home = process.env.HOME || process.env.USERPROFILE;
30
- if (home && home !== this.workspace) roots.push({ name: "home", path: this.displayPath(resolve(home)), default: false });
31
- roots.push({ name: "filesystem-root", path: this.displayPath(path.parse(this.workspace).root), default: false });
32
+ if (home && home !== this.workspace) roots.push({ name: "home", path: this.displayPath(resolve(home), context), default: false });
33
+ roots.push({ name: "filesystem-root", path: this.displayPath(path.parse(this.workspace).root, context), default: false });
32
34
  }
33
35
  return { roots };
34
36
  }
35
37
 
36
38
  async listDir(inputPath, context = {}) {
37
- const full = await this.resolveExistingPath(inputPath);
39
+ const full = await this.resolveExistingPath(inputPath, context);
38
40
  const entries = [];
39
41
  let resultBytes = 0;
40
42
  let truncated = false;
@@ -44,7 +46,7 @@ export class WorkspaceFileService {
44
46
  const info = await lstat(entryPath).catch(() => null);
45
47
  const item = {
46
48
  name: entry.name,
47
- path: this.displayPath(entryPath),
49
+ path: this.displayPath(entryPath, context),
48
50
  type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other",
49
51
  size: info?.size ?? 0,
50
52
  };
@@ -57,26 +59,26 @@ export class WorkspaceFileService {
57
59
  resultBytes += itemBytes;
58
60
  }
59
61
  entries.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
60
- return { path: this.displayPath(full), entries, truncated };
62
+ return { path: this.displayPath(full, context), entries, truncated };
61
63
  }
62
64
 
63
65
  async listFiles(inputPath, maxFiles, context = {}) {
64
- const root = await this.resolveExistingPath(inputPath);
66
+ const root = await this.resolveExistingPath(inputPath, context);
65
67
  const info = await stat(root);
66
- if (info.isFile()) return { path: this.displayPath(root), files: [this.displayPath(root)], truncated: false };
68
+ if (info.isFile()) return { path: this.displayPath(root, context), files: [this.displayPath(root, context)], truncated: false };
67
69
  if (!info.isDirectory()) throw new Error("path is not a file or directory");
68
70
  const files = [];
69
71
  let resultBytes = 0;
70
72
  const walkResult = await this.walk(root, async full => {
71
73
  this.throwIfCancelled(context);
72
- const shown = this.displayPath(full);
74
+ const shown = this.displayPath(full, context);
73
75
  const pathBytes = Buffer.byteLength(shown) + 8;
74
76
  if (files.length >= maxFiles || resultBytes + pathBytes > MAX_PATH_RESULT_BYTES) return false;
75
77
  files.push(shown);
76
78
  resultBytes += pathBytes;
77
79
  return true;
78
80
  }, context);
79
- return { path: this.displayPath(root), files, truncated: files.length >= maxFiles || resultBytes >= MAX_PATH_RESULT_BYTES || walkResult.truncated };
81
+ return { path: this.displayPath(root, context), files, truncated: files.length >= maxFiles || resultBytes >= MAX_PATH_RESULT_BYTES || walkResult.truncated };
80
82
  }
81
83
 
82
84
  async readFile(args, context = {}) {
@@ -85,7 +87,7 @@ export class WorkspaceFileService {
85
87
  context = {};
86
88
  }
87
89
  if (!args.path) throw new Error("path is required");
88
- const full = await this.resolveExistingPath(args.path);
90
+ const full = await this.resolveExistingPath(args.path, context);
89
91
  this.throwIfCancelled(context);
90
92
  const { buffer, info } = await readBoundedFile(full, MAX_WRITE_BYTES, "readable text file");
91
93
  const content = decodeUtf8(buffer);
@@ -107,7 +109,7 @@ export class WorkspaceFileService {
107
109
  const selectedBytes = Buffer.byteLength(selected);
108
110
  if (selectedBytes > maxBytes) throw new Error(`selected content exceeds max_bytes (${selectedBytes} > ${maxBytes})`);
109
111
  return {
110
- path: this.displayPath(full),
112
+ path: this.displayPath(full, context),
111
113
  size: info.size,
112
114
  sha256: sha256(content),
113
115
  content: selected,
@@ -120,7 +122,7 @@ export class WorkspaceFileService {
120
122
 
121
123
  async viewImage(args, context = {}) {
122
124
  if (!args.path) throw new Error("path is required");
123
- const full = await this.resolveExistingPath(args.path);
125
+ const full = await this.resolveExistingPath(args.path, context);
124
126
  this.throwIfCancelled(context);
125
127
  const { buffer, info } = await readBoundedFile(full, MAX_IMAGE_BYTES, "image");
126
128
  this.throwIfCancelled(context);
@@ -130,7 +132,7 @@ export class WorkspaceFileService {
130
132
  $mcp: {
131
133
  content: [{ type: "image", data: buffer.toString("base64"), mimeType }],
132
134
  structuredContent: {
133
- path: this.displayPath(full),
135
+ path: this.displayPath(full, context),
134
136
  size: info.size,
135
137
  sha256: createHash("sha256").update(buffer).digest("hex"),
136
138
  mime_type: mimeType,
@@ -147,7 +149,7 @@ export class WorkspaceFileService {
147
149
  const content = String(args.content ?? "");
148
150
  const bytes = Buffer.byteLength(content);
149
151
  if (bytes > MAX_WRITE_BYTES) throw new Error(`content exceeds maximum write size (${bytes} > ${MAX_WRITE_BYTES})`);
150
- const full = await this.resolveWritePath(args.path);
152
+ const full = await this.resolveWritePath(args.path, context);
151
153
  const existing = await lstat(full).catch(() => null);
152
154
  if (existing?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
153
155
  if (args.create_only && existing) throw new Error("file exists and create_only=true");
@@ -162,7 +164,7 @@ export class WorkspaceFileService {
162
164
  createOnly: args.create_only === true,
163
165
  expectedHash: args.expected_sha256 ? String(args.expected_sha256).toLowerCase() : undefined,
164
166
  });
165
- return { ok: true, path: this.displayPath(full), sha256: sha256(content), bytes };
167
+ return { ok: true, path: this.displayPath(full, context), sha256: sha256(content), bytes };
166
168
  });
167
169
  }
168
170
 
@@ -174,7 +176,7 @@ export class WorkspaceFileService {
174
176
  const oldText = String(args.old_text ?? "");
175
177
  const newText = String(args.new_text ?? "");
176
178
  if (!oldText) throw new Error("old_text must not be empty");
177
- const full = await this.resolveExistingPath(args.path);
179
+ const full = await this.resolveExistingPath(args.path, context);
178
180
  const info = await lstat(full);
179
181
  if (!info.isFile() || info.isSymbolicLink()) throw new Error("path is not a regular non-symbolic-link file");
180
182
  const current = await readUtf8File(full);
@@ -187,7 +189,7 @@ export class WorkspaceFileService {
187
189
  if (bytes > MAX_WRITE_BYTES) throw new Error(`edited content exceeds maximum write size (${bytes} > ${MAX_WRITE_BYTES})`);
188
190
  this.throwIfCancelled(context);
189
191
  await atomicWriteText(full, updated, info, { expectedHash: sha256(current) });
190
- return { ok: true, path: this.displayPath(full), replacements: args.replace_all ? occurrences : 1, sha256: sha256(updated), bytes };
192
+ return { ok: true, path: this.displayPath(full, context), replacements: args.replace_all ? occurrences : 1, sha256: sha256(updated), bytes };
191
193
  });
192
194
  }
193
195
 
@@ -203,13 +205,13 @@ export class WorkspaceFileService {
203
205
  for (const operation of parsed) {
204
206
  this.throwIfCancelled(context);
205
207
  if (operation.kind === "add") {
206
- const target = await this.resolveWritePath(operation.path);
208
+ const target = await this.resolveWritePath(operation.path, context);
207
209
  if (await lstat(target).catch(() => null)) throw new Error(`add target already exists: ${operation.path}`);
208
210
  assertTextSize(operation.content, operation.path);
209
211
  prepared.push({ kind: "add", source: null, target, content: operation.content, mode: 0o600 });
210
212
  continue;
211
213
  }
212
- const source = await this.resolveExistingPath(operation.path);
214
+ const source = await this.resolveExistingPath(operation.path, context);
213
215
  const sourceInfo = await lstat(source);
214
216
  if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink()) throw new Error(`patch source is not a regular file: ${operation.path}`);
215
217
  const original = await readUtf8File(source);
@@ -219,7 +221,7 @@ export class WorkspaceFileService {
219
221
  }
220
222
  const content = applyUpdateHunks(original, operation.hunks, operation.path);
221
223
  assertTextSize(content, operation.path);
222
- const target = operation.moveTo ? await this.resolveWritePath(operation.moveTo) : source;
224
+ const target = operation.moveTo ? await this.resolveWritePath(operation.moveTo, context) : source;
223
225
  if (target !== source && await lstat(target).catch(() => null)) throw new Error(`move target already exists: ${operation.moveTo}`);
224
226
  prepared.push({ kind: operation.moveTo ? "move" : "update", source, target, content, originalHash: sha256(original), mode: sourceInfo.mode & 0o777 });
225
227
  }
@@ -231,9 +233,9 @@ export class WorkspaceFileService {
231
233
  ...(transaction.warnings.length ? { warnings: transaction.warnings } : {}),
232
234
  files: prepared.map((item) => ({
233
235
  operation: item.kind,
234
- path: this.displayPath(item.target || item.source),
235
- from: item.kind === "move" ? this.displayPath(item.source) : undefined,
236
- sha256: item.content === undefined ? undefined : sha256(item.content),
236
+ path: this.displayPath(item.target || item.source, context),
237
+ ...(item.kind === "move" ? { from: this.displayPath(item.source, context) } : {}),
238
+ ...(item.content === undefined ? {} : { sha256: sha256(item.content) }),
237
239
  })),
238
240
  };
239
241
  });
@@ -242,7 +244,7 @@ export class WorkspaceFileService {
242
244
  async searchText(args, context = {}) {
243
245
  const query = String(args.query || "");
244
246
  if (!query) throw new Error("query is required");
245
- const root = await this.resolveExistingPath(args.path || ".");
247
+ const root = await this.resolveExistingPath(args.path || ".", context);
246
248
  const max = clampInteger(args.max_matches, 100, 1, 1000);
247
249
  const maxFiles = clampInteger(args.max_files, 10000, 1, 100000);
248
250
  let visitedFiles = 0;
@@ -250,7 +252,7 @@ export class WorkspaceFileService {
250
252
  const rootInfo = await stat(root);
251
253
  if (rootInfo.isFile()) {
252
254
  await this.searchOneFile(root, query, matches, max, context);
253
- return { query, root: this.displayPath(root), matches, visited_files: 1, truncated: matches.length >= max };
255
+ return { query, root: this.displayPath(root, context), matches, visited_files: 1, truncated: matches.length >= max };
254
256
  }
255
257
  if (!rootInfo.isDirectory()) throw new Error("path is not a file or directory");
256
258
  const walkResult = await this.walk(root, async full => {
@@ -260,7 +262,7 @@ export class WorkspaceFileService {
260
262
  await this.searchOneFile(full, query, matches, max, context);
261
263
  return matches.length < max && visitedFiles < maxFiles;
262
264
  }, context);
263
- return { query, root: this.displayPath(root), matches, visited_files: visitedFiles, truncated: matches.length >= max || visitedFiles >= maxFiles || walkResult.truncated };
265
+ return { query, root: this.displayPath(root, context), matches, visited_files: visitedFiles, truncated: matches.length >= max || visitedFiles >= maxFiles || walkResult.truncated };
264
266
  }
265
267
 
266
268
  async searchOneFile(full, query, matches, max, context = {}) {
@@ -274,7 +276,7 @@ export class WorkspaceFileService {
274
276
  const lines = text.split(/\r?\n/);
275
277
  for (let index = 0; index < lines.length; index += 1) {
276
278
  if (lines[index].includes(query)) {
277
- matches.push({ path: this.displayPath(full), line: index + 1, text: lines[index].slice(0, 500) });
279
+ matches.push({ path: this.displayPath(full, context), line: index + 1, text: lines[index].slice(0, 500) });
278
280
  if (matches.length >= max) break;
279
281
  }
280
282
  }
@@ -311,6 +313,7 @@ export async function readBoundedFile(filePath, maxBytes, label) {
311
313
  try {
312
314
  const info = await handle.stat();
313
315
  if (!info.isFile()) throw new Error(`${label} is not a regular file`);
316
+ if (Number(info.nlink) > 1) throw new Error(`refusing to read ${label} with multiple hard links`);
314
317
  if (info.size > maxBytes) throw new Error(`${label} exceeds maximum size (${info.size} > ${maxBytes})`);
315
318
  const buffer = Buffer.alloc(info.size);
316
319
  let offset = 0;
@@ -1,10 +1,11 @@
1
- export const ADMIN_AUTH_SCHEME: "hmac-sha256-v1";
1
+ export const ADMIN_AUTH_SCHEME: "device-admin-signature-v1";
2
2
  export const ADMIN_AUTH_TTL_SECONDS: number;
3
3
  export function adminAuthTranscript(input: {
4
4
  origin: unknown;
5
5
  method: unknown;
6
6
  pathname: unknown;
7
7
  bodyHash: unknown;
8
+ keyId: unknown;
8
9
  issuedAt: unknown;
9
10
  nonce: unknown;
10
11
  }): string;
@@ -1,4 +1,4 @@
1
- export const ADMIN_AUTH_SCHEME = "hmac-sha256-v1";
1
+ export const ADMIN_AUTH_SCHEME = "device-admin-signature-v1";
2
2
  export const ADMIN_AUTH_TTL_SECONDS = 5 * 60;
3
3
 
4
4
  export function adminAuthTranscript(input = {}) {
@@ -6,12 +6,12 @@ export function adminAuthTranscript(input = {}) {
6
6
  const method = requiredToken(input.method, "method", /^[A-Z]{3,10}$/);
7
7
  const pathname = requiredToken(input.pathname, "pathname", /^\/[A-Za-z0-9/_-]{1,255}$/);
8
8
  const bodyHash = requiredToken(input.bodyHash, "body hash", /^[a-f0-9]{64}$/);
9
+ const keyId = requiredToken(input.keyId, "session key id", /^device_[A-Za-z0-9_-]{32}$/);
9
10
  const issuedAt = requiredInteger(input.issuedAt, "issued at");
10
11
  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
+ return [ADMIN_AUTH_SCHEME, origin, method, pathname, bodyHash, keyId, issuedAt, nonce].join("\0");
12
13
  }
13
14
 
14
-
15
15
  function requiredOrigin(value) {
16
16
  let url;
17
17
  try { url = new URL(String(value || "")); } catch { throw new Error("admin authentication origin is invalid"); }
@@ -0,0 +1,20 @@
1
+ export const DEVICE_SESSION_CERTIFICATE_SCHEME: "device-session-certificate-v1";
2
+ export const DEVICE_SESSION_MAX_LIFETIME_SECONDS: number;
3
+
4
+ export function deviceSessionCertificateTranscript(input?: {
5
+ workerOrigin?: unknown;
6
+ server?: unknown;
7
+ version?: unknown;
8
+ rootKeyId?: unknown;
9
+ publicJwk?: unknown;
10
+ issuedAt?: unknown;
11
+ expiresAt?: unknown;
12
+ nonce?: unknown;
13
+ }): string;
14
+
15
+ export function canonicalPublicJwk(value: unknown): {
16
+ kty: "EC";
17
+ crv: "P-256";
18
+ x: string;
19
+ y: string;
20
+ };
@@ -0,0 +1,51 @@
1
+ export const DEVICE_SESSION_CERTIFICATE_SCHEME = "device-session-certificate-v1";
2
+ export const DEVICE_SESSION_MAX_LIFETIME_SECONDS = 24 * 60 * 60;
3
+
4
+ export function deviceSessionCertificateTranscript(input = {}) {
5
+ const publicJwk = canonicalPublicJwk(input.publicJwk);
6
+ return [
7
+ DEVICE_SESSION_CERTIFICATE_SCHEME,
8
+ requiredOrigin(input.workerOrigin),
9
+ requiredText(input.server, "server", 1, 128),
10
+ requiredText(input.version, "version", 1, 64),
11
+ requiredText(input.rootKeyId, "root key id", 16, 128),
12
+ JSON.stringify(publicJwk),
13
+ requiredInteger(input.issuedAt, "issued at"),
14
+ requiredInteger(input.expiresAt, "expires at"),
15
+ requiredText(input.nonce, "nonce", 24, 128),
16
+ ].join("\0");
17
+ }
18
+
19
+ export function canonicalPublicJwk(value) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("device session public key is invalid");
21
+ if (value.kty !== "EC" || value.crv !== "P-256" || typeof value.x !== "string" || typeof value.y !== "string" || value.d !== undefined) {
22
+ throw new Error("device session public key is invalid");
23
+ }
24
+ if (!/^[A-Za-z0-9_-]{42,44}$/.test(value.x) || !/^[A-Za-z0-9_-]{42,44}$/.test(value.y)) {
25
+ throw new Error("device session public key is invalid");
26
+ }
27
+ return { crv: "P-256", kty: "EC", x: value.x, y: value.y };
28
+ }
29
+
30
+ function requiredOrigin(value) {
31
+ let url;
32
+ try { url = new URL(String(value || "")); } catch { throw new Error("device session Worker origin is invalid"); }
33
+ const secure = url.protocol === "https:";
34
+ const loopback = url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
35
+ if ((!secure && !loopback) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
36
+ throw new Error("device session Worker origin is invalid");
37
+ }
38
+ return url.origin;
39
+ }
40
+
41
+ function requiredText(value, label, minimum, maximum) {
42
+ const text = String(value || "");
43
+ if (text.length < minimum || text.length > maximum || /[\0\r\n]/.test(text)) throw new Error(`device session ${label} is invalid`);
44
+ return text;
45
+ }
46
+
47
+ function requiredInteger(value, label) {
48
+ const number = Number(value);
49
+ if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`device session ${label} is invalid`);
50
+ return String(number);
51
+ }
@@ -18,7 +18,7 @@
18
18
  "The local daemon may advertise the full catalog as its capability ceiling. In remote mode, the Worker relays only the intersection allowed by the authenticated account role; a connector host may then expose an even smaller subset that Machine Bridge cannot observe or override.",
19
19
  "Use diagnose_runtime to distinguish a request that reached the daemon from local filesystem, process-spawn, shell, managed-job-storage, and resource failures. A tool call blocked before any response cannot be diagnosed by the server.",
20
20
  "Never request or return secret-file contents when a local resource alias can be used. Resources are registered through the local machine-mcp CLI; under canonical full policy, generate_ssh_key_resource can generate and register an Ed25519 key without returning private content and may be injected by path, stdin, or environment without entering MCP arguments.",
21
- "If execution-class tools are unavailable but write access remains, use stage_job to persist a reviewed plan without running it; the operator can launch it locally with machine-mcp job approve JOB_ID.",
21
+ "stage_job persists a validated non-running draft only. It is not an approval workflow and cannot be promoted from the terminal; execution requires owner-authorized start_job or an explicit local machine-mcp job submit PLAN.json operation.",
22
22
  "For multi-step, remote, long-running, or cleanup-sensitive work, prefer one start_job call with argv steps, job-scoped temporary_files, and idempotent finally_steps. The detached runner continues after MCP disconnects; inspect it with read_job or the local machine-mcp job CLI.",
23
23
  "Prefer sending remote shell programs through a process stdin rather than creating remote helper files. If temporary files are necessary, use {{temp:name}} or explicit finally_steps.",
24
24
  "Managed-job resource redaction is defense in depth, not a guarantee against transformed or partial secret output. Use capture_output=discard for steps that may echo credentials.",
@@ -1915,8 +1915,8 @@
1915
1915
  },
1916
1916
  {
1917
1917
  "name": "stage_job",
1918
- "title": "Stage managed job for local approval",
1919
- "description": "Validate and persist a managed-job plan without starting any process. Use this when execution tools are unavailable or local operator review is required; launch later with machine-mcp job approve JOB_ID.",
1918
+ "title": "Stage managed job draft",
1919
+ "description": "Validate and persist a durable managed-job draft without starting any process. This is a non-executing record only; a trusted owner client starts work with start_job after reviewing or reconstructing the plan.",
1920
1920
  "availability": "write",
1921
1921
  "annotations": {
1922
1922
  "readOnlyHint": false,
@@ -1,9 +1,10 @@
1
1
  import { ADMIN_AUTH_SCHEME, ADMIN_AUTH_TTL_SECONDS, adminAuthTranscript } from "../shared/admin-auth.mjs";
2
+ import { decodeBase64Url, verifyDeviceSessionCertificate, verifyP256Signature } from "./device-session-verifier.ts";
2
3
  import { json, methodNotAllowed, parseRequestBody } from "./http.ts";
3
4
  import { consumeBoundedNonce } from "./nonce-store.ts";
4
5
  import {
5
6
  accountByName, createAccount, publicAccount, replaceAccountPassword, revokeAccountCredentials,
6
- safeEqual, updateAccount, type AccountRecord, type OAuthStore,
7
+ updateAccount, type AccountRecord, type OAuthStore,
7
8
  } from "./oauth-state.ts";
8
9
 
9
10
  const BODY_LIMIT_BYTES = 64 * 1024;
@@ -16,18 +17,31 @@ export interface AccountAdminAuthorization {
16
17
 
17
18
  export async function accountAdminAuthorized(
18
19
  request: Request,
19
- expected: string,
20
+ rootPublicKeyJson: string,
21
+ workerOrigin: string,
22
+ server: string,
23
+ version: string,
20
24
  now = Math.floor(Date.now() / 1000),
21
25
  ): Promise<AccountAdminAuthorization | null> {
22
- if (!expected) return null;
26
+ if (!rootPublicKeyJson) return null;
23
27
  const scheme = request.headers.get("X-Bridge-Admin-Scheme") || "";
24
28
  const issuedAt = Number(request.headers.get("X-Bridge-Admin-Time"));
25
29
  const nonce = request.headers.get("X-Bridge-Admin-Nonce") || "";
26
30
  const bodyHash = request.headers.get("X-Bridge-Admin-Body-SHA256") || "";
27
- const suppliedSignature = request.headers.get("X-Bridge-Admin-Signature") || "";
28
- if (scheme !== ADMIN_AUTH_SCHEME) return null;
31
+ const keyId = request.headers.get("X-Bridge-Admin-Key") || "";
32
+ const suppliedSignature = decodeBase64Url(request.headers.get("X-Bridge-Admin-Signature") || "", 64);
33
+ if (scheme !== ADMIN_AUTH_SCHEME || !suppliedSignature) return null;
29
34
  if (!Number.isSafeInteger(issuedAt) || Math.abs(now - issuedAt) > ADMIN_AUTH_TTL_SECONDS) return null;
30
- if (!/^[A-Za-z0-9_-]{32,128}$/.test(nonce) || !/^[a-f0-9]{64}$/.test(bodyHash) || !/^[A-Za-z0-9_-]{43}$/.test(suppliedSignature)) return null;
35
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(nonce) || !/^[a-f0-9]{64}$/.test(bodyHash) || !/^device_[A-Za-z0-9_-]{32}$/.test(keyId)) return null;
36
+ const certificate = await verifyDeviceSessionCertificate({
37
+ encodedCertificate: request.headers.get("X-Bridge-Device-Certificate") || "",
38
+ rootPublicKeyJson,
39
+ workerOrigin,
40
+ server,
41
+ version,
42
+ now,
43
+ });
44
+ if (!certificate || certificate.sessionKeyId !== keyId) return null;
31
45
  let transcript: string;
32
46
  try {
33
47
  transcript = adminAuthTranscript({
@@ -35,25 +49,18 @@ export async function accountAdminAuthorized(
35
49
  method: request.method.toUpperCase(),
36
50
  pathname: new URL(request.url).pathname,
37
51
  bodyHash,
52
+ keyId,
38
53
  issuedAt,
39
54
  nonce,
40
55
  });
41
56
  } catch {
42
57
  return null;
43
58
  }
44
- const key = await crypto.subtle.importKey(
45
- "raw",
46
- new TextEncoder().encode(expected),
47
- { name: "HMAC", hash: "SHA-256" },
48
- false,
49
- ["sign"],
50
- );
51
- const expectedSignature = base64Url(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(transcript))));
52
- if (!(await safeEqual(suppliedSignature, expectedSignature))) return null;
59
+ if (!(await verifyP256Signature(certificate.sessionPublicJwk, transcript, suppliedSignature))) return null;
53
60
  const body = new Uint8Array(await request.clone().arrayBuffer());
54
61
  if (body.byteLength > BODY_LIMIT_BYTES) return null;
55
62
  const actualBodyHash = hex(new Uint8Array(await crypto.subtle.digest("SHA-256", body)));
56
- if (!(await safeEqual(bodyHash, actualBodyHash))) return null;
63
+ if (bodyHash !== actualBodyHash) return null;
57
64
  return { nonce, expiresAt: Math.max(now, issuedAt) + ADMIN_AUTH_TTL_SECONDS };
58
65
  }
59
66
 
@@ -161,12 +168,6 @@ function activeOwnerCount(store: OAuthStore): number {
161
168
  return Object.values(store.accounts).filter((account) => account.active && account.role === "owner").length;
162
169
  }
163
170
 
164
- function base64Url(bytes: Uint8Array): string {
165
- let binary = "";
166
- for (const byte of bytes) binary += String.fromCharCode(byte);
167
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
168
- }
169
-
170
171
  function hex(bytes: Uint8Array): string {
171
172
  return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
172
173
  }
@@ -86,8 +86,15 @@ export function accountAuthoritySnapshot(input: AccountIdentity & {
86
86
 
87
87
  export function decorateProjectOverview(value: unknown, account: AccountIdentity): unknown {
88
88
  if (!isRecord(value)) return value;
89
- const daemonPolicy = isDaemonPolicy(value.policy) ? value.policy : null;
90
- const daemonTools = Array.isArray(value.tools) ? value.tools.filter((item): item is string => typeof item === "string") : [];
89
+ const daemonPolicy = isDaemonPolicy(value.daemonPolicy)
90
+ ? value.daemonPolicy
91
+ : isDaemonPolicy(value.policy)
92
+ ? value.policy
93
+ : null;
94
+ const daemonToolSource = Array.isArray(value.daemonTools) ? value.daemonTools : value.tools;
95
+ const daemonTools = Array.isArray(daemonToolSource)
96
+ ? daemonToolSource.filter((item): item is string => typeof item === "string")
97
+ : [];
91
98
  const effectiveTools = ["server_info", ...accountRoleToolNames(account.role, daemonTools)];
92
99
  const authority = accountAuthoritySnapshot({ ...account, daemonPolicy, effectiveTools });
93
100
  return {