@mongodb-js/agent-engine-runner-shared 0.11.3

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 (220) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/LICENSE.md +201 -0
  3. package/README.md +29 -0
  4. package/dist/agent_config.d.ts +167 -0
  5. package/dist/agent_config.d.ts.map +1 -0
  6. package/dist/agent_config.js +544 -0
  7. package/dist/call_interrupted.d.ts +12 -0
  8. package/dist/call_interrupted.d.ts.map +1 -0
  9. package/dist/call_interrupted.js +11 -0
  10. package/dist/checkpoint_workspace.d.ts +25 -0
  11. package/dist/checkpoint_workspace.d.ts.map +1 -0
  12. package/dist/checkpoint_workspace.js +44 -0
  13. package/dist/context.d.ts +235 -0
  14. package/dist/context.d.ts.map +1 -0
  15. package/dist/context.js +322 -0
  16. package/dist/db_config.d.ts +28 -0
  17. package/dist/db_config.d.ts.map +1 -0
  18. package/dist/db_config.js +66 -0
  19. package/dist/db_naming.d.ts +54 -0
  20. package/dist/db_naming.d.ts.map +1 -0
  21. package/dist/db_naming.js +94 -0
  22. package/dist/error_reporting.d.ts +67 -0
  23. package/dist/error_reporting.d.ts.map +1 -0
  24. package/dist/error_reporting.js +311 -0
  25. package/dist/generated/workflow/v1/activity_pb.d.ts +342 -0
  26. package/dist/generated/workflow/v1/activity_pb.d.ts.map +1 -0
  27. package/dist/generated/workflow/v1/activity_pb.js +115 -0
  28. package/dist/generated/workflow/v1/common_pb.d.ts +184 -0
  29. package/dist/generated/workflow/v1/common_pb.d.ts.map +1 -0
  30. package/dist/generated/workflow/v1/common_pb.js +86 -0
  31. package/dist/generated/workflow/v1/runtime_pb.d.ts +200 -0
  32. package/dist/generated/workflow/v1/runtime_pb.d.ts.map +1 -0
  33. package/dist/generated/workflow/v1/runtime_pb.js +40 -0
  34. package/dist/generated/workflow/v1/state_pb.d.ts +254 -0
  35. package/dist/generated/workflow/v1/state_pb.d.ts.map +1 -0
  36. package/dist/generated/workflow/v1/state_pb.js +68 -0
  37. package/dist/guardrails_evaluator/core.d.ts +23 -0
  38. package/dist/guardrails_evaluator/core.d.ts.map +1 -0
  39. package/dist/guardrails_evaluator/core.js +122 -0
  40. package/dist/guardrails_evaluator/index.d.ts +10 -0
  41. package/dist/guardrails_evaluator/index.d.ts.map +1 -0
  42. package/dist/guardrails_evaluator/index.js +11 -0
  43. package/dist/guardrails_evaluator/regex.d.ts +20 -0
  44. package/dist/guardrails_evaluator/regex.d.ts.map +1 -0
  45. package/dist/guardrails_evaluator/regex.js +233 -0
  46. package/dist/hooks.d.ts +109 -0
  47. package/dist/hooks.d.ts.map +1 -0
  48. package/dist/hooks.js +216 -0
  49. package/dist/http_path.d.ts +18 -0
  50. package/dist/http_path.d.ts.map +1 -0
  51. package/dist/http_path.js +53 -0
  52. package/dist/index.d.ts +35 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +41 -0
  55. package/dist/launcher.d.ts +130 -0
  56. package/dist/launcher.d.ts.map +1 -0
  57. package/dist/launcher.js +325 -0
  58. package/dist/logger.d.ts +96 -0
  59. package/dist/logger.d.ts.map +1 -0
  60. package/dist/logger.js +204 -0
  61. package/dist/mcp_oauth.d.ts +51 -0
  62. package/dist/mcp_oauth.d.ts.map +1 -0
  63. package/dist/mcp_oauth.js +389 -0
  64. package/dist/mcp_oauth_secret.d.ts +21 -0
  65. package/dist/mcp_oauth_secret.d.ts.map +1 -0
  66. package/dist/mcp_oauth_secret.js +122 -0
  67. package/dist/mcp_tools.d.ts +71 -0
  68. package/dist/mcp_tools.d.ts.map +1 -0
  69. package/dist/mcp_tools.js +301 -0
  70. package/dist/memory_appbound.d.ts +42 -0
  71. package/dist/memory_appbound.d.ts.map +1 -0
  72. package/dist/memory_appbound.js +159 -0
  73. package/dist/memory_writer.d.ts +49 -0
  74. package/dist/memory_writer.d.ts.map +1 -0
  75. package/dist/memory_writer.js +171 -0
  76. package/dist/metrics.d.ts +84 -0
  77. package/dist/metrics.d.ts.map +1 -0
  78. package/dist/metrics.js +205 -0
  79. package/dist/models.d.ts +1458 -0
  80. package/dist/models.d.ts.map +1 -0
  81. package/dist/models.js +1726 -0
  82. package/dist/node_logger.d.ts +43 -0
  83. package/dist/node_logger.d.ts.map +1 -0
  84. package/dist/node_logger.js +158 -0
  85. package/dist/owner_callback.d.ts +16 -0
  86. package/dist/owner_callback.d.ts.map +1 -0
  87. package/dist/owner_callback.js +40 -0
  88. package/dist/progress.d.ts +57 -0
  89. package/dist/progress.d.ts.map +1 -0
  90. package/dist/progress.js +140 -0
  91. package/dist/runtime.d.ts +131 -0
  92. package/dist/runtime.d.ts.map +1 -0
  93. package/dist/runtime.js +351 -0
  94. package/dist/secure_llm_proxy.d.ts +115 -0
  95. package/dist/secure_llm_proxy.d.ts.map +1 -0
  96. package/dist/secure_llm_proxy.js +922 -0
  97. package/dist/secure_wrapper.d.ts +332 -0
  98. package/dist/secure_wrapper.d.ts.map +1 -0
  99. package/dist/secure_wrapper.js +1249 -0
  100. package/dist/server/aer.d.ts +61 -0
  101. package/dist/server/aer.d.ts.map +1 -0
  102. package/dist/server/aer.js +1124 -0
  103. package/dist/server/auth.d.ts +56 -0
  104. package/dist/server/auth.d.ts.map +1 -0
  105. package/dist/server/auth.js +132 -0
  106. package/dist/server/base.d.ts +104 -0
  107. package/dist/server/base.d.ts.map +1 -0
  108. package/dist/server/base.js +150 -0
  109. package/dist/server/callInterrupt.d.ts +49 -0
  110. package/dist/server/callInterrupt.d.ts.map +1 -0
  111. package/dist/server/callInterrupt.js +68 -0
  112. package/dist/server/callback_delivery.d.ts +14 -0
  113. package/dist/server/callback_delivery.d.ts.map +1 -0
  114. package/dist/server/callback_delivery.js +141 -0
  115. package/dist/server/chunk_types.d.ts +50 -0
  116. package/dist/server/chunk_types.d.ts.map +1 -0
  117. package/dist/server/chunk_types.js +62 -0
  118. package/dist/server/cors.d.ts +52 -0
  119. package/dist/server/cors.d.ts.map +1 -0
  120. package/dist/server/cors.js +107 -0
  121. package/dist/server/drain.d.ts +169 -0
  122. package/dist/server/drain.d.ts.map +1 -0
  123. package/dist/server/drain.js +455 -0
  124. package/dist/server/function.d.ts +77 -0
  125. package/dist/server/function.d.ts.map +1 -0
  126. package/dist/server/function.js +337 -0
  127. package/dist/server/http_retry.d.ts +37 -0
  128. package/dist/server/http_retry.d.ts.map +1 -0
  129. package/dist/server/http_retry.js +157 -0
  130. package/dist/server/index.d.ts +7 -0
  131. package/dist/server/index.d.ts.map +1 -0
  132. package/dist/server/index.js +5 -0
  133. package/dist/server/metadata.d.ts +50 -0
  134. package/dist/server/metadata.d.ts.map +1 -0
  135. package/dist/server/metadata.js +193 -0
  136. package/dist/server/oe_url.d.ts +36 -0
  137. package/dist/server/oe_url.d.ts.map +1 -0
  138. package/dist/server/oe_url.js +50 -0
  139. package/dist/server/owner_url.d.ts +35 -0
  140. package/dist/server/owner_url.d.ts.map +1 -0
  141. package/dist/server/owner_url.js +146 -0
  142. package/dist/server/query.d.ts +42 -0
  143. package/dist/server/query.d.ts.map +1 -0
  144. package/dist/server/query.js +28 -0
  145. package/dist/server/tool.d.ts +138 -0
  146. package/dist/server/tool.d.ts.map +1 -0
  147. package/dist/server/tool.js +1017 -0
  148. package/dist/span_names.d.ts +21 -0
  149. package/dist/span_names.d.ts.map +1 -0
  150. package/dist/span_names.js +31 -0
  151. package/dist/structured_logging/constants.d.ts +17 -0
  152. package/dist/structured_logging/constants.d.ts.map +1 -0
  153. package/dist/structured_logging/constants.js +71 -0
  154. package/dist/structured_logging/env.d.ts +18 -0
  155. package/dist/structured_logging/env.d.ts.map +1 -0
  156. package/dist/structured_logging/env.js +39 -0
  157. package/dist/structured_logging/install.d.ts +56 -0
  158. package/dist/structured_logging/install.d.ts.map +1 -0
  159. package/dist/structured_logging/install.js +107 -0
  160. package/dist/structured_logging/layout.d.ts +9 -0
  161. package/dist/structured_logging/layout.d.ts.map +1 -0
  162. package/dist/structured_logging/layout.js +144 -0
  163. package/dist/structured_logging/serialize.d.ts +27 -0
  164. package/dist/structured_logging/serialize.d.ts.map +1 -0
  165. package/dist/structured_logging/serialize.js +61 -0
  166. package/dist/structured_logging/stdio_capture.d.ts +59 -0
  167. package/dist/structured_logging/stdio_capture.d.ts.map +1 -0
  168. package/dist/structured_logging/stdio_capture.js +164 -0
  169. package/dist/structured_logging/uncaught.d.ts +14 -0
  170. package/dist/structured_logging/uncaught.d.ts.map +1 -0
  171. package/dist/structured_logging/uncaught.js +58 -0
  172. package/dist/structured_logging.d.ts +48 -0
  173. package/dist/structured_logging.d.ts.map +1 -0
  174. package/dist/structured_logging.js +47 -0
  175. package/dist/tls_client.d.ts +61 -0
  176. package/dist/tls_client.d.ts.map +1 -0
  177. package/dist/tls_client.js +298 -0
  178. package/dist/tool_api_error.d.ts +62 -0
  179. package/dist/tool_api_error.d.ts.map +1 -0
  180. package/dist/tool_api_error.js +399 -0
  181. package/dist/tool_memory_ownership.d.ts +10 -0
  182. package/dist/tool_memory_ownership.d.ts.map +1 -0
  183. package/dist/tool_memory_ownership.js +36 -0
  184. package/dist/toolpod_handlers.d.ts +126 -0
  185. package/dist/toolpod_handlers.d.ts.map +1 -0
  186. package/dist/toolpod_handlers.js +1016 -0
  187. package/dist/tracing/exporters.d.ts +51 -0
  188. package/dist/tracing/exporters.d.ts.map +1 -0
  189. package/dist/tracing/exporters.js +327 -0
  190. package/dist/tracing/index.d.ts +3 -0
  191. package/dist/tracing/index.d.ts.map +1 -0
  192. package/dist/tracing/index.js +2 -0
  193. package/dist/tracing/setup.d.ts +76 -0
  194. package/dist/tracing/setup.d.ts.map +1 -0
  195. package/dist/tracing/setup.js +436 -0
  196. package/dist/utils.d.ts +204 -0
  197. package/dist/utils.d.ts.map +1 -0
  198. package/dist/utils.js +867 -0
  199. package/dist/workflow/activity.d.ts +71 -0
  200. package/dist/workflow/activity.d.ts.map +1 -0
  201. package/dist/workflow/activity.js +357 -0
  202. package/dist/workflow/attempt.d.ts +12 -0
  203. package/dist/workflow/attempt.d.ts.map +1 -0
  204. package/dist/workflow/attempt.js +96 -0
  205. package/dist/workflow/client.d.ts +46 -0
  206. package/dist/workflow/client.d.ts.map +1 -0
  207. package/dist/workflow/client.js +299 -0
  208. package/dist/workflow/context.d.ts +37 -0
  209. package/dist/workflow/context.d.ts.map +1 -0
  210. package/dist/workflow/context.js +350 -0
  211. package/dist/workflow/heartbeat.d.ts +15 -0
  212. package/dist/workflow/heartbeat.d.ts.map +1 -0
  213. package/dist/workflow/heartbeat.js +78 -0
  214. package/dist/workflow/index.d.ts +14 -0
  215. package/dist/workflow/index.d.ts.map +1 -0
  216. package/dist/workflow/index.js +10 -0
  217. package/dist/workflow/memory.d.ts +17 -0
  218. package/dist/workflow/memory.d.ts.map +1 -0
  219. package/dist/workflow/memory.js +184 -0
  220. package/package.json +73 -0
