machine-bridge-mcp 0.16.2 → 0.17.1

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 (41) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/CONTRIBUTING.md +14 -0
  3. package/README.md +11 -1
  4. package/SECURITY.md +9 -1
  5. package/browser-extension/manifest.json +2 -2
  6. package/browser-extension/service-worker.js +52 -29
  7. package/docs/ARCHITECTURE.md +4 -2
  8. package/docs/CLIENTS.md +2 -0
  9. package/docs/ENGINEERING.md +3 -1
  10. package/docs/GETTING_STARTED.md +400 -0
  11. package/docs/MULTI_ACCOUNT.md +406 -0
  12. package/docs/PROJECT_STANDARDS.md +143 -0
  13. package/docs/RELEASING.md +12 -0
  14. package/docs/TESTING.md +7 -5
  15. package/docs/TOOL_REFERENCE.md +2836 -0
  16. package/package.json +13 -4
  17. package/scripts/commit-message-check.mjs +63 -0
  18. package/scripts/coverage-check.mjs +9 -1
  19. package/scripts/generate-policy-reference.mjs +1 -4
  20. package/scripts/generate-tool-reference.mjs +87 -0
  21. package/scripts/generate-worker-types.mjs +19 -0
  22. package/scripts/markdown.mjs +9 -0
  23. package/src/local/agent-context.mjs +8 -15
  24. package/src/local/cli.mjs +1 -107
  25. package/src/local/daemon-process.mjs +16 -4
  26. package/src/local/default-instructions.mjs +2 -45
  27. package/src/local/git-service.mjs +4 -9
  28. package/src/local/managed-job-plan.mjs +2 -7
  29. package/src/local/managed-jobs.mjs +2 -7
  30. package/src/local/numbers.mjs +9 -0
  31. package/src/local/process-execution.mjs +4 -9
  32. package/src/local/process-sessions.mjs +5 -10
  33. package/src/local/project-metadata.mjs +50 -0
  34. package/src/local/project-package.mjs +2 -45
  35. package/src/local/records.mjs +5 -0
  36. package/src/local/runtime.mjs +5 -12
  37. package/src/local/state-inventory.mjs +139 -0
  38. package/src/local/workspace-file-service.mjs +6 -12
  39. package/src/worker/index.ts +33 -17
  40. package/tsconfig.json +1 -1
  41. package/src/worker/worker-configuration.d.ts +0 -14716
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { lstatSync, realpathSync, statSync } from "node:fs";
3
3
  import { basename, isAbsolute, relative, resolve, sep } from "node:path";
4
4
  import { readBoundedRegularFileWithInfoSync } from "./secure-file.mjs";
5
+ import { clampInteger } from "./numbers.mjs";
5
6
 
6
7
  const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
7
8
  const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
@@ -112,7 +113,7 @@ function validateSteps(value, label, context, allowEmpty = false) {
112
113
  env_resources: envResources,
113
114
  stdin,
114
115
  stdin_resource: stdinResource,
115
- timeout_seconds: clampInt(input.timeout_seconds, 600, 1, 3600),
116
+ timeout_seconds: clampInteger(input.timeout_seconds, 600, 1, 3600),
116
117
  allow_failure: input.allow_failure === true,
117
118
  capture_output: input.capture_output === "discard" ? "discard" : "redacted",
118
119
  };
@@ -227,9 +228,3 @@ function boundedString(value, maxBytes, label) {
227
228
  if (Buffer.byteLength(value) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`);
228
229
  return value;
229
230
  }
230
-
231
- function clampInt(value, fallback, min, max) {
232
- const parsed = Number.parseInt(String(value ?? ""), 10);
233
- const number = Number.isFinite(parsed) ? parsed : fallback;
234
- return Math.min(Math.max(number, min), max);
235
- }
@@ -11,6 +11,7 @@ import { createToolAuthorizer } from "./policy.mjs";
11
11
  import { BridgeError } from "./errors.mjs";
12
12
  import { inspectResourceFile, normalizeResourceRegistry, publicResourceRegistry, validatePlan, validateResourceName } from "./managed-job-plan.mjs";
13
13
  export { inspectResourceFile, publicResourceRegistry, validateResourceName } from "./managed-job-plan.mjs";
14
+ import { clampInteger } from "./numbers.mjs";
14
15
 
15
16
  const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
16
17
  const MAX_JOBS = 50;
@@ -218,7 +219,7 @@ export class ManagedJobManager {
218
219
  this.authorizeTool("list_jobs");
219
220
  this.assertMaintenanceAvailable();
220
221
  this.prune();
221
- const limit = clampInt(args.limit, 20, 1, MAX_JOBS);
222
+ const limit = clampInteger(args.limit, 20, 1, MAX_JOBS);
222
223
  const jobs = [];
223
224
  for (const entry of safeReadDir(this.jobRoot)) {
224
225
  if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
@@ -850,9 +851,3 @@ function boundedString(value, maxBytes, label) {
850
851
  if (Buffer.byteLength(value) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`);
851
852
  return value;
852
853
  }
