machine-bridge-mcp 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/CONTRIBUTING.md +14 -0
  3. package/README.md +1 -1
  4. package/browser-extension/manifest.json +2 -2
  5. package/browser-extension/service-worker.js +52 -29
  6. package/docs/ENGINEERING.md +3 -1
  7. package/docs/PROJECT_STANDARDS.md +143 -0
  8. package/docs/RELEASING.md +12 -0
  9. package/docs/TESTING.md +7 -5
  10. package/docs/TOOL_REFERENCE.md +2836 -0
  11. package/package.json +14 -5
  12. package/scripts/commit-message-check.mjs +63 -0
  13. package/scripts/coverage-check.mjs +9 -1
  14. package/scripts/generate-policy-reference.mjs +1 -4
  15. package/scripts/generate-tool-reference.mjs +87 -0
  16. package/scripts/generate-worker-types.mjs +19 -0
  17. package/scripts/markdown.mjs +9 -0
  18. package/src/local/agent-context.mjs +8 -15
  19. package/src/local/cli.mjs +1 -107
  20. package/src/local/daemon-process.mjs +16 -4
  21. package/src/local/default-instructions.mjs +2 -45
  22. package/src/local/git-service.mjs +4 -9
  23. package/src/local/managed-job-plan.mjs +2 -7
  24. package/src/local/managed-jobs.mjs +13 -11
  25. package/src/local/numbers.mjs +9 -0
  26. package/src/local/process-execution.mjs +4 -9
  27. package/src/local/process-sessions.mjs +5 -10
  28. package/src/local/project-metadata.mjs +50 -0
  29. package/src/local/project-package.mjs +2 -45
  30. package/src/local/records.mjs +5 -0
  31. package/src/local/runtime.mjs +5 -12
  32. package/src/local/state-inventory.mjs +139 -0
  33. package/src/local/workspace-file-service.mjs +6 -12
  34. package/src/worker/errors.ts +41 -0
  35. package/src/worker/http.ts +252 -0
  36. package/src/worker/index.ts +33 -17
  37. package/src/worker/oauth-state.ts +170 -0
  38. package/src/worker/observability.ts +111 -0
  39. package/src/worker/pending-calls.ts +109 -0
  40. package/src/worker/policy.ts +79 -0
  41. package/tsconfig.json +1 -1
@@ -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;
@@ -697,10 +698,17 @@ function runnerProcessIsCurrent(status, dir, { ownerOnly = false } = {}) {
697
698
 
698
699
  function scrubFinishedPlan(dir, status) {
699
700
  if (PLAN_RETAINING_STATES.has(status.status)) return;
700
- rmSync(join(dir, "plan.json"), { force: true });
701
- rmSync(join(dir, "runner.pid"), { force: true });
702
- rmSync(join(dir, "recovery.lock"), { force: true });
703
- rmSync(join(dir, "transition.lock"), { force: true });
701
+ const safeRm = (path) => {
702
+ try {
703
+ rmSync(path, { force: true });
704
+ } catch (error) {
705
+ if (!["ENOENT", "EPERM", "EACCES"].includes(error.code)) throw error;
706
+ }
707
+ };
708
+ safeRm(join(dir, "plan.json"));
709
+ safeRm(join(dir, "runner.pid"));
710
+ safeRm(join(dir, "recovery.lock"));
711
+ safeRm(join(dir, "transition.lock"));
704
712
  }
705
713
 
706
714
  function reviewablePlan(plan) {
@@ -843,9 +851,3 @@ function boundedString(value, maxBytes, label) {
843
851
  if (Buffer.byteLength(value) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`);
844
852
  return value;
845
853
  }
846
-
847
- function clampInt(value, fallback, min, max) {
848
- const parsed = Number.parseInt(String(value ?? ""), 10);
849
- const number = Number.isFinite(parsed) ? parsed : fallback;
850
- return Math.min(Math.max(number, min), max);
851
- }
@@ -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";
@@ -0,0 +1,41 @@
1
+ const WORKER_ERROR_CODES = new Set([
2
+ "cancelled", "timeout", "authentication_failed", "authorization_denied", "policy_denied",
3
+ "invalid_request", "not_found", "conflict", "limit_exceeded", "permission_denied",
4
+ "path_boundary", "network_error", "protocol_error", "unavailable", "integrity_error",
5
+ "execution_failed", "internal_error",
6
+ ]);
7
+
8
+ export class WorkerToolError extends Error {
9
+ readonly code: string;
10
+ readonly retryable: boolean;
11
+
12
+ constructor(code: string, message: string, retryable = false) {
13
+ super(message);
14
+ this.name = "WorkerToolError";
15
+ this.code = normalizeCode(code);
16
+ this.retryable = retryable;
17
+ }
18
+ }
19
+
20
+ export function daemonToolError(value: unknown): WorkerToolError {
21
+ const input = asObject(value);
22
+ const code = normalizeCode(input.code);
23
+ const message = typeof input.message === "string" && input.message
24
+ ? input.message.slice(0, 2000)
25
+ : "daemon tool failed";
26
+ return new WorkerToolError(code, message, input.retryable === true);
27
+ }
28
+
29
+ export function publicWorkerToolError(error: unknown): { code: string; message: string; retryable: boolean } {
30
+ if (error instanceof WorkerToolError) return { code: error.code, message: error.message, retryable: error.retryable };
31
+ return { code: "execution_failed", message: "tool execution failed", retryable: false };
32
+ }
33
+
34
+ function normalizeCode(value: unknown): string {
35
+ const code = String(value || "execution_failed").toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, 64);
36
+ return WORKER_ERROR_CODES.has(code) ? code : "execution_failed";
37
+ }
38
+
39
+ function asObject(value: unknown): Record<string, unknown> {
40
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
41
+ }