@mono-agent/agent-runtime 0.19.1 → 0.20.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/MIGRATION.md +1 -1
- package/README.md +101 -4
- package/package.json +1 -1
- package/src/agent/sandbox-seam.js +16 -2
- package/src/agent/tools/bash.js +26 -4
- package/src/agent/tools/edit.js +72 -5
- package/src/agent/tools/exec.js +22 -4
- package/src/agent/tools/glob.js +65 -9
- package/src/agent/tools/grep.js +66 -11
- package/src/agent/tools/node-repl.js +5 -2
- package/src/agent/tools/pi-bridge.js +263 -48
- package/src/agent/tools/read.js +50 -10
- package/src/agent/tools/shared/path-resolver.js +67 -2
- package/src/agent/tools/shared/process-jobs.js +188 -0
- package/src/agent/tools/shared/process-runner.js +541 -30
- package/src/agent/tools/shared/protected-filesystem.js +150 -0
- package/src/agent/tools/web-search.js +63 -8
- package/src/agent/tools/write.js +52 -6
- package/src/ai/providers/acp.js +4 -0
- package/src/ai/providers/claude-cli.js +35 -2
- package/src/ai/providers/claude-sdk.js +12 -0
- package/src/ai/providers/codex-app.js +15 -2
- package/src/ai/providers/pi-native/stream-subscriber.js +29 -2
- package/src/ai/providers/pi-native/turn-runner.js +3 -0
- package/src/ai/providers/pi-native.js +7 -1
- package/src/ai/runtime/capabilities.js +2 -0
- package/src/ai/runtime/router.js +78 -6
- package/src/ai/streaming/codex-events.js +15 -0
- package/src/ai/streaming/opencode-events.js +5 -0
- package/src/ai/tool-lifecycle.js +347 -0
- package/src/ai/types.js +58 -0
- package/src/runtime.js +35 -21
- package/types/agent/sandbox-seam.d.ts +19 -6
- package/types/agent/tools/bash.d.ts +11 -26
- package/types/agent/tools/edit.d.ts +3 -2
- package/types/agent/tools/exec.d.ts +13 -26
- package/types/agent/tools/glob.d.ts +3 -2
- package/types/agent/tools/grep.d.ts +3 -2
- package/types/agent/tools/pi-bridge.d.ts +11 -4
- package/types/agent/tools/read.d.ts +3 -2
- package/types/agent/tools/shared/path-resolver.d.ts +8 -0
- package/types/agent/tools/shared/process-jobs.d.ts +64 -0
- package/types/agent/tools/shared/process-runner.d.ts +45 -3
- package/types/agent/tools/shared/protected-filesystem.d.ts +51 -0
- package/types/agent/tools/write.d.ts +3 -2
- package/types/ai/providers/pi-native/stream-subscriber.d.ts +2 -0
- package/types/ai/runtime/capabilities.d.ts +3 -0
- package/types/ai/streaming/codex-events.d.ts +1 -0
- package/types/ai/streaming/opencode-events.d.ts +1 -0
- package/types/ai/tool-lifecycle.d.ts +43 -0
- package/types/ai/types.d.ts +118 -0
package/src/agent/tools/grep.js
CHANGED
|
@@ -9,9 +9,17 @@ import {
|
|
|
9
9
|
import { boundedInt, safeStat } from "./shared/dedup.js";
|
|
10
10
|
import {
|
|
11
11
|
isPathAllowed,
|
|
12
|
+
isPathLexicallyAllowed,
|
|
13
|
+
protectedRelativePaths,
|
|
12
14
|
resolveToolPath,
|
|
13
15
|
workspaceRoot,
|
|
14
16
|
} from "./shared/path-resolver.js";
|
|
17
|
+
import {
|
|
18
|
+
normalizeProtectedSearchLine,
|
|
19
|
+
protectedFilesystemTargetPlan,
|
|
20
|
+
runProtectedFilesystemCommand,
|
|
21
|
+
scopeProtectedSearchGlob,
|
|
22
|
+
} from "./shared/protected-filesystem.js";
|
|
15
23
|
import {
|
|
16
24
|
capLines,
|
|
17
25
|
excludedGlobArgs,
|
|
@@ -24,7 +32,7 @@ const execFileAsync = promisify(execFile);
|
|
|
24
32
|
|
|
25
33
|
/**
|
|
26
34
|
* @param {{pattern: string, path?: string, glob?: string, type?: string, output_mode?: string, context?: number, case_insensitive?: boolean, multiline?: boolean, head_limit?: number, offset?: number, max_matches?: number, max_output_chars?: number, workdir?: string}} params
|
|
27
|
-
* @param {{sandboxPolicy?: any, ctx?: any}} [options]
|
|
35
|
+
* @param {{sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
|
|
28
36
|
*/
|
|
29
37
|
export async function grepToolImpl({
|
|
30
38
|
pattern,
|
|
@@ -40,13 +48,27 @@ export async function grepToolImpl({
|
|
|
40
48
|
max_matches,
|
|
41
49
|
max_output_chars,
|
|
42
50
|
workdir,
|
|
43
|
-
}, { sandboxPolicy, ctx } = {}) {
|
|
51
|
+
}, { sandboxPolicy, sandboxEngine, ctx } = {}) {
|
|
44
52
|
const target = resolveToolPath(path || workspaceRoot(workdir, ctx), workdir, ctx);
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
const protectedSearch = protectedFilesystemTargetPlan(target, { sandboxPolicy, ctx });
|
|
54
|
+
const protectedExecution = protectedSearch !== null;
|
|
55
|
+
if (protectedExecution) {
|
|
56
|
+
if (!isPathLexicallyAllowed(target, workdir, { sandboxPolicy, ctx })) {
|
|
57
|
+
return "Error: Protected filesystem search was denied.";
|
|
58
|
+
}
|
|
59
|
+
} else if (!isPathAllowed(target, workdir, { sandboxPolicy, ctx })) {
|
|
60
|
+
return `Error: Path not allowed: ${target}`;
|
|
61
|
+
}
|
|
62
|
+
let cwd;
|
|
63
|
+
let searchTarget;
|
|
64
|
+
if (protectedExecution) {
|
|
65
|
+
({ cwd, searchTarget } = protectedSearch);
|
|
66
|
+
} else {
|
|
67
|
+
const stat = safeStat(target);
|
|
68
|
+
if (!stat) return `Error: Path not found: ${target}`;
|
|
69
|
+
cwd = stat.isDirectory() ? target : dirname(target);
|
|
70
|
+
searchTarget = stat.isDirectory() ? "." : basename(target);
|
|
71
|
+
}
|
|
50
72
|
const mode = ["content", "count", "files_with_matches"].includes(output_mode) ? output_mode : "files_with_matches";
|
|
51
73
|
const args = ["--no-config", "--hidden", "--color=never"];
|
|
52
74
|
if (mode === "files_with_matches") args.push("--files-with-matches");
|
|
@@ -55,15 +77,47 @@ export async function grepToolImpl({
|
|
|
55
77
|
if (case_insensitive) args.push("-i");
|
|
56
78
|
if (mode === "content" && context) args.push(`-C${boundedInt(context, 0, { min: 0, max: 20 })}`);
|
|
57
79
|
if (multiline) args.push("-U", "--multiline-dotall");
|
|
58
|
-
if (glob) args.push(
|
|
80
|
+
if (glob) args.push(
|
|
81
|
+
"--glob",
|
|
82
|
+
protectedExecution ? scopeProtectedSearchGlob(glob, searchTarget) : glob,
|
|
83
|
+
);
|
|
59
84
|
if (type) args.push("--type", type);
|
|
60
|
-
args.push(...excludedGlobArgs()
|
|
85
|
+
args.push(...excludedGlobArgs());
|
|
86
|
+
const protectedPaths = protectedRelativePaths(cwd, { sandboxPolicy, ctx });
|
|
87
|
+
for (const protectedPath of protectedPaths) {
|
|
88
|
+
args.push("--glob", `!${protectedPath}`, "--glob", `!${protectedPath}/**`);
|
|
89
|
+
}
|
|
90
|
+
args.push("--", pattern, searchTarget);
|
|
61
91
|
const resultLimit = boundedInt(head_limit ?? max_matches, DEFAULT_MAX_SEARCH_LINES, { min: 1, max: 1000 });
|
|
62
92
|
const rgPath = resolveRgPath({ ctx });
|
|
63
93
|
if (!rgPath) return ripgrepMissingMessage(ctx);
|
|
64
94
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
95
|
+
let stdout;
|
|
96
|
+
if (!protectedExecution) {
|
|
97
|
+
({ stdout } = await execFileAsync(rgPath, args, { cwd, timeout: 15000, maxBuffer: SEARCH_MAX_BUFFER }));
|
|
98
|
+
} else {
|
|
99
|
+
const protectedResult = await runProtectedFilesystemCommand({
|
|
100
|
+
command: rgPath,
|
|
101
|
+
args,
|
|
102
|
+
cwd,
|
|
103
|
+
}, { sandboxPolicy, sandboxEngine, ctx, maxBufferBytes: SEARCH_MAX_BUFFER });
|
|
104
|
+
if (protectedResult?.code === 1) return "No matches found.";
|
|
105
|
+
if (protectedResult === null
|
|
106
|
+
|| protectedResult.code !== 0
|
|
107
|
+
|| protectedResult.bufferExceeded
|
|
108
|
+
|| protectedResult.timedOut) {
|
|
109
|
+
return "Error: Protected filesystem search was denied.";
|
|
110
|
+
}
|
|
111
|
+
stdout = protectedResult.stdout;
|
|
112
|
+
}
|
|
113
|
+
const normalized = stdout.trim().split("\n")
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.filter((line) => !protectedPaths.some((path) => (
|
|
116
|
+
line === path || line.startsWith(`${path}/`) || line.startsWith(`${path}:`)
|
|
117
|
+
)))
|
|
118
|
+
.map((line) => protectedExecution
|
|
119
|
+
? normalizeProtectedSearchLine(line, searchTarget)
|
|
120
|
+
: line.replace(/^\.\//, ""));
|
|
67
121
|
const formatted = capLines(normalized.join("\n"), {
|
|
68
122
|
label: "Grep",
|
|
69
123
|
noMatches: "No matches found.",
|
|
@@ -74,6 +128,7 @@ export async function grepToolImpl({
|
|
|
74
128
|
});
|
|
75
129
|
return formatted === "No matches found." ? formatted : `${formatted}\n\n${excludedPathSummary()}`;
|
|
76
130
|
} catch (err) {
|
|
131
|
+
if (protectedExecution) return "Error: Protected filesystem search was denied.";
|
|
77
132
|
if (err.code === 1) return "No matches found.";
|
|
78
133
|
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || /maxBuffer/i.test(err.message || "")) {
|
|
79
134
|
return `${capLines(err.stdout || "", {
|
|
@@ -254,9 +254,12 @@ async function terminateRecord(record) {
|
|
|
254
254
|
await record.done;
|
|
255
255
|
return;
|
|
256
256
|
}
|
|
257
|
-
killProcessGroup(record.child, "SIGTERM");
|
|
257
|
+
killProcessGroup(record.child, "SIGTERM", { fallbackToChildPid: true });
|
|
258
258
|
if (!record.killTimer) {
|
|
259
|
-
record.killTimer = setTimeout(
|
|
259
|
+
record.killTimer = setTimeout(
|
|
260
|
+
() => killProcessGroup(record.child, "SIGKILL", { fallbackToChildPid: true }),
|
|
261
|
+
KILL_GRACE_MS,
|
|
262
|
+
);
|
|
260
263
|
record.killTimer.unref?.();
|
|
261
264
|
}
|
|
262
265
|
await record.done;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
4
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
5
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
@@ -55,6 +56,11 @@ const DEFAULT_MCP_CALL_MAX_TOTAL_TIMEOUT_MS = 2_700_000;
|
|
|
55
56
|
const MCP_RAW_DETAIL_LIMIT = 4_000;
|
|
56
57
|
const MCP_IMAGE_INLINE_MAX_BYTES = 250_000;
|
|
57
58
|
const DEFAULT_BASH_TIMEOUT_MS = 120_000;
|
|
59
|
+
const MCP_APPS_EXTENSION_ID = "io.modelcontextprotocol/ui";
|
|
60
|
+
const MCP_APP_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
61
|
+
const MCP_APP_SUPPORTED_PROTOCOL_VERSIONS = ["2026-01-26", "2025-11-21"];
|
|
62
|
+
const MCP_APP_RESOURCE_MAX_BYTES = 2 * 1024 * 1024;
|
|
63
|
+
const PRIVATE_CAPABILITY_URL = Symbol.for("@mono-agent/private-capability-url");
|
|
58
64
|
|
|
59
65
|
function objectSchema(properties, required = []) {
|
|
60
66
|
return { type: "object", properties, required, additionalProperties: false };
|
|
@@ -261,7 +267,7 @@ function isStructuredToolRun(value) {
|
|
|
261
267
|
* @param {any} description
|
|
262
268
|
* @param {any} parameters
|
|
263
269
|
* @param {any} execute
|
|
264
|
-
* @param {{cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, forceSequential?: boolean}} [options]
|
|
270
|
+
* @param {{cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, processJobsController?: any, forceSequential?: boolean}} [options]
|
|
265
271
|
*/
|
|
266
272
|
function createBuiltinTool(name, label, description, parameters, execute, {
|
|
267
273
|
cwd,
|
|
@@ -271,6 +277,7 @@ function createBuiltinTool(name, label, description, parameters, execute, {
|
|
|
271
277
|
sandboxPolicy,
|
|
272
278
|
sandboxEngine,
|
|
273
279
|
ctx,
|
|
280
|
+
processJobsController,
|
|
274
281
|
forceSequential = false,
|
|
275
282
|
} = {}) {
|
|
276
283
|
return {
|
|
@@ -282,12 +289,22 @@ function createBuiltinTool(name, label, description, parameters, execute, {
|
|
|
282
289
|
async execute(toolCallId, params, signal) {
|
|
283
290
|
if (signal?.aborted) throw new Error("tool execution aborted");
|
|
284
291
|
const normalized = normalizePiBuiltinToolParams(name, params, { cwd, toolLimits, ctx });
|
|
292
|
+
if (processJobsController && params?.background === true && (name === "Bash" || name === "Exec")) {
|
|
293
|
+
// Foreground normalization always materializes the established default
|
|
294
|
+
// limits. A background omission instead belongs to the host's compiled
|
|
295
|
+
// process-job config, so preserve absence while still narrowing every
|
|
296
|
+
// explicitly supplied per-call value through the ordinary tool caps.
|
|
297
|
+
if (params.max_output_chars === undefined) delete normalized.max_output_chars;
|
|
298
|
+
if (params.timeout_ms === undefined && (name !== "Bash" || params.timeout === undefined)) {
|
|
299
|
+
delete normalized.timeout_ms;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
285
302
|
if (name === "Bash" && toolPolicy?.bashReadOnly && !isReadOnlyShellCommand(normalized.command)) {
|
|
286
303
|
throw new Error("Error: Planning shell policy allows only read-only inspection commands.");
|
|
287
304
|
}
|
|
288
305
|
const shouldTrackWrite = name === "Write" && typeof normalized.file_path === "string" && normalized.file_path.length > 0;
|
|
289
306
|
const beforeWrite = shouldTrackWrite ? readFileChangeSnapshot(normalized.file_path) : null;
|
|
290
|
-
const raw = await execute(normalized, { signal, sandboxPolicy, sandboxEngine, ctx });
|
|
307
|
+
const raw = await execute(normalized, { signal, sandboxPolicy, sandboxEngine, ctx, processJobsController });
|
|
291
308
|
// Image reads (e.g. Read on a .png) come back as a structured image
|
|
292
309
|
// result so vision models see pixels; emit an image content block and let
|
|
293
310
|
// the shared bloat guard cap oversize payloads.
|
|
@@ -417,7 +434,7 @@ export function createStructuredOutputTool(outputSchema, onStructuredOutput) {
|
|
|
417
434
|
|
|
418
435
|
/**
|
|
419
436
|
* @param {any} allowedTools
|
|
420
|
-
* @param {{disallowedTools?: any[], skillNames?: any[], skills?: any[], skillsRoot?: any, dataDir?: any, cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, persistArtifact?: any, onTruncate?: any, toolPayloadMaxBytes?: number, imageInlineMaxBytes?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, approvalManager?: any, approvalModel?: any, nodeReplController?: any, webController?: any, toolExecutionMode?: "sequential"|"safe-parallel", subagents?: any, subagentContext?: any, ctx?: any}} [options]
|
|
437
|
+
* @param {{disallowedTools?: any[], skillNames?: any[], skills?: any[], skillsRoot?: any, dataDir?: any, cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, persistArtifact?: any, onTruncate?: any, toolPayloadMaxBytes?: number, imageInlineMaxBytes?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, approvalManager?: any, approvalModel?: any, nodeReplController?: any, webController?: any, processJobsController?: any, toolExecutionMode?: "sequential"|"safe-parallel", subagents?: any, subagentContext?: any, ctx?: any}} [options]
|
|
421
438
|
*/
|
|
422
439
|
export function getPiBuiltinTools(allowedTools, {
|
|
423
440
|
disallowedTools = [],
|
|
@@ -439,6 +456,7 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
439
456
|
approvalModel = null,
|
|
440
457
|
nodeReplController = null,
|
|
441
458
|
webController = null,
|
|
459
|
+
processJobsController = null,
|
|
442
460
|
subagents = null,
|
|
443
461
|
subagentContext = null,
|
|
444
462
|
toolExecutionMode = "safe-parallel",
|
|
@@ -464,6 +482,7 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
464
482
|
toolPolicy,
|
|
465
483
|
sandboxPolicy,
|
|
466
484
|
sandboxEngine,
|
|
485
|
+
processJobsController,
|
|
467
486
|
forceSequential: toolExecutionMode === "sequential",
|
|
468
487
|
ctx,
|
|
469
488
|
};
|
|
@@ -514,6 +533,9 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
514
533
|
timeout_ms: processTimeoutSchema,
|
|
515
534
|
timeout: legacyBashTimeoutSchema,
|
|
516
535
|
max_output_chars: bashLimitSchema,
|
|
536
|
+
...(processJobsController ? {
|
|
537
|
+
background: { type: "boolean", description: "Run as a durable background process job and notify this conversation when it finishes. Do not use for commands that daemonize into another POSIX process group or session." },
|
|
538
|
+
} : {}),
|
|
517
539
|
}, ["command"]), bashToolRun, toolContext),
|
|
518
540
|
Exec: createBuiltinTool("Exec", "Exec", "Execute one program directly from an argv array without shell parsing. Prefer this for ordinary commands; use Bash only when shell syntax is required.", objectSchema({
|
|
519
541
|
executable: { type: "string", minLength: 1 },
|
|
@@ -521,6 +543,9 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
521
543
|
workdir: { type: "string" },
|
|
522
544
|
timeout_ms: processTimeoutSchema,
|
|
523
545
|
max_output_chars: bashLimitSchema,
|
|
546
|
+
...(processJobsController ? {
|
|
547
|
+
background: { type: "boolean", description: "Run as a durable background process job and notify this conversation when it finishes. Do not use for commands that daemonize into another POSIX process group or session." },
|
|
548
|
+
} : {}),
|
|
524
549
|
}, ["executable"]), execToolRun, toolContext),
|
|
525
550
|
NodeRepl: nodeReplController
|
|
526
551
|
? createBuiltinTool(
|
|
@@ -618,41 +643,61 @@ export async function prepareMcpStdioCommand(cfg = {}, { cwd = null, sandboxPoli
|
|
|
618
643
|
/**
|
|
619
644
|
* @param {any} name
|
|
620
645
|
* @param {any} cfg
|
|
621
|
-
* @param {{cwd?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
|
|
646
|
+
* @param {{cwd?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, mcpApps?: any}} [options]
|
|
622
647
|
*/
|
|
623
|
-
async function connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine, ctx } = {}) {
|
|
648
|
+
async function connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine, ctx, mcpApps } = {}) {
|
|
624
649
|
const brand = (ctx ?? readToolRuntime()).runtimeBrand;
|
|
650
|
+
const privateCapabilityUrl = cfg?.[PRIVATE_CAPABILITY_URL] === true;
|
|
625
651
|
const client = new McpClient(
|
|
626
652
|
{ name: `${brand.mcpClientName}/${name}`, version: brand.mcpClientVersion },
|
|
627
|
-
{
|
|
653
|
+
{
|
|
654
|
+
capabilities: mcpApps?.mimeTypes?.includes?.(MCP_APP_RESOURCE_MIME_TYPE)
|
|
655
|
+
? {
|
|
656
|
+
extensions: {
|
|
657
|
+
[MCP_APPS_EXTENSION_ID]: {
|
|
658
|
+
mimeTypes: [MCP_APP_RESOURCE_MIME_TYPE],
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
}
|
|
662
|
+
: {},
|
|
663
|
+
},
|
|
628
664
|
);
|
|
629
665
|
let transport;
|
|
630
|
-
if (cfg.type === "http") {
|
|
631
|
-
transport = new StreamableHTTPClientTransport(new URL(cfg.url), { requestInit: { headers: cfg.headers || {} } });
|
|
632
|
-
} else if (cfg.type === "sse") {
|
|
633
|
-
transport = new SSEClientTransport(new URL(cfg.url), {
|
|
634
|
-
// SSE EventSourceInit's typed shape omits `headers`, but the transport
|
|
635
|
-
// forwards them to the underlying EventSource — keep the header pass-through.
|
|
636
|
-
eventSourceInit: /** @type {any} */ ({ headers: cfg.headers || {} }),
|
|
637
|
-
requestInit: { headers: cfg.headers || {} },
|
|
638
|
-
});
|
|
639
|
-
} else {
|
|
640
|
-
const prepared = await prepareMcpStdioCommand(cfg, { cwd, sandboxPolicy, sandboxEngine, ctx });
|
|
641
|
-
transport = new StdioClientTransport({
|
|
642
|
-
command: prepared.command,
|
|
643
|
-
args: prepared.args || [],
|
|
644
|
-
cwd: prepared.cwd,
|
|
645
|
-
env: { ...process.env, ...(prepared.env || {}) },
|
|
646
|
-
});
|
|
647
|
-
// Monkey-patched cleanup handle: not part of the MCP transport's typed shape.
|
|
648
|
-
/** @type {any} */ (transport).__monoSandboxCleanup = prepared.cleanup;
|
|
649
|
-
}
|
|
650
666
|
try {
|
|
667
|
+
if (cfg.type === "http") {
|
|
668
|
+
transport = new StreamableHTTPClientTransport(new URL(cfg.url), { requestInit: { headers: cfg.headers || {} } });
|
|
669
|
+
} else if (cfg.type === "sse") {
|
|
670
|
+
transport = new SSEClientTransport(new URL(cfg.url), {
|
|
671
|
+
// SSE EventSourceInit's typed shape omits `headers`, but the transport
|
|
672
|
+
// forwards them to the underlying EventSource — keep the header pass-through.
|
|
673
|
+
eventSourceInit: /** @type {any} */ ({ headers: cfg.headers || {} }),
|
|
674
|
+
requestInit: { headers: cfg.headers || {} },
|
|
675
|
+
});
|
|
676
|
+
} else {
|
|
677
|
+
const prepared = await prepareMcpStdioCommand(cfg, { cwd, sandboxPolicy, sandboxEngine, ctx });
|
|
678
|
+
transport = new StdioClientTransport({
|
|
679
|
+
command: prepared.command,
|
|
680
|
+
args: prepared.args || [],
|
|
681
|
+
cwd: prepared.cwd,
|
|
682
|
+
env: { ...process.env, ...(prepared.env || {}) },
|
|
683
|
+
});
|
|
684
|
+
// Monkey-patched cleanup handle: not part of the MCP transport's typed shape.
|
|
685
|
+
/** @type {any} */ (transport).__monoSandboxCleanup = prepared.cleanup;
|
|
686
|
+
}
|
|
651
687
|
await client.connect(transport);
|
|
652
|
-
return {
|
|
688
|
+
return {
|
|
689
|
+
name,
|
|
690
|
+
client,
|
|
691
|
+
transport,
|
|
692
|
+
connectionId: randomUUID(),
|
|
693
|
+
retainedByMcpApps: false,
|
|
694
|
+
privateCapabilityUrl,
|
|
695
|
+
closed: false,
|
|
696
|
+
};
|
|
653
697
|
} catch (error) {
|
|
654
698
|
try { await transport?.close?.(); } catch { /* best-effort */ }
|
|
655
699
|
try { await /** @type {any} */ (transport)?.__monoSandboxCleanup?.(); } catch { /* best-effort */ }
|
|
700
|
+
if (privateCapabilityUrl) throw new Error("Private request-scoped MCP server connection failed.");
|
|
656
701
|
throw error;
|
|
657
702
|
}
|
|
658
703
|
}
|
|
@@ -741,7 +786,7 @@ function withTimeout(promise, timeoutMs, signal, label, registerReset) {
|
|
|
741
786
|
/**
|
|
742
787
|
* @param {any} mcpConfig
|
|
743
788
|
* @param {Set<any>} [reservedNames]
|
|
744
|
-
* @param {{limits?: any, cwd?: any, persistArtifact?: any, qaOutputDir?: any, onTruncate?: any, toolPayloadMaxBytes?: number, sandboxPolicy?: any, sandboxEngine?: any, onToolProgress?: any, ctx?: any}} [options]
|
|
789
|
+
* @param {{limits?: any, cwd?: any, persistArtifact?: any, qaOutputDir?: any, onTruncate?: any, toolPayloadMaxBytes?: number, sandboxPolicy?: any, sandboxEngine?: any, onToolProgress?: any, ctx?: any, mcpApps?: any, runId?: string}} [options]
|
|
745
790
|
*/
|
|
746
791
|
export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
747
792
|
limits = {},
|
|
@@ -754,11 +799,19 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
|
754
799
|
sandboxEngine = null,
|
|
755
800
|
onToolProgress = null,
|
|
756
801
|
ctx = null,
|
|
802
|
+
mcpApps = null,
|
|
803
|
+
runId = null,
|
|
757
804
|
} = {}) {
|
|
758
805
|
const clients = [];
|
|
759
806
|
const tools = [];
|
|
760
807
|
const entries = Object.entries(mcpConfig || {});
|
|
761
|
-
const settled = await Promise.allSettled(entries.map(([name, cfg]) => connectMcpClient(name, cfg, {
|
|
808
|
+
const settled = await Promise.allSettled(entries.map(([name, cfg]) => connectMcpClient(name, cfg, {
|
|
809
|
+
cwd,
|
|
810
|
+
sandboxPolicy,
|
|
811
|
+
sandboxEngine,
|
|
812
|
+
ctx,
|
|
813
|
+
mcpApps,
|
|
814
|
+
})));
|
|
762
815
|
const warnings = [];
|
|
763
816
|
const seen = new Set(reservedNames);
|
|
764
817
|
|
|
@@ -797,7 +850,9 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
|
797
850
|
type: "runtime_warning",
|
|
798
851
|
warning_kind: "mcp_list_tools_failed",
|
|
799
852
|
server: serverName,
|
|
800
|
-
message:
|
|
853
|
+
message: connected.privateCapabilityUrl
|
|
854
|
+
? "Private request-scoped MCP server did not expose its tools."
|
|
855
|
+
: error?.message || String(error),
|
|
801
856
|
});
|
|
802
857
|
continue;
|
|
803
858
|
}
|
|
@@ -848,21 +903,27 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
|
848
903
|
...(progress?.message === undefined ? {} : { message: progress.message }),
|
|
849
904
|
});
|
|
850
905
|
};
|
|
906
|
+
const request = connected.client.callTool(
|
|
907
|
+
{ name: sourceTool.name, arguments: normalizedParams || {} },
|
|
908
|
+
undefined,
|
|
909
|
+
// Forward the abort signal too, so a cancelled/timed-out call also cancels the
|
|
910
|
+
// in-flight MCP request on the wire (otherwise the SDK keeps awaiting until its own
|
|
911
|
+
// timeout, and an in-process loopback turn could post late after the bridge rejected).
|
|
912
|
+
{
|
|
913
|
+
timeout: mcpCallTimeoutMs,
|
|
914
|
+
resetTimeoutOnProgress: true,
|
|
915
|
+
maxTotalTimeout: mcpCallMaxTotalTimeoutMs,
|
|
916
|
+
signal,
|
|
917
|
+
onprogress,
|
|
918
|
+
},
|
|
919
|
+
).catch((error) => {
|
|
920
|
+
if (connected.privateCapabilityUrl) {
|
|
921
|
+
throw new Error("Private request-scoped MCP tool call failed.");
|
|
922
|
+
}
|
|
923
|
+
throw error;
|
|
924
|
+
});
|
|
851
925
|
const out = await withTimeout(
|
|
852
|
-
|
|
853
|
-
{ name: sourceTool.name, arguments: normalizedParams || {} },
|
|
854
|
-
undefined,
|
|
855
|
-
// Forward the abort signal too, so a cancelled/timed-out call also cancels the
|
|
856
|
-
// in-flight MCP request on the wire (otherwise the SDK keeps awaiting until its own
|
|
857
|
-
// timeout, and an in-process loopback turn could post late after the bridge rejected).
|
|
858
|
-
{
|
|
859
|
-
timeout: mcpCallTimeoutMs,
|
|
860
|
-
resetTimeoutOnProgress: true,
|
|
861
|
-
maxTotalTimeout: mcpCallMaxTotalTimeoutMs,
|
|
862
|
-
signal,
|
|
863
|
-
onprogress,
|
|
864
|
-
},
|
|
865
|
-
),
|
|
926
|
+
request,
|
|
866
927
|
mcpCallTimeoutMs,
|
|
867
928
|
signal,
|
|
868
929
|
`${serverName}:${sourceTool.name}`,
|
|
@@ -871,6 +932,19 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
|
871
932
|
},
|
|
872
933
|
);
|
|
873
934
|
const mcpCallDurationMs = Date.now() - mcpCallStartMs;
|
|
935
|
+
if (mcpApps) {
|
|
936
|
+
await registerMcpAppForToolResult({
|
|
937
|
+
mcpApps,
|
|
938
|
+
runId,
|
|
939
|
+
serverName,
|
|
940
|
+
connected,
|
|
941
|
+
sourceTool,
|
|
942
|
+
listedTools: listed.tools || [],
|
|
943
|
+
toolCallId,
|
|
944
|
+
toolInput: normalizedParams || {},
|
|
945
|
+
toolResult: out,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
874
948
|
const imageTruncations = [];
|
|
875
949
|
return {
|
|
876
950
|
content: coerceMcpContent(out, {
|
|
@@ -932,13 +1006,154 @@ async function closeWithTimeout(close, timeoutMs) {
|
|
|
932
1006
|
}
|
|
933
1007
|
}
|
|
934
1008
|
|
|
1009
|
+
async function registerMcpAppForToolResult({
|
|
1010
|
+
mcpApps,
|
|
1011
|
+
runId,
|
|
1012
|
+
serverName,
|
|
1013
|
+
connected,
|
|
1014
|
+
sourceTool,
|
|
1015
|
+
listedTools,
|
|
1016
|
+
toolCallId,
|
|
1017
|
+
toolInput,
|
|
1018
|
+
toolResult,
|
|
1019
|
+
}) {
|
|
1020
|
+
const resourceUri = mcpAppResourceUri(sourceTool);
|
|
1021
|
+
if (!resourceUri) return;
|
|
1022
|
+
const fail = async (code, message) => {
|
|
1023
|
+
try {
|
|
1024
|
+
await mcpApps.recordFailure({
|
|
1025
|
+
...(runId ? { runId } : {}),
|
|
1026
|
+
serverName,
|
|
1027
|
+
toolName: sourceTool.name,
|
|
1028
|
+
toolCallId,
|
|
1029
|
+
code,
|
|
1030
|
+
message,
|
|
1031
|
+
});
|
|
1032
|
+
} catch { /* A rich-part failure cannot turn a successful MCP call into a failed tool. */ }
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
if (!resourceUri.startsWith("ui://")) {
|
|
1036
|
+
await fail("app_resource_invalid", "The MCP App resource URI is invalid.");
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
const hostVersions = Array.isArray(mcpApps.protocolVersions)
|
|
1040
|
+
? mcpApps.protocolVersions.filter((value) => typeof value === "string")
|
|
1041
|
+
: [];
|
|
1042
|
+
const protocolVersion = MCP_APP_SUPPORTED_PROTOCOL_VERSIONS.find((version) => hostVersions.includes(version));
|
|
1043
|
+
const hostMimeTypes = Array.isArray(mcpApps.mimeTypes)
|
|
1044
|
+
? mcpApps.mimeTypes.filter((value) => typeof value === "string")
|
|
1045
|
+
: [];
|
|
1046
|
+
if (
|
|
1047
|
+
!protocolVersion
|
|
1048
|
+
|| !hostMimeTypes.includes(MCP_APP_RESOURCE_MIME_TYPE)
|
|
1049
|
+
) {
|
|
1050
|
+
await fail("app_capability_mismatch", "The MCP App protocol or resource MIME type is incompatible with this host.");
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
let resource;
|
|
1055
|
+
try {
|
|
1056
|
+
const response = await connected.client.readResource({ uri: resourceUri });
|
|
1057
|
+
resource = selectMcpAppResource(response, resourceUri);
|
|
1058
|
+
} catch {
|
|
1059
|
+
await fail("app_resource_invalid", "The MCP App resource could not be resolved through its originating connection.");
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
if (!resource) {
|
|
1063
|
+
await fail("app_resource_invalid", "The MCP App resource is missing, oversized, or has an incompatible MIME type.");
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const connection = {
|
|
1068
|
+
connectionId: connected.connectionId,
|
|
1069
|
+
readResource: async (uri) => await connected.client.readResource({ uri }),
|
|
1070
|
+
callTool: async (name, args, signal) => await connected.client.callTool(
|
|
1071
|
+
{ name, arguments: args && typeof args === "object" && !Array.isArray(args) ? args : {} },
|
|
1072
|
+
undefined,
|
|
1073
|
+
{
|
|
1074
|
+
timeout: 120_000,
|
|
1075
|
+
maxTotalTimeout: 120_000,
|
|
1076
|
+
...(signal ? { signal } : {}),
|
|
1077
|
+
},
|
|
1078
|
+
),
|
|
1079
|
+
close: async () => {
|
|
1080
|
+
connected.retainedByMcpApps = false;
|
|
1081
|
+
await closeConnectedMcpClient(connected, 5_000);
|
|
1082
|
+
},
|
|
1083
|
+
};
|
|
1084
|
+
try {
|
|
1085
|
+
const registered = await mcpApps.register({
|
|
1086
|
+
...(runId ? { runId } : {}),
|
|
1087
|
+
serverName,
|
|
1088
|
+
toolName: sourceTool.name,
|
|
1089
|
+
...(typeof sourceTool.title === "string" ? { title: sourceTool.title } : {}),
|
|
1090
|
+
...(typeof sourceTool.description === "string" ? { description: sourceTool.description } : {}),
|
|
1091
|
+
toolCallId,
|
|
1092
|
+
resourceUri,
|
|
1093
|
+
protocolVersion,
|
|
1094
|
+
toolInput,
|
|
1095
|
+
toolResult,
|
|
1096
|
+
resource,
|
|
1097
|
+
appVisibleTools: listedTools
|
|
1098
|
+
.filter((tool) => mcpToolVisibleToApp(tool))
|
|
1099
|
+
.map((tool) => tool.name),
|
|
1100
|
+
connection,
|
|
1101
|
+
});
|
|
1102
|
+
if (registered?.retainConnection === true && registered.part?.type === "mcp_app") {
|
|
1103
|
+
connected.retainedByMcpApps = true;
|
|
1104
|
+
}
|
|
1105
|
+
} catch {
|
|
1106
|
+
await fail("app_resource_invalid", "The MCP App host could not persist the negotiated resource.");
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function mcpAppResourceUri(tool) {
|
|
1111
|
+
const meta = tool?._meta || tool?.meta;
|
|
1112
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) return null;
|
|
1113
|
+
if (meta.ui && typeof meta.ui === "object" && !Array.isArray(meta.ui)
|
|
1114
|
+
&& typeof meta.ui.resourceUri === "string") return meta.ui.resourceUri;
|
|
1115
|
+
return typeof meta["ui/resourceUri"] === "string" ? meta["ui/resourceUri"] : null;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function mcpToolVisibleToApp(tool) {
|
|
1119
|
+
const meta = tool?._meta || tool?.meta;
|
|
1120
|
+
const visibility = meta?.ui?.visibility;
|
|
1121
|
+
return !Array.isArray(visibility) || visibility.includes("app");
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function selectMcpAppResource(response, resourceUri) {
|
|
1125
|
+
const content = Array.isArray(response?.contents)
|
|
1126
|
+
? response.contents.find((entry) => entry?.uri === resourceUri)
|
|
1127
|
+
: null;
|
|
1128
|
+
if (!content || typeof content.text !== "string") return null;
|
|
1129
|
+
const mimeType = content.mimeType || content.mime_type;
|
|
1130
|
+
if (mimeType !== MCP_APP_RESOURCE_MIME_TYPE) return null;
|
|
1131
|
+
if (Buffer.byteLength(content.text, "utf8") > MCP_APP_RESOURCE_MAX_BYTES) return null;
|
|
1132
|
+
return {
|
|
1133
|
+
uri: resourceUri,
|
|
1134
|
+
mimeType,
|
|
1135
|
+
text: content.text,
|
|
1136
|
+
...(content._meta && typeof content._meta === "object" && !Array.isArray(content._meta)
|
|
1137
|
+
? { _meta: content._meta }
|
|
1138
|
+
: {}),
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async function closeConnectedMcpClient(connected, timeoutMs) {
|
|
1143
|
+
if (!connected || connected.closed === true) return;
|
|
1144
|
+
connected.closed = true;
|
|
1145
|
+
const { client, transport } = connected;
|
|
1146
|
+
try { await closeWithTimeout(client?.close?.bind(client), timeoutMs); } catch { /* best-effort */ }
|
|
1147
|
+
try { await closeWithTimeout(transport?.close?.bind(transport), timeoutMs); } catch { /* best-effort */ }
|
|
1148
|
+
try { await transport?.__monoSandboxCleanup?.(); } catch { /* best-effort */ }
|
|
1149
|
+
}
|
|
1150
|
+
|
|
935
1151
|
export async function closePiMcpClients(clients, { timeoutMs = 5000 } = {}) {
|
|
936
1152
|
// Close the client first (stop accepting messages) then the transport (tear
|
|
937
1153
|
// down I/O), each bounded by a timeout so a hung stdio pipe cannot stall
|
|
938
1154
|
// shutdown — a common source of "Connection closed" churn on reconnect.
|
|
939
|
-
await Promise.all((clients || []).map(async (
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
try { await transport?.__monoSandboxCleanup?.(); } catch { /* best-effort */ }
|
|
1155
|
+
await Promise.all((clients || []).map(async (connected) => {
|
|
1156
|
+
if (connected?.retainedByMcpApps === true) return;
|
|
1157
|
+
await closeConnectedMcpClient(connected, timeoutMs);
|
|
943
1158
|
}));
|
|
944
1159
|
}
|
package/src/agent/tools/read.js
CHANGED
|
@@ -9,7 +9,16 @@ import {
|
|
|
9
9
|
} from "./shared/constants.js";
|
|
10
10
|
import { boundedInt, rememberRead, trimLine } from "./shared/dedup.js";
|
|
11
11
|
import { capChars } from "./shared/output-truncation.js";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
isPathAllowed,
|
|
14
|
+
isPathLexicallyAllowed,
|
|
15
|
+
resolveToolPath,
|
|
16
|
+
} from "./shared/path-resolver.js";
|
|
17
|
+
import {
|
|
18
|
+
protectedCommandSucceeded,
|
|
19
|
+
protectedFilesystemTargetPlan,
|
|
20
|
+
runProtectedFilesystemCommand,
|
|
21
|
+
} from "./shared/protected-filesystem.js";
|
|
13
22
|
|
|
14
23
|
// Raster image formats a vision model can consume directly. SVG is intentionally
|
|
15
24
|
// excluded — it is XML text, so it stays on the line-numbered text path.
|
|
@@ -33,14 +42,19 @@ const OUTPUT_MIME_BY_FORMAT = {
|
|
|
33
42
|
gif: "image/gif",
|
|
34
43
|
webp: "image/webp",
|
|
35
44
|
};
|
|
45
|
+
const PROTECTED_READ_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
46
|
+
const PROTECTED_READ_SOURCE = String.raw`
|
|
47
|
+
"use strict";
|
|
48
|
+
const { readFileSync } = require("node:fs");
|
|
49
|
+
process.stdout.write(readFileSync(process.argv[1]).toString("base64"));
|
|
50
|
+
`;
|
|
36
51
|
|
|
37
52
|
/**
|
|
38
|
-
* @param {
|
|
53
|
+
* @param {Buffer} source
|
|
39
54
|
* @param {string} filePath
|
|
40
55
|
* @param {string} imageMime
|
|
41
56
|
*/
|
|
42
|
-
async function readImageForModel(
|
|
43
|
-
const source = readFileSync(target);
|
|
57
|
+
async function readImageForModel(source, filePath, imageMime) {
|
|
44
58
|
const inputOptions = { animated: ANIMATED_IMAGE_MIME_TYPES.has(imageMime) };
|
|
45
59
|
|
|
46
60
|
try {
|
|
@@ -100,23 +114,49 @@ async function readImageForModel(target, filePath, imageMime) {
|
|
|
100
114
|
|
|
101
115
|
/**
|
|
102
116
|
* @param {{file_path: string, offset?: number, start_line?: number, limit?: number, max_output_chars?: number, workdir?: string}} params
|
|
103
|
-
* @param {{sandboxPolicy?: any, ctx?: any}} [options]
|
|
117
|
+
* @param {{sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
|
|
104
118
|
*/
|
|
105
|
-
export async function readToolImpl({ file_path, offset = 0, start_line, limit, max_output_chars, workdir }, { sandboxPolicy, ctx } = {}) {
|
|
119
|
+
export async function readToolImpl({ file_path, offset = 0, start_line, limit, max_output_chars, workdir }, { sandboxPolicy, sandboxEngine, ctx } = {}) {
|
|
106
120
|
const target = resolveToolPath(file_path, workdir, ctx);
|
|
107
|
-
|
|
108
|
-
|
|
121
|
+
const protectedTarget = protectedFilesystemTargetPlan(target, { sandboxPolicy, ctx });
|
|
122
|
+
const protectedExecution = protectedTarget !== null;
|
|
123
|
+
if (protectedExecution) {
|
|
124
|
+
if (!isPathLexicallyAllowed(target, workdir, { sandboxPolicy, ctx })) {
|
|
125
|
+
return "Error: Protected filesystem read was denied.";
|
|
126
|
+
}
|
|
127
|
+
} else if (!isPathAllowed(target, workdir, { sandboxPolicy, ctx })) {
|
|
128
|
+
return `Error: Path not allowed: ${file_path}`;
|
|
129
|
+
}
|
|
130
|
+
if (!protectedExecution && !existsSync(target)) return `Error: File not found: ${file_path}`;
|
|
131
|
+
let source;
|
|
132
|
+
try {
|
|
133
|
+
if (protectedExecution) {
|
|
134
|
+
const protectedResult = await runProtectedFilesystemCommand({
|
|
135
|
+
command: process.execPath,
|
|
136
|
+
args: ["--input-type=commonjs", "--eval", PROTECTED_READ_SOURCE, target],
|
|
137
|
+
cwd: protectedTarget.cwd,
|
|
138
|
+
}, { sandboxPolicy, sandboxEngine, ctx, maxBufferBytes: PROTECTED_READ_MAX_BUFFER_BYTES });
|
|
139
|
+
if (!protectedCommandSucceeded(protectedResult)) {
|
|
140
|
+
return "Error: Protected filesystem read was denied.";
|
|
141
|
+
}
|
|
142
|
+
source = Buffer.from(protectedResult.stdout, "base64");
|
|
143
|
+
} else {
|
|
144
|
+
source = readFileSync(target);
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
return "Error: Protected filesystem read was denied.";
|
|
148
|
+
}
|
|
109
149
|
// Image files are returned as an image result so vision models see pixels
|
|
110
150
|
// rather than the raw bytes decoded (and garbled) as utf8 text. The builtin
|
|
111
151
|
// tool wrapper turns this into an image content block; oversize images are
|
|
112
152
|
// capped by the shared tool-result bloat guard.
|
|
113
153
|
const imageMime = IMAGE_MIME_BY_EXT[extname(target).toLowerCase()];
|
|
114
154
|
if (imageMime !== undefined) {
|
|
115
|
-
const image = await readImageForModel(
|
|
155
|
+
const image = await readImageForModel(source, file_path, imageMime);
|
|
116
156
|
if (image.error !== undefined) return image.error;
|
|
117
157
|
return { kind: "image", data: image.data.toString("base64"), mimeType: image.mimeType };
|
|
118
158
|
}
|
|
119
|
-
const content =
|
|
159
|
+
const content = source.toString("utf8");
|
|
120
160
|
let lines = content.split("\n");
|
|
121
161
|
const total = lines.length;
|
|
122
162
|
const explicitStartLine = Number(start_line);
|