853
-
854
- function clampInt(value, fallback, min, max) {
855
- const parsed = Number.parseInt(String(value ?? ""), 10);
856
- const number = Number.isFinite(parsed) ? parsed : fallback;
857
- return Math.min(Math.max(number, min), max);
858
- }
@@ -0,0 +1,9 @@
1
+ export function clampInteger(value, fallback, minimum, maximum) {
2
+ const parsed = typeof value === "number"
3
+ ? value
4
+ : typeof value === "string" && value.trim()
5
+ ? Number(value)
6
+ : Number.NaN;
7
+ const number = Number.isInteger(parsed) ? parsed : fallback;
8
+ return Math.min(Math.max(number, minimum), maximum);
9
+ }
@@ -3,6 +3,7 @@ import { stat } from "node:fs/promises";
3
3
  import { executionEnv, workspaceShellCommand } from "./shell.mjs";
4
4
  import { MAX_COMMAND_BYTES, terminateProcessTreeWithEscalation, validateArgv } from "./process-sessions.mjs";
5
5
  import { BridgeError } from "./errors.mjs";
6
+ import { clampInteger } from "./numbers.mjs";
6
7
 
7
8
  const MAX_STDIN_BYTES = 1024 * 1024;
8
9
  const DEFAULT_OUTPUT_BYTES = 512 * 1024;
@@ -25,7 +26,7 @@ export class ProcessExecutionService {
25
26
  const argv = validateArgv(args.argv);
26
27
  const cwd = await this.resolveExistingPath(args.cwd || ".");
27
28
  if (!(await stat(cwd)).isDirectory()) throw new BridgeError("invalid_request", "cwd is not a directory");
28
- return this.run(argv[0], argv.slice(1), clampInt(args.timeout_seconds, 120, 1, 600) * 1000, false, DEFAULT_OUTPUT_BYTES, context, cwd);
29
+ return this.run(argv[0], argv.slice(1), clampInteger(args.timeout_seconds, 120, 1, 600) * 1000, false, DEFAULT_OUTPUT_BYTES, context, cwd);
29
30
  }
30
31
 