@@ -0,0 +1,1016 @@
1
+ /**
2
+ * Built-in Tool Pod handler functions (TypeScript port of Python's
3
+ * `toolpod_handlers.py`).
4
+ *
5
+ * These handlers execute filesystem and shell operations inside the Tool Pod.
6
+ * They are registered at startup via `registerBuiltinTools` (gated on
7
+ * `features.deep_agent`) and dispatched by `ToolServer` when requests arrive
8
+ * from `AgentEngineToolPodBackend` through the OE secure path.
9
+ *
10
+ * All handlers accept a single named-argument object (the `ServerToolFn`
11
+ * contract), return a JSON-serializable object, and catch their own
12
+ * exceptions (returning `{error: ...}` on failure). `shell_execute` and
13
+ * `filesystem_glob` are async (the latter streams matches via
14
+ * `fs.promises.glob` so a broad pattern never materializes a huge list); the
15
+ * rest are synchronous.
16
+ *
17
+ * Return formats match what `AgentEngineToolPodBackend` expects to parse:
18
+ * - filesystem_ls -> {entries: [{path, is_dir}], truncated}
19
+ * - filesystem_read -> {content, encoding: "utf-8"}
20
+ * - filesystem_write -> {path}
21
+ * - filesystem_edit -> {occurrences}
22
+ * - filesystem_glob -> {matches: [{path, is_dir}], truncated}
23
+ * - filesystem_grep -> {matches: [{path, line, text}], truncated}
24
+ * - filesystem_download -> {path, content_base64, encoding: "base64"}
25
+ * - shell_execute -> {output, exit_code, truncated}
26
+ *
27
+ * Read-only skill roots resolve lazily at first use / startup, not at
28
+ * import, so a static SDK import before the agent root is set still works.
29
+ */
30
+ import * as crypto from "node:crypto";
31
+ import * as fs from "node:fs";
32
+ import * as path from "node:path";
33
+ import { spawn } from "node:child_process";
34
+ import { StringDecoder } from "node:string_decoder";
35
+ import { getCurrentSessionId } from "./context.js";
36
+ import { getLogger } from "./logger.js";
37
+ const logger = getLogger("agent_engine_runner_shared.toolpod_handlers");
38
+ // ---------------------------------------------------------------------------
39
+ // realpath helpers
40
+ // ---------------------------------------------------------------------------
41
+ /**
42
+ * Python's `os.path.realpath` resolves symlinks for the components that
43
+ * exist and leaves the non-existent tail lexical; Node's `fs.realpathSync`
44
+ * throws on any missing component. Resolve the longest existing ancestor and
45
+ * re-join the remainder so a workspace path that doesn't exist yet (and a
46
+ * symlinked mount like macOS `/tmp` -> `/private/tmp`) both normalize the way
47
+ * the Python handler expects.
48
+ */
49
+ function realpathBestEffort(p) {
50
+ let current = path.resolve(p);
51
+ const tail = [];
52
+ // Walk up until an existing ancestor is found, then realpath it.
53
+ for (;;) {
54
+ try {
55
+ const real = fs.realpathSync(current);
56
+ return tail.length ? path.join(real, ...tail.reverse()) : real;
57
+ }
58
+ catch {
59
+ const parent = path.dirname(current);
60
+ if (parent === current) {
61
+ // Reached the filesystem root without finding an existing component.
62
+ return path.resolve(p);
63
+ }
64
+ tail.push(path.basename(current));
65
+ current = parent;
66
+ }
67
+ }
68
+ }
69
+ function realAbs(p) {
70
+ return realpathBestEffort(path.resolve(p));
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // Configuration
74
+ // ---------------------------------------------------------------------------
75
+ function envInt(name, fallback) {
76
+ const raw = process.env[name];
77
+ if (raw === undefined || raw === "")
78
+ return fallback;
79
+ const parsed = Number.parseInt(raw, 10);
80
+ return Number.isNaN(parsed) ? fallback : parsed;
81
+ }
82
+ // Default the workspace to a path *outside* the typical project tree. Putting
83
+ // it under the project root causes dev-mode file watchers to restart the
84
+ // AER/OE processes on every write, killing in-flight LLM streams. Explicit
85
+ // `WORKSPACE_DIR` env wins so production containers can pin any path.
86
+ // `realpath` (not just `resolve`) so a symlinked mount and its target compare
87
+ // equal when callers pass either spelling.
88
+ export const WORKSPACE_DIR = realAbs(process.env["WORKSPACE_DIR"] ?? "/tmp/agent-workspace");
89
+ function agentSourceRootFromEnv() {
90
+ const configPath = process.env["AGENTIC_AGENT_CONFIG_PATH"];
91
+ if (configPath)
92
+ return path.dirname(realAbs(configPath));
93
+ const workdir = process.env["AGENTIC_AGENT_WORKDIR"];
94
+ if (workdir)
95
+ return realAbs(workdir);
96
+ return null;
97
+ }
98
+ function readonlySkillsRootFromEnv() {
99
+ const sourceRoot = agentSourceRootFromEnv();
100
+ const explicit = process.env["AGENTIC_SKILLS_DIR"];
101
+ const skillsDirSetting = explicit || "skills";
102
+ if (sourceRoot === null) {
103
+ if (explicit) {
104
+ logger.warn(`Ignoring AGENTIC_SKILLS_DIR=${JSON.stringify(explicit)} because the agent source root is unknown`);
105
+ }
106
+ return null;
107
+ }
108
+ if (path.isAbsolute(skillsDirSetting)) {
109
+ logger.warn(`Ignoring AGENTIC_SKILLS_DIR=${JSON.stringify(skillsDirSetting)} because it must be relative to the agent source root ${sourceRoot}`);
110
+ return null;
111
+ }
112
+ const skillsDir = realAbs(path.join(sourceRoot, skillsDirSetting));
113
+ if (!isWithinRoot(skillsDir, sourceRoot)) {
114
+ logger.warn(`Ignoring AGENTIC_SKILLS_DIR=${JSON.stringify(skillsDirSetting)} because it resolves outside the agent source root ${sourceRoot}`);
115
+ return null;
116
+ }
117
+ return skillsDir;
118
+ }
119
+ function isReadableDir(candidate) {
120
+ try {
121
+ if (!fs.statSync(candidate).isDirectory())
122
+ return false;
123
+ // X_OK too: opening files under a directory requires search permission.
124
+ fs.accessSync(candidate, fs.constants.R_OK | fs.constants.X_OK);
125
+ return true;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
131
+ // Resolved lazily, not at import: a static import before the app sets
132
+ // AGENTIC_AGENT_WORKDIR/AGENTIC_AGENT_CONFIG_PATH must still see the right
133
+ // root. Memoized — the skill root is process-global and must not
134
+ // shift between sessions. Roots that aren't readable directories are excluded
135
+ // (agents that bundle no skills); a configured-but-unusable root throws at
136
+ // startup in validateReadonlyResourceRootsAtStartup.
137
+ let readonlyResourceRootsMemo;
138
+ function getReadonlyResourceRoots() {
139
+ if (readonlyResourceRootsMemo === undefined) {
140
+ readonlyResourceRootsMemo = [readonlySkillsRootFromEnv()]
141
+ .filter((r) => r !== null)
142
+ .filter(isReadableDir);
143
+ }
144
+ return readonlyResourceRootsMemo;
145
+ }
146
+ const SHELL_OUTPUT_MAX_BYTES = envInt("SHELL_OUTPUT_MAX_BYTES", 65536);
147
+ // `timeout=null` means "use the backend default", not "run forever".
148
+ const SHELL_DEFAULT_TIMEOUT_SECONDS = envInt("SHELL_DEFAULT_TIMEOUT_SECONDS", 30);
149
+ // Cap filesystem_read / filesystem_edit raw bytes so broad reads on giant
150
+ // files don't exhaust pod memory. filesystem_edit loads the whole file, so the
151
+ // same cap applies.
152
+ const FILESYSTEM_READ_MAX_BYTES = envInt("FILESYSTEM_READ_MAX_BYTES", 10 * 1024 * 1024);
153
+ // Cap filesystem_grep matches + wall-clock budget. Deepagents' grep contract is
154
+ // literal substring matching (not regex), so classic ReDoS doesn't apply — but
155
+ // the time budget still protects against deep tree walks pegging a worker.
156
+ const MAX_GREP_MATCHES = envInt("MAX_GREP_MATCHES", 1000);
157
+ const MAX_GREP_SECONDS = Number.parseFloat(process.env["MAX_GREP_SECONDS"] ?? "") || 5;
158
+ const MAX_GLOB_MATCHES = envInt("MAX_GLOB_MATCHES", 1000);
159
+ const MAX_LS_ENTRIES = envInt("MAX_LS_ENTRIES", 1000);
160
+ const DOWNLOAD_MAX_BYTES = envInt("DOWNLOAD_MAX_BYTES", 10 * 1024 * 1024);
161
+ // Warn (but do not reject) when filesystem_write writes a file larger than
162
+ // this. Tool Pod workspace storage is RAM-backed; large writes reduce pod
163
+ // memory without the usual page-cache eviction safety net.
164
+ const FILESYSTEM_WRITE_WARN_BYTES = envInt("FILESYSTEM_WRITE_WARN_BYTES", 1 * 1024 * 1024);
165
+ // Known writable RAM-backed mounts inside a Tool Pod. Validated at startup.
166
+ const KNOWN_WORKSPACE_PREFIXES = ["/tmp", "/scratch"];
167
+ // Env vars shell_execute may propagate to the child. Everything else (tenant
168
+ // API keys, DB URIs, service URLs) is stripped — a prompt-injected agent
169
+ // running `env` must not see them (OWASP LLM01). Per-call scrub; the pod-level
170
+ // mount may be revisited in the future.
171
+ const SHELL_ENV_ALLOWLIST = new Set([
172
+ "PATH",
173
+ "HOME",
174
+ "LANG",
175
+ "LC_ALL",
176
+ "LC_CTYPE",
177
+ "TZ",
178
+ ]);
179
+ // Wall-clock budget for shell cleanup: SIGKILL reaping after a timeout.
180
+ const SHELL_CLEANUP_TIMEOUT_MS = 5000;
181
+ // Sentinel exit codes. A child killed by a signal reports a negative code on
182
+ // Unix; these small reserved values flag framework-level states instead.
183
+ const EXIT_CODE_TIMEOUT = -1;
184
+ const EXIT_CODE_FRAMEWORK_ERROR = -2;
185
+ /**
186
+ * Canonical set of built-in tool names registered by `registerBuiltinTools`.
187
+ * Consumed by `ToolServer.onStartup`'s completeness assertion and by tests so
188
+ * the callsites can't drift silently.
189
+ */
190
+ export const BUILTIN_TOOL_NAMES = new Set([
191
+ "filesystem_ls",
192
+ "filesystem_read",
193
+ "filesystem_write",
194
+ "filesystem_edit",
195
+ "filesystem_glob",
196
+ "filesystem_grep",
197
+ "filesystem_download",
198
+ "shell_execute",
199
+ ]);
200
+ // ---------------------------------------------------------------------------
201
+ // Path resolution
202
+ // ---------------------------------------------------------------------------
203
+ function isWithinRoot(resolved, root) {
204
+ const realResolved = realpathBestEffort(resolved);
205
+ return realResolved === root || realResolved.startsWith(root + path.sep);
206
+ }
207
+ // Reserved subdirectory under WORKSPACE_DIR holding per-session subtrees.
208
+ const SESSIONS_NAMESPACE = ".sessions";
209
+ /**
210
+ * Return a filesystem-safe directory name derived from the current session id,
211
+ * or `""` when no session is active (dev scripts, low-level tests fall back to
212
+ * the base WORKSPACE_DIR).
213
+ *
214
+ * The session id is hashed so any caller-provided string (including forms like
215
+ * `team/thread-1` or unicode) maps to a fixed-charset slot name that cannot
216
+ * contain path separators or collapse to `.`/`..`. The slot is not a security
217
+ * boundary — cross-session isolation is enforced by `isWithinWorkspace`.
218
+ */
219
+ function sessionSubdir() {
220
+ const sid = getCurrentSessionId();
221
+ if (!sid)
222
+ return "";
223
+ const normalized = Buffer.from(sid.normalize("NFC"), "utf-8");
224
+ return crypto.createHash("sha256").update(normalized).digest("hex");
225
+ }
226
+ function sessionsRoot() {
227
+ return path.normalize(path.join(WORKSPACE_DIR, SESSIONS_NAMESPACE));
228
+ }
229
+ /**
230
+ * The workspace directory for the current session: WORKSPACE_DIR when no
231
+ * session is active, else `WORKSPACE_DIR/.sessions/<slot>`.
232
+ */
233
+ function effectiveWorkspaceDir() {
234
+ const sub = sessionSubdir();
235
+ return sub ? path.normalize(path.join(sessionsRoot(), sub)) : WORKSPACE_DIR;
236
+ }
237
+ /**
238
+ * True if *resolved* is the caller's allowed workspace or a path under it.
239
+ * When a session is active this narrows to the session's own subtree so
240
+ * concurrent sessions on the same Tool Pod can't read/write each other's
241
+ * files. Uses realpath so symlinks can't escape the boundary.
242
+ */
243
+ function isWithinWorkspace(resolved) {
244
+ return isWithinRoot(resolved, effectiveWorkspaceDir());
245
+ }
246
+ function isWithinReadonlyResourceRoot(resolved) {
247
+ return getReadonlyResourceRoots().some((root) => isWithinRoot(resolved, root));
248
+ }
249
+ function isWithinReadableRoot(resolved) {
250
+ return isWithinWorkspace(resolved) || isWithinReadonlyResourceRoot(resolved);
251
+ }
252
+ /**
253
+ * Resolve *path* to an absolute path under WORKSPACE_DIR (or the per-session
254
+ * subtree). Relative paths join the effective workspace; absolute paths already
255
+ * under WORKSPACE_DIR are kept; other absolute paths have their leading `/`
256
+ * stripped and are rebased. Read-only ops may pass absolute paths under bundled
257
+ * resource roots (e.g. `<agent-dir>/skills`). Escapes via `..` or symlinks
258
+ * throw so the agent gets a clear signal instead of silent clamping.
259
+ */
260
+ function resolvePath(inputPath, opts = {}) {
261
+ const allowReadonlyRoots = opts.allowReadonlyRoots ?? false;
262
+ const base = effectiveWorkspaceDir();
263
+ let resolved;
264
+ if (path.isAbsolute(inputPath)) {
265
+ const normalized = path.normalize(inputPath);
266
+ // Accept the input as-is or after symlink resolution, so a symlinked
267
+ // workspace mount (macOS /tmp -> /private/tmp) is recognized.
268
+ const realNormalized = realpathBestEffort(normalized);
269
+ if (normalized === WORKSPACE_DIR ||
270
+ normalized.startsWith(WORKSPACE_DIR + path.sep) ||
271
+ realNormalized === WORKSPACE_DIR ||
272
+ realNormalized.startsWith(WORKSPACE_DIR + path.sep)) {
273
+ resolved = normalized;
274
+ }
275
+ else if (isWithinReadonlyResourceRoot(normalized)) {
276
+ if (allowReadonlyRoots)
277
+ return normalized;
278
+ throw new Error(`Path is inside a read-only resource root: ${JSON.stringify(inputPath)}. ` +
279
+ "Use read-only filesystem tools or write to the workspace.");
280
+ }
281
+ else {
282
+ fs.mkdirSync(base, { recursive: true });
283
+ resolved = path.normalize(path.join(base, inputPath.replace(/^[/\\]+/, "")));
284
+ }
285
+ }
286
+ else {
287
+ // Auto-create the session subdir so a brand-new session's first `ls('.')`
288
+ // doesn't ENOENT before any write.
289
+ fs.mkdirSync(base, { recursive: true });
290
+ resolved = path.normalize(path.join(base, inputPath));
291
+ }
292
+ if (!isWithinWorkspace(resolved)) {
293
+ throw new Error(`Path escapes workspace sandbox: ${JSON.stringify(inputPath)}. ` +
294
+ "Use a path inside the workspace (relative paths recommended).");
295
+ }
296
+ return resolved;
297
+ }
298
+ // ---------------------------------------------------------------------------
299
+ // Small helpers
300
+ // ---------------------------------------------------------------------------
301
+ function errResult(e) {
302
+ return { error: e instanceof Error ? e.message : String(e) };
303
+ }
304
+ /** True if `file`'s realpath resolves to a directory (follows symlinks, like
305
+ * Python's `os.DirEntry.is_dir()`); false on any error (e.g. broken symlink). */
306
+ function isDir(file) {
307
+ try {
308
+ return fs.statSync(file).isDirectory();
309
+ }
310
+ catch {
311
+ return false;
312
+ }
313
+ }
314
+ /** Translate an fnmatch-style glob (`*`, `?`, `[...]`) to a RegExp anchored to
315
+ * the whole filename — mirrors Python's `fnmatch.fnmatch` for grep's filter. */
316
+ function fnmatchToRegExp(pattern) {
317
+ let re = "";
318
+ for (let i = 0; i < pattern.length; i++) {
319
+ const c = pattern[i] ?? "";
320
+ if (c === "*")
321
+ re += ".*";
322
+ else if (c === "?")
323
+ re += ".";
324
+ else if (c === "[") {
325
+ let j = i + 1;
326
+ if (pattern[j] === "!")
327
+ j++;
328
+ if (pattern[j] === "]")
329
+ j++;
330
+ while (j < pattern.length && pattern[j] !== "]")
331
+ j++;
332
+ if (j >= pattern.length) {
333
+ re += "\\[";
334
+ }
335
+ else {
336
+ let inner = pattern.slice(i + 1, j);
337
+ if (inner.startsWith("!"))
338
+ inner = "^" + inner.slice(1);
339
+ re += "[" + inner.replace(/\\/g, "\\\\") + "]";
340
+ i = j;
341
+ }
342
+ }
343
+ else
344
+ re += c.replace(/[.+^${}()|\\]/g, "\\$&");
345
+ }
346
+ return new RegExp(`^${re}$`);
347
+ }
348
+ // ---------------------------------------------------------------------------
349
+ // Filesystem handlers
350
+ // ---------------------------------------------------------------------------
351
+ /**
352
+ * List directory contents, sorted by name. When the directory has more than
353
+ * MAX_LS_ENTRIES entries, `truncated=true` and the returned subset is the
354
+ * readdir-order first MAX_LS_ENTRIES entries (filesystem-defined), then sorted
355
+ * by path. The early break is intentional — sorting all entries first would
356
+ * defeat the memory cap on directories with hundreds of thousands of entries.
357
+ */
358
+ export function filesystemLs(args) {
359
+ try {
360
+ const resolved = resolvePath(String(args["path"] ?? ""), {
361
+ allowReadonlyRoots: true,
362
+ });
363
+ const entries = [];
364
+ let truncated = false;
365
+ // opendir + early break so a runaway directory never materializes every
366
+ // entry into memory (readdirSync would read them all at once).
367
+ const dir = fs.opendirSync(resolved);
368
+ try {
369
+ for (;;) {
370
+ const dirent = dir.readSync();
371
+ if (dirent === null)
372
+ break;
373
+ if (entries.length >= MAX_LS_ENTRIES) {
374
+ truncated = true;
375
+ break;
376
+ }
377
+ const full = path.join(resolved, dirent.name);
378
+ entries.push({ path: full, is_dir: isDir(full) });
379
+ }
380
+ }
381
+ finally {
382
+ dir.closeSync();
383
+ }
384
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
385
+ return { entries, truncated };
386
+ }
387
+ catch (e) {
388
+ return errResult(e);
389
+ }
390
+ }
391
+ /**
392
+ * Read file content as text with line-based slicing (lines
393
+ * `[offset, offset+limit)`). Rejects files larger than
394
+ * FILESYSTEM_READ_MAX_BYTES upfront so pathologically large files cannot
395
+ * exhaust pod memory.
396
+ */
397
+ export function filesystemRead(args) {
398
+ try {
399
+ const filePath = String(args["file_path"] ?? "");
400
+ const offset = Number(args["offset"] ?? 0);
401
+ const limit = Number(args["limit"] ?? 2000);
402
+ const resolved = resolvePath(filePath, { allowReadonlyRoots: true });
403
+ const size = fs.statSync(resolved).size;
404
+ if (size > FILESYSTEM_READ_MAX_BYTES) {
405
+ return {
406
+ error: `File exceeds FILESYSTEM_READ_MAX_BYTES (${size} > ${FILESYSTEM_READ_MAX_BYTES})`,
407
+ };
408
+ }
409
+ const text = fs.readFileSync(resolved, "utf-8");
410
+ // Split keeping newlines so re-joined content is byte-identical to Python's
411
+ // line-iterator slice. A trailing empty element (from a final newline) is
412
+ // dropped so it isn't counted as a line.
413
+ const lines = text.split(/(?<=\n)/);
414
+ if (lines.length && lines[lines.length - 1] === "")
415
+ lines.pop();
416
+ const selected = lines.slice(offset, offset + limit);
417
+ return { content: selected.join(""), encoding: "utf-8" };
418
+ }
419
+ catch (e) {
420
+ return errResult(e);
421
+ }
422
+ }
423
+ /**
424
+ * Write *content* to a new file, creating parent directories. Per
425
+ * `BackendProtocol.write` this is create-only: if the file exists the call
426
+ * fails. Agents modify existing files via filesystem_edit (which has a
427
+ * unique-match guard).
428
+ */
429
+ export function filesystemWrite(args) {
430
+ const filePath = String(args["file_path"] ?? "");
431
+ const content = String(args["content"] ?? "");
432
+ let resolved;
433
+ try {
434
+ resolved = resolvePath(filePath);
435
+ }
436
+ catch (e) {
437
+ return errResult(e);
438
+ }
439
+ try {
440
+ const contentBytes = Buffer.byteLength(content, "utf-8");
441
+ if (contentBytes > FILESYSTEM_WRITE_WARN_BYTES) {
442
+ logger.warn(`filesystem_write: writing large file ${JSON.stringify(resolved)} (${contentBytes} bytes). ` +
443
+ "Tool Pod workspace is RAM-backed — large files reduce available pod memory.");
444
+ }
445
+ const parent = path.dirname(resolved);
446
+ if (parent)
447
+ fs.mkdirSync(parent, { recursive: true });
448
+ // "wx" == O_CREAT | O_EXCL: fail if the file already exists.
449
+ fs.writeFileSync(resolved, content, { encoding: "utf-8", flag: "wx" });
450
+ return { path: resolved };
451
+ }
452
+ catch (e) {
453
+ if (e.code === "EEXIST") {
454
+ return {
455
+ error: `File already exists at ${JSON.stringify(filePath)}; use filesystem_edit to modify existing files.`,
456
+ };
457
+ }
458
+ return errResult(e);
459
+ }
460
+ }
461
+ /**
462
+ * Find and replace text in a file, returning the number of occurrences
463
+ * replaced. When *replace_all* is false the match must be unique — a non-unique
464
+ * `old_string` is rejected so a caller cannot silently corrupt the wrong
465
+ * region. Files larger than FILESYSTEM_READ_MAX_BYTES (before or after the
466
+ * edit) are rejected to mirror filesystem_read's memory guard.
467
+ */
468
+ export function filesystemEdit(args) {
469
+ try {
470
+ const filePath = String(args["file_path"] ?? "");
471
+ const oldString = String(args["old_string"] ?? "");
472
+ const newString = String(args["new_string"] ?? "");
473
+ const replaceAll = Boolean(args["replace_all"] ?? false);
474
+ // Empty old_string: count("") is len+1 (fails unique-match) and a
475
+ // replace-all interleaves new_string between every character.
476
+ if (oldString === "")
477
+ return { error: "old_string must be non-empty" };
478
+ const resolved = resolvePath(filePath);
479
+ const size = fs.statSync(resolved).size;
480
+ if (size > FILESYSTEM_READ_MAX_BYTES) {
481
+ return {
482
+ error: `File exceeds FILESYSTEM_READ_MAX_BYTES (${size} > ${FILESYSTEM_READ_MAX_BYTES})`,
483
+ };
484
+ }
485
+ const text = fs.readFileSync(resolved, "utf-8");
486
+ const matches = countOccurrences(text, oldString);
487
+ if (matches === 0) {
488
+ return { error: `old_string not found in ${JSON.stringify(filePath)}` };
489
+ }
490
+ let count;
491
+ let newText;
492
+ if (replaceAll) {
493
+ count = matches;
494
+ newText = text.split(oldString).join(newString);
495
+ }
496
+ else {
497
+ if (matches > 1) {
498
+ return {
499
+ error: `old_string is not unique (${matches} matches in ${JSON.stringify(filePath)}); ` +
500
+ "set replace_all=true or supply a longer anchor",
501
+ };
502
+ }
503
+ count = 1;
504
+ newText = text.replace(oldString, newString);
505
+ }
506
+ // Re-check the cap on the post-replacement text so a large new_string can't
507
+ // grow the file past the guard the up-front check enforces.
508
+ const newSize = Buffer.byteLength(newText, "utf-8");
509
+ if (newSize > FILESYSTEM_READ_MAX_BYTES) {
510
+ return {
511
+ error: `Edit would exceed FILESYSTEM_READ_MAX_BYTES (${newSize} > ${FILESYSTEM_READ_MAX_BYTES}); ` +
512
+ "reduce new_string size or split the edit into smaller pieces",
513
+ };
514
+ }
515
+ fs.writeFileSync(resolved, newText, "utf-8");
516
+ return { occurrences: count };
517
+ }
518
+ catch (e) {
519
+ return errResult(e);
520
+ }
521
+ }
522
+ /** Count non-overlapping occurrences of *needle* in *haystack* (Python
523
+ * `str.count` semantics; needle is guaranteed non-empty by the caller). */
524
+ function countOccurrences(haystack, needle) {
525
+ let count = 0;
526
+ let idx = haystack.indexOf(needle);
527
+ while (idx !== -1) {
528
+ count++;
529
+ idx = haystack.indexOf(needle, idx + needle.length);
530
+ }
531
+ return count;
532
+ }
533
+ /**
534
+ * Match files using a glob pattern under *path* (workspace-relative, default
535
+ * `"."`). Supports recursive `**` patterns. Bounded by MAX_GLOB_MATCHES.
536
+ * Absolute patterns and `..` traversal segments are rejected so they cannot
537
+ * bypass the sandbox; returned matches are also filtered through
538
+ * `isWithinReadableRoot` for defense-in-depth against symlinks.
539
+ */
540
+ export async function filesystemGlob(args) {
541
+ try {
542
+ const pattern = String(args["pattern"] ?? "");
543
+ const inputPath = String(args["path"] ?? ".");
544
+ if (path.isAbsolute(pattern)) {
545
+ return {
546
+ error: `Absolute pattern ${JSON.stringify(pattern)} is not allowed; use a workspace-relative pattern.`,
547
+ };
548
+ }
549
+ // Reject traversal up front. glob enumerates the filesystem while expanding
550
+ // a pattern like `../../etc/*`; even though matches are filtered, the
551
+ // enumeration itself can reveal structure outside the readable roots.
552
+ const patternParts = pattern.replace(/\\/g, "/").split("/");
553
+ if (patternParts.includes("..")) {
554
+ return {
555
+ error: `Pattern ${JSON.stringify(pattern)} contains traversal segments; use a workspace-relative pattern.`,
556
+ };
557
+ }
558
+ const resolved = resolvePath(inputPath, { allowReadonlyRoots: true });
559
+ const matches = [];
560
+ let truncated = false;
561
+ // `fs.glob` yields matches lazily (unlike `fs.globSync`, which materializes
562
+ // the full list first), so the MAX_GLOB_MATCHES break below stops the walk
563
+ // before a broad pattern allocates an unbounded array — parity with the
564
+ // Python handler's `glob.iglob` generator. Paths are relative to `cwd`;
565
+ // re-join to absolute for the readable-root filter and returned `path`.
566
+ for await (const rel of fs.promises.glob(pattern, { cwd: resolved })) {
567
+ const full = path.resolve(resolved, rel);
568
+ if (!isWithinReadableRoot(full))
569
+ continue;
570
+ matches.push({ path: full, is_dir: isDir(full) });
571
+ if (matches.length >= MAX_GLOB_MATCHES) {
572
+ truncated = true;
573
+ break;
574
+ }
575
+ }
576
+ matches.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
577
+ return { matches, truncated };
578
+ }
579
+ catch (e) {
580
+ return errResult(e);
581
+ }
582
+ }
583
+ /**
584
+ * Search file contents for a literal substring (per `BackendProtocol.grep`,
585
+ * not a regex). Walks the tree under *path* (default `"."`); when *glob* is
586
+ * given only files whose names match are searched. Binary files are skipped.
587
+ * Bounded by MAX_GREP_MATCHES and MAX_GREP_SECONDS so a deep-tree walk cannot
588
+ * peg a worker indefinitely.
589
+ */
590
+ export function filesystemGrep(args) {
591
+ try {
592
+ const pattern = String(args["pattern"] ?? "");
593
+ const inputPath = args["path"] == null ? "." : String(args["path"]);
594
+ const glob = args["glob"] == null ? null : String(args["glob"]);
595
+ const globRe = glob ? fnmatchToRegExp(glob) : null;
596
+ const searchPath = resolvePath(inputPath, { allowReadonlyRoots: true });
597
+ const matches = [];
598
+ let truncated = false;
599
+ const deadline = Date.now() + MAX_GREP_SECONDS * 1000;
600
+ // Iterative directory walk (avoids recursion depth limits on deep trees).
601
+ const stack = [searchPath];
602
+ walk: while (stack.length > 0) {
603
+ const dirpath = stack.pop();
604
+ if (dirpath === undefined)
605
+ continue;
606
+ let dirents;
607
+ try {
608
+ dirents = fs.readdirSync(dirpath, { withFileTypes: true });
609
+ }
610
+ catch {
611
+ continue;
612
+ }
613
+ for (const dirent of dirents) {
614
+ const full = path.join(dirpath, dirent.name);
615
+ if (isDir(full)) {
616
+ stack.push(full);
617
+ continue;
618
+ }
619
+ if (Date.now() > deadline) {
620
+ truncated = true;
621
+ break walk;
622
+ }
623
+ if (globRe && !globRe.test(dirent.name))
624
+ continue;
625
+ // A symlinked file inside a readable root can point outside the
626
+ // sandbox; resolve before reading follows the link.
627
+ if (!isWithinReadableRoot(full))
628
+ continue;
629
+ if (grepFileStreaming(full, pattern, matches, deadline) === "truncated") {
630
+ truncated = true;
631
+ break walk;
632
+ }
633
+ }
634
+ }
635
+ return { matches, truncated };
636
+ }
637
+ catch (e) {
638
+ return errResult(e);
639
+ }
640
+ }
641
+ /**
642
+ * Grep a single file for a literal substring, reading it in bounded chunks so
643
+ * a large file is never loaded fully into memory — parity with Python's
644
+ * line-by-line `for line in fh`. Hits are appended to *matches*; returns
645
+ * `"truncated"` when the deadline or MAX_GREP_MATCHES cap is reached, else
646
+ * `"ok"`. Binary files (first NUL byte) and unreadable files are skipped.
647
+ * A `StringDecoder` reassembles multi-byte UTF-8 sequences split across chunks.
648
+ */
649
+ function grepFileStreaming(full, pattern, matches, deadline) {
650
+ let fd;
651
+ try {
652
+ fd = fs.openSync(full, "r");
653
+ }
654
+ catch {
655
+ return "ok";
656
+ }
657
+ const CHUNK_BYTES = 65536;
658
+ const buf = Buffer.allocUnsafe(CHUNK_BYTES);
659
+ const decoder = new StringDecoder("utf-8");
660
+ let leftover = "";
661
+ let lineNo = 0;
662
+ const check = (text) => {
663
+ lineNo += 1;
664
+ if (Date.now() > deadline)
665
+ return "truncated";
666
+ if (text.includes(pattern)) {
667
+ matches.push({ path: full, line: lineNo, text });
668
+ if (matches.length >= MAX_GREP_MATCHES)
669
+ return "truncated";
670
+ }
671
+ return "no";
672
+ };
673
+ try {
674
+ for (;;) {
675
+ const bytesRead = fs.readSync(fd, buf, 0, CHUNK_BYTES, null);
676
+ if (bytesRead === 0)
677
+ break;
678
+ const chunk = buf.subarray(0, bytesRead);
679
+ if (chunk.includes(0))
680
+ return "ok"; // binary file — skip
681
+ leftover += decoder.write(chunk);
682
+ let nl;
683
+ while ((nl = leftover.indexOf("\n")) !== -1) {
684
+ const line = leftover.slice(0, nl);
685
+ leftover = leftover.slice(nl + 1);
686
+ if (check(line) === "truncated")
687
+ return "truncated";
688
+ }
689
+ }
690
+ leftover += decoder.end();
691
+ if (leftover.length > 0 && check(leftover) === "truncated") {
692
+ return "truncated";
693
+ }
694
+ return "ok";
695
+ }
696
+ finally {
697
+ fs.closeSync(fd);
698
+ }
699
+ }
700
+ /**
701
+ * Download a file's raw bytes base64-encoded so the JSON transport can carry
702
+ * arbitrary binary content. `AgentEngineToolPodBackend.downloadFiles` base64-decodes
703
+ * this to produce the response bytes.
704
+ */
705
+ export function filesystemDownload(args) {
706
+ try {
707
+ const filePath = String(args["file_path"] ?? "");
708
+ const resolved = resolvePath(filePath, { allowReadonlyRoots: true });
709
+ const size = fs.statSync(resolved).size;
710
+ if (size > DOWNLOAD_MAX_BYTES) {
711
+ return {
712
+ error: `File exceeds DOWNLOAD_MAX_BYTES (${size} > ${DOWNLOAD_MAX_BYTES})`,
713
+ };
714
+ }
715
+ const raw = fs.readFileSync(resolved);
716
+ return {
717
+ path: resolved,
718
+ content_base64: raw.toString("base64"),
719
+ encoding: "base64",
720
+ };
721
+ }
722
+ catch (e) {
723
+ return errResult(e);
724
+ }
725
+ }
726
+ // ---------------------------------------------------------------------------
727
+ // Shell handler
728
+ // ---------------------------------------------------------------------------
729
+ function truncateOutput(text) {
730
+ const encoded = Buffer.from(text, "utf-8");
731
+ if (encoded.length <= SHELL_OUTPUT_MAX_BYTES)
732
+ return [text, false];
733
+ return [encoded.subarray(0, SHELL_OUTPUT_MAX_BYTES).toString("utf-8"), true];
734
+ }
735
+ /**
736
+ * The minimal env the shell child inherits. The Tool-Pod container currently
737
+ * mounts tenant secrets via `envFrom.secretRef`; without scrubbing, a
738
+ * prompt-injected agent running `shell_execute("env")` would exfiltrate them
739
+ * (OWASP LLM01). Until the pod-level mount is removed, reconstruct the
740
+ * child env from an explicit allowlist.
741
+ *
742
+ * Scope limit: this scrubs only the child's *own* environment. The child runs
743
+ * as the same UID as the tool server (PID 1), so it can still read the
744
+ * parent's full environment via `/proc/1/environ` regardless of whether the
745
+ * image runs as root or a dedicated user. The scrub blunts casual
746
+ * exfiltration; it is not an OS-enforced secret boundary.
747
+ */
748
+ function buildShellEnv() {
749
+ const env = {};
750
+ for (const key of SHELL_ENV_ALLOWLIST) {
751
+ const value = process.env[key];
752
+ if (value !== undefined)
753
+ env[key] = value;
754
+ }
755
+ return env;
756
+ }
757
+ /**
758
+ * Run a shell command and capture its output. Per
759
+ * `SandboxBackendProtocol.execute`, *timeout* is `null` = "use the backend
760
+ * default" (SHELL_DEFAULT_TIMEOUT_SECONDS), not "no timeout".
761
+ *
762
+ * Threat model: `shell: true` is intentional — agents need pipes, redirects,
763
+ * globs. The sandbox is the Tool Pod itself.
764
+ *
765
+ * Per-session isolation gap: unlike the filesystem handlers (which enforce the
766
+ * workspace boundary via `resolvePath()` + `isWithinWorkspace()` realpath
767
+ * checks), `cwd` here is only the spawned shell's default working directory —
768
+ * the process is not OS-confined, so `..`/absolute paths can reach sibling
769
+ * sessions or arbitrary pod paths. Treat as session-shared until the
770
+ * bubblewrap sandbox lands. There is no upstream approval gate on the command
771
+ * string; treat the content as agent-authored. This means shell_execute does
772
+ * NOT match the per-session filesystem isolation the fs handlers enforce —
773
+ * an accepted, tracked risk, not local path validation.
774
+ *
775
+ * Output handling: stdout/stderr are drained as they stream. Each stream stops
776
+ * *appending* once SHELL_OUTPUT_MAX_BYTES is captured but the stream is never
777
+ * paused, so the child never blocks on a full pipe — a runaway `yes` runs to
778
+ * its timeout without OOMing the pod. Partial output is preserved on timeout.
779
+ * Framework errors (e.g. missing `/bin/sh`) return
780
+ * `exit_code = EXIT_CODE_FRAMEWORK_ERROR`, distinct from the timeout sentinel.
781
+ */
782
+ export async function shellExecute(args) {
783
+ const command = String(args["command"] ?? "");
784
+ const rawTimeout = args["timeout"];
785
+ const effectiveTimeout = rawTimeout == null ? SHELL_DEFAULT_TIMEOUT_SECONDS : Number(rawTimeout);
786
+ let cwd;
787
+ try {
788
+ cwd = effectiveWorkspaceDir();
789
+ fs.mkdirSync(cwd, { recursive: true });
790
+ }
791
+ catch (e) {
792
+ return {
793
+ output: "",
794
+ exit_code: EXIT_CODE_FRAMEWORK_ERROR,
795
+ truncated: false,
796
+ error: e instanceof Error ? e.message : String(e),
797
+ };
798
+ }
799
+ return new Promise((resolve) => {
800
+ const cap = SHELL_OUTPUT_MAX_BYTES;
801
+ const stdoutBuf = [];
802
+ const stderrBuf = [];
803
+ let stdoutCaptured = 0;
804
+ let stderrCaptured = 0;
805
+ let overflow = false;
806
+ let settled = false;
807
+ const capture = (buf, captured, chunk) => {
808
+ // Keep consuming past the cap (drop the excess) so the child never blocks
809
+ // on a full pipe; flag overflow so the result is marked truncated.
810
+ if (captured < cap) {
811
+ const take = Math.min(chunk.length, cap - captured);
812
+ buf.push(chunk.subarray(0, take));
813
+ if (chunk.length > take)
814
+ overflow = true;
815
+ return captured + take;
816
+ }
817
+ overflow = true;
818
+ return captured;
819
+ };
820
+ let child;
821
+ try {
822
+ child = spawn(command, {
823
+ shell: true,
824
+ cwd,
825
+ env: buildShellEnv(),
826
+ // New process group so a timeout can SIGKILL the whole tree (the shell
827
+ // *and* its children, e.g. a `sleep`). Killing only the shell leaves
828
+ // grandchildren holding the stdout pipe open, delaying `close`.
829
+ detached: true,
830
+ });
831
+ }
832
+ catch (e) {
833
+ resolve({
834
+ output: "",
835
+ exit_code: EXIT_CODE_FRAMEWORK_ERROR,
836
+ truncated: false,
837
+ error: e instanceof Error ? e.message : String(e),
838
+ });
839
+ return;
840
+ }
841
+ // Kill the child's whole process group (negative pid); fall back to the
842
+ // child alone if the group is already gone (ESRCH).
843
+ const killTree = (signal) => {
844
+ try {
845
+ if (child.pid !== undefined)
846
+ process.kill(-child.pid, signal);
847
+ else
848
+ child.kill(signal);
849
+ }
850
+ catch {
851
+ try {
852
+ child.kill(signal);
853
+ }
854
+ catch {
855
+ /* already reaped */
856
+ }
857
+ }
858
+ };
859
+ let timeoutFired = false;
860
+ let killTimer = null;
861
+ const timer = setTimeout(() => {
862
+ timeoutFired = true;
863
+ killTree("SIGKILL");
864
+ // Guard against a child wedged in uninterruptible sleep never emitting
865
+ // `close`: settle after the cleanup budget. `code=null, signal=SIGKILL`
866
+ // marks it as our kill so `settle` reports a timeout.
867
+ killTimer = setTimeout(() => settle(null, "SIGKILL"), SHELL_CLEANUP_TIMEOUT_MS);
868
+ }, effectiveTimeout * 1000);
869
+ // `code`/`signal` come from the `close` event. A timeout is only real when
870
+ // we fired the timer AND the process was actually terminated by our
871
+ // SIGKILL (signal-killed → `code === null`). If the command exits on its
872
+ // own right at the deadline, `close` still delivers a numeric exit code, so
873
+ // we report that instead of a spurious timeout — closing the timer-vs-close
874
+ // race the old time-based `timedOut` flag lost (Node runs the timers phase
875
+ // before the poll phase, so the timer could win by an event-loop tick).
876
+ const settle = (code, signal) => {
877
+ if (settled)
878
+ return;
879
+ settled = true;
880
+ clearTimeout(timer);
881
+ if (killTimer)
882
+ clearTimeout(killTimer);
883
+ const wasTimeout = timeoutFired && code === null && signal === "SIGKILL";
884
+ const stdoutStr = Buffer.concat(stdoutBuf).toString("utf-8");
885
+ const stderrStr = Buffer.concat(stderrBuf).toString("utf-8");
886
+ let combined = stdoutStr + stderrStr;
887
+ if (wasTimeout) {
888
+ combined = `Command timed out after ${effectiveTimeout}s\n` + combined;
889
+ }
890
+ const [output, postTruncated] = truncateOutput(combined);
891
+ resolve({
892
+ output,
893
+ exit_code: wasTimeout
894
+ ? EXIT_CODE_TIMEOUT
895
+ : (code ?? EXIT_CODE_FRAMEWORK_ERROR),
896
+ truncated: overflow || postTruncated,
897
+ });
898
+ };
899
+ child.stdout?.on("data", (chunk) => {
900
+ stdoutCaptured = capture(stdoutBuf, stdoutCaptured, chunk);
901
+ });
902
+ child.stderr?.on("data", (chunk) => {
903
+ stderrCaptured = capture(stderrBuf, stderrCaptured, chunk);
904
+ });
905
+ child.on("error", (e) => {
906
+ // Framework-level spawn failure (e.g. /bin/sh missing).
907
+ if (settled)
908
+ return;
909
+ settled = true;
910
+ clearTimeout(timer);
911
+ if (killTimer)
912
+ clearTimeout(killTimer);
913
+ resolve({
914
+ output: "",
915
+ exit_code: EXIT_CODE_FRAMEWORK_ERROR,
916
+ truncated: false,
917
+ error: e instanceof Error ? e.message : String(e),
918
+ });
919
+ });
920
+ // `close` (not `exit`) fires after stdio streams are fully drained, so all
921
+ // buffered output is captured before we settle. `signal` lets `settle`
922
+ // distinguish our timeout SIGKILL from a clean exit at the deadline.
923
+ child.on("close", (code, signal) => settle(code, signal));
924
+ });
925
+ }
926
+ // ---------------------------------------------------------------------------
927
+ // Registration
928
+ // ---------------------------------------------------------------------------
929
+ const HANDLERS = {
930
+ filesystem_ls: filesystemLs,
931
+ filesystem_read: filesystemRead,
932
+ filesystem_write: filesystemWrite,
933
+ filesystem_edit: filesystemEdit,
934
+ filesystem_glob: filesystemGlob,
935
+ filesystem_grep: filesystemGrep,
936
+ filesystem_download: filesystemDownload,
937
+ shell_execute: shellExecute,
938
+ };
939
+ // Invariant: BUILTIN_TOOL_NAMES (the public contract) must match the actual
940
+ // handler set. Checked at import so drift fails fast.
941
+ {
942
+ const declared = [...BUILTIN_TOOL_NAMES].sort();
943
+ const registered = Object.keys(HANDLERS).sort();
944
+ if (JSON.stringify(declared) !== JSON.stringify(registered)) {
945
+ throw new Error(`BUILTIN_TOOL_NAMES drift: declared=${JSON.stringify(declared)} vs registered=${JSON.stringify(registered)}`);
946
+ }
947
+ }
948
+ /**
949
+ * Fail fast at startup when an explicit AGENTIC_SKILLS_DIR resolves to no
950
+ * usable skills root. A default "skills" dir that doesn't exist is not an
951
+ * error (agents may bundle no skills); one that exists but isn't a readable
952
+ * directory was previously a silent no-op, so it only warns for one release
953
+ * to avoid crash-looping already-deployed agents — upgrade to a throw after.
954
+ */
955
+ function validateReadonlyResourceRootsAtStartup() {
956
+ const roots = getReadonlyResourceRoots();
957
+ const explicit = process.env["AGENTIC_SKILLS_DIR"];
958
+ if (explicit && roots.length === 0) {
959
+ throw new Error(`AGENTIC_SKILLS_DIR=${JSON.stringify(explicit)} was set but no readable skills root ` +
960
+ `could be resolved. Ensure AGENTIC_AGENT_CONFIG_PATH or AGENTIC_AGENT_WORKDIR is set ` +
961
+ `before the Tool Pod starts and that the directory exists under the agent source root.`);
962
+ }
963
+ // The resolver filters unusable roots, so re-derive the candidate: a
964
+ // default skills dir that exists but is not a readable directory is a
965
+ // misconfiguration, not "no skills bundled".
966
+ const candidate = readonlySkillsRootFromEnv();
967
+ if (!explicit &&
968
+ candidate !== null &&
969
+ roots.length === 0 &&
970
+ fs.existsSync(candidate)) {
971
+ logger.warn(`Configured read-only skills root ${JSON.stringify(candidate)} is not a ` +
972
+ `readable directory; bundled skills will be unavailable. Check the ` +
973
+ `bundled skills path and AGENTIC_SKILLS_DIR.`);
974
+ }
975
+ }
976
+ /**
977
+ * Register all built-in tool handlers on *runtime* (callable lookup +
978
+ * metadata for all 8 handlers). Called by `ToolServer.onStartup` when
979
+ * `features.deep_agent` is on.
980
+ *
981
+ * If a user `@app.tool()` registered a tool with a reserved built-in name,
982
+ * this logs a WARNING and overrides it with the built-in — without the warning
983
+ * the collision was silent and surfaced only when the built-in was invoked.
984
+ */
985
+ export function registerBuiltinTools(runtime) {
986
+ // Warn before side effects if WORKSPACE_DIR is outside the known writable
987
+ // mounts. On production Tool Pods only /tmp and /scratch are RAM-backed;
988
+ // any other path will likely fail or write to a read-only layer. Warning
989
+ // rather than hard error so local dev with non-standard mounts still starts.
990
+ const inKnownMount = KNOWN_WORKSPACE_PREFIXES.includes(WORKSPACE_DIR) ||
991
+ KNOWN_WORKSPACE_PREFIXES.some((p) => WORKSPACE_DIR.startsWith(p + path.sep));
992
+ if (!inKnownMount) {
993
+ logger.warn(`WORKSPACE_DIR=${JSON.stringify(WORKSPACE_DIR)} is outside the known writable mounts (/tmp, /scratch). ` +
994
+ "On production Tool Pods only /tmp and /scratch are writable; " +
995
+ "filesystem and shell handlers may fail at runtime.");
996
+ }
997
+ fs.mkdirSync(WORKSPACE_DIR, { recursive: true });
998
+ logger.info(`Tool Pod workspace: ${WORKSPACE_DIR}`);
999
+ validateReadonlyResourceRootsAtStartup();
1000
+ for (const [name, func] of Object.entries(HANDLERS)) {
1001
+ if (runtime.tools[name] !== undefined && runtime.tools[name] !== func) {
1002
+ logger.warn(`registerBuiltinTools: overriding user-registered tool ${JSON.stringify(name)} ` +
1003
+ "with built-in handler. Rename the @app.tool() or remove it to avoid " +
1004
+ "surprising runtime behavior.");
1005
+ }
1006
+ runtime.tools[name] = func;
1007
+ runtime.toolDefinitions[name] = {
1008
+ name,
1009
+ description: "",
1010
+ is_local: false,
1011
+ network: [],
1012
+ timeout_seconds: 30,
1013
+ redact_fields: [],
1014
+ };
1015
+ }
1016
+ }