@mono-agent/agent-runtime 0.19.0 → 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.
Files changed (51) hide show
  1. package/MIGRATION.md +1 -1
  2. package/README.md +101 -4
  3. package/package.json +1 -1
  4. package/src/agent/sandbox-seam.js +16 -2
  5. package/src/agent/tools/bash.js +26 -4
  6. package/src/agent/tools/edit.js +72 -5
  7. package/src/agent/tools/exec.js +22 -4
  8. package/src/agent/tools/glob.js +65 -9
  9. package/src/agent/tools/grep.js +66 -11
  10. package/src/agent/tools/node-repl.js +5 -2
  11. package/src/agent/tools/pi-bridge.js +263 -48
  12. package/src/agent/tools/read.js +50 -10
  13. package/src/agent/tools/shared/path-resolver.js +67 -2
  14. package/src/agent/tools/shared/process-jobs.js +188 -0
  15. package/src/agent/tools/shared/process-runner.js +541 -30
  16. package/src/agent/tools/shared/protected-filesystem.js +150 -0
  17. package/src/agent/tools/web-search.js +63 -8
  18. package/src/agent/tools/write.js +52 -6
  19. package/src/ai/providers/acp.js +4 -0
  20. package/src/ai/providers/claude-cli.js +35 -2
  21. package/src/ai/providers/claude-sdk.js +12 -0
  22. package/src/ai/providers/codex-app.js +15 -2
  23. package/src/ai/providers/pi-native/stream-subscriber.js +29 -2
  24. package/src/ai/providers/pi-native/turn-runner.js +3 -0
  25. package/src/ai/providers/pi-native.js +7 -1
  26. package/src/ai/runtime/capabilities.js +2 -0
  27. package/src/ai/runtime/router.js +78 -6
  28. package/src/ai/streaming/codex-events.js +15 -0
  29. package/src/ai/streaming/opencode-events.js +5 -0
  30. package/src/ai/tool-lifecycle.js +347 -0
  31. package/src/ai/types.js +58 -0
  32. package/src/runtime.js +35 -21
  33. package/types/agent/sandbox-seam.d.ts +19 -6
  34. package/types/agent/tools/bash.d.ts +11 -26
  35. package/types/agent/tools/edit.d.ts +3 -2
  36. package/types/agent/tools/exec.d.ts +13 -26
  37. package/types/agent/tools/glob.d.ts +3 -2
  38. package/types/agent/tools/grep.d.ts +3 -2
  39. package/types/agent/tools/pi-bridge.d.ts +11 -4
  40. package/types/agent/tools/read.d.ts +3 -2
  41. package/types/agent/tools/shared/path-resolver.d.ts +8 -0
  42. package/types/agent/tools/shared/process-jobs.d.ts +64 -0
  43. package/types/agent/tools/shared/process-runner.d.ts +45 -3
  44. package/types/agent/tools/shared/protected-filesystem.d.ts +51 -0
  45. package/types/agent/tools/write.d.ts +3 -2
  46. package/types/ai/providers/pi-native/stream-subscriber.d.ts +2 -0
  47. package/types/ai/runtime/capabilities.d.ts +3 -0
  48. package/types/ai/streaming/codex-events.d.ts +1 -0
  49. package/types/ai/streaming/opencode-events.d.ts +1 -0
  50. package/types/ai/tool-lifecycle.d.ts +43 -0
  51. package/types/ai/types.d.ts +118 -0
@@ -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
- if (!isPathAllowed(target, workdir, { sandboxPolicy, ctx })) return `Error: Path not allowed: ${target}`;
46
- const stat = safeStat(target);
47
- if (!stat) return `Error: Path not found: ${target}`;
48
- const cwd = stat.isDirectory() ? target : dirname(target);
49
- const searchTarget = stat.isDirectory() ? "." : basename(target);
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("--glob", glob);
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(), "--", pattern, searchTarget);
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
- const { stdout } = await execFileAsync(rgPath, args, { cwd, timeout: 15000, maxBuffer: SEARCH_MAX_BUFFER });
66
- const normalized = stdout.trim().split("\n").filter(Boolean).map((line) => line.replace(/^\.\//, ""));
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(() => killProcessGroup(record.child, "SIGKILL"), KILL_GRACE_MS);
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
- { capabilities: {} },
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 { name, client, transport };
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, { cwd, sandboxPolicy, sandboxEngine, ctx })));
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: error?.message || String(error),
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
- connected.client.callTool(
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 ({ client, transport }) => {
940
- try { await closeWithTimeout(client?.close?.bind(client), timeoutMs); } catch { /* best-effort */ }
941
- try { await closeWithTimeout(transport?.close?.bind(transport), timeoutMs); } catch { /* best-effort */ }
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
  }
@@ -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 { isPathAllowed, resolveToolPath } from "./shared/path-resolver.js";
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 {string} target
53
+ * @param {Buffer} source
39
54
  * @param {string} filePath
40
55
  * @param {string} imageMime
41
56
  */
42
- async function readImageForModel(target, filePath, imageMime) {
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
- if (!isPathAllowed(target, workdir, { sandboxPolicy, ctx })) return `Error: Path not allowed: ${file_path}`;
108
- if (!existsSync(target)) return `Error: File not found: ${file_path}`;
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(target, file_path, imageMime);
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 = readFileSync(target, "utf8");
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);