31
32
  async runRegistered(args, context = {}) {
@@ -36,7 +37,7 @@ export class ProcessExecutionService {
36
37
  if (!(await stat(cwd)).isDirectory()) throw new BridgeError("invalid_request", "registered command cwd is not a directory");
37
38
  const requested = args.timeout_seconds === undefined
38
39
  ? command.timeoutSeconds
39
- : clampInt(args.timeout_seconds, command.timeoutSeconds, 1, 600);
40
+ : clampInteger(args.timeout_seconds, command.timeoutSeconds, 1, 600);
40
41
  const timeoutSeconds = Math.min(requested, command.timeoutSeconds);
41
42
  const result = await this.run(argv[0], argv.slice(1), timeoutSeconds * 1000, false, DEFAULT_OUTPUT_BYTES, context, cwd);
42
43
  return { name: command.name, cwd: this.displayPath(cwd), timeout_seconds: timeoutSeconds, ...result };
@@ -53,7 +54,7 @@ export class ProcessExecutionService {
53
54
  if (command.includes("\0")) throw new BridgeError("invalid_request", "command contains a NUL byte");
54
55
  if (Buffer.byteLength(command) > MAX_COMMAND_BYTES) throw new BridgeError("limit_exceeded", `command exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
55
56
  const shell = workspaceShellCommand(command);
56
- return this.run(shell.cmd, shell.args, clampInt(timeoutSeconds, 120, 1, 600) * 1000, false, DEFAULT_OUTPUT_BYTES, context);
57
+ return this.run(shell.cmd, shell.args, clampInteger(timeoutSeconds, 120, 1, 600) * 1000, false, DEFAULT_OUTPUT_BYTES, context);
57
58
  }
58
59
 
59
60
  terminateAll(signal = "SIGTERM", escalate = false) {
@@ -145,9 +146,3 @@ function appendLimited(current, chunk, maximum) {
145
146
  function finalizeOutput(value, truncated) {
146
147
  return truncated > 0 ? `${value}\n\n[truncated ${truncated} bytes]` : value;
147
148
  }
148
-
149
- function clampInt(value, fallback, minimum, maximum) {
150
- const parsed = Number.parseInt(String(value ?? ""), 10);
151
- const number = Number.isFinite(parsed) ? parsed : fallback;
152
- return Math.min(Math.max(number, minimum), maximum);
153
- }
@@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { basename } from "node:path";
4
4
  import { executionEnv } from "./shell.mjs";
5
5
  import { createToolAuthorizer } from "./policy.mjs";
6
+ import { clampInteger } from "./numbers.mjs";
6
7
 
7
8
  export const MAX_COMMAND_BYTES = 64 * 1024;
8
9
  const MAX_ARGV_ITEMS = 256;
@@ -112,10 +113,10 @@ export class ProcessSessionManager {
112
113
  async read(args, context = {}) {
113
114
  this.authorizeTool("read_process");
114
115
  const session = this.get(args.session_id);
115
- const stdoutOffset = clampInt(args.stdout_offset, 0, 0, Number.MAX_SAFE_INTEGER);
116
- const stderrOffset = clampInt(args.stderr_offset, 0, 0, Number.MAX_SAFE_INTEGER);
117
- const maxBytes = clampInt(args.max_bytes, 64 * 1024, 1, 256 * 1024);
118
- const waitMs = clampInt(args.wait_ms, 0, 0, 30_000);
116
+ const stdoutOffset = clampInteger(args.stdout_offset, 0, 0, Number.MAX_SAFE_INTEGER);
117
+ const stderrOffset = clampInteger(args.stderr_offset, 0, 0, Number.MAX_SAFE_INTEGER);
118
+ const maxBytes = clampInteger(args.max_bytes, 64 * 1024, 1, 256 * 1024);
119
+ const waitMs = clampInteger(args.wait_ms, 0, 0, 30_000);
119
120
  this.throwIfCancelled(context);
120
121
  const waitForExit = args.wait_for_exit === true;
121
122
  if (waitMs > 0 && session.closedAt === null) {
@@ -341,9 +342,3 @@ function boundedErrorMessage(error) {
341
342
  const message = error instanceof Error ? error.message : String(error);
342
343
  return message.replace(/[\r\n]+/g, " ").slice(0, 4096) || "process failed";
343
344
  }
344
-
345
- function clampInt(value, fallback, min, max) {
346
- const parsed = Number.parseInt(String(value ?? ""), 10);
347
- const number = Number.isFinite(parsed) ? parsed : fallback;
348
- return Math.min(Math.max(number, min), max);
349
- }
@@ -0,0 +1,50 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { lstat, open } from "node:fs/promises";
3
+
4
+ const SKIPPABLE_METADATA_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "EBUSY"]);
5
+
6
+ export function safeSingleLine(value, maxLength) {
7
+ if (typeof value !== "string") return "";
8
+ return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength);
9
+ }
10
+
11
+ export function skippableMetadataError(error) {
12
+ return SKIPPABLE_METADATA_CODES.has(error?.code);
13
+ }
14
+
15
+ export async function isRegularNonSymlink(filePath) {
16
+ const info = await lstat(filePath).catch((error) => skippableMetadataError(error) ? null : Promise.reject(error));
17
+ return Boolean(info && !info.isSymbolicLink() && info.isFile());
18
+ }
19
+
20
+ export async function readOptionalRegularUtf8(filePath, maxBytes) {
21
+ const handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0))
22
+ .catch((error) => skippableMetadataError(error) ? null : Promise.reject(error));
23
+ if (!handle) return null;
24
+ try {
25
+ let pathInfo;
26
+ let current;
27
+ try {
28
+ [pathInfo, current] = await Promise.all([lstat(filePath), handle.stat()]);
29
+ } catch (error) {
30
+ if (skippableMetadataError(error)) return null;
31
+ throw error;
32
+ }
33
+ if (pathInfo.isSymbolicLink() || !pathInfo.isFile() || !current.isFile()
34
+ || pathInfo.dev !== current.dev || pathInfo.ino !== current.ino || current.size > maxBytes) return null;
35
+ const buffer = Buffer.alloc(current.size);
36
+ let offset = 0;
37
+ while (offset < buffer.length) {
38
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
39
+ if (!bytesRead) break;
40
+ offset += bytesRead;
41
+ }
42
+ try {
43
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
44
+ } catch {
45
+ return null;
46
+ }
47
+ } finally {
48
+ await handle.close();
49
+ }
50
+ }
@@ -1,6 +1,6 @@
1
- import { constants as fsConstants } from "node:fs";
2
- import { lstat, open } from "node:fs/promises";
3
1
  import { join } from "node:path";
2
+ import { isRegularNonSymlink, readOptionalRegularUtf8, safeSingleLine } from "./project-metadata.mjs";
3
+ import { isPlainRecord } from "./records.mjs";
4
4
 
5
5
  const MAX_METADATA_FILE_BYTES = 1024 * 1024;
6
6
  const MAX_PACKAGE_COMMANDS = 64;
@@ -189,46 +189,3 @@ function safePackageManager(value) {
189
189
  const normalized = value.trim();
190
190
  return /^(?:npm|pnpm|yarn|bun)@[A-Za-z0-9][A-Za-z0-9.+_-]{0,90}$/.test(normalized) ? normalized : "";
191
191
  }
192
-
193
- async function isRegularNonSymlink(filePath) {
194
- const info = await lstat(filePath).catch((error) => skippableMetadataError(error) ? null : Promise.reject(error));
195
- return Boolean(info && !info.isSymbolicLink() && info.isFile());
196
- }
197
-
198
- async function readOptionalRegularUtf8(filePath, maxBytes) {
199
- const info = await lstat(filePath).catch((error) => skippableMetadataError(error) ? null : Promise.reject(error));
200
- if (!info || info.isSymbolicLink() || !info.isFile() || info.size > maxBytes) return null;
201
- const handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0)).catch((error) => skippableMetadataError(error) ? null : Promise.reject(error));
202
- if (!handle) return null;
203
- try {
204
- const current = await handle.stat();
205
- if (!current.isFile() || current.size > maxBytes) return null;
206
- const buffer = Buffer.alloc(current.size);
207
- let offset = 0;
208
- while (offset < buffer.length) {
209
- const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
210
- if (!bytesRead) break;
211
- offset += bytesRead;
212
- }
213
- try {
214
- return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
215
- } catch {
216
- return null;
217
- }
218
- } finally {
219
- await handle.close();
220
- }
221
- }
222
-
223
- function skippableMetadataError(error) {
224
- return ["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "EBUSY"].includes(error?.code);
225
- }
226
-
227
- function safeSingleLine(value, maxLength) {
228
- if (typeof value !== "string") return "";
229
- return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength);
230
- }
231
-
232
- function isPlainRecord(value) {
233
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
234
- }
@@ -0,0 +1,5 @@
1
+ export function isPlainRecord(value) {
2
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
3
+ const prototype = Object.getPrototypeOf(value);
4
+ return prototype === Object.prototype || prototype === null;
5
+ }
@@ -26,6 +26,8 @@ import { AppAutomationManager } from "./app-automation.mjs";
26
26
  import { BrowserBridgeManager } from "./browser-bridge.mjs";
27
27
  import { CapabilityObserver } from "./capability-observer.mjs";
28
28
  import { readBoundedRegularFileSync } from "./secure-file.mjs";
29
+ import { clampInteger } from "./numbers.mjs";
30
+ import { isPlainRecord } from "./records.mjs";
29
31
 
30
32
  const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
31
33
  const MAX_CONCURRENT_TOOL_CALLS = 16;
@@ -58,7 +60,7 @@ const RUNTIME_TOOL_HANDLERS = Object.freeze({
58
60
  browser_upload_files: (runtime, args, context) => runtime.browserBridgeManager.uploadFiles(args, context),
59
61
  list_roots: (runtime) => runtime.listRoots(),
60
62
  list_dir: (runtime, args, context) => runtime.listDir(args.path || ".", context),
61
- list_files: (runtime, args, context) => runtime.listFiles(args.path || ".", clampInt(args.max_files, 1000, 1, 10000), context),
63
+ list_files: (runtime, args, context) => runtime.listFiles(args.path || ".", clampInteger(args.max_files, 1000, 1, 10000), context),
62
64
  read_file: (runtime, args, context) => runtime.readFile(args, context),
63
65
  view_image: (runtime, args, context) => runtime.viewImage(args, context),
64
66
  write_file: (runtime, args, context) => runtime.writeFile(args, context),
@@ -82,7 +84,7 @@ const RUNTIME_TOOL_HANDLERS = Object.freeze({
82
84
  read_process: (runtime, args, context) => runtime.processSessionManager.read(args, context),
83
85
  write_process: (runtime, args, context) => runtime.processSessionManager.write(args, context),
84
86
  kill_process: (runtime, args, context) => runtime.processSessionManager.kill(args, context),
85
- exec_command: (runtime, args, context) => runtime.execCommand(args.command, clampInt(args.timeout_seconds, 120, 1, 600), context),
87
+ exec_command: (runtime, args, context) => runtime.execCommand(args.command, clampInteger(args.timeout_seconds, 120, 1, 600), context),
86
88
  });
87
89
 
88
90
  export function runtimeToolHandlerNames() {
@@ -809,13 +811,10 @@ function normalizeRelayToolCall(message) {
809
811
  id,
810
812
  tool,
811
813
  arguments: argumentsValue,
812
- timeoutMs: clampInt(message.timeout_ms, 60_000, 1000, 610_000),
814
+ timeoutMs: clampInteger(message.timeout_ms, 60_000, 1000, 610_000),
813
815
  };
814
816
  }
815
817
 
816
- function isPlainRecord(value) {
817
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
818
- }
819
818
 
820
819
 
821
820
  function collectToolPathCandidates(error, toolArgs, workspace) {
@@ -857,9 +856,3 @@ function replacePathPrefix(message, pathValue, replacement) {
857
856
  function shortCallId(value) {
858
857
  return String(value || "").slice(0, 20);
859
858
  }
860
-
861
- function clampInt(value, fallback, minimum, maximum) {
862
- const parsed = Number.parseInt(String(value ?? ""), 10);
863
- const number = Number.isFinite(parsed) ? parsed : fallback;
864
- return Math.min(Math.max(number, minimum), maximum);
865
- }
@@ -0,0 +1,139 @@
1
+ import { existsSync, readdirSync, realpathSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { activeManagedJobs } from "./managed-jobs.mjs";
4
+ import { inspectProcessInstance } from "./process-identity.mjs";
5
+ import { readBoundedRegularFileSync } from "./secure-file.mjs";
6
+ import { expandHome, readDaemonLockOwner, resolveWorkspace } from "./state.mjs";
7
+
8
+ const PROFILE_NAME = /^[a-f0-9]{24}$/;
9
+ const WORKER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
10
+ const MAX_STATE_BYTES = 2 * 1024 * 1024;
11
+
12
+ export function knownWorkerNames(stateRoot) {
13
+ const profiles = profilesDirectory(stateRoot);
14
+ if (!existsSync(profiles)) return [];
15
+ const names = new Set();
16
+ for (const entry of profileDirectories(profiles)) {
17
+ const profileDir = resolve(profiles, entry.name);
18
+ const stateFile = resolve(profileDir, "state.json");
19
+ if (!existsSync(stateFile)) {
20
+ const evidence = readdirSync(profileDir).some((name) => /^state\.json\.corrupt-/.test(name) || name === "daemon.lock");
21
+ if (evidence) throw unreadableWorkerState(entry.name);
22
+ continue;
23
+ }
24
+ let state;
25
+ try {
26
+ state = readStateJson(stateFile);
27
+ } catch {
28
+ throw unreadableWorkerState(entry.name);
29
+ }
30
+ if (!state || typeof state !== "object" || Array.isArray(state)) throw unreadableWorkerState(entry.name);
31
+ const name = String(state?.worker?.name || "");
32
+ if (name && !WORKER_NAME.test(name)) {
33
+ throw new Error(`profile ${entry.name} contains an invalid Worker name; local state was kept for inspection`);
34
+ }
35
+ if (name) names.add(name);
36
+ }
37
+ return [...names];
38
+ }
39
+
40
+ export function knownProfileStates(stateRoot) {
41
+ const canonicalStateRoot = canonicalRoot(stateRoot);
42
+ const profiles = resolve(canonicalStateRoot, "profiles");
43
+ if (!existsSync(profiles)) return [];
44
+ const states = [];
45
+ const seen = new Set();
46
+ for (const entry of profileDirectories(profiles)) {
47
+ const profileDir = resolve(profiles, entry.name);
48
+ const statePath = resolve(profileDir, "state.json");
49
+ const candidates = [];
50
+ if (existsSync(statePath)) {
51
+ try {
52
+ const value = readStateJson(statePath);
53
+ if (typeof value?.workspace?.path === "string") candidates.push(value.workspace.path);
54
+ } catch {
55
+ // A daemon lock may still provide a verified workspace for corrupt state.
56
+ }
57
+ }
58
+ const daemonLock = resolve(profileDir, "daemon.lock");
59
+ const daemonOwner = readDaemonLockOwner(daemonLock);
60
+ if (existsSync(daemonLock) && !daemonOwner) {
61
+ throw new Error(`cannot inspect daemon lock for profile ${entry.name}; service definitions and state were kept`);
62
+ }
63
+ if (typeof daemonOwner?.workspace === "string") candidates.push(daemonOwner.workspace);
64
+ for (const candidate of candidates) {
65
+ try {
66
+ const workspace = resolveWorkspace(candidate);
67
+ if (seen.has(workspace)) break;
68
+ states.push({
69
+ schemaVersion: 5,
70
+ workspace: { path: workspace, hash: entry.name },
71
+ paths: { stateRoot: canonicalStateRoot, profileDir, statePath },
72
+ });
73
+ seen.add(workspace);
74
+ break;
75
+ } catch {
76
+ // Ignore an invalid historical candidate and try the remaining evidence.
77
+ }
78
+ }
79
+ }
80
+ return states;
81
+ }
82
+
83
+ export function activeStateJobs(stateRoot) {
84
+ const profiles = profilesDirectory(stateRoot);
85
+ if (!existsSync(profiles)) return [];
86
+ const active = [];
87
+ for (const profile of profileDirectories(profiles)) {
88
+ for (const job of activeManagedJobs(resolve(profiles, profile.name, "jobs"))) {
89
+ active.push({ profile: profile.name, ...job });
90
+ }
91
+ }
92
+ return active;
93
+ }
94
+
95
+ export function activeStateLocks(stateRoot) {
96
+ const profiles = profilesDirectory(stateRoot);
97
+ if (!existsSync(profiles)) return [];
98
+ const active = [];
99
+ for (const profile of profileDirectories(profiles)) {
100
+ for (const [kind, name] of [["daemon", "daemon.lock"], ["startup", "startup.lock"]]) {
101
+ const lockPath = resolve(profiles, profile.name, name);
102
+ if (!existsSync(lockPath)) continue;
103
+ const owner = readDaemonLockOwner(lockPath);
104
+ if (!owner) {
105
+ active.push({ kind, pid: null, path: lockPath, reason: "invalid_or_unreadable_lock" });
106
+ continue;
107
+ }
108
+ const maxAgeMs = kind === "startup" ? 2 * 60 * 60 * 1000 : Number.POSITIVE_INFINITY;
109
+ const identity = inspectProcessInstance(owner, { maxAgeMs });
110
+ if (identity.current || (identity.alive && !identity.reclaimable)) {
111
+ active.push({ kind, pid: owner.pid, path: lockPath, reason: identity.reason });
112
+ }
113
+ }
114
+ }
115
+ return active;
116
+ }
117
+
118
+ function profilesDirectory(stateRoot) {
119
+ return resolve(canonicalRoot(stateRoot), "profiles");
120
+ }
121
+
122
+ function canonicalRoot(stateRoot) {
123
+ const expanded = resolve(expandHome(stateRoot));
124
+ if (!existsSync(expanded)) return expanded;
125
+ return realpathSync.native ? realpathSync.native(expanded) : realpathSync(expanded);
126
+ }
127
+
128
+ function profileDirectories(profiles) {
129
+ return readdirSync(profiles, { withFileTypes: true })
130
+ .filter((entry) => entry.isDirectory() && PROFILE_NAME.test(entry.name));
131
+ }
132
+
133
+ function readStateJson(path) {
134
+ return JSON.parse(readBoundedRegularFileSync(path, MAX_STATE_BYTES).toString("utf8"));
135
+ }
136
+
137
+ function unreadableWorkerState(profile) {
138
+ return new Error(`cannot determine deployed Worker from profile ${profile}; local state was kept for inspection`);
139
+ }
@@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs";
3
3
  import { chmod, link, lstat, mkdir, open, opendir, rename, rm, stat, writeFile } 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 { clampInteger } from "./numbers.mjs";
6
7
 
7
8
  export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
8
9
  const MAX_DIRECTORY_ENTRIES = 10_000;
@@ -89,11 +90,11 @@ export class WorkspaceFileService {
89
90
  const { buffer, info } = await readBoundedFile(full, MAX_WRITE_BYTES, "readable text file");
90
91
  const content = decodeUtf8(buffer);
91
92
  this.throwIfCancelled(context);
92
- const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, MAX_WRITE_BYTES);
93
- const startLine = args.start_line === undefined ? 1 : clampInt(args.start_line, 1, 1, Number.MAX_SAFE_INTEGER);
93
+ const maxBytes = clampInteger(args.max_bytes, 1024 * 1024, 1, MAX_WRITE_BYTES);
94
+ const startLine = args.start_line === undefined ? 1 : clampInteger(args.start_line, 1, 1, Number.MAX_SAFE_INTEGER);
94
95
  const rawLines = content.split(/\r?\n/);
95
96
  const totalLines = content.endsWith("\n") ? Math.max(1, rawLines.length - 1) : rawLines.length;
96
- const endLine = args.end_line === undefined ? totalLines : clampInt(args.end_line, totalLines, 1, Number.MAX_SAFE_INTEGER);
97
+ const endLine = args.end_line === undefined ? totalLines : clampInteger(args.end_line, totalLines, 1, Number.MAX_SAFE_INTEGER);
97
98
  if (endLine < startLine) throw new Error("end_line must be greater than or equal to start_line");
98
99
  if (startLine > totalLines) throw new Error(`start_line exceeds total lines (${startLine} > ${totalLines})`);
99
100
  const selectedEnd = Math.min(endLine, totalLines);
@@ -237,8 +238,8 @@ export class WorkspaceFileService {
237
238
  const query = String(args.query || "");
238
239
  if (!query) throw new Error("query is required");
239
240
  const root = await this.resolveExistingPath(args.path || ".");
240
- const max = clampInt(args.max_matches, 100, 1, 1000);
241
- const maxFiles = clampInt(args.max_files, 10000, 1, 100000);
241
+ const max = clampInteger(args.max_matches, 100, 1, 1000);
242
+ const maxFiles = clampInteger(args.max_files, 10000, 1, 100000);
242
243
  let visitedFiles = 0;
243
244
  const matches = [];
244
245
  const rootInfo = await stat(root);
@@ -435,13 +436,6 @@ export function sha256(value) {
435
436
  return createHash("sha256").update(String(value)).digest("hex");
436
437
  }
437
438
 
438
- function clampInt(value, fallback, min, max) {
439
- const parsed = Number.parseInt(String(value ?? ""), 10);
440
- const number = Number.isFinite(parsed) ? parsed : fallback;
441
- return Math.min(Math.max(number, min), max);
442
- }
443
-
444
-
445
439
  function detectImageMime(buffer) {
446
440
  if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
447
441
  if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
@@ -17,7 +17,7 @@ import {
17
17
  } from "./http";
18
18
 
19
19
  const SERVER_NAME = String(serverMetadata.name);
20
- const SERVER_VERSION = "0.16.2";
20
+ const SERVER_VERSION = "0.17.1";
21
21
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
22
22
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
23
23
  const JSONRPC_VERSION = "2.0";
@@ -162,14 +162,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
162
162
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
163
163
  const size = typeof message === "string" ? new TextEncoder().encode(message).byteLength : message.byteLength;
164
164
  if (size > MAX_DAEMON_MESSAGE_BYTES) {
165
- try { ws.close(1009, "message too large"); } catch {}
165
+ closeWebSocketQuietly(ws, 1009, "message too large");
166
166
  return;
167
167
  }
168
168
  let text: string;
169
169
  try {
170
170
  text = typeof message === "string" ? message : new TextDecoder("utf-8", { fatal: true }).decode(message);
171
171
  } catch {
172
- try { ws.close(1007, "invalid UTF-8"); } catch {}
172
+ closeWebSocketQuietly(ws, 1007, "invalid UTF-8");
173
173
  return;
174
174
  }
175
175
  let parsed: unknown;
@@ -187,12 +187,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
187
187
 
188
188
  const socketAttachment = this.socketAttachment(ws);
189
189
  if (!socketAttachment) {
190
- try { ws.close(1008, "missing daemon attachment"); } catch {}
190
+ closeWebSocketQuietly(ws, 1008, "missing daemon attachment");
191
191
  return;
192
192
  }
193
193
 
194
194
  if (socketAttachment.role === "expired") {
195
- try { ws.close(1008, "expired daemon candidate"); } catch {}
195
+ closeWebSocketQuietly(ws, 1008, "expired daemon candidate");
196
196
  return;
197
197
  }
198
198
 
@@ -204,7 +204,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
204
204
  const previousDaemons = this.daemonSockets().filter((socket) => socket !== ws);
205
205
  if (socketAttachment.role === "candidate") {
206
206
  if (!isFreshDaemonCandidate(socketAttachment.connectedAt)) {
207
- try { ws.close(1008, "stale daemon candidate"); } catch {}
207
+ closeWebSocketQuietly(ws, 1008, "stale daemon candidate");
208
208
  await this.scheduleCandidateAlarm();
209
209
  return;
210
210
  }
@@ -225,17 +225,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
225
225
  role: "expired",
226
226
  connectedAt: socketAttachment.connectedAt,
227
227
  } satisfies DaemonAttachment);
228
- try { ws.close(1011, "daemon hello acknowledgement failed"); } catch {}
228
+ closeWebSocketQuietly(ws, 1011, "daemon hello acknowledgement failed");
229
229
  return;
230
230
  }
231
231
  for (const socket of previousDaemons) {
232
- try { socket.close(1012, "replaced by authenticated daemon"); } catch {}
232
+ closeWebSocketQuietly(socket, 1012, "replaced by authenticated daemon");
233
233
  }
234
234
  return;
235
235
  }
236
236
 
237
237
  if (socketAttachment.role !== "daemon") {
238
- try { ws.close(1008, "daemon hello required"); } catch {}
238
+ closeWebSocketQuietly(ws, 1008, "daemon hello required");
239
239
  return;
240
240
  }
241
241
 
@@ -387,7 +387,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
387
387
  clientRequestKey: requestKey,
388
388
  timeoutMs,
389
389
  onTimeout: (record) => {
390
- try { record.socket.send(JSON.stringify({ type: "cancel_call", id: record.id })); } catch {}
390
+ sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
391
391
  return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
392
392
  },
393
393
  });
@@ -410,7 +410,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
410
410
  private cancelClientRequest(requestKey?: string): void {
411
411
  if (!requestKey) return;
412
412
  this.pending.cancelRequest(requestKey, (record) => {
413
- try { record.socket.send(JSON.stringify({ type: "cancel_call", id: record.id })); } catch {}
413
+ sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
414
414
  return new WorkerToolError("cancelled", "tool call cancelled by client");
415
415
  });
416
416
  }
@@ -422,7 +422,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
422
422
  if (!expected || !(await safeEqual(supplied, expected))) return new Response("Unauthorized daemon", { status: 401 });
423
423
 
424
424
  for (const socket of this.nonDaemonSockets()) {
425
- try { socket.close(1012, "replaced by newer daemon candidate"); } catch {}
425
+ closeWebSocketQuietly(socket, 1012, "replaced by newer daemon candidate");
426
426
  }
427
427
 
428
428
  const pair = new WebSocketPair();
@@ -508,8 +508,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
508
508
  role: "expired",
509
509
  connectedAt: attachment?.connectedAt ?? new Date(0).toISOString(),
510
510
  } satisfies DaemonAttachment);
511
- try { socket.send(JSON.stringify({ type: "error", error: "daemon_hello_timeout" })); } catch {}
512
- try { socket.close(1008, "daemon hello timeout"); } catch {}
511
+ sendWebSocketQuietly(socket, { type: "error", error: "daemon_hello_timeout" });
512
+ closeWebSocketQuietly(socket, 1008, "daemon hello timeout");
513
513
  continue;
514
514
  }
515
515
  nextDeadline = Math.min(nextDeadline, deadline);
@@ -524,7 +524,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
524
524
  const attachment = this.socketAttachment(socket);
525
525
  const connectedAt = Date.parse(attachment?.connectedAt ?? "");
526
526
  if (!Number.isFinite(connectedAt)) {
527
- try { socket.close(1008, "invalid daemon candidate timestamp"); } catch {}
527
+ closeWebSocketQuietly(socket, 1008, "invalid daemon candidate timestamp");
528
528
  continue;
529
529
  }
530
530
  nextDeadline = Math.min(nextDeadline, connectedAt + DAEMON_HELLO_TIMEOUT_MS);
@@ -885,8 +885,24 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
885
885
  }
886
886
 
887
887
  function rejectDaemonMessage(ws: WebSocket, error: string, closeCode: number, closeReason: string): void {
888
- try { ws.send(JSON.stringify({ type: "error", error })); } catch {}
889
- try { ws.close(closeCode, closeReason); } catch {}
888
+ sendWebSocketQuietly(ws, { type: "error", error });
889
+ closeWebSocketQuietly(ws, closeCode, closeReason);
890
+ }
891
+
892
+ function sendWebSocketQuietly(ws: WebSocket, value: unknown): void {
893
+ try {
894
+ ws.send(typeof value === "string" ? value : JSON.stringify(value));
895
+ } catch {
896
+ // Best-effort protocol cleanup must not replace the primary timeout or rejection.
897
+ }
898
+ }
899
+
900
+ function closeWebSocketQuietly(ws: WebSocket, code?: number, reason?: string): void {
901
+ try {
902
+ ws.close(code, reason);
903
+ } catch {
904
+ // The socket may already be closed or detached; no recovery remains at this boundary.
905
+ }
890
906
  }
891
907
 
892
908
  function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
package/tsconfig.json CHANGED
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "include": [
19
19
  "src/worker/**/*.ts",
20
- "src/worker/**/*.d.ts",
20
+ ".wrangler/worker-configuration.d.ts",
21
21
  "src/shared/**/*.json"
22
22
  ]
23
23
  }