machine-bridge-mcp 3.0.0-beta.17 → 3.0.0-beta.21

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 (74) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +2 -0
  3. package/SECURITY.md +9 -7
  4. package/browser-extension/browser-error-boundary.js +45 -0
  5. package/browser-extension/browser-operations.js +1 -1
  6. package/browser-extension/manifest.json +1 -1
  7. package/browser-extension/service-worker.js +9 -8
  8. package/docs/ARCHITECTURE.md +17 -15
  9. package/docs/AUDIT.md +30 -0
  10. package/docs/ENGINEERING.md +3 -3
  11. package/docs/GETTING_STARTED.md +1 -1
  12. package/docs/LOCAL_AUTHORIZATION.md +2 -2
  13. package/docs/LOCAL_AUTOMATION.md +1 -1
  14. package/docs/LOGGING.md +3 -1
  15. package/docs/MULTI_ACCOUNT.md +2 -2
  16. package/docs/OPERATIONS.md +13 -5
  17. package/docs/OVERVIEW.md +2 -2
  18. package/docs/PRIVACY.md +1 -1
  19. package/docs/TESTING.md +17 -13
  20. package/docs/THREAT_MODEL.md +13 -8
  21. package/docs/UPGRADING.md +19 -1
  22. package/package.json +1 -1
  23. package/scripts/coverage-check.mjs +12 -0
  24. package/src/local/account-admin.mjs +68 -1
  25. package/src/local/agent-skill-discovery.mjs +16 -5
  26. package/src/local/app-automation.mjs +27 -7
  27. package/src/local/cli-options.mjs +1 -1
  28. package/src/local/path-inspection.mjs +23 -0
  29. package/src/local/process-tree-ownership.mjs +17 -5
  30. package/src/local/process-tree.mjs +6 -2
  31. package/src/local/relay-connection.mjs +24 -11
  32. package/src/local/resource-operations.mjs +37 -8
  33. package/src/local/runtime-activation.mjs +16 -0
  34. package/src/local/runtime-capabilities.mjs +15 -3
  35. package/src/local/runtime.mjs +7 -4
  36. package/src/local/worker-deployment.mjs +16 -6
  37. package/src/local/workspace-file-service.mjs +29 -11
  38. package/src/worker/daemon-socket-attachment.ts +52 -0
  39. package/src/worker/daemon-sockets.ts +17 -48
  40. package/src/worker/durable-stream-calls.ts +129 -0
  41. package/src/worker/durable-stream-result.ts +22 -0
  42. package/src/worker/http.ts +37 -15
  43. package/src/worker/index.ts +143 -146
  44. package/src/worker/mcp-pending-call-expiry.ts +20 -0
  45. package/src/worker/mcp-pending-call-inspection.ts +33 -0
  46. package/src/worker/mcp-pending-call-records.ts +58 -0
  47. package/src/worker/mcp-pending-call-storage.ts +18 -0
  48. package/src/worker/mcp-pending-call-store.ts +285 -0
  49. package/src/worker/mcp-resumption-index.ts +41 -0
  50. package/src/worker/mcp-resumption-records.ts +8 -2
  51. package/src/worker/mcp-resumption.ts +84 -86
  52. package/src/worker/mcp-stream-dispatch.ts +42 -54
  53. package/src/worker/mcp-stream-proxy.ts +7 -69
  54. package/src/worker/mcp-stream-subscription.ts +98 -0
  55. package/src/worker/oauth-controller.ts +11 -2
  56. package/src/worker/oauth-refresh-exchange.ts +147 -0
  57. package/src/worker/oauth-refresh-families.ts +59 -17
  58. package/src/worker/oauth-state.ts +13 -2
  59. package/src/worker/oauth-token-derivation.ts +33 -0
  60. package/src/worker/oauth-token-issuance.ts +106 -0
  61. package/src/worker/oauth-tokens.ts +12 -184
  62. package/src/worker/observability.ts +19 -0
  63. package/src/worker/pending-admission.ts +15 -0
  64. package/src/worker/pending-call-contract.ts +4 -7
  65. package/src/worker/pending-calls.ts +4 -10
  66. package/src/worker/runtime-alarm-storage.ts +35 -0
  67. package/src/worker/runtime-alarm.ts +30 -20
  68. package/src/worker/websocket-protocol.ts +4 -0
  69. package/src/worker/worker-edge-guard.ts +81 -0
  70. package/src/worker/worker-edge-log.ts +63 -0
  71. package/src/worker/worker-entry.ts +50 -0
  72. package/src/worker/worker-metadata.ts +39 -0
  73. package/src/worker/worker-static-routes.ts +38 -50
  74. package/wrangler.jsonc +8 -0
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { basename, extname, join } from "node:path";
4
4
  import { performance } from "node:perf_hooks";
5
5
  import { createToolAuthorizer } from "./policy.mjs";
6
+ import { classifyOperationalError } from "./log.mjs";
6
7
 
7
8
  const MAX_APPLICATIONS = 1000;
8
9
  const MAX_APPLICATION_SCAN_ENTRIES = 20_000;
