@mono-agent/agent-runtime 0.20.14 → 0.21.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 (82) hide show
  1. package/ARCHITECTURE.md +50 -11
  2. package/MIGRATION.md +30 -7
  3. package/README.md +219 -35
  4. package/package.json +9 -4
  5. package/src/agent/tool-bloat.js +145 -9
  6. package/src/agent/tools/agent-tool.js +104 -5
  7. package/src/agent/tools/bash.js +10 -2
  8. package/src/agent/tools/codex-subscription-search.js +122 -28
  9. package/src/agent/tools/exec.js +10 -2
  10. package/src/agent/tools/monitor.js +11 -2
  11. package/src/agent/tools/pi-bridge.js +33 -14
  12. package/src/agent/tools/shared/monitors.js +22 -3
  13. package/src/agent/tools/shared/path-resolver.js +25 -6
  14. package/src/agent/tools/shared/process-jobs.js +6 -1
  15. package/src/agent/tools/shared/process-runner.js +3 -1
  16. package/src/agent/tools/shared/tool-context.js +8 -0
  17. package/src/agent/tools/web-access-interstitial.js +70 -0
  18. package/src/agent/tools/web-browser-render.js +83 -58
  19. package/src/agent/tools/web-controller.js +112 -21
  20. package/src/agent/tools/web-document-extractor.js +379 -0
  21. package/src/agent/tools/web-fetch.js +271 -243
  22. package/src/agent/tools/web-request.js +65 -0
  23. package/src/agent/tools/web-search-output.js +165 -0
  24. package/src/agent/tools/web-search-state.js +75 -0
  25. package/src/agent/tools/web-search.js +532 -71
  26. package/src/ai/failure.js +3 -3
  27. package/src/ai/index.js +1 -0
  28. package/src/ai/observer.js +8 -0
  29. package/src/ai/pi-interop.js +156 -0
  30. package/src/ai/provider-check.js +131 -0
  31. package/src/ai/providers/pi-native/compaction-driver.js +45 -21
  32. package/src/ai/providers/pi-native/compaction-summary.js +140 -0
  33. package/src/ai/providers/pi-native/harness-adapter.js +40 -2
  34. package/src/ai/providers/pi-native/prompt-cache-diagnostics.js +103 -0
  35. package/src/ai/providers/pi-native/provider-attribution.js +102 -0
  36. package/src/ai/providers/pi-native/result-builder.js +28 -4
  37. package/src/ai/providers/pi-native/session-lifecycle.js +167 -24
  38. package/src/ai/providers/pi-native/stream-subscriber.js +30 -2
  39. package/src/ai/providers/pi-native/terminal-recovery.js +40 -0
  40. package/src/ai/providers/pi-native/turn-runner.js +245 -13
  41. package/src/ai/providers/pi-native.js +159 -40
  42. package/src/ai/runtime/live-input-events.js +250 -54
  43. package/src/ai/runtime/router.js +30 -11
  44. package/src/ai/tool-lifecycle.js +32 -18
  45. package/src/ai/types.js +26 -5
  46. package/src/runtime.js +24 -5
  47. package/types/agent/tool-bloat.d.ts +1 -1
  48. package/types/agent/tools/agent-tool.d.ts +4 -1
  49. package/types/agent/tools/bash.d.ts +5 -3
  50. package/types/agent/tools/codex-subscription-search.d.ts +6 -2
  51. package/types/agent/tools/exec.d.ts +5 -3
  52. package/types/agent/tools/monitor.d.ts +5 -2
  53. package/types/agent/tools/pi-bridge.d.ts +6 -4
  54. package/types/agent/tools/shared/monitors.d.ts +17 -2
  55. package/types/agent/tools/shared/process-jobs.d.ts +5 -1
  56. package/types/agent/tools/shared/process-runner.d.ts +3 -2
  57. package/types/agent/tools/shared/tool-context.d.ts +2 -0
  58. package/types/agent/tools/web-access-interstitial.d.ts +23 -0
  59. package/types/agent/tools/web-browser-render.d.ts +4 -1
  60. package/types/agent/tools/web-controller.d.ts +4 -2
  61. package/types/agent/tools/web-document-extractor.d.ts +27 -0
  62. package/types/agent/tools/web-fetch.d.ts +19 -24
  63. package/types/agent/tools/web-request.d.ts +20 -0
  64. package/types/agent/tools/web-search-output.d.ts +31 -0
  65. package/types/agent/tools/web-search-state.d.ts +21 -0
  66. package/types/agent/tools/web-search.d.ts +10 -45
  67. package/types/ai/index.d.ts +1 -0
  68. package/types/ai/observer.d.ts +6 -0
  69. package/types/ai/pi-interop.d.ts +61 -0
  70. package/types/ai/provider-check.d.ts +53 -0
  71. package/types/ai/providers/pi-native/compaction-driver.d.ts +2 -1
  72. package/types/ai/providers/pi-native/compaction-summary.d.ts +19 -0
  73. package/types/ai/providers/pi-native/harness-adapter.d.ts +3 -1
  74. package/types/ai/providers/pi-native/prompt-cache-diagnostics.d.ts +3 -0
  75. package/types/ai/providers/pi-native/provider-attribution.d.ts +26 -0
  76. package/types/ai/providers/pi-native/result-builder.d.ts +11 -1
  77. package/types/ai/providers/pi-native/session-lifecycle.d.ts +23 -5
  78. package/types/ai/providers/pi-native/terminal-recovery.d.ts +2 -0
  79. package/types/ai/providers/pi-native/turn-runner.d.ts +36 -5
  80. package/types/ai/runtime/live-input-events.d.ts +32 -8
  81. package/types/ai/tool-lifecycle.d.ts +4 -3
  82. package/types/ai/types.d.ts +140 -12
