machine-bridge-mcp 0.14.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.
- package/CHANGELOG.md +39 -0
- package/README.md +18 -8
- package/SECURITY.md +10 -3
- package/browser-extension/browser-operations.js +515 -0
- package/browser-extension/devtools-input.js +17 -2
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +245 -60
- package/browser-extension/pairing.js +1 -1
- package/browser-extension/service-worker.js +151 -354
- package/docs/ARCHITECTURE.md +6 -4
- package/docs/AUDIT.md +24 -1
- package/docs/LOCAL_AUTOMATION.md +10 -10
- package/docs/LOGGING.md +7 -1
- package/docs/OPERATIONS.md +13 -5
- package/docs/POLICY_REFERENCE.md +88 -0
- package/docs/TESTING.md +9 -2
- package/package.json +16 -3
- package/scripts/coverage-check.mjs +108 -0
- package/scripts/generate-policy-reference.mjs +95 -0
- package/src/local/agent-context.mjs +1 -103
- package/src/local/app-automation.mjs +7 -9
- package/src/local/browser-bridge.mjs +69 -143
- package/src/local/browser-extension-protocol.mjs +75 -0
- package/src/local/browser-pairing-store.mjs +83 -0
- package/src/local/call-registry.mjs +113 -0
- package/src/local/capability-ranking.mjs +103 -0
- package/src/local/cli-local-admin.mjs +308 -0
- package/src/local/cli-options.mjs +151 -0
- package/src/local/cli-policy.mjs +77 -0
- package/src/local/cli.mjs +16 -507
- package/src/local/errors.mjs +122 -0
- package/src/local/git-service.mjs +88 -0
- package/src/local/lifecycle.mjs +64 -0
- package/src/local/log.mjs +50 -19
- package/src/local/managed-job-plan.mjs +235 -0
- package/src/local/managed-jobs.mjs +16 -220
- package/src/local/observability.mjs +83 -0
- package/src/local/policy.mjs +148 -0
- package/src/local/process-execution.mjs +153 -0
- package/src/local/process-sessions.mjs +10 -20
- package/src/local/process-tracker.mjs +55 -0
- package/src/local/runtime.mjs +154 -672
- package/src/local/service.mjs +8 -3
- package/src/local/stdio.mjs +3 -11
- package/src/local/tool-executor.mjs +102 -0
- package/src/local/tools.mjs +21 -104
- package/src/local/workspace-file-service.mjs +451 -0
- package/src/shared/policy-contract.json +54 -0
- package/src/shared/tool-catalog.json +11 -11
- package/src/worker/index.ts +69 -524
package/src/local/runtime.mjs
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
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,
|
|
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.
|
|
102
|
-
this.
|
|
103
|
-
this.
|
|
104
|
-
this.
|
|
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
|
-
|
|
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
|
|
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-
|
|
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
|
-
|
|
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.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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.
|
|
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, {
|
|
307
|
-
|
|
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
|
-
|
|
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.
|
|
326
|
-
this.callProcesses.delete(callId);
|
|
382
|
+
this.callRegistry.finish(callId);
|
|
327
383
|
}
|
|
328
384
|
|
|
329
385
|
cancelCall(callId, reason = "cancelled") {
|
|
330
|
-
this.
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
622
|
-
|
|
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
|
-
|
|
633
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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
|
-
|
|
661
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
847
|
-
|
|
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
|
-
|
|
855
|
-
|
|
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
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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
|
-
|
|
969
|
-
|
|
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
|
-
|
|
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
|
+
}
|