@@ -60,6 +61,9 @@ export class AppAutomationManager {
60
61
  applications: filtered,
61
62
  truncated: discovery.truncated || matched.length > limit,
62
63
  scanned_entries: discovery.visitedEntries,
64
+ warnings: discovery.warnings.map((warning) => ({
65
+ path: this.displayPath(warning.path), error_class: warning.error_class,
66
+ })),
63
67
  capabilities: this.capabilities(),
64
68
  };
65
69
  }
@@ -238,32 +242,48 @@ async function listExecutableRoots(roots, extensions, context, throwIfCancelled)
238
242
 
239
243
  async function scanApplicationRoots(roots, { context, throwIfCancelled, match, descend, makeItem }) {
240
244
  const results = [];
245
+ const warnings = [];
241
246
  const seenRoots = new Set();
242
247
  let visited = 0;
243
248
  for (const inputRoot of roots) {
244
- const root = await realpath(inputRoot).catch(() => "");
245
- if (!root || seenRoots.has(root)) continue;
249
+ let root;
250
+ try { root = await realpath(inputRoot); } catch (error) {
251
+ if (error?.code !== "ENOENT" && warnings.length < 50) {
252
+ warnings.push({ path: inputRoot, error_class: classifyOperationalError(error) });
253
+ }
254
+ continue;
255
+ }
256
+ if (seenRoots.has(root)) continue;
246
257
  seenRoots.add(root);
247
258
  const stack = [{ directory: root, depth: 0 }];
248
259
  while (stack.length) {
249
260
  const current = stack.pop();
250
- const handle = await opendir(current.directory).catch(() => null);
251
- if (!handle) continue;
261
+ let handle;
262
+ try { handle = await opendir(current.directory); } catch (error) {
263
+ if (error?.code !== "ENOENT" && warnings.length < 50) {
264
+ warnings.push({ path: current.directory, error_class: classifyOperationalError(error) });
265
+ }
266
+ continue;
267
+ }
252
268
  for await (const entry of handle) {
253
269
  throwIfCancelled(context);
254
270
  visited += 1;
255
- if (visited > MAX_APPLICATION_SCAN_ENTRIES) return { applications: results, truncated: true, visitedEntries: visited };
271
+ if (visited > MAX_APPLICATION_SCAN_ENTRIES) {
272
+ return { applications: results, warnings, truncated: true, visitedEntries: visited };
273
+ }
256
274
  const path = join(current.directory, entry.name);
257
275
  if (match(entry)) {
258
276
  results.push(makeItem(path, entry));
259
- if (results.length >= MAX_APPLICATIONS) return { applications: results, truncated: true, visitedEntries: visited };
277
+ if (results.length >= MAX_APPLICATIONS) {
278
+ return { applications: results, warnings, truncated: true, visitedEntries: visited };
279
+ }
260
280
  } else if (current.depth < MAX_APPLICATION_SCAN_DEPTH && descend(entry)) {
261
281
  stack.push({ directory: path, depth: current.depth + 1 });
262
282
  }
263
283
  }
264
284
  }
265
285
  }
266
- return { applications: results, truncated: false, visitedEntries: visited };
286
+ return { applications: results, warnings, truncated: false, visitedEntries: visited };
267
287
  }
268
288
 