@@ -7,8 +7,8 @@ import { resolveSandboxPolicy } from "./tool-context.js";
7
7
  // ToolContext is threaded (`ctx ?? readToolRuntime()`), so hosts that only call
8
8
  // the deep-path configureToolRuntime keep their historical behavior.
9
9
  function configured(ctx) {
10
- const { workspace, repoRoot } = ctx ?? readToolRuntime();
11
- return { workspace, repoRoot };
10
+ const { workspace, repoRoot, additionalReadRoots, additionalWriteRoots } = ctx ?? readToolRuntime();
11
+ return { workspace, repoRoot, additionalReadRoots, additionalWriteRoots };
12
12
  }
13
13
 
14
14
  export function workspaceRoot(workdir, ctx) {
@@ -50,8 +50,12 @@ function isPathAllowedFor(path, workdir, access, options) {
50
50
  && insideSandboxRoots(Array.isArray(field) ? field : [], r)
51
51
  && (access !== "write" || !sandboxDeniesWrite(policy, r, ctx));
52
52
  }
53
- const { workspace, repoRoot } = configured(ctx);
54
- return insideLegacyRoots([workdir, workspace, repoRoot, process.cwd(), "/tmp"], r);
53
+ const { workspace, repoRoot, additionalReadRoots, additionalWriteRoots } = configured(ctx);
54
+ const additionalRoots = access === "write"
55
+ ? additionalWriteRoots
56
+ : [...(additionalReadRoots ?? []), ...(additionalWriteRoots ?? [])];
57
+ return insideLegacyRoots([workdir, workspace, repoRoot, process.cwd(), "/tmp"], r)
58
+ || insideAdditionalRoots(Array.isArray(additionalRoots) ? additionalRoots : [], r);
55
59
  }
56
60
 
57
61
  function isPathLexicallyAllowedFor(path, workdir, access, options) {
@@ -64,8 +68,12 @@ function isPathLexicallyAllowedFor(path, workdir, access, options) {
64
68
  && insideLexicalRoots(Array.isArray(field) ? field : [], r)
65
69
  && (access !== "write" || !sandboxLexicallyDeniesWrite(policy, r, ctx));
66
70
  }
67
- const { workspace, repoRoot } = configured(ctx);
68
- return insideLegacyRoots([workdir, workspace, repoRoot, process.cwd(), "/tmp"], r);
71
+ const { workspace, repoRoot, additionalReadRoots, additionalWriteRoots } = configured(ctx);
72
+ const additionalRoots = access === "write"
73
+ ? additionalWriteRoots
74
+ : [...(additionalReadRoots ?? []), ...(additionalWriteRoots ?? [])];
75
+ return insideLegacyRoots([workdir, workspace, repoRoot, process.cwd(), "/tmp"], r)
76
+ || insideLexicalRoots(Array.isArray(additionalRoots) ? additionalRoots : [], r);
69
77
  }
70
78
 
