machine-bridge-mcp 0.15.0 → 0.16.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 (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +12 -2
  3. package/SECURITY.md +7 -0
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +5 -3
  6. package/docs/AUDIT.md +12 -1
  7. package/docs/LOGGING.md +7 -1
  8. package/docs/OPERATIONS.md +9 -2
  9. package/docs/POLICY_REFERENCE.md +88 -0
  10. package/docs/TESTING.md +7 -0
  11. package/package.json +14 -3
  12. package/scripts/coverage-check.mjs +108 -0
  13. package/scripts/generate-policy-reference.mjs +95 -0
  14. package/src/local/agent-context.mjs +1 -103
  15. package/src/local/app-automation.mjs +7 -9
  16. package/src/local/browser-bridge.mjs +26 -156
  17. package/src/local/browser-extension-protocol.mjs +75 -0
  18. package/src/local/browser-pairing-store.mjs +83 -0
  19. package/src/local/call-registry.mjs +113 -0
  20. package/src/local/capability-ranking.mjs +103 -0
  21. package/src/local/cli-local-admin.mjs +308 -0
  22. package/src/local/cli-options.mjs +151 -0
  23. package/src/local/cli-policy.mjs +77 -0
  24. package/src/local/cli.mjs +16 -521
  25. package/src/local/errors.mjs +122 -0
  26. package/src/local/git-service.mjs +88 -0
  27. package/src/local/lifecycle.mjs +64 -0
  28. package/src/local/log.mjs +50 -19
  29. package/src/local/managed-job-plan.mjs +235 -0
  30. package/src/local/managed-jobs.mjs +16 -220
  31. package/src/local/observability.mjs +83 -0
  32. package/src/local/policy.mjs +148 -0
  33. package/src/local/process-execution.mjs +153 -0
  34. package/src/local/process-sessions.mjs +10 -20
  35. package/src/local/process-tracker.mjs +55 -0
  36. package/src/local/runtime.mjs +154 -672
  37. package/src/local/service.mjs +1 -0
  38. package/src/local/stdio.mjs +3 -11
  39. package/src/local/tool-executor.mjs +102 -0
  40. package/src/local/tools.mjs +21 -104
  41. package/src/local/workspace-file-service.mjs +451 -0
  42. package/src/shared/policy-contract.json +54 -0
  43. package/src/shared/tool-catalog.json +4 -4
  44. package/src/worker/index.ts +69 -524
@@ -1,15 +1,22 @@
1
- import { spawn } from "node:child_process";
2
- import { createHash, randomBytes } from "node:crypto";
3
- import { constants as fsConstants, mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
4
- import { chmod, link, lstat, mkdir, open, opendir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
3
+ import { lstat, realpath, rm, stat, writeFile } from "node:fs/promises";
5
4
  import { tmpdir } from "node:os";
6
5
  import path, { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
6
  import { RelayConnection } from "./relay-connection.mjs";
8
- import { applyUpdateHunks, parsePatchEnvelope } from "./patch.mjs";
9
- import { executionEnv, workspaceShellCommand } from "./shell.mjs";
10
- import { MAX_COMMAND_BYTES, ProcessSessionManager, terminateProcessTree, terminateProcessTreeWithEscalation, validateArgv } from "./process-sessions.mjs";
7
+ import { MAX_COMMAND_BYTES, ProcessSessionManager } from "./process-sessions.mjs";
11
8
  export { MAX_COMMAND_BYTES } from "./process-sessions.mjs";
12
- import { allToolNames, assertCanonicalFullPolicy, isCanonicalFullPolicy, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, POLICY_PROFILES, SERVER_NAME, toolNamesForPolicy } from "./tools.mjs";
9
+ import { allToolNames, assertCanonicalFullPolicy, isCanonicalFullPolicy, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, POLICY_PROFILES, PolicyGate, SERVER_NAME } from "./tools.mjs";
10
+ import { publicError } from "./errors.mjs";
11
+ import { ProcessTracker } from "./process-tracker.mjs";
12
+ import { CallRegistry } from "./call-registry.mjs";
13
+ import { RuntimeObservability } from "./observability.mjs";
14
+ import { ToolExecutor } from "./tool-executor.mjs";
15
+ import { boundedErrorMessage, ProcessExecutionService } from "./process-execution.mjs";
16
+ import { GitService } from "./git-service.mjs";
17
+ import { LifecycleController } from "./lifecycle.mjs";
18
+ import { MAX_WRITE_BYTES, readBoundedFile, sha256, WorkspaceFileService } from "./workspace-file-service.mjs";
19
+ export { MAX_WRITE_BYTES, sha256 } from "./workspace-file-service.mjs";
13
20
  import { classifyOperationalError } from "./log.mjs";
14
21
  import { inspectResourceFile, ManagedJobManager } from "./managed-jobs.mjs";
15
22
  import { generateRegisteredSshKey } from "./resource-operations.mjs";
@@ -20,13 +27,8 @@ import { BrowserBridgeManager } from "./browser-bridge.mjs";
20
27
  import { CapabilityObserver } from "./capability-observer.mjs";
21
28
  import { readBoundedRegularFileSync } from "./secure-file.mjs";
22
29
 
23
- export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
24
30
  const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
25
31
  const MAX_CONCURRENT_TOOL_CALLS = 16;
26
- const MAX_DIRECTORY_ENTRIES = 10_000;
27
- const MAX_PATH_RESULT_BYTES = 4 * 1024 * 1024;
28
- const MAX_WALK_ENTRIES = 200_000;
29
- const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
30
32
  const SLOW_TOOL_CALL_MS = 30_000;
31
33
 
32
34
  const RUNTIME_TOOL_HANDLERS = Object.freeze({
@@ -95,21 +97,44 @@ export class LocalRuntime {
95
97
  this.workspace = realpathSync.native ? realpathSync.native(this.workspaceInput) : realpathSync(this.workspaceInput);
96
98
  this.workspaceCanonicalPromise = null;
97
99
  this.policy = normalizePolicy(policy);
100
+ this.policyGate = new PolicyGate(this.policy);
98
101
  this.logger = logger;
99
102
  this.onSuperseded = typeof onSuperseded === "function" ? onSuperseded : null;
100
103
  this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
101
- this.activeToolCalls = 0;
102
- this.activeProcesses = new Set();
103
- this.callProcesses = new Map();
104
- this.cancelledCalls = new Set();
104
+ this.processTracker = new ProcessTracker();
105
+ this.lifecycle = new LifecycleController("local runtime");
106
+ this.observability = new RuntimeObservability();
107
+ this.callRegistry = new CallRegistry({
108
+ maximum: MAX_CONCURRENT_TOOL_CALLS,
109
+ onCancel: (record) => {
110
+ this.processSessionManager?.notifyCancellation();
111
+ this.browserBridgeManager?.cancelCall(record.id);
112
+ this.processTracker.terminateCall(record.id);
113
+ this.logger.event?.("debug", "tool.call.cancel_requested", {
114
+ call_id: shortCallId(record.id), tool: record.tool, origin: record.origin,
115
+ });
116
+ },
117
+ onFinish: (record) => this.processTracker.releaseCall(record.id),
118
+ });
105
119
  this.mutationQueue = Promise.resolve();
106
120
  this.capabilityObserver = new CapabilityObserver();
107
121
  this.runtimeDir = createRuntimeDir();
122
+ this.workspaceFileService = new WorkspaceFileService({
123
+ workspace: this.workspace,
124
+ policy: this.policy,
125
+ policyGate: this.policyGate,
126
+ resolveExistingPath: (value) => this.resolveExistingPath(value),
127
+ resolveWritePath: (value) => this.resolveWritePath(value),
128
+ displayPath: (value) => this.displayPath(value),
129
+ throwIfCancelled: (context) => this.throwIfCancelled(context),
130
+ withMutationLock: (operation) => this.withMutationLock(operation),
131
+ });
108
132
  if (typeof jobRoot !== "string" || !jobRoot.trim()) throw new Error("persistent managed-job root is required");
109
133
  this.managedJobManager = new ManagedJobManager({
110
134
  jobRoot,
111
135
  workspace: this.workspace,
112
136
  policy: this.policy,
137
+ authorizeTool: (tool) => this.policyGate.assert(tool),
113
138
  resources,
114
139
  resourceStatePath,
115
140
  stateRoot: browserStateRoot,
@@ -119,9 +144,9 @@ export class LocalRuntime {
119
144
  this.processSessionManager = new ProcessSessionManager({
120
145
  workspace: this.workspace,
121
146
  policy: this.policy,
147
+ authorizeTool: (tool) => this.policyGate.assert(tool),
122
148
  runtimeDir: this.runtimeDir,
123
- activeProcesses: this.activeProcesses,
124
- callProcesses: this.callProcesses,
149
+ processTracker: this.processTracker,
125
150
  resolveCwd: async (input) => {
126
151
  const cwd = await this.resolveExistingPath(input);
127
152
  if (!(await stat(cwd)).isDirectory()) throw new Error("cwd is not a directory");
@@ -139,12 +164,30 @@ export class LocalRuntime {
139
164
  home: agentHome,
140
165
  codexHome,
141
166
  });
167
+ this.processExecutionService = new ProcessExecutionService({
168
+ workspace: this.workspace,
169
+ policy: this.policy,
170
+ policyGate: this.policyGate,
171
+ runtimeDir: this.runtimeDir,
172
+ processTracker: this.processTracker,
173
+ resolveExistingPath: (value) => this.resolveExistingPath(value),
174
+ resolveLocalCommand: (args, context) => this.agentContextManager.resolveLocalCommand(args, context),
175
+ displayPath: (value) => this.displayPath(value),
176
+ throwIfCancelled: (context) => this.throwIfCancelled(context),
177
+ });
178
+ this.gitService = new GitService({
179
+ resolveExistingPath: (value) => this.resolveExistingPath(value),
180
+ displayPath: (value) => this.displayPath(value),
181
+ runProcess: (...args) => this.processExecutionService.run(...args),
182
+ maximumBytes: MAX_WRITE_BYTES,
183
+ });
142
184
  const runProcess = (cmd, argv, timeoutMs, allowFailure, maxOutputBytes, context, cwd, stdin) => this.runProcess(cmd, argv, timeoutMs, allowFailure, maxOutputBytes, context, cwd, stdin);
143
185
  const readResourceText = (name) => this.readLocalResourceText(name);
144
186
  const readResourceBinary = (name) => this.readLocalResourceBinary(name);
145
187
  this.appAutomationManager = new AppAutomationManager({
146
188
  ...applicationAutomation,
147
189
  policy: this.policy,
190
+ authorizeTool: (tool) => this.policyGate.assert(tool),
148
191
  displayPath: (value) => this.displayPath(value),
149
192
  runProcess,
150
193
  readResourceText,
@@ -152,12 +195,25 @@ export class LocalRuntime {
152
195
  });
153
196
  this.browserBridgeManager = new BrowserBridgeManager({
154
197
  policy: this.policy,
198
+ authorizeTool: (tool) => this.policyGate.assert(tool),
155
199
  stateRoot: browserStateRoot,
156
200
  runProcess,
157
201
  readResourceText,
158
202
  readResourceBinary,
159
203
  throwIfCancelled: (context) => this.throwIfCancelled(context),
160
204
  });
205
+ this.toolExecutor = new ToolExecutor({
206
+ handlers: Object.fromEntries(Object.entries(RUNTIME_TOOL_HANDLERS).map(([name, handler]) => [
207
+ name,
208
+ (args, context) => handler(this, args, context),
209
+ ])),
210
+ policyGate: this.policyGate,
211
+ callRegistry: this.callRegistry,
212
+ observability: this.observability,
213
+ logger: this.logger,
214
+ safeMessage: (error, args) => this.safeErrorMessage(error, args),
215
+ slowMs: SLOW_TOOL_CALL_MS,
216
+ });
161
217
  this.relay = createRelayConnection(this, {
162
218
  workerUrl: remoteWorkerUrl,
163
219
  secret: remoteSecret,
@@ -167,7 +223,7 @@ export class LocalRuntime {
167
223
  }
168
224
 
169
225
  tools() {
170
- return toolNamesForPolicy(this.policy).filter((name) => name !== "server_info");
226
+ return this.policyGate.names().filter((name) => name !== "server_info");
171
227
  }
172
228
 
173
229
  runtimeInfo() {
@@ -200,15 +256,19 @@ export class LocalRuntime {
200
256
  relay_readiness: "authenticated-hello-acknowledged",
201
257
  brief_relay_interruptions: "debug-only",
202
258
  raw_transport_details: "debug-only",
203
- per_tool_events: "debug-only",
259
+ per_tool_events: "structured-debug-events",
204
260
  default_logs_include_tool_failures: false,
205
261
  tool_arguments_or_results_logged: false,
206
262
  capability_routing: this.capabilityObserver.snapshot(),
263
+ tool_calls: this.observability.snapshot(),
264
+ in_flight_calls: this.callRegistry.snapshot(),
207
265
  },
208
266
  runtime: {
209
267
  environment: this.policy.minimalEnv ? "isolated-minimal" : "full-parent",
268
+ lifecycle: this.lifecycle.snapshot(),
210
269
  relay: this.relay?.status?.() || null,
211
270
  runtime_dir: this.policy.exposeAbsolutePaths ? this.runtimeDir : "<private-runtime-dir>",
271
+ processes: this.processTracker.snapshot(),
212
272
  process_sessions: this.processSessionManager.status(),
213
273
  managed_jobs: this.managedJobManager.status(),
214
274
  local_resources: this.managedJobManager.resourceInfo(),
@@ -218,20 +278,33 @@ export class LocalRuntime {
218
278
 
219
279
  async start() {
220
280
  if (!this.relay) throw new Error("remote daemon start requires a Worker URL and daemon secret");
281
+ if (!this.lifecycle.beginStart()) return;
221
282
  if (this.policy.profile === "full") {
222
283
  void this.browserBridgeManager.ensureStarted().catch((error) => {
223
284
  this.logger.warn?.("browser bridge did not start; browser tools remain unavailable", { error_class: classifyOperationalError(error) });
224
285
  });
225
286
  }
226
- return this.relay.start();
287
+ try {
288
+ await this.relay.start();
289
+ this.lifecycle.markRunning();
290
+ } catch (error) {
291
+ this.lifecycle.markFailed(error);
292
+ throw error;
293
+ }
227
294
  }
228
295
 
229
296
  stop() {
230
- this.relay?.stop();
231
- this.terminateActiveProcesses("SIGKILL");
232
- this.processSessionManager.clear();
233
- this.browserBridgeManager?.stop();
234
- rmSync(this.runtimeDir, { recursive: true, force: true });
297
+ if (!this.lifecycle.beginStop()) return;
298
+ try {
299
+ this.relay?.stop();
300
+ this.callRegistry.cancelAll("runtime stopped");
301
+ this.terminateActiveProcesses("SIGKILL");
302
+ this.processSessionManager.clear();
303
+ this.browserBridgeManager?.stop();
304
+ rmSync(this.runtimeDir, { recursive: true, force: true });
305
+ } finally {
306
+ this.lifecycle.markStopped();
307
+ }
235
308
  }
236
309
 
237
310
  send(value) {
@@ -288,58 +361,39 @@ export class LocalRuntime {
288
361
  async handleRelayToolCall(message) {
289
362
  const envelope = normalizeRelayToolCall(message);
290
363
  if (!envelope.ok) {
291
- this.logger.warn?.("invalid tool_call envelope");
292
- if (envelope.id) this.send({ type: "tool_result", id: envelope.id, ok: false, error: { message: "invalid tool_call envelope" } });
293
- return;
294
- }
295
- if (this.activeToolCalls >= MAX_CONCURRENT_TOOL_CALLS) {
296
- this.send({ type: "tool_result", id: envelope.id, ok: false, error: { message: "too many concurrent tool calls" } });
364
+ this.logger.event?.("warn", "relay.tool_call.invalid", { has_call_id: Boolean(envelope.id) });
365
+ if (envelope.id) this.send({ type: "tool_result", id: envelope.id, ok: false, error: { code: "invalid_request", message: "invalid tool_call envelope", retryable: false } });
297
366
  return;
298
367
  }
299
-
300
- const deadline = setTimeout(() => this.cancelCall(envelope.id, "relay deadline exceeded"), envelope.timeoutMs);
301
- deadline.unref?.();
302
- this.activeToolCalls += 1;
303
- const started = Date.now();
304
- this.logger.debug?.("tool call started", { call_id: shortCallId(envelope.id), tool: envelope.tool });
305
368
  try {
306
- const result = await this.executeTool(envelope.tool, envelope.arguments, { callId: envelope.id });
307
- if (this.cancelledCalls.has(envelope.id)) throw new Error("tool call cancelled");
369
+ const result = await this.executeTool(envelope.tool, envelope.arguments, {
370
+ callId: envelope.id,
371
+ origin: "relay",
372
+ timeoutMs: envelope.timeoutMs,
373
+ });
308
374
  this.send({ type: "tool_result", id: envelope.id, ok: true, result });
309
- const durationMs = Date.now() - started;
310
- this.logger.debug?.(durationMs >= SLOW_TOOL_CALL_MS ? "slow tool call completed" : "tool call completed", { call_id: shortCallId(envelope.id), tool: envelope.tool, duration_ms: durationMs });
311
375
  } catch (error) {
312
- const safeError = this.safeErrorMessage(error, envelope.arguments);
313
- this.send({ type: "tool_result", id: envelope.id, ok: false, error: { message: safeError } });
314
- const durationMs = Date.now() - started;
315
- this.logger.debug?.("tool call failed", { call_id: shortCallId(envelope.id), tool: envelope.tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
316
- } finally {
317
- clearTimeout(deadline);
318
- this.activeToolCalls -= 1;
319
- this.finishCall(envelope.id);
376
+ this.send({ type: "tool_result", id: envelope.id, ok: false, error: publicError(error) });
320
377
  }
321
378
  }
322
379
 
323
380
  finishCall(callId) {
324
381
  if (!callId) return;
325
- this.cancelledCalls.delete(callId);
326
- this.callProcesses.delete(callId);
382
+ this.callRegistry.finish(callId);
327
383
  }
328
384
 
329
385
  cancelCall(callId, reason = "cancelled") {
330
- this.cancelledCalls.add(callId);
331
- this.processSessionManager.notifyCancellation();
332
- this.browserBridgeManager?.cancelCall(callId);
333
- const children = [...(this.callProcesses.get(callId) || [])];
334
- for (const child of children) terminateProcessTreeWithEscalation(child);
335
- this.logger.debug?.("tool call cancellation requested", { call_id: shortCallId(callId), reason });
386
+ return this.callRegistry.cancel(callId, reason);
336
387
  }
337
388
 
338
389
  async executeTool(tool, args, context = {}) {
339
- if (!["server_info", ...this.tools()].includes(tool)) throw new Error(`tool disabled or unknown: ${tool}`);
340
- const handler = RUNTIME_TOOL_HANDLERS[tool];
341
- if (!handler) throw new Error(`runtime handler is missing for tool: ${tool}`);
342
- return handler(this, args, context);
390
+ this.lifecycle.assertOperational();
391
+ return this.toolExecutor.execute(tool, args, {
392
+ callId: context.callId,
393
+ origin: context.origin || "local",
394
+ timeoutMs: context.timeoutMs,
395
+ context,
396
+ });
343
397
  }
344
398
 
345
399
  async projectOverview(context = {}) {
@@ -357,318 +411,38 @@ export class LocalRuntime {
357
411
  };
358
412
  }
359
413
 
360
- listRoots() {
361
- const roots = [{ name: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace", path: this.displayPath(this.workspace), default: true }];
362
- if (this.policy.unrestrictedPaths) {
363
- const home = process.env.HOME || process.env.USERPROFILE;
364
- if (home && home !== this.workspace) roots.push({ name: "home", path: this.displayPath(resolve(home)), default: false });
365
- roots.push({ name: "filesystem-root", path: this.displayPath(path.parse(this.workspace).root), default: false });
366
- }
367
- return { roots };
368
- }
369
-
370
- async listDir(inputPath, context = {}) {
371
- const full = await this.resolveExistingPath(inputPath);
372
- const entries = [];
373
- let resultBytes = 0;
374
- let truncated = false;
375
- for await (const entry of await opendir(full)) {
376
- this.throwIfCancelled(context);
377
- const entryPath = resolve(full, entry.name);
378
- const info = await lstat(entryPath).catch(() => null);
379
- const item = {
380
- name: entry.name,
381
- path: this.displayPath(entryPath),
382
- type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other",
383
- size: info?.size ?? 0,
384
- };
385
- const itemBytes = Buffer.byteLength(item.name) + Buffer.byteLength(item.path) + 64;
386
- if (entries.length >= MAX_DIRECTORY_ENTRIES || resultBytes + itemBytes > MAX_PATH_RESULT_BYTES) {
387
- truncated = true;
388
- break;
389
- }
390
- entries.push(item);
391
- resultBytes += itemBytes;
392
- }
393
- entries.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
394
- return { path: this.displayPath(full), entries, truncated };
395
- }
414
+ listRoots() { return this.workspaceFileService.listRoots(); }
396
415
 
397
- async listFiles(inputPath, maxFiles, context = {}) {
398
- const root = await this.resolveExistingPath(inputPath);
399
- const info = await stat(root);
400
- if (info.isFile()) return { path: this.displayPath(root), files: [this.displayPath(root)], truncated: false };
401
- if (!info.isDirectory()) throw new Error("path is not a file or directory");
402
- const files = [];
403
- let resultBytes = 0;
404
- const walkResult = await this.walk(root, async full => {
405
- this.throwIfCancelled(context);
406
- const shown = this.displayPath(full);
407
- const pathBytes = Buffer.byteLength(shown) + 8;
408
- if (files.length >= maxFiles || resultBytes + pathBytes > MAX_PATH_RESULT_BYTES) return false;
409
- files.push(shown);
410
- resultBytes += pathBytes;
411
- return true;
412
- }, context);
413
- return { path: this.displayPath(root), files, truncated: files.length >= maxFiles || resultBytes >= MAX_PATH_RESULT_BYTES || walkResult.truncated };
414
- }
415
-
416
- async readFile(args, context = {}) {
417
- if (typeof args === "string") {
418
- args = { path: args, max_bytes: typeof context === "number" ? context : undefined };
419
- context = {};
420
- }
421
- if (!args.path) throw new Error("path is required");
422
- const full = await this.resolveExistingPath(args.path);
423
- this.throwIfCancelled(context);
424
- const { buffer, info } = await readBoundedFile(full, MAX_WRITE_BYTES, "readable text file");
425
- const content = decodeUtf8(buffer);
426
- this.throwIfCancelled(context);
427
- const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, MAX_WRITE_BYTES);
428
- const startLine = args.start_line === undefined ? 1 : clampInt(args.start_line, 1, 1, Number.MAX_SAFE_INTEGER);
429
- const rawLines = content.split(/\r?\n/);
430
- const totalLines = content.endsWith("\n") ? Math.max(1, rawLines.length - 1) : rawLines.length;
431
- const endLine = args.end_line === undefined ? totalLines : clampInt(args.end_line, totalLines, 1, Number.MAX_SAFE_INTEGER);
432
- if (endLine < startLine) throw new Error("end_line must be greater than or equal to start_line");
433
- if (startLine > totalLines) throw new Error(`start_line exceeds total lines (${startLine} > ${totalLines})`);
434
- const selectedEnd = Math.min(endLine, totalLines);
435
- let selected = rawLines.slice(startLine - 1, selectedEnd).join("\n");
436
- if (selectedEnd < totalLines || content.endsWith("\n")) selected += "\n";
437
- const selectedBytes = Buffer.byteLength(selected);
438
- if (selectedBytes > maxBytes) throw new Error(`selected content exceeds max_bytes (${selectedBytes} > ${maxBytes})`);
439
- return {
440
- path: this.displayPath(full),
441
- size: info.size,
442
- sha256: sha256(content),
443
- content: selected,
444
- start_line: startLine,
445
- end_line: selectedEnd,
446
- total_lines: totalLines,
447
- complete: startLine === 1 && selectedEnd === totalLines,
448
- };
449
- }
416
+ listDir(pathValue, context = {}) { return this.workspaceFileService.listDir(pathValue, context); }
450
417
 
451
- async viewImage(args, context = {}) {
452
- if (!args.path) throw new Error("path is required");
453
- const full = await this.resolveExistingPath(args.path);
454
- this.throwIfCancelled(context);
455
- const { buffer, info } = await readBoundedFile(full, MAX_IMAGE_BYTES, "image");
456
- this.throwIfCancelled(context);
457
- const mimeType = detectImageMime(buffer);
458
- if (!mimeType) throw new Error("unsupported image format; expected PNG, JPEG, GIF, or WebP");
459
- return {
460
- $mcp: {
461
- content: [{ type: "image", data: buffer.toString("base64"), mimeType }],
462
- structuredContent: {
463
- path: this.displayPath(full),
464
- size: info.size,
465
- sha256: createHash("sha256").update(buffer).digest("hex"),
466
- mime_type: mimeType,
467
- },
468
- },
469
- };
470
- }
418
+ listFiles(pathValue, maxFiles, context = {}) { return this.workspaceFileService.listFiles(pathValue, maxFiles, context); }
471
419
 
472
- async writeFile(args, context = {}) {
473
- return this.withMutationLock(async () => {
474
- this.throwIfCancelled(context);
475
- if (!this.policy.allowWrite) throw new Error("write_file is disabled by daemon policy");
476
- if (!args.path) throw new Error("path is required");
477
- const content = String(args.content ?? "");
478
- const bytes = Buffer.byteLength(content);
479
- if (bytes > MAX_WRITE_BYTES) throw new Error(`content exceeds maximum write size (${bytes} > ${MAX_WRITE_BYTES})`);
480
- const full = await this.resolveWritePath(args.path);
481
- const existing = await lstat(full).catch(() => null);
482
- if (existing?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
483
- if (args.create_only && existing) throw new Error("file exists and create_only=true");
484
- if (existing && !existing.isFile()) throw new Error("path is not a regular file");
485
- if (args.expected_sha256) {
486
- if (!existing) throw new Error("expected_sha256 requires an existing file");
487
- const current = await readUtf8File(full);
488
- if (sha256(current) !== String(args.expected_sha256).toLowerCase()) throw new Error("expected_sha256 mismatch");
489
- }
490
- this.throwIfCancelled(context);
491
- await atomicWriteText(full, content, existing, {
492
- createOnly: args.create_only === true,
493
- expectedHash: args.expected_sha256 ? String(args.expected_sha256).toLowerCase() : undefined,
494
- });
495
- return { ok: true, path: this.displayPath(full), sha256: sha256(content), bytes };
496
- });
497
- }
420
+ readFile(args, context = {}) { return this.workspaceFileService.readFile(args, context); }
498
421
 
499
- async editFile(args, context = {}) {
500
- return this.withMutationLock(async () => {
501
- this.throwIfCancelled(context);
502
- if (!this.policy.allowWrite) throw new Error("edit_file is disabled by daemon policy");
503
- if (!args.path) throw new Error("path is required");
504
- const oldText = String(args.old_text ?? "");
505
- const newText = String(args.new_text ?? "");
506
- if (!oldText) throw new Error("old_text must not be empty");
507
- const full = await this.resolveExistingPath(args.path);
508
- const info = await lstat(full);
509
- if (!info.isFile() || info.isSymbolicLink()) throw new Error("path is not a regular non-symbolic-link file");
510
- const current = await readUtf8File(full);
511
- if (args.expected_sha256 && sha256(current) !== String(args.expected_sha256).toLowerCase()) throw new Error("expected_sha256 mismatch");
512
- const occurrences = countOccurrences(current, oldText);
513
- if (occurrences === 0) throw new Error("old_text was not found");
514
- if (!args.replace_all && occurrences !== 1) throw new Error(`old_text occurs ${occurrences} times; provide a unique fragment or set replace_all=true`);
515
- const updated = args.replace_all ? current.split(oldText).join(newText) : current.replace(oldText, newText);
516
- const bytes = Buffer.byteLength(updated);
517
- if (bytes > MAX_WRITE_BYTES) throw new Error(`edited content exceeds maximum write size (${bytes} > ${MAX_WRITE_BYTES})`);
518
- this.throwIfCancelled(context);
519
- await atomicWriteText(full, updated, info, { expectedHash: sha256(current) });
520
- return { ok: true, path: this.displayPath(full), replacements: args.replace_all ? occurrences : 1, sha256: sha256(updated), bytes };
521
- });
522
- }
422
+ viewImage(args, context = {}) { return this.workspaceFileService.viewImage(args, context); }
523
423
 
524
- async applyPatch(args, context = {}) {
525
- return this.withMutationLock(async () => {
526
- this.throwIfCancelled(context);
527
- if (!this.policy.allowWrite) throw new Error("apply_patch is disabled by daemon policy");
528
- const patchText = String(args.patch ?? "");
529
- if (!patchText) throw new Error("patch is required");
530
- if (Buffer.byteLength(patchText) > MAX_WRITE_BYTES) throw new Error("patch exceeds maximum size");
531
- const parsed = parsePatchEnvelope(patchText);
532
- const prepared = [];
533
- for (const operation of parsed) {
534
- this.throwIfCancelled(context);
535
- if (operation.kind === "add") {
536
- const target = await this.resolveWritePath(operation.path);
537
- if (await lstat(target).catch(() => null)) throw new Error(`add target already exists: ${operation.path}`);
538
- assertTextSize(operation.content, operation.path);
539
- prepared.push({ kind: "add", source: null, target, content: operation.content, mode: 0o600 });
540
- continue;
541
- }
542
- const source = await this.resolveExistingPath(operation.path);
543
- const sourceInfo = await lstat(source);
544
- if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink()) throw new Error(`patch source is not a regular file: ${operation.path}`);
545
- const original = await readUtf8File(source);
546
- if (operation.kind === "delete") {
547
- prepared.push({ kind: "delete", source, target: null, originalHash: sha256(original), mode: sourceInfo.mode & 0o777 });
548
- continue;
549
- }
550
- const content = applyUpdateHunks(original, operation.hunks, operation.path);
551
- assertTextSize(content, operation.path);
552
- const target = operation.moveTo ? await this.resolveWritePath(operation.moveTo) : source;
553
- if (target !== source && await lstat(target).catch(() => null)) throw new Error(`move target already exists: ${operation.moveTo}`);
554
- prepared.push({ kind: operation.moveTo ? "move" : "update", source, target, content, originalHash: sha256(original), mode: sourceInfo.mode & 0o777 });
555
- }
556
- assertNoResolvedPatchCollisions(prepared);
557
- this.throwIfCancelled(context);
558
- await commitPatchTransaction(prepared);
559
- return {
560
- ok: true,
561
- files: prepared.map((item) => ({
562
- operation: item.kind,
563
- path: this.displayPath(item.target || item.source),
564
- from: item.kind === "move" ? this.displayPath(item.source) : undefined,
565
- sha256: item.content === undefined ? undefined : sha256(item.content),
566
- })),
567
- };
568
- });
569
- }
424
+ writeFile(args, context = {}) { return this.workspaceFileService.writeFile(args, context); }
570
425
 
571
- async searchText(args, context = {}) {
572
- const query = String(args.query || "");
573
- if (!query) throw new Error("query is required");
574
- const root = await this.resolveExistingPath(args.path || ".");
575
- const max = clampInt(args.max_matches, 100, 1, 1000);
576
- const maxFiles = clampInt(args.max_files, 10000, 1, 100000);
577
- let visitedFiles = 0;
578
- const matches = [];
579
- const rootInfo = await stat(root);
580
- if (rootInfo.isFile()) {
581
- await this.searchOneFile(root, query, matches, max, context);
582
- return { query, root: this.displayPath(root), matches, visited_files: 1, truncated: matches.length >= max };
583
- }
584
- if (!rootInfo.isDirectory()) throw new Error("path is not a file or directory");
585
- const walkResult = await this.walk(root, async full => {
586
- this.throwIfCancelled(context);
587
- if (matches.length >= max || visitedFiles >= maxFiles) return false;
588
- visitedFiles += 1;
589
- await this.searchOneFile(full, query, matches, max, context);
590
- return matches.length < max && visitedFiles < maxFiles;
591
- }, context);
592
- return { query, root: this.displayPath(root), matches, visited_files: visitedFiles, truncated: matches.length >= max || visitedFiles >= maxFiles || walkResult.truncated };
593
- }
426
+ editFile(args, context = {}) { return this.workspaceFileService.editFile(args, context); }
594
427
 
595
- async searchOneFile(full, query, matches, max, context = {}) {
596
- this.throwIfCancelled(context);
597
- const bounded = await readBoundedFile(full, 1024 * 1024, "search file").catch(() => null);
598
- if (!bounded || bounded.buffer.includes(0)) return;
599
- const buffer = bounded.buffer;
600
- let text;
601
- try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch { return; }
602
- if (!text) return;
603
- const lines = text.split(/\r?\n/);
604
- for (let index = 0; index < lines.length; index += 1) {
605
- if (lines[index].includes(query)) {
606
- matches.push({ path: this.displayPath(full), line: index + 1, text: lines[index].slice(0, 500) });
607
- if (matches.length >= max) break;
608
- }
609
- }
610
- }
428
+ applyPatch(args, context = {}) { return this.workspaceFileService.applyPatch(args, context); }
611
429
 
612
- async gitStatus(args = {}, context = {}) {
613
- const git = await this.gitContext(args.path || ".", context);
614
- if (!git.ok) return git.result;
615
- const commandArgs = ["-c", "core.fsmonitor=false", "-C", git.root, "status", "--short", "--branch"];
616
- if (git.pathspec) commandArgs.push("--", git.pathspec);
617
- const result = await this.runProcess("git", commandArgs, 30_000, true, 512 * 1024, context);
618
- return { ...result, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root) };
619
- }
430
+ searchText(args, context = {}) { return this.workspaceFileService.searchText(args, context); }
620
431
 
621
- async gitDiff(args = {}, context = {}) {
622
- const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, MAX_WRITE_BYTES);
623
- const git = await this.gitContext(args.path || ".", context);
624
- if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
625
- const commandArgs = ["-c", "core.fsmonitor=false", "-c", "diff.external=", "-C", git.root, "diff", "--no-ext-diff", "--no-textconv"];
626
- if (args.staged) commandArgs.push("--cached");
627
- if (git.pathspec) commandArgs.push("--", git.pathspec);
628
- const result = await this.runProcess("git", commandArgs, 60_000, true, maxBytes, context);
629
- return { ...result, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root), staged: args.staged === true };
432
+ gitStatus(args = {}, context = {}) {
433
+ return this.gitService.status(args, context);
630
434
  }
631
435
 
632
- async gitLog(args = {}, context = {}) {
633
- const git = await this.gitContext(args.path || ".", context);
634
- if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
635
- const maxCount = clampInt(args.max_count, 20, 1, 100);
636
- const format = "%H%x1f%h%x1f%aI%x1f%an%x1f%ae%x1f%s%x1e";
637
- const commandArgs = ["-c", "core.fsmonitor=false", "-C", git.root, "log", `--max-count=${maxCount}`, `--format=${format}`];
638
- if (git.pathspec) commandArgs.push("--", git.pathspec);
639
- const result = await this.runProcess("git", commandArgs, 30_000, true, 1024 * 1024, context);
640
- const commits = result.stdout.split("\x1e").map((record) => record.trim()).filter(Boolean).map((record) => {
641
- const [hash, short, authored_at, author_name, author_email, subject] = record.split("\x1f");
642
- const commit = { hash, short, authored_at, author_name, subject };
643
- if (args.include_author_email === true) commit.author_email = author_email;
644
- return commit;
645
- });
646
- return { code: result.code, stderr: result.stderr, commits, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root) };
436
+ gitDiff(args = {}, context = {}) {
437
+ return this.gitService.diff(args, context);
647
438
  }
648
439
 
649
- async gitShow(args = {}, context = {}) {
650
- const git = await this.gitContext(args.path || ".", context);
651
- if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
652
- const revision = validateRevision(args.revision || "HEAD");
653
- const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, MAX_WRITE_BYTES);
654
- const commandArgs = ["-c", "core.fsmonitor=false", "-c", "diff.external=", "-C", git.root, "show", "--no-ext-diff", "--no-textconv", "--decorate=no", revision];
655
- if (git.pathspec) commandArgs.push("--", git.pathspec);
656
- const result = await this.runProcess("git", commandArgs, 60_000, true, maxBytes, context);
657
- return { ...result, revision, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root) };
440
+ gitLog(args = {}, context = {}) {
441
+ return this.gitService.log(args, context);
658
442
  }
659
443
 
660
- async gitContext(inputPath, context = {}) {
661
- const target = await this.resolveExistingPath(inputPath);
662
- const info = await stat(target);
663
- const cwd = info.isDirectory() ? target : dirname(target);
664
- const result = await this.runProcess("git", ["-c", "core.fsmonitor=false", "-C", cwd, "rev-parse", "--show-toplevel"], 10_000, true, 512 * 1024, context);
665
- if (result.code !== 0) return { ok: false, result, target };
666
- const root = result.stdout.trim();
667
- const repoRelative = relative(root, target);
668
- if (repoRelative.startsWith(`..${sep}`) || repoRelative === ".." || isAbsolute(repoRelative)) {
669
- return { ok: false, target, result: { code: 128, stdout: "", stderr: "target is outside the detected git repository" } };
670
- }
671
- return { ok: true, target, root, pathspec: repoRelative || "" };
444
+ gitShow(args = {}, context = {}) {
445
+ return this.gitService.show(args, context);
672
446
  }
673
447
 
674
448
  async diagnoseRuntime(context = {}) {
@@ -716,8 +490,7 @@ export class LocalRuntime {
716
490
  }
717
491
 
718
492
  if (this.policy.execMode === "shell") {
719
- const shell = workspaceShellCommand(process.platform === "win32" ? "cd" : "pwd");
720
- const result = await this.runProcess(shell.cmd, shell.args, 5000, true, 4096, context, this.workspace)
493
+ const result = await this.processExecutionService.probeShell(context)
721
494
  .catch((error) => ({ code: 127, error_class: classifyOperationalError(error) }));
722
495
  checks.push({
723
496
  layer: "local-shell",
@@ -753,7 +526,7 @@ export class LocalRuntime {
753
526
 
754
527
  async generateSshKeyResource(args = {}, context = {}) {
755
528
  this.throwIfCancelled(context);
756
- assertCanonicalFullPolicy(this.policy);
529
+ this.policyGate.assert("generate_ssh_key_resource");
757
530
  if (!this.resourceStatePath) throw new Error("local resource state is unavailable in this runtime");
758
531
  const home = process.env.HOME || process.env.USERPROFILE;
759
532
  if (!home) throw new Error("HOME or USERPROFILE is required to choose a default SSH key path");
@@ -843,149 +616,24 @@ export class LocalRuntime {
843
616
  }
844
617
  }
845
618
 
846
- async runDirectProcess(args, context = {}) {
847
- if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") throw new Error("run_process is disabled by daemon policy");
848
- const argv = validateArgv(args.argv);
849
- const cwd = await this.resolveExistingPath(args.cwd || ".");
850
- if (!(await stat(cwd)).isDirectory()) throw new Error("cwd is not a directory");
851
- return this.runProcess(argv[0], argv.slice(1), clampInt(args.timeout_seconds, 120, 1, 600) * 1000, false, 512 * 1024, context, cwd);
619
+ runDirectProcess(args, context = {}) {
620
+ return this.processExecutionService.runDirect(args, context);
852
621
  }
853
622
 
854
- async runLocalCommand(args, context = {}) {
855
- if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") throw new Error("run_local_command is disabled by daemon policy");
856
- const command = await this.agentContextManager.resolveLocalCommand(args, context);
857
- const argv = validateArgv(command.argv);
858
- const cwd = await this.resolveExistingPath(command.cwd);
859
- if (!(await stat(cwd)).isDirectory()) throw new Error("registered command cwd is not a directory");
860
- const requestedTimeout = args.timeout_seconds === undefined
861
- ? command.timeoutSeconds
862
- : clampInt(args.timeout_seconds, command.timeoutSeconds, 1, 600);
863
- const timeoutSeconds = Math.min(requestedTimeout, command.timeoutSeconds);
864
- const result = await this.runProcess(argv[0], argv.slice(1), timeoutSeconds * 1000, false, 512 * 1024, context, cwd);
865
- return {
866
- name: command.name,
867
- cwd: this.displayPath(cwd),
868
- timeout_seconds: timeoutSeconds,
869
- ...result,
870
- };
623
+ runLocalCommand(args, context = {}) {
624
+ return this.processExecutionService.runRegistered(args, context);
871
625
  }
872
626
 
873
- async execCommand(command, timeoutSeconds, context = {}) {
874
- if (this.policy.execMode !== "shell") throw new Error("exec_command requires shell execution mode");
875
- if (!command || typeof command !== "string") throw new Error("command is required");
876
- if (command.includes("\0")) throw new Error("command contains a NUL byte");
877
- if (Buffer.byteLength(command) > MAX_COMMAND_BYTES) throw new Error(`command exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
878
- const shell = workspaceShellCommand(command);
879
- return this.runProcess(shell.cmd, shell.args, clampInt(timeoutSeconds, 120, 1, 600) * 1000, false, 512 * 1024, context);
627
+ execCommand(command, timeoutSeconds, context = {}) {
628
+ return this.processExecutionService.runShell(command, timeoutSeconds, context);
880
629
  }
881
630
 
882
631
  terminateActiveProcesses(signal = "SIGTERM", escalate = false) {
883
- const children = [...this.activeProcesses];
884
- for (const child of children) {
885
- if (escalate && signal !== "SIGKILL") terminateProcessTreeWithEscalation(child);
886
- else terminateProcessTree(child, signal);
887
- }
888
- }
889
-
890
- async runProcess(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = 512 * 1024, context = {}, cwd = this.workspace, stdin = null) {
891
- this.throwIfCancelled(context);
892
- return new Promise((resolvePromise, reject) => {
893
- if (stdin !== null && Buffer.byteLength(String(stdin)) > 1024 * 1024) throw new Error("process stdin exceeds 1 MiB");
894
- const child = spawn(cmd, args, {
895
- cwd,
896
- env: executionEnv(this.workspace, { fullEnv: this.policy.minimalEnv === false, runtimeDir: this.runtimeDir }),
897
- detached: process.platform !== "win32",
898
- windowsHide: true,
899
- });
900
- this.activeProcesses.add(child);
901
- if (stdin !== null) {
902
- child.stdin.on("error", () => {});
903
- child.stdin.end(String(stdin));
904
- }
905
- if (context.callId) {
906
- const set = this.callProcesses.get(context.callId) || new Set();
907
- set.add(child);
908
- this.callProcesses.set(context.callId, set);
909
- }
910
- let stdout = "";
911
- let stderr = "";
912
- let stdoutTruncated = 0;
913
- let stderrTruncated = 0;
914
- let timedOut = false;
915
- let settled = false;
916
- let killTimer = null;
917
- const timer = setTimeout(() => {
918
- timedOut = true;
919
- killTimer = terminateProcessTreeWithEscalation(child);
920
- }, timeoutMs);
921
- timer.unref?.();
922
- const cleanup = () => {
923
- clearTimeout(timer);
924
- if (killTimer && !timedOut) clearTimeout(killTimer);
925
- this.activeProcesses.delete(child);
926
- if (context.callId) {
927
- const set = this.callProcesses.get(context.callId);
928
- set?.delete(child);
929
- if (!set?.size) this.callProcesses.delete(context.callId);
930
- }
931
- };
932
- const finish = callback => {
933
- if (settled) return;
934
- settled = true;
935
- cleanup();
936
- callback();
937
- };
938
- child.stdout.on("data", chunk => {
939
- const next = appendLimited(stdout, chunk, maxOutputBytes);
940
- stdout = next.value;
941
- stdoutTruncated += next.truncated;
942
- });
943
- child.stderr.on("data", chunk => {
944
- const next = appendLimited(stderr, chunk, maxOutputBytes);
945
- stderr = next.value;
946
- stderrTruncated += next.truncated;
947
- });
948
- child.on("error", error => finish(() => {
949
- if (allowFailure) resolvePromise({ code: 127, stdout: finalizeOutput(stdout, stdoutTruncated), stderr: boundedErrorMessage(error) });
950
- else reject(error);
951
- }));
952
- child.on("close", code => finish(() => {
953
- const result = { code, stdout: finalizeOutput(stdout, stdoutTruncated), stderr: finalizeOutput(stderr, stderrTruncated) };
954
- if (context.callId && this.cancelledCalls.has(context.callId)) {
955
- reject(new Error("tool call cancelled"));
956
- return;
957
- }
958
- if (timedOut) {
959
- reject(new Error(`command timed out after ${timeoutMs}ms`));
960
- return;
961
- }
962
- if (code === 0 || allowFailure) resolvePromise(result);
963
- else reject(new Error(stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
964
- }));
965
- });
632
+ this.processExecutionService.terminateAll(signal, escalate);
966
633
  }
967
634
 
968
- async walk(root, onFile, context = {}) {
969
- const stack = [root];
970
- let visitedEntries = 0;
971
- while (stack.length) {
972
- this.throwIfCancelled(context);
973
- const current = stack.pop();
974
- const entries = await opendir(current).catch(() => null);
975
- if (!entries) continue;
976
- for await (const entry of entries) {
977
- this.throwIfCancelled(context);
978
- visitedEntries += 1;
979
- if (visitedEntries > MAX_WALK_ENTRIES) return { truncated: true, visitedEntries };
980
- const full = resolve(current, entry.name);
981
- if (entry.isDirectory()) stack.push(full);
982
- else if (entry.isFile()) {
983
- const keepGoing = await onFile(full);
984
- if (keepGoing === false) return { truncated: true, visitedEntries };
985
- }
986
- }
987
- }
988
- return { truncated: false, visitedEntries };
635
+ runProcess(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = 512 * 1024, context = {}, cwd = this.workspace, stdin = null) {
636
+ return this.processExecutionService.run(cmd, args, timeoutMs, allowFailure, maxOutputBytes, context, cwd, stdin);
989
637
  }
990
638
 
991
639
  resolvePath(inputPath = ".") {
@@ -999,6 +647,8 @@ export class LocalRuntime {
999
647
  this.workspaceCanonicalPromise = realpath(this.workspaceInput).then((canonical) => {
1000
648
  this.workspace = canonical;
1001
649
  this.processSessionManager.workspace = canonical;
650
+ this.processExecutionService.workspace = canonical;
651
+ this.workspaceFileService.workspace = canonical;
1002
652
  return canonical;
1003
653
  }).catch((error) => {
1004
654
  this.workspaceCanonicalPromise = null;
@@ -1058,7 +708,7 @@ export class LocalRuntime {
1058
708
  }
1059
709
 
1060
710
  throwIfCancelled(context = {}) {
1061
- if (context.callId && this.cancelledCalls.has(context.callId)) throw new Error("tool call cancelled");
711
+ this.callRegistry.throwIfCancelled(context);
1062
712
  }
1063
713
 
1064
714
  async withMutationLock(callback) {
@@ -1104,186 +754,12 @@ function assertContainedPath(root, target) {
1104
754
  throw new Error("path is outside the configured workspace; restart with --unrestricted-paths to allow it");
1105
755
  }
1106
756
 
1107
- async function readBoundedFile(filePath, maxBytes, label) {
1108
- const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
1109
- const handle = await open(filePath, flags);
1110
- try {
1111
- const info = await handle.stat();
1112
- if (!info.isFile()) throw new Error(`${label} is not a regular file`);
1113
- if (info.size > maxBytes) throw new Error(`${label} exceeds maximum size (${info.size} > ${maxBytes})`);
1114
- const buffer = Buffer.alloc(info.size);
1115
- let offset = 0;
1116
- while (offset < buffer.length) {
1117
- const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
1118
- if (bytesRead === 0) break;
1119
- offset += bytesRead;
1120
- }
1121
- return { buffer: buffer.subarray(0, offset), info };
1122
- } finally {
1123
- await handle.close();
1124
- }
1125
- }
1126
-
1127
- function decodeUtf8(buffer) {
1128
- try { return new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
1129
- throw new Error("file is not valid UTF-8 text");
1130
- }
1131
- }
1132
-
1133
- async function readUtf8File(filePath) {
1134
- const { buffer } = await readBoundedFile(filePath, MAX_WRITE_BYTES, "text file");
1135
- return decodeUtf8(buffer);
1136
- }
1137
-
1138
- async function atomicWriteText(full, content, existing = null, options = {}) {
1139
- await mkdir(dirname(full), { recursive: true });
1140
- const temp = join(dirname(full), `.${basename(full)}.mbm-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
1141
- try {
1142
- await writeFile(temp, content, { encoding: "utf8", flag: "wx", mode: existing ? existing.mode & 0o777 : 0o600 });
1143
- if (existing) await chmod(temp, existing.mode & 0o777).catch(() => {});
1144
- if (options.expectedHash) {
1145
- const current = await readUtf8File(full).catch(() => null);
1146
- if (current === null || sha256(current) !== options.expectedHash) throw new Error("file changed before atomic commit");
1147
- }
1148
- if (options.createOnly) {
1149
- await link(temp, full);
1150
- await rm(temp, { force: true });
1151
- } else {
1152
- await rename(temp, full);
1153
- }
1154
- } catch (error) {
1155
- await rm(temp, { force: true }).catch(() => {});
1156
- throw error;
1157
- }
1158
- }
1159
-
1160
- function assertNoResolvedPatchCollisions(operations) {
1161
- const owners = new Map();
1162
- for (const operation of operations) {
1163
- const paths = operation.source === operation.target
1164
- ? [operation.source]
1165
- : [operation.source, operation.target].filter(Boolean);
1166
- for (const full of paths) {
1167
- const key = process.platform === "win32" ? String(full).toLowerCase() : String(full);
1168
- const previous = owners.get(key);
1169
- if (previous && previous !== operation) throw new Error(`patch operations resolve to the same path: ${full}`);
1170
- owners.set(key, operation);
1171
- }
1172
- }
1173
- }
1174
-
1175
- async function commitPatchTransaction(operations) {
1176
- const staged = [];
1177
- const committed = [];
1178
- try {
1179
- for (const operation of operations) {
1180
- if (operation.content === undefined) continue;
1181
- await mkdir(dirname(operation.target), { recursive: true });
1182
- const temp = join(dirname(operation.target), `.${basename(operation.target)}.mbm-patch-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
1183
- await writeFile(temp, operation.content, { encoding: "utf8", flag: "wx", mode: operation.mode });
1184
- await chmod(temp, operation.mode).catch(() => {});
1185
- staged.push({ operation, temp });
1186
- }
1187
-
1188
- for (const operation of operations) {
1189
- if (operation.source) {
1190
- const current = await readUtf8File(operation.source);
1191
- if (sha256(current) !== operation.originalHash) throw new Error(`patch source changed during apply: ${operation.source}`);
1192
- }
1193
- if (operation.kind === "add" || operation.kind === "move") {
1194
- if (await lstat(operation.target).catch(() => null)) throw new Error(`patch target appeared during apply: ${operation.target}`);
1195
- }
1196
- }
1197
-
1198
- for (const operation of operations) {
1199
- let backup = null;
1200
- if (operation.source) {
1201
- backup = join(dirname(operation.source), `.${basename(operation.source)}.mbm-backup-${process.pid}-${randomBytes(6).toString("hex")}`);
1202
- await rename(operation.source, backup);
1203
- }
1204
- const record = { operation, backup, targetCreated: false };
1205
- committed.push(record);
1206
- const stage = staged.find((item) => item.operation === operation);
1207
- if (stage) {
1208
- await rename(stage.temp, operation.target);
1209
- record.targetCreated = true;
1210
- }
1211
- }
1212
- } catch (error) {
1213
- for (const item of committed.reverse()) {
1214
- if (item.targetCreated) await rm(item.operation.target, { force: true }).catch(() => {});
1215
- if (item.backup) await rename(item.backup, item.operation.source).catch(() => {});
1216
- }
1217
- throw error;
1218
- } finally {
1219
- for (const item of staged) await rm(item.temp, { force: true }).catch(() => {});
1220
- }
1221
- for (const item of committed) if (item.backup) await rm(item.backup, { force: true }).catch(() => {});
1222
- }
1223
-
1224
- function assertTextSize(content, label) {
1225
- const bytes = Buffer.byteLength(content);
1226
- if (bytes > MAX_WRITE_BYTES) throw new Error(`patched file exceeds maximum size for ${label} (${bytes} > ${MAX_WRITE_BYTES})`);
1227
- }
1228
-
1229
- function countOccurrences(content, needle) {
1230
- let count = 0;
1231
- let offset = 0;
1232
- while ((offset = content.indexOf(needle, offset)) !== -1) {
1233
- count += 1;
1234
- offset += needle.length;
1235
- }
1236
- return count;
1237
- }
1238
-
1239
- function validateRevision(value) {
1240
- const revision = String(value || "HEAD");
1241
- if (!revision || revision.length > 256 || revision.startsWith("-") || revision.includes("\0") || /[\r\n]/.test(revision)) throw new Error("invalid Git revision");
1242
- return revision;
1243
- }
1244
-
1245
- function boundedErrorMessage(error) {
1246
- const message = error instanceof Error ? error.message : String(error);
1247
- return message.replace(/[\r\n]+/g, " ").slice(0, 4096) || "tool call failed";
1248
- }
1249
-
1250
- export function sha256(value) {
1251
- return createHash("sha256").update(String(value)).digest("hex");
1252
- }
1253
-
1254
- function appendLimited(current, chunk, max) {
1255
- const text = String(chunk || "");
1256
- const budget = Math.max(0, max - Buffer.byteLength(current));
1257
- const textBytes = Buffer.byteLength(text);
1258
- if (textBytes <= budget) return { value: current + text, truncated: 0 };
1259
- const slice = Buffer.from(text).subarray(0, budget).toString();
1260
- return { value: current + slice, truncated: textBytes - Buffer.byteLength(slice) };
1261
- }
1262
-
1263
- function finalizeOutput(value, truncated) {
1264
- return truncated > 0 ? `${value}\n\n[truncated ${truncated} bytes]` : value;
1265
- }
1266
-
1267
- function clampInt(value, fallback, min, max) {
1268
- const parsed = Number.parseInt(String(value ?? ""), 10);
1269
- const number = Number.isFinite(parsed) ? parsed : fallback;
1270
- return Math.min(Math.max(number, min), max);
1271
- }
1272
-
1273
757
  function createRuntimeDir() {
1274
758
  const root = mkdtempSync(join(tmpdir(), "machine-bridge-mcp-"));
1275
759
  for (const name of ["home", "tmp", "cache"]) mkdirSync(join(root, name), { recursive: true, mode: 0o700 });
1276
760
  return root;
1277
761
  }
1278
762
 
1279
- function detectImageMime(buffer) {
1280
- if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
1281
- if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
1282
- if (buffer.length >= 6 && ["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString("ascii"))) return "image/gif";
1283
- if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
1284
- return "";
1285
- }
1286
-
1287
763
  function createRelayConnection(runtime, { workerUrl, secret, expectedVersion, onFatal }) {
1288
764
  if (!workerUrl) return null;
1289
765
  return new RelayConnection({
@@ -1381,3 +857,9 @@ function replacePathPrefix(message, pathValue, replacement) {
1381
857
  function shortCallId(value) {
1382
858
  return String(value || "").slice(0, 20);
1383
859
  }
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
+ }