269
289
  function requiredApplication(value) {
@@ -45,7 +45,7 @@ const STATIC_POSITIONAL_RULES = new Map([
45
45
  ]);
46
46
  const RESOURCE_POSITIONAL_LIMITS = new Map(Object.entries({ add: 3, "generate-ssh-key": 3, remove: 2, check: 2 }));
47
47
  const JOB_POSITIONAL_LIMITS = new Map(Object.entries({ read: 2, inspect: 2, cancel: 2, approve: 2, submit: 2 }));
48
- const ACCOUNT_POSITIONAL_LIMITS = new Map(Object.entries({ list: 1, add: 3, role: 3, enable: 2, disable: 2, "rotate-password": 2, remove: 2 }));
48
+ const ACCOUNT_POSITIONAL_LIMITS = new Map(Object.entries({ list: 1, clients: 1, "revoke-client": 2, add: 3, role: 3, enable: 2, disable: 2, "rotate-password": 2, remove: 2 }));
49
49
  const APPROVAL_POSITIONAL_LIMITS = new Map(Object.entries({ list: 1, revoke: 2, clear: 1 }));
50
50
  const ACTION_POSITIONAL_RULES = new Map(Object.entries({
51
51
  workspace(args) {
@@ -0,0 +1,23 @@
1
+ import { lstat, opendir } from "node:fs/promises";
2
+
3
+ export function isMissingPathError(error) {
4
+ return error?.code === "ENOENT";
5
+ }
6
+
7
+ export async function pathEntryIfExists(path, inspect = lstat) {
8
+ try {
9
+ return await inspect(path);
10
+ } catch (error) {
11
+ if (isMissingPathError(error)) return null;
12
+ throw error;
13
+ }
14
+ }
15
+
16
+ export async function openDirectoryIfExists(path, openDirectory = opendir) {
17
+ try {
18
+ return await openDirectory(path);
19
+ } catch (error) {
20
+ if (isMissingPathError(error)) return null;
21
+ throw error;
22
+ }
23
+ }
@@ -12,22 +12,34 @@ export function captureProcessTreeOwnership(child, options = {}) {
12
12
  return { platform, pid, members: processGroupMembers(pid, options) };
13
13
  }
14
14
 
15
+ export function refreshProcessTreeOwnership(snapshot, options = {}) {
16
+ if (!snapshot?.pid || snapshot.platform === "win32") return snapshot;
17
+ const members = [...(snapshot.members || [])];
18
+ for (const observed of processGroupMembers(snapshot.pid, options)) {
19
+ if (!members.some((expected) => sameProcessIdentity(expected, observed))) members.push(observed);
20
+ }
21
+ return { ...snapshot, members };
22
+ }
23
+
15
24
  export function processTreeOwnershipStillCurrent(snapshot, child, options = {}) {
16
25
  if (!snapshot?.pid) return false;
17
26
  if (snapshot.platform === "win32") return !childHasExited(child);
18
27
  if (!Array.isArray(snapshot.members) || snapshot.members.length === 0) return !childHasExited(child);
19
28
  const current = processGroupMembers(snapshot.pid, options);
20
- return snapshot.members.some((expected) => current.some((observed) => sameProcessIdentity(expected, observed)));
29
+ if (snapshot.members.some((expected) => current.some((observed) => sameProcessIdentity(expected, observed)))) return true;
30
+ return snapshot.members.some((expected) => processGroupMembers(snapshot.pid, options, expected.pid)
31
+ .some((observed) => sameProcessIdentity(expected, observed)));
21
32
  }
22
33
 
23
- function processGroupMembers(groupId, options = {}) {
34
+ function processGroupMembers(groupId, options = {}, pid = 0) {
24
35
  const list = typeof options.listProcessGroups === "function" ? options.listProcessGroups : listProcessGroups;
25
- return list(options).filter((entry) => entry.pgid === groupId).map(({ pid, startedAt }) => ({ pid, startedAt }));
36
+ return list(options, pid).filter((entry) => entry.pgid === groupId).map(({ pid: memberPid, startedAt }) => ({ pid: memberPid, startedAt }));
26
37
  }
27
38
 
28
- function listProcessGroups(options = {}) {
39
+ function listProcessGroups(options = {}, pid = 0) {
29
40
  const run = typeof options.spawnSyncProcess === "function" ? options.spawnSyncProcess : spawnSync;
30
- const result = run("ps", ["-axo", "pid=,pgid=,lstart="], {
41
+ const args = pid ? ["-p", String(pid), "-o", "pid=,pgid=,lstart="] : ["-axo", "pid=,pgid=,lstart="];
42
+ const result = run("ps", args, {
31
43
  encoding: "utf8",
32
44
  timeout: PROCESS_SNAPSHOT_TIMEOUT_MS,
33
45
  maxBuffer: PROCESS_SNAPSHOT_BYTES,
@@ -2,8 +2,9 @@ import { spawn } from "node:child_process";
2
2
  import {
3
3
  captureProcessTreeOwnership,
4
4
  processTreeOwnershipStillCurrent,
5
+ refreshProcessTreeOwnership,
5
6
  } from "./process-tree-ownership.mjs";
6
- export { captureProcessTreeOwnership, processTreeOwnershipStillCurrent } from "./process-tree-ownership.mjs";
7
+ export { captureProcessTreeOwnership, processTreeOwnershipStillCurrent, refreshProcessTreeOwnership } from "./process-tree-ownership.mjs";
7
8
 
8
9
  export const DEFAULT_PROCESS_TERMINATION_GRACE_MS = 2000;
9
10
 
@@ -40,10 +41,13 @@ export function terminateProcessTreeWithEscalation(child, options = {}) {
40
41
  : DEFAULT_PROCESS_TERMINATION_GRACE_MS;
41
42
  const schedule = typeof options.setTimeout === "function" ? options.setTimeout : setTimeout;
42
43
  const terminate = typeof options.terminate === "function" ? options.terminate : terminateProcessTree;
43
- const ownership = typeof options.captureOwnership === "function"
44
+ let ownership = typeof options.captureOwnership === "function"
44
45
  ? options.captureOwnership(child)
45
46
  : captureProcessTreeOwnership(child, options);
46
47
  terminate(child, "SIGTERM", options);
48
+ ownership = typeof options.refreshOwnership === "function"
49
+ ? options.refreshOwnership(ownership, child)
50
+ : refreshProcessTreeOwnership(ownership, options);
47
51
  return schedule(() => {
48
52
  try {
49
53
  const owned = typeof options.isTerminationTargetOwned === "function"
@@ -167,7 +167,7 @@ export class RelayConnection {
167
167
  this.logger.debug?.("remote relay welcome received");
168
168
  Promise.resolve(this.helloMessage(message)).then((hello) => {
169
169
  if (this.socket !== socket || this.closed || this.authenticated) return;
170
- if (!this.sendOnSocket(socket, hello)) this.failPermanently("relay_authentication_failed");
170
+ this.sendOnSocket(socket, hello);
171
171
  }).catch((error) => {
172
172
  this.logger.debug?.("could not create daemon authentication proof", { error_class: classifyOperationalError(error) });
173
173
  this.failPermanently("relay_authentication_failed");
@@ -256,18 +256,18 @@ export class RelayConnection {
256
256
 
257
257
  handleServerError(message = {}) {
258
258
  const errorCode = sanitizeProtocolErrorCode(message?.error);
259
- this.logger.debug?.("remote relay reported a protocol error", { error_code: errorCode });
260
- if (errorCode === "daemon_hello_timeout" && !this.authenticated) {
261
- const socket = this.socket;
262
- if (this.closed || !socket) return true;
263
- this.pendingCloseCategory = "relay_handshake_timeout";
264
- terminateSocket(socket);
265
- return true;
266
- }
267
- if (errorCode === "daemon_ready_timeout" && this.authenticated && !this.ready) {
259
+ const reconnectCategory = relayServerErrorReconnectCategory(errorCode, {
260
+ authenticated: this.authenticated,
261
+ ready: this.ready,
262
+ });
263
+ this.logger.debug?.(
264
+ reconnectCategory ? "remote relay requested connection recovery" : "remote relay reported a protocol error",
265
+ { error_code: errorCode, reconnect_category: reconnectCategory || "none" },
266
+ );
267
+ if (reconnectCategory) {
268
268
  const socket = this.socket;
269
269
  if (this.closed || !socket) return true;
270
- this.pendingCloseCategory = "relay_readiness_timeout";
270
+ this.pendingCloseCategory = reconnectCategory;
271
271
  terminateSocket(socket);
272
272
  return true;
273
273
  }
@@ -587,12 +587,25 @@ export class RelayConnection {
587
587
  }
588
588
  }
589
589
 
590
+ export function relayServerErrorReconnectCategory(errorCode, state = {}) {
591
+ const code = sanitizeProtocolErrorCode(errorCode);
592
+ const authenticated = state?.authenticated === true;
593
+ const ready = state?.ready === true;
594
+ if (code === "daemon_hello_timeout" && !authenticated) return "relay_handshake_timeout";
595
+ if (code === "daemon_ready_timeout" && authenticated && !ready) return "relay_readiness_timeout";
596
+ if (code === "daemon_transport_error") return "relay_transport_error";
597
+ if (code === "daemon_liveness_timeout") return "relay_heartbeat_timeout";
598
+ return "";
599
+ }
600
+
590
601
  export function relayCloseCategory(code, reason = "") {
591
602
  const numeric = Number(code);
592
603
  const reasonText = String(reason || "");
593
604
  if (isSupersededClose(numeric, reasonText)) return "superseded";
594
605
  if (numeric === 1008 && reasonText === "daemon hello timeout") return "relay_handshake_timeout";
595
606
  if (numeric === 1008 && reasonText === "daemon ready timeout") return "relay_readiness_timeout";
607
+ if ([1008, 1012].includes(numeric) && ["daemon pong failed", "daemon send failed"].includes(reasonText)) return "relay_transport_error";
608
+ if ([1008, 1012].includes(numeric) && reasonText === "daemon liveness timeout") return "relay_heartbeat_timeout";
596
609
  if (numeric === 1008 && ["stale daemon candidate", "expired daemon candidate"].includes(reasonText)) return "relay_restarting_or_unavailable";
597
610
  if (numeric === 1008 && ["daemon hello required", "missing daemon attachment", "invalid daemon candidate timestamp"].includes(reasonText)) return "relay_protocol_error";
598
611
  if (numeric === 1000) return "normal_close";
@@ -5,12 +5,21 @@ import { inspectResourceFile, validateResourceName } from "./managed-jobs.mjs";
5
5
  import { generateSshKeyPair } from "./ssh-key.mjs";
6
6
  import { acquireStartupLockWithWait, loadState, saveState } from "./state.mjs";
7
7
 
8
- export async function generateRegisteredSshKey({ workspace, stateDir, name: rawName, targetPath, comment = "" }) {
8
+ export async function generateRegisteredSshKey(
9
+ { workspace, stateDir, name: rawName, targetPath, comment = "" },
10
+ options = {},
11
+ ) {
9
12
  const name = validateResourceName(rawName);
10
13
  if (typeof targetPath !== "string" || !targetPath.trim()) throw new Error("SSH private key target path is required");
11
14
  const target = resolve(targetPath);
12
- const state = loadState(workspace, { stateDir });
13
- const lock = await acquireStartupLockWithWait(state, { operation: "generate-ssh-key" });
15
+ const loadStateFn = options.loadState || loadState;
16
+ const acquireLockFn = options.acquireStartupLockWithWait || acquireStartupLockWithWait;
17
+ const generateKeyFn = options.generateSshKeyPair || generateSshKeyPair;
18
+ const inspectResourceFn = options.inspectResourceFile || inspectResourceFile;
19
+ const saveStateFn = options.saveState || saveState;
20
+ const removeFileFn = options.removeFile || rm;
21
+ const state = loadStateFn(workspace, { stateDir });
22
+ const lock = await acquireLockFn(state, { operation: "generate-ssh-key" });
14
23
  let key = null;
15
24
  try {
16
25
  state.resources ||= {};
@@ -19,19 +28,25 @@ export async function generateRegisteredSshKey({ workspace, stateDir, name: rawN
19
28
  throw new Error(`local resource ${name} is already registered to a different file; remove it first`);
20
29
  }
21
30
  if (!existing && Object.keys(state.resources).length >= 64) throw new Error("local resource registry limit reached (64)");
22
- key = await generateSshKeyPair({
31
+ key = await generateKeyFn({
23
32
  privateKeyPath: target,
24
33
  type: "ed25519",
25
34
  comment: comment || `machine-mcp:${name}`,
26
35
  });
27
- const inspected = inspectResourceFile(key.privateKeyPath);
36
+ const inspected = inspectResourceFn(key.privateKeyPath);
28
37
  state.resources[name] = inspected;
29
38
  try {
30
- saveState(state);
39
+ saveStateFn(state);
31
40
  } catch (error) {
32
41
  if (key.created) {
33
- await rm(key.privateKeyPath, { force: true }).catch(() => {});
34
- await rm(key.publicKeyPath, { force: true }).catch(() => {});
42
+ try {
43
+ await removeGeneratedSshKeyPair(key, removeFileFn);
44
+ } catch (cleanupError) {
45
+ throw new AggregateError(
46
+ [error, cleanupError],
47
+ "SSH key registration failed and generated-key rollback was incomplete",
48
+ );
49
+ }
35
50
  }
36
51
  throw error;
37
52
  }
@@ -53,6 +68,20 @@ export async function generateRegisteredSshKey({ workspace, stateDir, name: rawN
53
68
  }
54
69
  }
55
70
 
71
+
72
+ export async function removeGeneratedSshKeyPair(key, removeFile = rm) {
73
+ if (!key?.created) return;
74
+ const failures = [];
75
+ for (const [kind, filePath] of [["private", key.privateKeyPath], ["public", key.publicKeyPath]]) {
76
+ try {
77
+ await removeFile(filePath, { force: true });
78
+ } catch (cause) {
79
+ failures.push(new Error(`generated SSH ${kind} key cleanup failed`, { cause }));
80
+ }
81
+ }
82
+ if (failures.length) throw new AggregateError(failures, "generated SSH key rollback was incomplete");
83
+ }
84
+
56
85
  function samePathIdentity(left, right) {
57
86
  const a = canonicalIfExisting(left);
58
87
  const b = canonicalIfExisting(right);
@@ -19,11 +19,14 @@ export async function activatePersistentRuntime(options = {}) {
19
19
  let daemonLock = null;
20
20
  let candidateRuntime = null;
21
21
  let candidateRelayVerified = false;
22
+ let providerStopped = false;
23
+ let serviceStarted = false;
22
24
  try {
23
25
  const providerStop = await options.stopAutostart();
24
26
  if (providerStop?.ok === false && providerStop?.active === true) {
25
27
  throw new Error("the existing autostart service could not be stopped before activation");
26
28
  }
29
+ providerStopped = providerStop?.active_before === true && providerStop?.active === false;
27
30
 
28
31
  daemonLock = await options.acquireDaemonLock();
29
32
  if (!daemonLock?.acquired) {
@@ -60,6 +63,7 @@ export async function activatePersistentRuntime(options = {}) {
60
63
  if (serviceStart?.ok === false) {
61
64
  throw new Error(`autostart start failed (${serviceStart.provider || "unknown"})`);
62
65
  }
66
+ serviceStarted = true;
63
67
 
64
68
  const convergence = await waitForActivatedRuntime({
65
69
  expectedVersion: options.expectedVersion,
@@ -85,6 +89,9 @@ export async function activatePersistentRuntime(options = {}) {
85
89
  try { startupLock.release(); }
86
90
  catch (failure) { cleanupErrors.push(failure); }
87
91
  }
92
+ try {
93
+ await restoreStoppedProvider(options.startAutostart, { providerStopped, serviceStarted });
94
+ } catch (failure) { cleanupErrors.push(failure); }
88
95
  if (cleanupErrors.length) {
89
96
  throw new AggregateError(
90
97
  [error, ...cleanupErrors],
@@ -95,6 +102,15 @@ export async function activatePersistentRuntime(options = {}) {
95
102
  }
96
103
  }
97
104
 
105
+
106
+ async function restoreStoppedProvider(startAutostart, { providerStopped, serviceStarted }) {
107
+ if (!providerStopped || serviceStarted) return;
108
+ const restored = await startAutostart();
109
+ if (restored?.ok === false) {
110
+ throw new Error(`previous autostart service could not be restored (${restored.provider || "unknown"})`);
111
+ }
112
+ }
113
+
98
114
  export async function waitForActivatedRuntime({
99
115
  inspectDaemon,
100
116
  checkWorker,
@@ -1,3 +1,5 @@
1
+ import { classifyOperationalError } from "./log.mjs";
2
+
1
3
  export async function sessionBootstrap({
2
4
  agentContextManager,
3
5
  appAutomationManager,
@@ -26,9 +28,18 @@ export async function resolveTaskCapabilities({
26
28
  const result = await agentContextManager.resolveTaskCapabilities(args, context);
27
29
  const task = String(args.task || "");
28
30
  if (policy.profile === "full") {
29
- const applications = await appAutomationManager
30
- .listApplications({ query: "", max_results: 500 }, context)
31
- .catch(() => ({ applications: [] }));
31
+ let applications;
32
+ try {
33
+ applications = await appAutomationManager.listApplications({ query: "", max_results: 500 }, context);
34
+ } catch (error) {
35
+ applications = { applications: [], warnings: [{ error_class: classifyOperationalError(error) }], truncated: false };
36
+ }
37
+ result.application_discovery = {
38
+ available: !(applications.warnings?.length),
39
+ warning_count: applications.warnings?.length ?? 0,
40
+ truncated: applications.truncated === true,
41
+ ...(applications.warnings?.[0]?.error_class ? { error_class: applications.warnings[0].error_class } : {}),
42
+ };
32
43
  const lower = task.toLowerCase();
33
44
  result.application_matches = applications.applications
34
45
  .map((application) => ({ application, score: applicationMatchScore(lower, application) }))
@@ -38,6 +49,7 @@ export async function resolveTaskCapabilities({
38
49
  .map(({ application, score }) => ({ ...application, score }));
39
50
  } else {
40
51
  result.application_matches = [];
52
+ result.application_discovery = { available: false, warning_count: 0, truncated: false, reason: "policy" };
41
53
  }
42
54
  if (result.application_matches.length) {
43
55
  result.recommended_tools = [...new Set([
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { realpathSync, rmSync } from "node:fs";
3
- import { lstat, realpath, stat } from "node:fs/promises";
3
+ import { realpath, stat } from "node:fs/promises";
4
4
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
5
  import { isRelayReadyContext } from "./relay-connection.mjs";
6
6
  import { ProcessSessionManager } from "./process-sessions.mjs";
@@ -37,6 +37,7 @@ import { createRuntimeRelayConnection, normalizeRelayResumeCalls, normalizeRelay
37
37
  import { RelayCallRecovery } from "./relay-call-recovery.mjs";
38
38
  import { RuntimeResourceService } from "./runtime-resource-service.mjs";
39
39
  import { assertContainedPath, createRuntimeDir, redactRuntimeErrorMessage } from "./runtime-paths.mjs";
40
+ import { pathEntryIfExists } from "./path-inspection.mjs";
40
41
  import {
41
42
  resolveTaskCapabilities as resolveRuntimeTaskCapabilities,
42
43
  sessionBootstrap as buildRuntimeSessionBootstrap,
@@ -358,7 +359,9 @@ export class LocalRuntime {
358
359
  const relaySessionId = Number(relayContext.sessionId) || 0;
359
360
  if (!id || !relaySessionId) return this.handleRelayProtocolViolation("invalid_relay_probe");
360
361
  const outcome = this.relay?.sendForSession?.({ type: "relay_probe_result", id }, relaySessionId);
361
- if (!outcome?.ok) this.handleRelayProtocolViolation("relay_probe_delivery_failed");
362
+ if (!outcome?.ok && !["send_failed", "transport_unavailable", "session_ended"].includes(outcome?.reason)) {
363
+ this.handleRelayProtocolViolation("relay_probe_delivery_failed");
364
+ }
362
365
  }
363
366
 
364
367
  async handleRelayToolCall(message) {
@@ -598,10 +601,10 @@ export class LocalRuntime {
598
601
 
599
602
  async resolveWritePath(inputPath = ".", context = {}) {
600
603
  const candidate = this.resolvePath(inputPath);
601
- const candidateInfo = await lstat(candidate).catch(() => null);
604
+ const candidateInfo = await pathEntryIfExists(candidate);
602
605
  if (candidateInfo?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
603
606
  let ancestor = candidate;
604
- while (!(await lstat(ancestor).catch(() => null))) {
607
+ while (!(await pathEntryIfExists(ancestor))) {
605
608
  const parent = dirname(ancestor);
606
609
  if (parent === ancestor) break;
607
610
  ancestor = parent;
@@ -12,6 +12,8 @@ import {
12
12
  workerHealthUserReason,
13
13
  } from "./worker-health.mjs";
14
14
 
15
+ const DEFAULT_DEPLOYMENT_HEALTH_ATTEMPTS = 20;
16
+
15
17
  export async function ensureWorkerDeployment(state, args = {}, options = {}) {
16
18
  const logger = options.logger || console;
17
19
  const expectedVersion = options.expectedVersion || currentPackageVersion(options.packageRoot || packageRoot);
@@ -24,18 +26,24 @@ export async function ensureWorkerDeployment(state, args = {}, options = {}) {
24
26
  const complete = hasCompleteWorkerState(state.worker);
25
27
 
26
28
  if (!args.forceWorker && !args.rotateSecrets && complete && state.worker.deployHash === desiredHash) {
27
- const health = await retryHealthFn(state.worker.url, expectedVersion, options.existingHealthAttempts || 2, healthOptions);
29
+ const recordedCurrentDeployment = state.worker.deployedVersion === expectedVersion;
30
+ const attempts = recordedCurrentDeployment
31
+ ? (options.recordedDeploymentHealthAttempts ?? DEFAULT_DEPLOYMENT_HEALTH_ATTEMPTS)
32
+ : (options.existingHealthAttempts ?? 2);
33
+ const health = await retryHealthFn(state.worker.url, expectedVersion, attempts, healthOptions);
28
34
  if (health.ok) {
29
35
  logger.success?.("Worker unchanged and healthy", { url: state.worker.url });
30
36
  logger.debug?.("Worker health route", { network_route: health.networkRoute || "unknown" });
31
37
  return state.worker;
32
38
  }
39
+ logger.debug?.("Worker health check detail", { health_error: health.error, network_route: health.networkRoute || "unknown" });
40
+ if (recordedCurrentDeployment) {
41
+ throw workerVerificationError(health.error, { deploymentSucceeded: false, recordedCurrentDeployment: true });
42
+ }
33
43
  if (!workerHealthRequiresRedeploy(health.error)) {
34
- logger.debug?.("Worker health check detail", { health_error: health.error, network_route: health.networkRoute || "unknown" });
35
44
  throw workerVerificationError(health.error, { deploymentSucceeded: false });
36
45
  }
37
46
  logger.warn?.("Recorded Worker is stale; redeploying the same Worker", { reason: workerHealthUserReason(health.error) });
38
- logger.debug?.("Worker health check detail", { health_error: health.error, network_route: health.networkRoute || "unknown" });
39
47
  }
40
48
 
41
49
  logger.info?.("Checking Cloudflare Wrangler login");
@@ -68,7 +76,7 @@ export async function ensureWorkerDeployment(state, args = {}, options = {}) {
68
76
  state.worker.updatedAt = new Date().toISOString();
69
77
  saveStateFn(state);
70
78
 
71
- const health = await retryHealthFn(state.worker.url, expectedVersion, options.deploymentHealthAttempts || 8, healthOptions);
79
+ const health = await retryHealthFn(state.worker.url, expectedVersion, options.deploymentHealthAttempts ?? DEFAULT_DEPLOYMENT_HEALTH_ATTEMPTS, healthOptions);
72
80
  if (!health.ok) {
73
81
  logger.debug?.("Worker post-deployment health detail", { health_error: health.error, network_route: health.networkRoute || "unknown" });
74
82
  throw workerVerificationError(health.error, { deploymentSucceeded: true });
@@ -143,12 +151,14 @@ function collectHashFiles(target, out) {
143
151
  }
144
152
  }
145
153
 
146
- function workerVerificationError(reason, { deploymentSucceeded }) {
154
+ function workerVerificationError(reason, { deploymentSucceeded, recordedCurrentDeployment = false }) {
147
155
  const readable = workerHealthUserReason(reason);
148
156
  const guidance = workerVerificationGuidance(reason);
149
157
  const message = deploymentSucceeded
150
158
  ? `Cloudflare reported the Worker deployment succeeded, but ${readable}. The deployment fingerprint was saved, so retrying will verify the same Worker instead of deploying again. ${guidance}`
151
- : `The recorded Worker could not be verified because ${readable}. No deployment was attempted. ${guidance}`;
159
+ : recordedCurrentDeployment
160
+ ? `The current Worker deployment fingerprint is already recorded, but ${readable}. No deployment was attempted because duplicating the recorded deployment is unsafe; use --force-worker only after diagnosis. ${guidance}`
161
+ : `The recorded Worker could not be verified because ${readable}. No deployment was attempted. ${guidance}`;
152
162
  const error = new Error(message);
153
163
  error.code = "worker_health_unverified";
154
164
  error.healthError = reason;
@@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs";
3
3
  import { link, lstat, mkdir, open, opendir, rename, rm, stat } from "node:fs/promises";
4
4
  import path, { basename, dirname, join, resolve } from "node:path";
5
5
  import { applyUpdateHunks, parsePatchEnvelope } from "./patch.mjs";
6
+ import { openDirectoryIfExists, pathEntryIfExists } from "./path-inspection.mjs";
6
7
  import { clampInteger } from "./numbers.mjs";
7
8
 
8
9
  export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
@@ -43,12 +44,13 @@ export class WorkspaceFileService {
43
44
  for await (const entry of await opendir(full)) {
44
45
  this.throwIfCancelled(context);
45
46
  const entryPath = resolve(full, entry.name);
46
- const info = await lstat(entryPath).catch(() => null);
47
+ const info = await pathEntryIfExists(entryPath);
48
+ if (!info) continue;
47
49
  const item = {
48
50
  name: entry.name,
49
51
  path: this.displayPath(entryPath, context),
50
52
  type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other",
51
- size: info?.size ?? 0,
53
+ size: info.size,
52
54
  };
53
55
  const itemBytes = Buffer.byteLength(item.name) + Buffer.byteLength(item.path) + 64;
54
56
  if (entries.length >= MAX_DIRECTORY_ENTRIES || resultBytes + itemBytes > MAX_PATH_RESULT_BYTES) {
@@ -150,7 +152,7 @@ export class WorkspaceFileService {
150
152
  const bytes = Buffer.byteLength(content);
151
153
  if (bytes > MAX_WRITE_BYTES) throw new Error(`content exceeds maximum write size (${bytes} > ${MAX_WRITE_BYTES})`);
152
154
  const full = await this.resolveWritePath(args.path, context);
153
- const existing = await lstat(full).catch(() => null);
155
+ const existing = await pathEntryIfExists(full);
154
156
  if (existing?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
155
157
  if (args.create_only && existing) throw new Error("file exists and create_only=true");
156
158
  if (existing && !existing.isFile()) throw new Error("path is not a regular file");
@@ -206,7 +208,7 @@ export class WorkspaceFileService {
206
208
  this.throwIfCancelled(context);
207
209
  if (operation.kind === "add") {
208
210
  const target = await this.resolveWritePath(operation.path, context);
209
- if (await lstat(target).catch(() => null)) throw new Error(`add target already exists: ${operation.path}`);
211
+ if (await pathEntryIfExists(target)) throw new Error(`add target already exists: ${operation.path}`);
210
212
  assertTextSize(operation.content, operation.path);
211
213
  prepared.push({ kind: "add", source: null, target, content: operation.content, mode: 0o600 });
212
214
  continue;
@@ -222,7 +224,7 @@ export class WorkspaceFileService {
222
224
  const content = applyUpdateHunks(original, operation.hunks, operation.path);
223
225
  assertTextSize(content, operation.path);
224
226
  const target = operation.moveTo ? await this.resolveWritePath(operation.moveTo, context) : source;
225
- if (target !== source && await lstat(target).catch(() => null)) throw new Error(`move target already exists: ${operation.moveTo}`);
227
+ if (target !== source && await pathEntryIfExists(target)) throw new Error(`move target already exists: ${operation.moveTo}`);
226
228
  prepared.push({ kind: operation.moveTo ? "move" : "update", source, target, content, originalHash: sha256(original), mode: sourceInfo.mode & 0o777 });
227
229
  }
228
230
  assertNoResolvedPatchCollisions(prepared);
@@ -267,8 +269,12 @@ export class WorkspaceFileService {
267
269
 
268
270
  async searchOneFile(full, query, matches, max, context = {}) {
269
271
  this.throwIfCancelled(context);
270
- const bounded = await readBoundedFile(full, 1024 * 1024, "search file").catch(() => null);
271
- if (!bounded || bounded.buffer.includes(0)) return;
272
+ let bounded;
273
+ try { bounded = await readBoundedFile(full, 1024 * 1024, "search file"); } catch (error) {
274
+ if (isSkippableSearchFileError(error)) return;
275
+ throw error;
276
+ }
277
+ if (bounded.buffer.includes(0)) return;
272
278
  const buffer = bounded.buffer;
273
279
  let text;
274
280
  try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch { return; }
@@ -288,7 +294,7 @@ export class WorkspaceFileService {
288
294
  while (stack.length) {
289
295
  this.throwIfCancelled(context);
290
296
  const current = stack.pop();
291
- const entries = await opendir(current).catch(() => null);
297
+ const entries = await openDirectoryIfExists(current);
292
298
  if (!entries) continue;
293
299
  for await (const entry of entries) {
294
300
  this.throwIfCancelled(context);
@@ -328,6 +334,14 @@ export async function readBoundedFile(filePath, maxBytes, label) {
328
334
  }
329
335
  }
330
336
 
337
+ function isSkippableSearchFileError(error) {
338
+ const message = String(error?.message || "");
339
+ return error?.code === "ELOOP"
340
+ || message.startsWith("search file exceeds maximum size")
341
+ || message === "search file is not a regular file"
342
+ || message === "refusing to read search file with multiple hard links";
343
+ }
344
+
331
345
  function decodeUtf8(buffer) {
332
346
  try { return new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
333
347
  throw new Error("file is not valid UTF-8 text");
@@ -364,8 +378,12 @@ async function atomicWriteText(full, content, existing = null, options = {}) {
364
378
  try {
365
379
  await writeFlushedText(temp, content, existing ? existing.mode & 0o777 : 0o600);
366
380
  if (options.expectedHash) {
367
- const current = await readUtf8File(full).catch(() => null);
368
- if (current === null || sha256(current) !== options.expectedHash) throw new Error("file changed before atomic commit");
381
+ let current;
382
+ try { current = await readUtf8File(full); } catch (error) {
383
+ if (error?.code === "ENOENT") throw new Error("file changed before atomic commit", { cause: error });
384
+ throw error;
385
+ }
386
+ if (sha256(current) !== options.expectedHash) throw new Error("file changed before atomic commit");
369
387
  }
370
388
  if (options.createOnly) {
371
389
  await link(temp, full);
@@ -414,7 +432,7 @@ export async function commitPatchTransaction(operations, options = {}) {
414
432
  if (sha256(current) !== operation.originalHash) throw new Error(`patch source changed during apply: ${operation.source}`);
415
433
  }
416
434
  if (operation.kind === "add" || operation.kind === "move") {
417
- if (await lstat(operation.target).catch(() => null)) throw new Error(`patch target appeared during apply: ${operation.target}`);
435
+ if (await pathEntryIfExists(operation.target)) throw new Error(`patch target appeared during apply: ${operation.target}`);
418
436
  }
419
437
  }
420
438