71
79
  export function isWorkdirAllowed(workdir, options = {}) {
@@ -108,6 +116,17 @@ function insideSandboxRoots(roots, target) {
108
116
  && allowedRoots.some((root) => isInsidePath(root, real));
109
117
  }
110
118
 
119
+ // Additional file-tool roots are an operator-authored capability boundary even
120
+ // when process sandboxing is off. Unlike the legacy workspace allowance, keep
121
+ // both lexical and real paths inside the configured set so an allowed symlink
122
+ // cannot expose an unrelated path.
123
+ function insideAdditionalRoots(roots, target) {
124
+ const allowedRoots = normalizeRoots(roots);
125
+ const real = realTargetPath(target);
126
+ return allowedRoots.some((root) => isInsidePath(root, target))
127
+ && allowedRoots.some((root) => isInsidePath(root, real));
128
+ }
129
+
111
130
  // A protected root rejects either spelling: the lexical request and its
112
131
  // existing/nearest-existing realpath. This closes symlink aliases in both
113
132
  // directions without weakening ordinary readable/writable root checks.
@@ -14,6 +14,7 @@ import { startPreparedProcess } from "./process-runner.js";
14
14
  * prepared: import("../../sandbox-seam.js").PreparedSandboxCommand,
15
15
  * summary: string,
16
16
  * description?: string,
17
+ * wakeOnCompletion?: boolean,
17
18
  * timeoutMs?: number,
18
19
  * maxOutputChars?: number,
19
20
  * launch: (options?: {timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number, onStdout?: (chunk: Buffer) => void, onStderr?: (chunk: Buffer) => void}) => ReturnType<typeof startPreparedProcess>,
@@ -30,6 +31,7 @@ import { startPreparedProcess } from "./process-runner.js";
30
31
  * prepared: import("../../sandbox-seam.js").PreparedSandboxCommand,
31
32
  * summary: string,
32
33
  * description?: string,
34
+ * wakeOnCompletion?: boolean,
33
35
  * timeoutMs?: number,
34
36
  * maxOutputChars?: number,
35
37
  * startedAt: number,
@@ -42,6 +44,7 @@ export async function handOffProcessJob({
42
44
  prepared,
43
45
  summary,
44
46
  description,
47
+ wakeOnCompletion,
45
48
  timeoutMs,
46
49
  maxOutputChars,
47
50
  startedAt,
@@ -56,6 +59,7 @@ export async function handOffProcessJob({
56
59
  prepared: ownedPrepared,
57
60
  summary,
58
61
  ...(description === undefined ? {} : { description }),
62
+ ...(wakeOnCompletion === undefined ? {} : { wakeOnCompletion }),
59
63
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
60
64
  ...(maxOutputChars === undefined ? {} : { maxOutputChars }),
61
65
  launch(options = {}) {
@@ -89,7 +93,7 @@ export async function handOffProcessJob({
89
93
  ...(result.maxRuntimeMs === undefined ? {} : { max_runtime_ms: result.maxRuntimeMs }),
90
94
  };
91
95
  return {
92
- text: `${BACKGROUND_START_GUIDANCE}\n${JSON.stringify(payload)}`,
96
+ text: `${wakeOnCompletion === false ? "Background process job started with wake_on_completion=false: its terminal lifecycle card will update, but this conversation will not receive a completion turn. Do not report the work as finished yet." : BACKGROUND_START_GUIDANCE}\n${JSON.stringify(payload)}`,
93
97
  outcome: {
94
98
  status: "ok",
95
99
  code: "background_started",
@@ -147,6 +151,7 @@ const PUBLIC_BACKGROUND_START_FAILURES = Object.freeze({
147
151
  process_job_cleanup_incomplete: "Process-job cleanup could not be confirmed.",
148
152
  process_job_store_error: "Process-job storage failed.",
149
153
  process_job_wake_failed: "Process-job wake delivery failed.",
154
+ process_job_wake_unknown: "Process-job wake delivery outcome is unknown; replay was suppressed.",
150
155
  process_job_response_too_large: "The process-job response exceeded its size limit.",
151
156
  process_job_invalid: "The process-job request is invalid.",
152
157
  });
@@ -136,7 +136,7 @@ input.once("end", () => {
136
136
  * or exceeds that cap.
137
137
  *
138
138
  * @param {{command: string, args?: string[], cwd?: string, env?: Record<string, string|undefined>}} commandSpec
139
- * @param {{timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number, input?: string|Buffer}} [options]
139
+ * @param {{timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number, input?: string|Buffer, exactEnvironment?: boolean}} [options]
140
140
  */
141
141
  export function runPreparedProcess(
142
142
  commandSpec,
@@ -145,6 +145,7 @@ export function runPreparedProcess(
145
145
  signal,
146
146
  maxBufferBytes = DEFAULT_PROCESS_BUFFER_BYTES,
147
147
  input,
148
+ exactEnvironment = false,
148
149
  } = {},
149
150
  ) {
150
151
  return startPreparedProcess(commandSpec, {
@@ -152,6 +153,7 @@ export function runPreparedProcess(
152
153
  signal,
153
154
  maxBufferBytes,
154
155
  input,
156
+ exactEnvironment,
155
157
  }).completion;
156
158
  }
157
159
 
@@ -18,6 +18,8 @@
18
18
  // workspace — fallback for tool workdir resolution. Default: process.cwd().
19
19
  // repoRoot — secondary allowed root (the host's installation root).
20
20
  // Tool path-allowlist checks accept this in addition to workspace.
21
+ // additionalReadRoots — extra read-only roots for managed filesystem tools.
22
+ // additionalWriteRoots — extra read/write roots for managed filesystem tools.
21
23
  // runId — used as the subdirectory under toolArtifactDir for tool output.
22
24
  // toolArtifactDir — root for {dir}/tool-output/{runId}/{file} artifact writes
23
25
  // from capChars/formatSearchLines. Null = no persistence.
@@ -52,6 +54,8 @@ import { DEFAULT_RUNTIME_BRAND, resolveRuntimeBrand } from "../../../runtime-bra
52
54
  * @typedef {Object} ToolContext
53
55
  * @property {string} [workspace]
54
56
  * @property {string} [repoRoot]
57
+ * @property {ReadonlyArray<string>} [additionalReadRoots]
58
+ * @property {ReadonlyArray<string>} [additionalWriteRoots]
55
59
  * @property {string} [runId]
56
60
  * @property {string} [toolArtifactDir]
57
61
  * @property {string} [ripgrepPath]
@@ -69,6 +73,8 @@ import { DEFAULT_RUNTIME_BRAND, resolveRuntimeBrand } from "../../../runtime-bra
69
73
  const TOOL_CONTEXT_KEYS = /** @type {const} */ ([
70
74
  "workspace",
71
75
  "repoRoot",
76
+ "additionalReadRoots",
77
+ "additionalWriteRoots",
72
78
  "runId",
73
79
  "toolArtifactDir",
74
80
  "ripgrepPath",
@@ -89,6 +95,8 @@ export function createToolContext(input = {}) {
89
95
  const ctx = {
90
96
  workspace: undefined,
91
97
  repoRoot: undefined,
98
+ additionalReadRoots: undefined,
99
+ additionalWriteRoots: undefined,
92
100
  runId: undefined,
93
101
  toolArtifactDir: undefined,
94
102
  ripgrepPath: undefined,
@@ -0,0 +1,70 @@
1
+ // @ts-check
2
+
3
+ const MAX_INTERSTITIAL_SAMPLE_CHARS = 32 * 1024;
4
+
5
+ /**
6
+ * Classify access and authentication interstitials without treating incidental
7
+ * words such as "captcha" or "access denied" as conclusive evidence.
8
+ *
9
+ * @param {{url?: string, text?: string, statusCode?: number}} input
10
+ * @returns {{code: "access_challenge"|"authentication_required", message: string}|undefined}
11
+ */
12
+ export function classifyWebAccessInterstitial({ url, text, statusCode } = {}) {
13
+ const finalUrl = String(url || "");
14
+ const pathname = urlPathname(finalUrl);
15
+ const sample = normalizedSample(text);
16
+
17
+ const challengeArtifact = /\b(?:cf-chl-[\w-]+|cloudflare ray id|challenge-platform)\b/iu.test(sample);
18
+ const humanCheck = /\bverify (?:you are|that you are)(?: a)? human\b/iu.test(sample);
19
+ const browserCheck = /\bchecking your browser before accessing\b|\bunusual traffic from (?:your computer|this computer) network\b/iu.test(sample);
20
+ const securityVerification = /\bperforming security verification\b/iu.test(sample);
21
+ const javascriptCookieGate = /\benable javascript and cookies to continue\b/iu.test(sample);
22
+ const waitHeading = /\bjust a moment(?:\.{1,3})?\b/iu.test(sample);
23
+ const blockedAccess = /\baccess denied\b[\s\S]{0,240}\b(?:blocked|permission|reference|administrator)\b/iu.test(sample);
24
+
25
+ if (/\/(?:captcha|challenge)(?:\/|$)/iu.test(pathname)
26
+ || challengeArtifact
27
+ || humanCheck
28
+ || browserCheck
29
+ || blockedAccess
30
+ || (securityVerification && javascriptCookieGate)
31
+ || (waitHeading && (securityVerification || javascriptCookieGate))) {
32
+ return {
33
+ code: "access_challenge",
34
+ message: "Page presented an access challenge; no bypass was attempted.",
35
+ };
36
+ }
37
+
38
+ if (statusCode === 401 || statusCode === 407
39
+ || /\/(?:login|signin|sign-in)(?:\/|$)/iu.test(pathname)
40
+ || /\bauthentication required\b/iu.test(sample)
41
+ || /\b(?:sign|log) in to continue\b/iu.test(sample)
42
+ || (/\bsession (?:has )?expired\b/iu.test(sample) && /\b(?:sign|log) in\b/iu.test(sample))) {
43
+ return {
44
+ code: "authentication_required",
45
+ message: "Page requires authentication; no login was attempted.",
46
+ };
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ function urlPathname(value) {
52
+ try { return new URL(value).pathname; }
53
+ catch { return ""; }
54
+ }
55
+
56
+ /**
57
+ * @param {{url?: string, text?: string, statusCode?: number}} input
58
+ */
59
+ export function assertNoWebAccessInterstitial(input) {
60
+ const classified = classifyWebAccessInterstitial(input);
61
+ if (classified) throw Object.assign(new Error(classified.message), { code: classified.code });
62
+ }
63
+
64
+ function normalizedSample(value) {
65
+ return String(value || "")
66
+ .slice(0, MAX_INTERSTITIAL_SAMPLE_CHARS)
67
+ .replace(/<[^>]*>/gu, " ")
68
+ .replace(/\s+/gu, " ")
69
+ .trim();
70
+ }
@@ -7,11 +7,37 @@ import { passthroughSandbox } from "../sandbox-seam.js";
7
7
  import { runPreparedProcess } from "./shared/process-runner.js";
8
8
  import { readToolRuntime } from "./shared/runtime-context.js";
9
9
  import { resolveSandboxPolicy } from "./shared/tool-context.js";
10
+ import { assertNoWebAccessInterstitial } from "./web-access-interstitial.js";
10
11
 
11
12
  const BROWSER_TIMEOUT_MS = 20_000;
12
13
  const BROWSER_CLOSE_TIMEOUT_MS = 5_000;
13
14
  const BROWSER_OUTPUT_BYTES = 2 * 1024 * 1024;
14
15
  const MAX_BROWSER_NAMESPACE_CHARS = 16;
16
+ const BROWSER_HOST_ENV_KEYS = [
17
+ "COMSPEC", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "PATH", "PATHEXT",
18
+ "SHELL", "SystemRoot", "TEMP", "TMP", "TMPDIR", "USER", "WINDIR",
19
+ ];
20
+ const BLOCKED_BROWSER_ENV_KEYS = [
21
+ "ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
22
+ "all_proxy", "http_proxy", "https_proxy", "no_proxy",
23
+ "AGENT_BROWSER_ACTION_POLICY", "AGENT_BROWSER_ALLOWED_DOMAINS", "AGENT_BROWSER_ALLOW_FILE_ACCESS",
24
+ "AGENT_BROWSER_ANNOTATE", "AGENT_BROWSER_ARGS", "AGENT_BROWSER_CDP", "AGENT_BROWSER_COLOR_SCHEME",
25
+ "AGENT_BROWSER_CONFIRM_ACTIONS", "AGENT_BROWSER_CONFIRM_INTERACTIVE", "AGENT_BROWSER_CONTENT_BOUNDARIES",
26
+ "AGENT_BROWSER_DEFAULT_TIMEOUT", "AGENT_BROWSER_DOWNLOAD_PATH", "AGENT_BROWSER_ENABLE",
27
+ "AGENT_BROWSER_ENCRYPTION_KEY", "AGENT_BROWSER_ENGINE", "AGENT_BROWSER_EXECUTABLE_PATH",
28
+ "AGENT_BROWSER_EXTENSIONS", "AGENT_BROWSER_HEADED", "AGENT_BROWSER_HIDE_SCROLLBARS",
29
+ "AGENT_BROWSER_IDLE_TIMEOUT_MS", "AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "AGENT_BROWSER_INIT_SCRIPTS",
30
+ "AGENT_BROWSER_IOS_DEVICE", "AGENT_BROWSER_IOS_UDID", "AGENT_BROWSER_MAX_OUTPUT",
31
+ "AGENT_BROWSER_NAMESPACE", "AGENT_BROWSER_NO_AUTO_DIALOG", "AGENT_BROWSER_NO_XVFB",
32
+ "AGENT_BROWSER_PLUGINS", "AGENT_BROWSER_PROFILE", "AGENT_BROWSER_PROVIDER", "AGENT_BROWSER_PROXY",
33
+ "AGENT_BROWSER_PROXY_BYPASS", "AGENT_BROWSER_RESTORE", "AGENT_BROWSER_RESTORE_CHECK_FN",
34
+ "AGENT_BROWSER_RESTORE_CHECK_TEXT", "AGENT_BROWSER_RESTORE_CHECK_URL", "AGENT_BROWSER_SANDBOX_VERSION",
35
+ "AGENT_BROWSER_SCREENSHOT_DIR", "AGENT_BROWSER_SCREENSHOT_FORMAT", "AGENT_BROWSER_SCREENSHOT_QUALITY",
36
+ "AGENT_BROWSER_SESSION", "AGENT_BROWSER_SESSION_NAME", "AGENT_BROWSER_SKILLS_DIR",
37
+ "AGENT_BROWSER_SNAPSHOT_ID", "AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_STATE",
38
+ "AGENT_BROWSER_STATE_EXPIRE_DAYS", "AGENT_BROWSER_STREAM_PORT", "AGENT_BROWSER_USER_AGENT",
39
+ "AGENT_BROWSER_WEBGPU",
40
+ ];
15
41
 
16
42
  /**
17
43
  * Render one public page in a fresh anonymous agent-browser session.
@@ -42,6 +68,13 @@ export async function renderWithAgentBrowser(
42
68
  let tempDir = null;
43
69
  let unregister = () => {};
44
70
  let closed = false;
71
+ const renderDeadlineAt = Date.now() + BROWSER_TIMEOUT_MS;
72
+
73
+ function remainingRenderMs() {
74
+ const remaining = renderDeadlineAt - Date.now();
75
+ if (remaining <= 0) throw Object.assign(new Error(`agent-browser timed out after ${BROWSER_TIMEOUT_MS}ms`), { code: "browser_render_failed" });
76
+ return remaining;
77
+ }
45
78
 
46
79
  async function closeSession() {
47
80
  if (closed) return;
@@ -85,59 +118,9 @@ export async function renderWithAgentBrowser(
85
118
  args: [...baseArgs, ...commandArgs],
86
119
  cwd: workspace,
87
120
  env: {
88
- // Delete every documented agent-browser behavior/auth/persistence
89
- // override inherited from the host. Empty strings are not safe here:
90
- // agent-browser treats some of them (notably SESSION_NAME) as
91
- // configured-but-invalid values.
92
- AGENT_BROWSER_ACTION_POLICY: undefined,
93
- AGENT_BROWSER_ALLOWED_DOMAINS: undefined,
94
- AGENT_BROWSER_ANNOTATE: undefined,
95
- AGENT_BROWSER_ARGS: undefined,
96
- AGENT_BROWSER_COLOR_SCHEME: undefined,
97
- AGENT_BROWSER_CONFIRM_ACTIONS: undefined,
98
- AGENT_BROWSER_CONFIRM_INTERACTIVE: undefined,
99
- AGENT_BROWSER_CONTENT_BOUNDARIES: undefined,
100
- AGENT_BROWSER_DEFAULT_TIMEOUT: undefined,
101
- AGENT_BROWSER_DOWNLOAD_PATH: undefined,
102
- AGENT_BROWSER_ENABLE: undefined,
103
- AGENT_BROWSER_ENCRYPTION_KEY: undefined,
104
- AGENT_BROWSER_ENGINE: undefined,
105
- AGENT_BROWSER_EXECUTABLE_PATH: undefined,
106
- AGENT_BROWSER_EXTENSIONS: undefined,
107
- AGENT_BROWSER_HEADED: undefined,
108
- AGENT_BROWSER_HIDE_SCROLLBARS: undefined,
109
- AGENT_BROWSER_IDLE_TIMEOUT_MS: undefined,
110
- AGENT_BROWSER_INIT_SCRIPTS: undefined,
111
- AGENT_BROWSER_IOS_DEVICE: undefined,
112
- AGENT_BROWSER_IOS_UDID: undefined,
113
- AGENT_BROWSER_MAX_OUTPUT: undefined,
114
- AGENT_BROWSER_NAMESPACE: undefined,
115
- AGENT_BROWSER_NO_AUTO_DIALOG: undefined,
116
- AGENT_BROWSER_NO_XVFB: undefined,
117
- AGENT_BROWSER_PLUGINS: undefined,
118
- AGENT_BROWSER_PROFILE: undefined,
119
- AGENT_BROWSER_PROVIDER: undefined,
120
- AGENT_BROWSER_PROXY: undefined,
121
- AGENT_BROWSER_PROXY_BYPASS: undefined,
122
- AGENT_BROWSER_RESTORE: undefined,
123
- AGENT_BROWSER_RESTORE_CHECK_FN: undefined,
124
- AGENT_BROWSER_RESTORE_CHECK_TEXT: undefined,
125
- AGENT_BROWSER_RESTORE_CHECK_URL: undefined,
126
- AGENT_BROWSER_SCREENSHOT_DIR: undefined,
127
- AGENT_BROWSER_SCREENSHOT_FORMAT: undefined,
128
- AGENT_BROWSER_SCREENSHOT_QUALITY: undefined,
129
- AGENT_BROWSER_SESSION: undefined,
130
- AGENT_BROWSER_SESSION_NAME: undefined,
131
- AGENT_BROWSER_SKILLS_DIR: undefined,
132
- AGENT_BROWSER_SOCKET_DIR: undefined,
133
- AGENT_BROWSER_STATE: undefined,
134
- AGENT_BROWSER_STATE_EXPIRE_DAYS: undefined,
135
- AGENT_BROWSER_STREAM_PORT: undefined,
136
- AGENT_BROWSER_USER_AGENT: undefined,
137
- AGENT_BROWSER_WEBGPU: undefined,
121
+ ...isolatedBrowserEnvironment(),
138
122
  AGENT_BROWSER_AUTO_CONNECT: "false",
139
123
  AGENT_BROWSER_AUTOSAVE_INTERVAL_MS: "0",
140
- AGENT_BROWSER_CDP: undefined,
141
124
  AGENT_BROWSER_CONFIG: configPath,
142
125
  AGENT_BROWSER_RESTORE_SAVE: "never",
143
126
  NO_COLOR: "1",
@@ -145,10 +128,14 @@ export async function renderWithAgentBrowser(
145
128
  },
146
129
  });
147
130
  try {
148
- const result = await runPreparedProcess(prepared, {
131
+ const result = await runPreparedProcess({
132
+ ...prepared,
133
+ env: isolatedBrowserEnvironment(prepared.env, configPath),
134
+ }, {
149
135
  timeoutMs,
150
136
  signal: abortSignal,
151
137
  maxBufferBytes: BROWSER_OUTPUT_BYTES,
138
+ exactEnvironment: true,
152
139
  });
153
140
  if (result.timedOut) throw new Error(`agent-browser timed out after ${timeoutMs}ms`);
154
141
  if (result.aborted) throw new Error("agent-browser was aborted");
@@ -156,7 +143,7 @@ export async function renderWithAgentBrowser(
156
143
  if (result.spawnError) throw result.spawnError;
157
144
  if (result.signal) throw new Error(`agent-browser terminated by ${result.signal}`);
158
145
  if (result.code !== 0) {
159
- throw new Error(`agent-browser exited ${result.code}: ${String(result.stderr || result.stdout).trim()}`);
146
+ throw new Error(`agent-browser exited ${result.code}`);
160
147
  }
161
148
  return String(result.stdout || "").trim();
162
149
  } finally {
@@ -168,17 +155,55 @@ export async function renderWithAgentBrowser(
168
155
  tempDir = await mkdtemp(join(workspace, ".mono-agent-web-"));
169
156
  await writeFile(join(tempDir, "agent-browser.json"), "{}\n", { encoding: "utf8", mode: 0o600 });
170
157
  unregister = registerCleanup?.(closeSession) ?? (() => {});
171
- await run(["open", parsed.href]);
172
- await run(["wait", "--load", "domcontentloaded"]);
173
- const output = await run(["read"]);
158
+ await run(["open", parsed.href], remainingRenderMs());
159
+ await run(["wait", "--load", "domcontentloaded"], remainingRenderMs());
160
+ const finalUrlOutput = await run(["get", "url"], remainingRenderMs());
161
+ const finalUrl = validateFinalUrl(extractBrowserText(finalUrlOutput), parsed, sandbox, policy);
162
+ const output = await run(["read"], remainingRenderMs());
174
163
  const text = extractBrowserText(output);
175
164
  if (!text) throw new Error("agent-browser returned no readable rendered content");
176
- return text;
165
+ assertNoWebAccessInterstitial({ url: finalUrl, text });
166
+ return { text, finalUrl };
177
167
  } finally {
178
168
  await closeSession();
179
169
  }
180
170
  }
181
171
 
172
+ function validateFinalUrl(value, requested, sandbox, policy) {
173
+ let finalUrl;
174
+ try { finalUrl = new URL(String(value || "").trim()); }
175
+ catch { throw Object.assign(new Error("agent-browser returned an invalid final URL"), { code: "browser_render_failed" }); }
176
+ if (!["http:", "https:"].includes(finalUrl.protocol) || finalUrl.username || finalUrl.password) {
177
+ throw Object.assign(new Error("agent-browser navigated to an unsupported final URL"), { code: "network_denied" });
178
+ }
179
+ const requestedHost = requested.hostname.toLowerCase();
180
+ const finalHost = finalUrl.hostname.toLowerCase();
181
+ if (!(finalHost === requestedHost || finalHost.endsWith(`.${requestedHost}`))
182
+ || !sandbox.networkAllowsUrl(policy, finalUrl.href)) {
183
+ throw Object.assign(new Error("agent-browser final URL is outside the allowed domain policy"), { code: "network_denied" });
184
+ }
185
+ return finalUrl.href;
186
+ }
187
+
188
+ function isolatedBrowserEnvironment(source = process.env, configPath) {
189
+ /** @type {Record<string, string|undefined>} */
190
+ const env = {};
191
+ for (const key of BROWSER_HOST_ENV_KEYS) {
192
+ const value = source?.[key];
193
+ if (value !== undefined) env[key] = value;
194
+ }
195
+ // Undefined means deletion to the sandbox seam. runPreparedProcess then
196
+ // receives this map as an exact environment, so omitted host variables
197
+ // cannot reappear during its ordinary process.env merge.
198
+ for (const key of BLOCKED_BROWSER_ENV_KEYS) env[key] = undefined;
199
+ env.AGENT_BROWSER_AUTO_CONNECT = "false";
200
+ env.AGENT_BROWSER_AUTOSAVE_INTERVAL_MS = "0";
201
+ if (configPath !== undefined) env.AGENT_BROWSER_CONFIG = configPath;
202
+ env.AGENT_BROWSER_RESTORE_SAVE = "never";
203
+ env.NO_COLOR = "1";
204
+ return env;
205
+ }
206
+
182
207
  function compactBrowserNamespace(value) {
183
208
  const candidate = String(value || "").trim();
184
209
  if (/^[A-Za-z0-9_-]+$/u.test(candidate) && candidate.length <= MAX_BROWSER_NAMESPACE_CHARS) {
@@ -206,7 +231,7 @@ function findText(value, depth = 0) {
206
231
  return value.map((entry) => findText(entry, depth + 1)).filter(Boolean).join("\n").trim();
207
232
  }
208
233
  if (typeof value !== "object") return "";
209
- for (const key of ["markdown", "content", "text", "result", "output", "data"]) {
234
+ for (const key of ["markdown", "content", "text", "url", "result", "output", "data"]) {
210
235
  if (!(key in value)) continue;
211
236
  const text = findText(value[key], depth + 1);
212
237
  if (text) return text;
@@ -1,10 +1,11 @@
1
1
  // @ts-check
2
2
 
3
- import { randomUUID } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import { readToolRuntime } from "./shared/runtime-context.js";
5
5
  import { resolveSandboxPolicy } from "./shared/tool-context.js";
6
- import { performWebFetch } from "./web-fetch.js";
6
+ import { performWebFetch, formatWebFetchDocument } from "./web-fetch.js";
7
7
  import { performWebSearch } from "./web-search.js";
8
+ import { createWebSearchRunState, webSearchBudgetSnapshot } from "./web-search-state.js";
8
9
 
9
10
  const MAX_CACHE_ENTRIES = 64;
10
11
  const MAX_SHARED_SEARCH_ENTRIES = 256;
@@ -28,10 +29,12 @@ const sharedSearchCache = new Map();
28
29
  * cleanup. Search results are the exception: they live in the process-wide
29
30
  * cache above so sibling subagents and later turns can reuse them.
30
31
  *
31
- * @param {{searchConfig?: any, fetchConfig?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, fetchImpl?: typeof fetch, browserRenderer?: any, codexSearch?: any}} [options]
32
+ * @param {{coordinator?: any, searchConfig?: any, searchState?: any, fetchConfig?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, fetchImpl?: typeof fetch, browserRenderer?: any, codexSearch?: any}} [options]
32
33
  */
33
34
  export function createWebToolController({
35
+ coordinator,
34
36
  searchConfig,
37
+ searchState: suppliedSearchState,
35
38
  fetchConfig,
36
39
  sandboxPolicy,
37
40
  sandboxEngine,
@@ -46,6 +49,7 @@ export function createWebToolController({
46
49
  const fetchInFlight = new Map();
47
50
  const cleanups = new Set();
48
51
  let closed = false;
52
+ const searchState = createWebSearchRunState(searchConfig, suppliedSearchState);
49
53
 
50
54
  function registerCleanup(cleanup) {
51
55
  if (closed) {
@@ -74,7 +78,7 @@ export function createWebToolController({
74
78
  const result = await task;
75
79
  if (!result.error) {
76
80
  cache.set(key, cloneResult(result));
77
- while (cache.size > MAX_CACHE_ENTRIES) {
81
+ while (cache.size > MAX_CACHE_ENTRIES || [...cache.values()].reduce((n, r) => n + Buffer.byteLength(r.document?.body || "", "utf8"), 0) > 32 * 1024 * 1024) {
78
82
  cache.delete(cache.keys().next().value);
79
83
  }
80
84
  }
@@ -88,12 +92,15 @@ export function createWebToolController({
88
92
  * @param {string} key
89
93
  * @param {() => Promise<any>} execute
90
94
  */
91
- async function cachedSearch(key, execute) {
95
+ async function cachedSearch(key, query, execute) {
92
96
  if (closed) return closedResult();
93
97
  const cached = readSharedSearch(key);
94
- if (cached) return withCacheHit(cached);
98
+ if (cached) return withSearchCacheHit(cached, searchState, query);
95
99
  const active = searchInFlight.get(key);
96
- if (active) return withCacheHit(await active);
100
+ if (active) {
101
+ const result = await active;
102
+ return result.error ? withCacheHit(result) : withSearchCacheHit(result, searchState, query);
103
+ }
97
104
  const task = Promise.resolve().then(execute);
98
105
  searchInFlight.set(key, task);
99
106
  try {
@@ -111,6 +118,7 @@ export function createWebToolController({
111
118
  namespace,
112
119
 
113
120
  async search(params, execution = {}) {
121
+ if (execution.signal?.aborted) return { text: "Error: WebSearch was aborted.", error: true, outcome: { status: "error", code: "aborted" } };
114
122
  // The key must pin the backend, the endpoint AND the network policy the
115
123
  // search actually ran under. A params-only key was safe while the cache
116
124
  // lived and died with one run; process-wide it would let controllers with
@@ -132,30 +140,37 @@ export function createWebToolController({
132
140
  // strict as the key claims.
133
141
  const resolvedCtx = ctx ?? readToolRuntime();
134
142
  const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
135
- const key = stableKey({ params, searchConfig, policy });
136
- return cachedSearch(key, async () => performWebSearch(params, {
143
+ const key = stableKey({ params, searchConfig: safeSearchCacheIdentity(searchConfig), policy, coordination: coordinator?.scope });
144
+ return cachedSearch(key, params.query, async () => performWebSearch(params, {
145
+ coordinator,
137
146
  searchConfig,
138
147
  sandboxPolicy: policy,
139
148
  ctx: resolvedCtx,
140
149
  fetchImpl,
141
150
  codexSearch,
151
+ searchState,
142
152
  signal: execution.signal,
143
153
  }));
144
154
  },
145
155
 
146
156
  async fetch(params, execution = {}) {
147
- const key = stableKey(params);
148
- return cachedRun(fetchCache, fetchInFlight, key, async () => performWebFetch(params, {
149
- fetchConfig,
150
- sandboxPolicy,
151
- sandboxEngine,
152
- ctx,
153
- fetchImpl,
154
- browserRenderer,
155
- signal: execution.signal,
156
- namespace,
157
- registerCleanup,
157
+ if (execution.signal?.aborted) return { text: "Error: WebFetch was aborted.", error: true, outcome: { status: "error", code: "aborted" } };
158
+ const resolvedCtx = ctx ?? readToolRuntime();
159
+ const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
160
+ const { start_line, max_lines, max_output_chars, ...request } = params;
161
+ if ((start_line !== undefined && (!Number.isSafeInteger(start_line) || start_line < 1))
162
+ || (max_lines !== undefined && (!Number.isSafeInteger(max_lines) || max_lines < 1 || max_lines > 10000))) {
163
+ return { text: "Error: Invalid WebFetch line range.", error: true, outcome: { status: "error", code: "invalid_range" } };
164
+ }
165
+ const key = stableKey({ request, fetchConfig, policy, coordination: coordinator?.scope });
166
+ const result = await cachedRun(fetchCache, fetchInFlight, key, async () => performWebFetch(request, {
167
+ documentOnly: true, coordinator, fetchConfig, sandboxPolicy: policy, sandboxEngine,
168
+ ctx: resolvedCtx, fetchImpl, browserRenderer, signal: execution.signal,
169
+ namespace, registerCleanup,
158
170
  }));
171
+ if (result.error || !result.document) return result;
172
+ const sliced = formatWebFetchDocument({ ...result.document, outcome: result.outcome }, params, resolvedCtx);
173
+ return { ...sliced, document: undefined, outcome: { ...sliced.outcome, cacheHit: result.outcome.cacheHit } };
159
174
  },
160
175
 
161
176
  async close() {
@@ -174,6 +189,19 @@ export function createWebToolController({
174
189
  };
175
190
  }
176
191
 
192
+ function safeSearchCacheIdentity(searchConfig) {
193
+ if (!searchConfig || typeof searchConfig !== "object") return searchConfig;
194
+ const { maxRequestsPerRun: _budget, ...identity } = searchConfig;
195
+ if (typeof searchConfig?.ollama?.apiKey !== "string") return identity;
196
+ return {
197
+ ...identity,
198
+ ollama: {
199
+ ...searchConfig.ollama,
200
+ apiKey: `sha256:${createHash("sha256").update(searchConfig.ollama.apiKey).digest("hex")}`,
201
+ },
202
+ };
203
+ }
204
+
177
205
  function readSharedSearch(key) {
178
206
  const entry = sharedSearchCache.get(key);
179
207
  if (!entry) return null;
@@ -218,7 +246,15 @@ function sortValue(value) {
218
246
  function cloneResult(result) {
219
247
  return {
220
248
  ...result,
221
- outcome: result.outcome ? { ...result.outcome } : result.outcome,
249
+ outcome: result.outcome ? {
250
+ ...result.outcome,
251
+ ...(Array.isArray(result.outcome.providerAttempts)
252
+ ? { providerAttempts: result.outcome.providerAttempts.map((entry) => ({ ...entry })) }
253
+ : {}),
254
+ ...(Array.isArray(result.outcome.failureMetadata)
255
+ ? { failureMetadata: result.outcome.failureMetadata.map((entry) => ({ ...entry })) }
256
+ : {}),
257
+ } : result.outcome,
222
258
  };
223
259
  }
224
260
 
@@ -228,10 +264,65 @@ function withCacheHit(result) {
228
264
  outcome: {
229
265
  ...(result.outcome || {}),
230
266
  cacheHit: true,
267
+ attempts: 0, durationMs: 0, queueWaitMs: 0, backendDurationMs: 0,
268
+ cooldownSkipCount: 0, quotaSkipCount: 0,
269
+ ...(Number.isSafeInteger(result.outcome?.requestsThisCall) ? { requestsThisCall: 0 } : {}),
231
270
  },
232
271
  };
233
272
  }
234
273
 
274
+ function withSearchCacheHit(result, searchState, requestedQuery) {
275
+ const cloned = withCacheHit(result);
276
+ const budget = webSearchBudgetSnapshot(searchState, 0);
277
+ const resultCount = Number.isSafeInteger(cloned.outcome?.resultCount) ? cloned.outcome.resultCount : 0;
278
+ const nextAction = resultCount > 0
279
+ ? "fetch_existing_sources"
280
+ : budget.requestsRemaining > 0 ? "refine_query" : "use_available_evidence";
281
+ const action = nextAction === "fetch_existing_sources"
282
+ ? "Use WebFetch on the strongest returned URLs before searching again."
283
+ : nextAction === "refine_query"
284
+ ? "Refine the query only for a material evidence gap."
285
+ : "Do not retry WebSearch in this run; use available evidence and state the limitation.";
286
+ const control = `[Search control: requests=${budget.requestsUsed}/${budget.maxRequestsPerRun}; remaining=${budget.requestsRemaining}; ${action}]`;
287
+ const query = collapseWhitespace(requestedQuery).slice(0, 500);
288
+ const metadata = `[Search metadata: backend=${cloned.outcome?.backend || "unknown"}; attempted=none; actual_query=${JSON.stringify(query)}; fallback=none]`;
289
+ const textWithControl = typeof cloned.text === "string" && cloned.text.startsWith("[Search control:")
290
+ ? cloned.text.replace(/^\[Search control:[^\n]*\]/u, control)
291
+ : `${control}\n${cloned.text}`;
292
+ const text = textWithControl.includes("[Search metadata:")
293
+ ? textWithControl.replace(/^\[Search metadata:[^\n]*\]/mu, metadata)
294
+ : textWithControl.replace("[BEGIN UNTRUSTED WEB SEARCH RESULTS]", `[BEGIN UNTRUSTED WEB SEARCH RESULTS]\n${metadata}`);
295
+ const {
296
+ retryAfterMs: _retryAfterMs,
297
+ retryAt: _retryAt,
298
+ retryAtMs: _retryAtMs,
299
+ ...cachedOutcome
300
+ } = cloned.outcome || {};
301
+ return {
302
+ ...cloned,
303
+ text,
304
+ outcome: {
305
+ ...cachedOutcome,
306
+ ...budget,
307
+ bytes: Buffer.byteLength(text, "utf8"),
308
+ attemptedBackends: [],
309
+ actualQueries: [],
310
+ providerAttempts: [],
311
+ failureMetadata: [],
312
+ providerFailureCount: 0,
313
+ rateLimited: false,
314
+ cooldownBackends: [],
315
+ fallbackUsed: false,
316
+ retryInRun: budget.requestsRemaining > 0,
317
+ nextAction,
318
+ },
319
+ };
320
+ }
321
+
322
+ function collapseWhitespace(value) {
323
+ return typeof value === "string" ? value.replace(/\s+/gu, " ").trim() : "";
324
+ }
325
+
235
326
  function closedResult() {
236
327
  const text = "Error: Web tool controller has already closed.";
237
328
  return {