@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.
- package/CHANGELOG.md +55 -0
- package/LICENSE.md +201 -0
- package/README.md +29 -0
- package/dist/agent_config.d.ts +167 -0
- package/dist/agent_config.d.ts.map +1 -0
- package/dist/agent_config.js +544 -0
- package/dist/call_interrupted.d.ts +12 -0
- package/dist/call_interrupted.d.ts.map +1 -0
- package/dist/call_interrupted.js +11 -0
- package/dist/checkpoint_workspace.d.ts +25 -0
- package/dist/checkpoint_workspace.d.ts.map +1 -0
- package/dist/checkpoint_workspace.js +44 -0
- package/dist/context.d.ts +235 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +322 -0
- package/dist/db_config.d.ts +28 -0
- package/dist/db_config.d.ts.map +1 -0
- package/dist/db_config.js +66 -0
- package/dist/db_naming.d.ts +54 -0
- package/dist/db_naming.d.ts.map +1 -0
- package/dist/db_naming.js +94 -0
- package/dist/error_reporting.d.ts +67 -0
- package/dist/error_reporting.d.ts.map +1 -0
- package/dist/error_reporting.js +311 -0
- package/dist/generated/workflow/v1/activity_pb.d.ts +342 -0
- package/dist/generated/workflow/v1/activity_pb.d.ts.map +1 -0
- package/dist/generated/workflow/v1/activity_pb.js +115 -0
- package/dist/generated/workflow/v1/common_pb.d.ts +184 -0
- package/dist/generated/workflow/v1/common_pb.d.ts.map +1 -0
- package/dist/generated/workflow/v1/common_pb.js +86 -0
- package/dist/generated/workflow/v1/runtime_pb.d.ts +200 -0
- package/dist/generated/workflow/v1/runtime_pb.d.ts.map +1 -0
- package/dist/generated/workflow/v1/runtime_pb.js +40 -0
- package/dist/generated/workflow/v1/state_pb.d.ts +254 -0
- package/dist/generated/workflow/v1/state_pb.d.ts.map +1 -0
- package/dist/generated/workflow/v1/state_pb.js +68 -0
- package/dist/guardrails_evaluator/core.d.ts +23 -0
- package/dist/guardrails_evaluator/core.d.ts.map +1 -0
- package/dist/guardrails_evaluator/core.js +122 -0
- package/dist/guardrails_evaluator/index.d.ts +10 -0
- package/dist/guardrails_evaluator/index.d.ts.map +1 -0
- package/dist/guardrails_evaluator/index.js +11 -0
- package/dist/guardrails_evaluator/regex.d.ts +20 -0
- package/dist/guardrails_evaluator/regex.d.ts.map +1 -0
- package/dist/guardrails_evaluator/regex.js +233 -0
- package/dist/hooks.d.ts +109 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +216 -0
- package/dist/http_path.d.ts +18 -0
- package/dist/http_path.d.ts.map +1 -0
- package/dist/http_path.js +53 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/launcher.d.ts +130 -0
- package/dist/launcher.d.ts.map +1 -0
- package/dist/launcher.js +325 -0
- package/dist/logger.d.ts +96 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +204 -0
- package/dist/mcp_oauth.d.ts +51 -0
- package/dist/mcp_oauth.d.ts.map +1 -0
- package/dist/mcp_oauth.js +389 -0
- package/dist/mcp_oauth_secret.d.ts +21 -0
- package/dist/mcp_oauth_secret.d.ts.map +1 -0
- package/dist/mcp_oauth_secret.js +122 -0
- package/dist/mcp_tools.d.ts +71 -0
- package/dist/mcp_tools.d.ts.map +1 -0
- package/dist/mcp_tools.js +301 -0
- package/dist/memory_appbound.d.ts +42 -0
- package/dist/memory_appbound.d.ts.map +1 -0
- package/dist/memory_appbound.js +159 -0
- package/dist/memory_writer.d.ts +49 -0
- package/dist/memory_writer.d.ts.map +1 -0
- package/dist/memory_writer.js +171 -0
- package/dist/metrics.d.ts +84 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +205 -0
- package/dist/models.d.ts +1458 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +1726 -0
- package/dist/node_logger.d.ts +43 -0
- package/dist/node_logger.d.ts.map +1 -0
- package/dist/node_logger.js +158 -0
- package/dist/owner_callback.d.ts +16 -0
- package/dist/owner_callback.d.ts.map +1 -0
- package/dist/owner_callback.js +40 -0
- package/dist/progress.d.ts +57 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +140 -0
- package/dist/runtime.d.ts +131 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +351 -0
- package/dist/secure_llm_proxy.d.ts +115 -0
- package/dist/secure_llm_proxy.d.ts.map +1 -0
- package/dist/secure_llm_proxy.js +922 -0
- package/dist/secure_wrapper.d.ts +332 -0
- package/dist/secure_wrapper.d.ts.map +1 -0
- package/dist/secure_wrapper.js +1249 -0
- package/dist/server/aer.d.ts +61 -0
- package/dist/server/aer.d.ts.map +1 -0
- package/dist/server/aer.js +1124 -0
- package/dist/server/auth.d.ts +56 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +132 -0
- package/dist/server/base.d.ts +104 -0
- package/dist/server/base.d.ts.map +1 -0
- package/dist/server/base.js +150 -0
- package/dist/server/callInterrupt.d.ts +49 -0
- package/dist/server/callInterrupt.d.ts.map +1 -0
- package/dist/server/callInterrupt.js +68 -0
- package/dist/server/callback_delivery.d.ts +14 -0
- package/dist/server/callback_delivery.d.ts.map +1 -0
- package/dist/server/callback_delivery.js +141 -0
- package/dist/server/chunk_types.d.ts +50 -0
- package/dist/server/chunk_types.d.ts.map +1 -0
- package/dist/server/chunk_types.js +62 -0
- package/dist/server/cors.d.ts +52 -0
- package/dist/server/cors.d.ts.map +1 -0
- package/dist/server/cors.js +107 -0
- package/dist/server/drain.d.ts +169 -0
- package/dist/server/drain.d.ts.map +1 -0
- package/dist/server/drain.js +455 -0
- package/dist/server/function.d.ts +77 -0
- package/dist/server/function.d.ts.map +1 -0
- package/dist/server/function.js +337 -0
- package/dist/server/http_retry.d.ts +37 -0
- package/dist/server/http_retry.d.ts.map +1 -0
- package/dist/server/http_retry.js +157 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +5 -0
- package/dist/server/metadata.d.ts +50 -0
- package/dist/server/metadata.d.ts.map +1 -0
- package/dist/server/metadata.js +193 -0
- package/dist/server/oe_url.d.ts +36 -0
- package/dist/server/oe_url.d.ts.map +1 -0
- package/dist/server/oe_url.js +50 -0
- package/dist/server/owner_url.d.ts +35 -0
- package/dist/server/owner_url.d.ts.map +1 -0
- package/dist/server/owner_url.js +146 -0
- package/dist/server/query.d.ts +42 -0
- package/dist/server/query.d.ts.map +1 -0
- package/dist/server/query.js +28 -0
- package/dist/server/tool.d.ts +138 -0
- package/dist/server/tool.d.ts.map +1 -0
- package/dist/server/tool.js +1017 -0
- package/dist/span_names.d.ts +21 -0
- package/dist/span_names.d.ts.map +1 -0
- package/dist/span_names.js +31 -0
- package/dist/structured_logging/constants.d.ts +17 -0
- package/dist/structured_logging/constants.d.ts.map +1 -0
- package/dist/structured_logging/constants.js +71 -0
- package/dist/structured_logging/env.d.ts +18 -0
- package/dist/structured_logging/env.d.ts.map +1 -0
- package/dist/structured_logging/env.js +39 -0
- package/dist/structured_logging/install.d.ts +56 -0
- package/dist/structured_logging/install.d.ts.map +1 -0
- package/dist/structured_logging/install.js +107 -0
- package/dist/structured_logging/layout.d.ts +9 -0
- package/dist/structured_logging/layout.d.ts.map +1 -0
- package/dist/structured_logging/layout.js +144 -0
- package/dist/structured_logging/serialize.d.ts +27 -0
- package/dist/structured_logging/serialize.d.ts.map +1 -0
- package/dist/structured_logging/serialize.js +61 -0
- package/dist/structured_logging/stdio_capture.d.ts +59 -0
- package/dist/structured_logging/stdio_capture.d.ts.map +1 -0
- package/dist/structured_logging/stdio_capture.js +164 -0
- package/dist/structured_logging/uncaught.d.ts +14 -0
- package/dist/structured_logging/uncaught.d.ts.map +1 -0
- package/dist/structured_logging/uncaught.js +58 -0
- package/dist/structured_logging.d.ts +48 -0
- package/dist/structured_logging.d.ts.map +1 -0
- package/dist/structured_logging.js +47 -0
- package/dist/tls_client.d.ts +61 -0
- package/dist/tls_client.d.ts.map +1 -0
- package/dist/tls_client.js +298 -0
- package/dist/tool_api_error.d.ts +62 -0
- package/dist/tool_api_error.d.ts.map +1 -0
- package/dist/tool_api_error.js +399 -0
- package/dist/tool_memory_ownership.d.ts +10 -0
- package/dist/tool_memory_ownership.d.ts.map +1 -0
- package/dist/tool_memory_ownership.js +36 -0
- package/dist/toolpod_handlers.d.ts +126 -0
- package/dist/toolpod_handlers.d.ts.map +1 -0
- package/dist/toolpod_handlers.js +1016 -0
- package/dist/tracing/exporters.d.ts +51 -0
- package/dist/tracing/exporters.d.ts.map +1 -0
- package/dist/tracing/exporters.js +327 -0
- package/dist/tracing/index.d.ts +3 -0
- package/dist/tracing/index.d.ts.map +1 -0
- package/dist/tracing/index.js +2 -0
- package/dist/tracing/setup.d.ts +76 -0
- package/dist/tracing/setup.d.ts.map +1 -0
- package/dist/tracing/setup.js +436 -0
- package/dist/utils.d.ts +204 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +867 -0
- package/dist/workflow/activity.d.ts +71 -0
- package/dist/workflow/activity.d.ts.map +1 -0
- package/dist/workflow/activity.js +357 -0
- package/dist/workflow/attempt.d.ts +12 -0
- package/dist/workflow/attempt.d.ts.map +1 -0
- package/dist/workflow/attempt.js +96 -0
- package/dist/workflow/client.d.ts +46 -0
- package/dist/workflow/client.d.ts.map +1 -0
- package/dist/workflow/client.js +299 -0
- package/dist/workflow/context.d.ts +37 -0
- package/dist/workflow/context.d.ts.map +1 -0
- package/dist/workflow/context.js +350 -0
- package/dist/workflow/heartbeat.d.ts +15 -0
- package/dist/workflow/heartbeat.d.ts.map +1 -0
- package/dist/workflow/heartbeat.js +78 -0
- package/dist/workflow/index.d.ts +14 -0
- package/dist/workflow/index.d.ts.map +1 -0
- package/dist/workflow/index.js +10 -0
- package/dist/workflow/memory.d.ts +17 -0
- package/dist/workflow/memory.d.ts.map +1 -0
- package/dist/workflow/memory.js +184 -0
- 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
|
+
}
|