mtech-ai-local-runner 0.1.46 → 0.1.48
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/README.md +107 -2
- package/dist/src/adapters/claude-code/adapter.js +57 -36
- package/dist/src/adapters/claude-code/runtime-options.js +114 -33
- package/dist/src/asks/commands.js +100 -0
- package/dist/src/asks/doctor.js +106 -0
- package/dist/src/asks/hook-client.js +236 -0
- package/dist/src/asks/hub-protocol.js +97 -0
- package/dist/src/asks/hub.js +202 -0
- package/dist/src/asks/inbox.js +347 -0
- package/dist/src/asks/integrations.js +433 -0
- package/dist/src/asks/messenger.js +135 -0
- package/dist/src/asks/notifier.js +73 -0
- package/dist/src/asks/routing.js +239 -0
- package/dist/src/asks/runtime.js +198 -0
- package/dist/src/asks/shim.js +78 -0
- package/dist/src/asks/store.js +210 -0
- package/dist/src/asks/templates.js +44 -0
- package/dist/src/asks/types.js +64 -0
- package/dist/src/cli.js +90 -31
- package/dist/src/config.js +22 -2
- package/dist/src/daemon/runtime.js +36 -4
- package/dist/src/gateway-client.js +18 -3
- package/dist/src/registration/local-config.js +7 -1
- package/dist/src/safety/claude-binary-identity.js +81 -0
- package/dist/src/safety/local-edit-content.js +14 -0
- package/dist/src/safety/local-tool-policy.js +3 -0
- package/dist/src/safety/local-tool-server.js +2 -2
- package/dist/src/safety/local-tool-worker.js +3 -0
- package/package.json +1 -1
|
@@ -25,7 +25,11 @@ import { SessionOpenPoller, abortableDelay } from "./session-open-poller.js";
|
|
|
25
25
|
import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
|
|
26
26
|
import { packagedHookCommandParts } from "../safety/hook-command.js";
|
|
27
27
|
import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
|
|
28
|
+
import { AskRuntime, withAsks } from '../asks/runtime.js';
|
|
28
29
|
export async function buildDaemon(input) {
|
|
30
|
+
const claudeParts = [input.claudeSecurityHost, input.claudeSupportedProbe, input.claudeBinaryIdentity].filter(Boolean).length;
|
|
31
|
+
if (claudeParts !== 0 && claudeParts !== 3)
|
|
32
|
+
throw new Error('CLAUDE_SUPPORTED_HOST_REQUIRED');
|
|
29
33
|
if (input.codexSupportedProbe && !input.codexSecurityHost)
|
|
30
34
|
throw new Error('CODEX_SUPPORTED_HOST_REQUIRED');
|
|
31
35
|
const production = !input.codexSecurityHost && !input.codexSupportedProbe ? createCodexDaemonHost({ runnerId: input.runnerId, binary: input.env.AI_GATEWAY_LOCAL_RUNNER_CODEX_BINARY ?? 'codex', home: input.env.HOME ?? '', codexHome: input.env.CODEX_HOME, searchPath: input.env.PATH }) : undefined;
|
|
@@ -41,6 +45,10 @@ export async function buildDaemon(input) {
|
|
|
41
45
|
pendingCodexClaim.job.lease_expires_at = expiresAt;
|
|
42
46
|
};
|
|
43
47
|
const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
|
|
48
|
+
// AI-first ask delivery (Work Item e1c08b33) rides on the session-open loop below; it starts no executor.
|
|
49
|
+
const asks = new AskRuntime({ stateRoot: input.stateRoot, env: input.env,
|
|
50
|
+
report: (deliveryId, report, signal) => gateway.reportAskDelivery({ deliveryId, ...report }, signal),
|
|
51
|
+
log: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } });
|
|
44
52
|
const localFixHost = input.localFixHost ?? await loadInstalledLocalFixHost({ stateRoot: input.stateRoot, runnerId: input.runnerId, gateway });
|
|
45
53
|
// A test-injected host wins; otherwise only the bundled reviewed registration can enable P1.
|
|
46
54
|
let p1Host = input.p1Host;
|
|
@@ -52,11 +60,12 @@ export async function buildDaemon(input) {
|
|
|
52
60
|
// The same parts the CLI self-test ran before starting this daemon (verifyPackagedHook).
|
|
53
61
|
const hook = packagedHookCommandParts();
|
|
54
62
|
const plugin = new ClaudeCodePlugin({
|
|
55
|
-
probe: async () => (await discoverAdapters(input.env, undefined, ['claude_code_cli']))[0],
|
|
63
|
+
probe: async () => input.claudeSupportedProbe ? input.claudeSupportedProbe() : (await discoverAdapters(input.env, undefined, ['claude_code_cli']))[0],
|
|
56
64
|
options: ({ worktreePath, jobId }) => createClaudeCodeAdapterOptions({
|
|
57
65
|
worktreePath, jobId, stateRoot: input.stateRoot, env: input.env,
|
|
58
66
|
maxBudgetUsd: Number.MAX_SAFE_INTEGER, hookExecutablePath: hook.hookScriptPath, hookNodeExecutablePath: hook.nodeExecutablePath,
|
|
59
67
|
binaryPath: input.env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY,
|
|
68
|
+
securityHost: input.claudeSecurityHost, binaryIdentity: input.claudeBinaryIdentity,
|
|
60
69
|
}),
|
|
61
70
|
});
|
|
62
71
|
const codex = new CodexPlugin({
|
|
@@ -109,6 +118,23 @@ export async function buildDaemon(input) {
|
|
|
109
118
|
const poller = new JobPoller({
|
|
110
119
|
reportError: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "poller_failed", ...diagnostic })}\n`); },
|
|
111
120
|
heartbeat: async () => {
|
|
121
|
+
if (input.claudeSupportedProbe) {
|
|
122
|
+
let current;
|
|
123
|
+
try {
|
|
124
|
+
current = await plugin.probe();
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
current = { available: false, authenticated: false, capabilities: [] };
|
|
128
|
+
}
|
|
129
|
+
const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
|
|
130
|
+
const index = probes.findIndex(p => p.id === 'claude_code_cli');
|
|
131
|
+
if (index >= 0)
|
|
132
|
+
probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
|
|
133
|
+
if (ready)
|
|
134
|
+
versions.claude_code_cli = '1';
|
|
135
|
+
else
|
|
136
|
+
delete versions.claude_code_cli;
|
|
137
|
+
}
|
|
112
138
|
let current;
|
|
113
139
|
try {
|
|
114
140
|
current = await codex.probe();
|
|
@@ -132,11 +158,13 @@ export async function buildDaemon(input) {
|
|
|
132
158
|
catch {
|
|
133
159
|
sessionOpen = undefined;
|
|
134
160
|
}
|
|
161
|
+
const askInbox = await asks.advertisement();
|
|
135
162
|
await gateway.heartbeatLocalRunner({
|
|
136
163
|
deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
|
|
137
164
|
displayName: runnerDisplayName(), packageVersion: packageVersion(),
|
|
138
165
|
capabilities: localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationMode),
|
|
139
|
-
|
|
166
|
+
mode: "full",
|
|
167
|
+
...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
|
|
140
168
|
});
|
|
141
169
|
},
|
|
142
170
|
wait: async () => {
|
|
@@ -162,7 +190,11 @@ export async function buildDaemon(input) {
|
|
|
162
190
|
});
|
|
163
191
|
const opener = new SessionOpener({ home: input.env.HOME ?? "" });
|
|
164
192
|
const sessionOpens = new SessionOpenPoller({
|
|
165
|
-
wait: signal =>
|
|
193
|
+
wait: async (signal) => {
|
|
194
|
+
const result = await gateway.waitForSessionOpens(25, signal);
|
|
195
|
+
await asks.handleWait(result);
|
|
196
|
+
return result;
|
|
197
|
+
},
|
|
166
198
|
open: request => opener.open(request),
|
|
167
199
|
report: async (openId, result, signal) => {
|
|
168
200
|
await gateway.reportSessionOpen({ openId, status: result.status, surface: result.surface,
|
|
@@ -183,7 +215,7 @@ export async function buildDaemon(input) {
|
|
|
183
215
|
return;
|
|
184
216
|
throw new Error('Runner state requires authoritative reconciliation');
|
|
185
217
|
},
|
|
186
|
-
poller, sessionOpens,
|
|
218
|
+
poller, sessionOpens: withAsks(asks, sessionOpens),
|
|
187
219
|
reportRecoveryRequired: reason => { process.stderr.write(`${JSON.stringify({ event: 'runner_recovery_required', reason })}\n`); },
|
|
188
220
|
});
|
|
189
221
|
// Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
|
|
@@ -431,9 +431,10 @@ export class McpGatewayClient {
|
|
|
431
431
|
display_name: input.displayName,
|
|
432
432
|
package_version: input.packageVersion,
|
|
433
433
|
capabilities: input.capabilities,
|
|
434
|
+
...(input.mode ? { mode: input.mode } : {}),
|
|
434
435
|
});
|
|
435
436
|
}
|
|
436
|
-
async heartbeatLocalRunner(input) {
|
|
437
|
+
async heartbeatLocalRunner(input, signal) {
|
|
437
438
|
return this.callTool("local_agent_runner_heartbeat", {
|
|
438
439
|
runner_id: this.runnerId,
|
|
439
440
|
runner: {
|
|
@@ -441,9 +442,10 @@ export class McpGatewayClient {
|
|
|
441
442
|
display_name: input.displayName,
|
|
442
443
|
package_version: input.packageVersion,
|
|
443
444
|
capabilities: input.capabilities,
|
|
445
|
+
...(input.mode ? { mode: input.mode } : {}),
|
|
444
446
|
...(input.sessionOpen ? { session_open: input.sessionOpen } : {}),
|
|
445
447
|
},
|
|
446
|
-
});
|
|
448
|
+
}, signal);
|
|
447
449
|
}
|
|
448
450
|
async waitForSessionOpens(waitSeconds = 25, signal) {
|
|
449
451
|
const result = await this.callTool("local_agent_session_opens_wait", {
|
|
@@ -453,7 +455,20 @@ export class McpGatewayClient {
|
|
|
453
455
|
// The Gateway asks for a longer pause while session open is off. Only a sane whole number of seconds is honoured.
|
|
454
456
|
const retryAfter = result?.retry_after_seconds;
|
|
455
457
|
const retryAfterSeconds = typeof retryAfter === "number" && Number.isInteger(retryAfter) && retryAfter >= 1 && retryAfter <= 3600 ? retryAfter : undefined;
|
|
456
|
-
return { opens: Array.isArray(result?.opens) ? result.opens : [], retryAfterSeconds
|
|
458
|
+
return { opens: Array.isArray(result?.opens) ? result.opens : [], retryAfterSeconds,
|
|
459
|
+
// AI-first ask delivery (Work Item e1c08b33): ids only, present while the Gateway's switch is on.
|
|
460
|
+
...(Array.isArray(result?.ask_deliveries) ? { askDeliveries: result.ask_deliveries } : {}),
|
|
461
|
+
...(Array.isArray(result?.closed_delivery_ids) ? { closedDeliveryIds: result.closed_delivery_ids } : {}) };
|
|
462
|
+
}
|
|
463
|
+
/** Reports one delivery's progress. No session id, cwd, prompt or other text is ever sent. */
|
|
464
|
+
async reportAskDelivery(input, signal) {
|
|
465
|
+
return this.callTool("local_agent_ask_delivery_report", {
|
|
466
|
+
runner_id: this.runnerId,
|
|
467
|
+
delivery_id: input.deliveryId,
|
|
468
|
+
event: input.event,
|
|
469
|
+
...(input.via ? { via: input.via } : {}),
|
|
470
|
+
...(input.app ? { app: input.app } : {}),
|
|
471
|
+
}, signal);
|
|
457
472
|
}
|
|
458
473
|
async reportSessionOpen(input, signal) {
|
|
459
474
|
return this.callTool("local_agent_session_open_report", {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { chmod, mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
|
+
/** The mode a stored configuration runs in (Work Item e1c08b33): a missing mode is a full runner. */
|
|
5
|
+
export function runnerMode(config) {
|
|
6
|
+
return config.mode ?? "full";
|
|
7
|
+
}
|
|
4
8
|
export class FileLocalConfigStore {
|
|
5
9
|
stateRoot;
|
|
6
10
|
filePath;
|
|
@@ -49,8 +53,9 @@ export class FileLocalConfigStore {
|
|
|
49
53
|
|| typeof candidate.workspaceRoot !== "string"
|
|
50
54
|
|| !isAbsolute(candidate.workspaceRoot)
|
|
51
55
|
|| candidate.concurrency !== 1
|
|
56
|
+
|| (candidate.mode !== undefined && candidate.mode !== "full" && candidate.mode !== "messenger")
|
|
52
57
|
|| Object.keys(candidate).some((key) => ![
|
|
53
|
-
"schemaVersion", "gatewayMcpUrl", "runnerId", "workspaceRoot", "concurrency",
|
|
58
|
+
"schemaVersion", "gatewayMcpUrl", "runnerId", "workspaceRoot", "concurrency", "mode",
|
|
54
59
|
].includes(key))) {
|
|
55
60
|
return null;
|
|
56
61
|
}
|
|
@@ -60,6 +65,7 @@ export class FileLocalConfigStore {
|
|
|
60
65
|
runnerId: candidate.runnerId,
|
|
61
66
|
workspaceRoot: candidate.workspaceRoot,
|
|
62
67
|
concurrency: 1,
|
|
68
|
+
...(candidate.mode === undefined ? {} : { mode: candidate.mode }),
|
|
63
69
|
};
|
|
64
70
|
}
|
|
65
71
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lstat, realpath, readlink } from 'node:fs/promises';
|
|
3
|
+
import { measureCommandFile } from './registered-command-integrity.js';
|
|
4
|
+
const live = new WeakMap();
|
|
5
|
+
const rejected = () => Error('CLAUDE_BINARY_IDENTITY_REJECTED');
|
|
6
|
+
const inside = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
7
|
+
const validPath = (value) => typeof value === 'string' && path.isAbsolute(value) && path.resolve(value) === value && value !== '/' && value.length <= 4096 && !/[\r\n\0]/.test(value);
|
|
8
|
+
/** Measurement only, never a release certificate or atomic path-based exec.
|
|
9
|
+
* Explicit roots are supplied by the trusted host, not task/config JSON. */
|
|
10
|
+
async function measure(input) {
|
|
11
|
+
if (!validPath(input.launchPath) || !Array.isArray(input.allowedRoots) || !input.allowedRoots.length || input.allowedRoots.length > 8 || !input.allowedRoots.every(validPath))
|
|
12
|
+
throw rejected();
|
|
13
|
+
const roots = [...new Set(input.allowedRoots)], uid = process.getuid?.();
|
|
14
|
+
if (uid === undefined)
|
|
15
|
+
throw rejected();
|
|
16
|
+
for (const root of roots) {
|
|
17
|
+
const s = await lstat(root, { bigint: true });
|
|
18
|
+
if (await realpath(root) !== root || !s.isDirectory() || (s.mode & 18n) !== 0n || (s.uid !== BigInt(uid) && s.uid !== 0n))
|
|
19
|
+
throw rejected();
|
|
20
|
+
}
|
|
21
|
+
const allowed = (file) => roots.some(root => inside(root, file));
|
|
22
|
+
const target = await realpath(input.launchPath);
|
|
23
|
+
if (!allowed(input.launchPath) || !allowed(target))
|
|
24
|
+
throw rejected();
|
|
25
|
+
const inspect = async () => {
|
|
26
|
+
const stamps = [];
|
|
27
|
+
for (const file of [...new Set([input.launchPath, target])]) {
|
|
28
|
+
const root = roots.filter(r => inside(r, file)).sort((a, b) => b.length - a.length)[0];
|
|
29
|
+
const parts = [root];
|
|
30
|
+
let current = root;
|
|
31
|
+
for (const part of path.relative(root, file).split(path.sep).filter(Boolean)) {
|
|
32
|
+
current = path.join(current, part);
|
|
33
|
+
parts.push(current);
|
|
34
|
+
}
|
|
35
|
+
for (let i = 0; i < parts.length; i++) {
|
|
36
|
+
const name = parts[i], s = await lstat(name, { bigint: true }), link = s.isSymbolicLink(), last = i === parts.length - 1;
|
|
37
|
+
if ((s.uid !== BigInt(uid) && s.uid !== 0n) || (!link && (s.mode & 18n) !== 0n) || !allowed(await realpath(name))
|
|
38
|
+
|| (!last && !s.isDirectory() && !link) || (last && !link && (!s.isFile() || s.nlink !== 1n || (s.mode & 73n) === 0n)))
|
|
39
|
+
throw rejected();
|
|
40
|
+
stamps.push([name, s.dev.toString(), s.ino.toString(), s.uid.toString(), s.mode.toString(), link ? await readlink(name) : null,
|
|
41
|
+
last || link ? s.mtimeNs.toString() : null, last || link ? s.ctimeNs.toString() : null, last ? s.nlink.toString() : null]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (await realpath(input.launchPath) !== target)
|
|
45
|
+
throw rejected();
|
|
46
|
+
return JSON.stringify(stamps);
|
|
47
|
+
};
|
|
48
|
+
const before = await inspect(), sha256 = await measureCommandFile(target), after = await inspect();
|
|
49
|
+
if (before !== after)
|
|
50
|
+
throw rejected();
|
|
51
|
+
return { sha256, stamp: after };
|
|
52
|
+
}
|
|
53
|
+
export async function captureClaudeBinaryIdentity(input) {
|
|
54
|
+
try {
|
|
55
|
+
const copy = structuredClone(input), measured = await measure(copy), identity = Object.freeze({ sha256: measured.sha256 });
|
|
56
|
+
live.set(identity, { input: copy, measured });
|
|
57
|
+
return identity;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw rejected();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function claudeBinaryLaunchPath(identity) {
|
|
64
|
+
const value = live.get(identity);
|
|
65
|
+
if (!value)
|
|
66
|
+
throw rejected();
|
|
67
|
+
return value.input.launchPath;
|
|
68
|
+
}
|
|
69
|
+
export async function assertCurrentClaudeBinary(identity, launchPath, sha256) {
|
|
70
|
+
try {
|
|
71
|
+
const value = live.get(identity);
|
|
72
|
+
if (!value || launchPath !== value.input.launchPath || sha256 !== value.measured.sha256)
|
|
73
|
+
throw rejected();
|
|
74
|
+
const current = await measure(value.input);
|
|
75
|
+
if (current.sha256 !== sha256 || current.stamp !== value.measured.stamp)
|
|
76
|
+
throw rejected();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw rejected();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** local_edit accepts the complete replacement file, never an edit script.
|
|
2
|
+
* Pure function: the identical predicate is embedded in the isolated worker.
|
|
3
|
+
* Ordinary source strings and YAML separators are not patch headers. */
|
|
4
|
+
export function isPatchContent(content) {
|
|
5
|
+
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/);
|
|
6
|
+
return lines.some((line, index) => {
|
|
7
|
+
const value = line.trimStart();
|
|
8
|
+
return /^\*\*\* (?:Begin Patch|End Patch|End of File|(?:Update|Add|Delete) File:|Move to:)(?:\s|$)/.test(value)
|
|
9
|
+
|| /^@@(?:\s|$)/.test(value)
|
|
10
|
+
|| /^diff --git\s/.test(value)
|
|
11
|
+
|| /^(?:--- (?:a\/|\/dev\/null)|\+\+\+ (?:b\/|\/dev\/null))/.test(value)
|
|
12
|
+
|| /^---[ \t]+\S/.test(value) && /^\+\+\+[ \t]+\S/.test(lines[index + 1]?.trimStart() ?? '');
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPatchContent } from './local-edit-content.js';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { randomBytes } from 'node:crypto';
|
|
3
4
|
import { lstat, realpath } from 'node:fs/promises';
|
|
@@ -46,6 +47,8 @@ export class LocalToolPolicy {
|
|
|
46
47
|
const file = await this.authorizedFile(input.path, tool === 'local_edit');
|
|
47
48
|
if (tool === 'local_edit' && (typeof input.content !== 'string' || Buffer.byteLength(input.content) > 1024 * 1024))
|
|
48
49
|
throw new Error('DENIED_CONTENT');
|
|
50
|
+
if (tool === 'local_edit' && isPatchContent(input.content))
|
|
51
|
+
throw new Error('DENIED_PATCH_CONTENT');
|
|
49
52
|
if (tool === 'local_search' && (typeof input.query !== 'string' || input.query.length > 4096))
|
|
50
53
|
throw new Error('DENIED_QUERY');
|
|
51
54
|
const temp = file + '.runner-edit-' + randomBytes(16).toString('hex');
|
|
@@ -23,12 +23,12 @@ const MAX_BODY = 1024 * 1024;
|
|
|
23
23
|
const schemas = {
|
|
24
24
|
local_read: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false },
|
|
25
25
|
local_search: { type: 'object', properties: { path: { type: 'string' }, query: { type: 'string' } }, required: ['path', 'query'], additionalProperties: false },
|
|
26
|
-
local_edit: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'], additionalProperties: false },
|
|
26
|
+
local_edit: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string', description: 'The complete new contents of the entire file. Do not send a diff, patch, hunk, or edit instructions.' } }, required: ['path', 'content'], additionalProperties: false },
|
|
27
27
|
local_test: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'], additionalProperties: false },
|
|
28
28
|
};
|
|
29
29
|
/** Same immutable-by-copy inventory used by native admission and MCP discovery. */
|
|
30
30
|
export function localToolInventory() {
|
|
31
|
-
return LOCAL_TOOL_NAMES.map(name => ({ name, description: `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
|
|
31
|
+
return LOCAL_TOOL_NAMES.map(name => ({ name, description: name === 'local_edit' ? 'Replace the entire file at path with content (or create it). Read the current file first and include all unchanged text in content. This is not a diff or patch tool; patch headers and hunks are rejected.' : `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
|
|
32
32
|
}
|
|
33
33
|
/** Job-only JSON MCP endpoint; intentionally has no Gateway transport or discovery. */
|
|
34
34
|
export class LocalToolServer {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { isPatchContent } from './local-edit-content.js';
|
|
1
2
|
/** Standalone worker: --eval avoids ambient module/config lookup. All paths are host-supplied. */
|
|
2
3
|
export const LOCAL_TOOL_WORKER = String.raw `
|
|
3
4
|
const fs = require('node:fs');
|
|
4
5
|
const input = JSON.parse(process.argv[1]);
|
|
6
|
+
const isPatchContent = ${isPatchContent.toString()};
|
|
7
|
+
if (input.tool === 'local_edit' && isPatchContent(input.content)) throw Error('DENIED_PATCH_CONTENT');
|
|
5
8
|
let current = '';
|
|
6
9
|
for (const part of input.path.split('/').filter(Boolean)) {
|
|
7
10
|
current += '/' + part;
|