feat: Add environment tools matching Python parity - #555
Conversation
5e87d4d to
4124503
Compare
Correctness:
- escapeRegExp escaped nothing: the character class closed early, so
old_string was matched as a live regex. Metacharacters could match the
wrong bytes and an unbalanced group threw out of runAsync instead of
returning the {status: 'error'} contract.
- EditFile expanded $&, $` and $1 in new_string. Use a function
replacement so the text is inserted literally.
- The truncation notice reported the original length as if it were the
limit ("truncated to 10 chars" for 5 characters of content).
Types (no suppressions anywhere in the diff):
- Each tool declares and exports its result type (ExecuteResult,
ReadFileResult, WriteFileResult, EditFileResult), narrowing the
Promise<unknown> from BaseTool. Callers and tests are typed with no
annotations.
- catch blocks bind unknown and narrow through toError /
isFileNotFoundError instead of `catch (e: any)`; the exec failure path
uses child_process.ExecException, which already declares killed, code,
stdout and stderr.
- isValidLineNumber is a module-level type predicate, replacing an inline
closure, a dead !isNaN check and two unchecked casts on unknown.
Security:
- Execute now passes through the standard tool-confirmation gate before
it shells out, matching how RunSkillInlineScriptTool gates
model-provided code. workingDir is not a boundary for a shell command,
and these tools are public API.
Also: @experimental on all four tool classes (they are public API and
adk-python decorates all four), drop the leading underscore from the
description constant, and document that path containment is lexical and
does not resolve symlinks.
4124503 to
c3360f4
Compare
kalenkevich
left a comment
There was a problem hiding this comment.
Please also add integration tests that will create/modify and execute real temp files
| * @param relativeOrAbsolutePath The path to check. | ||
| * @return The resolved absolute path. | ||
| */ | ||
| export function resolveAndValidatePath( |
There was a problem hiding this comment.
Done — resolveAndValidatePath now lives in core/src/utils/file_utils.ts alongside materializeFiles / guessMimeType, and core/src/tools/environment/utils.ts keeps only the non-filesystem helpers (truncate, toError, isFileNotFoundError).
ReadFileTool, WriteFileTool and EditFileTool import it from ../../utils/file_utils.js. Pure move: no behaviour change, no signature change, and the existing traversal test cases are unchanged (only the import path moved). It stays internal — file_utils.ts is not re-exported from index.ts/common.ts, so this does not widen the public surface.
| const {stdout, stderr} = await execAsync(command, { | ||
| cwd: this.workingDir, | ||
| timeout: this.executeTimeoutMs, | ||
| }); |
There was a problem hiding this comment.
will it work with cmd and powershell?
There was a problem hiding this comment.
Good question — I checked it against the implementation instead of assuming, and the answer was split: cmd yes, powershell no. I've fixed the second half.
cmd — yes, and it always has. runAsync goes through promisify(exec), and exec sets options.shell = true, which Node's normalizeSpawnArguments resolves on Windows to process.env.comspec || 'cmd.exe', invoked as cmd.exe /d /s /c "<command>". So every command this tool runs on Windows already goes through cmd. The run-tests (windows-latest) job on this PR is the evidence: core/test/tools/environment/tools_test.ts runs there and its Execute cases (echo test && echo err >&2, echo out && echo err >&2 && exit 10) pass under cmd.exe — that CRLF normalizer in the test file exists precisely because cmd is what runs them.
powershell — no, it was unreachable. Node never picks PowerShell on its own; it needs an explicit shell, and ExecuteToolParams had no way to pass one. So rather than just answering, I fixed it: ExecuteToolParams.shell (and EnvironmentToolsetParams.shell, which forwards it) is now passed to exec. Node special-cases only cmd (/d /s /c, verbatim args); every other shell is spawned as <shell> -c <command>, and -c is PowerShell's documented short form of -Command, so powershell.exe, pwsh, bash and zsh all work. Leaving it unset keeps today's behaviour byte-for-byte.
New tests, each of which fails without the change:
- POSIX: a stand-in shell script that echoes back
$2proves the configured shell — not/bin/sh— received the command, both onExecuteTooldirectly and throughEnvironmentToolset. With the forwarding removed both fail withexpected 'hello' to be 'fake-shell ran: echo hello'. - Windows-only (
it.skipIf(!IS_WINDOWS), so they really execute in thewindows-latestjob):echo %COMSPEC%under the default shell and under an explicitcmd.exe(%VAR%only expands incmd);Write-Output (3 + 4)→7underpowershell.exe(PowerShell syntaxcmdcannot run); andexit 3underpowershell.exesurfacing asexit_code: 3.
One caveat worth flagging rather than hiding: ENVIRONMENT_INSTRUCTION tells the model to chain dependent commands with &&, and Windows PowerShell 5.1 has no && operator — it arrived in PowerShell 7. So pwsh is the right choice if you want PowerShell and the chaining guidance to hold; that trade-off is documented on the new option.
There was a problem hiding this comment.
Follow-up with the actual CI result, since the point of the answer was evidence rather than assertion: powershell.exe is confirmed working, on the run-tests (windows-latest) job for e64640d.
core/test/tools/environment/tools_test.ts reports 37 tests | 2 skipped on Windows against 37 tests | 3 skipped on Ubuntu — the two POSIX-only cases are the ones skipped on Windows, so all three Windows shell cases ran and passed there: default shell → cmd.exe, explicit cmd.exe, and powershell.exe evaluating Write-Output (3 + 4) to 7.
Two notes on the first attempt, which failed on Windows before this:
- Confirmed from PowerShell's own CLI parser that
-c(what Node passes to any non-cmdshell) is the accepted short form of-Command:MatchSwitch(switchKey, "command", "c")inCommandLineParameterParser.cs, while-ConfigurationNameneeds at least-config, so there is no ambiguity. - The
ExecuteTool > reports timeoutcase (pre-existing, unrelated to the shell option) tore down withEBUSY: resource busy or locked, rmdiron Windows: a command killed on timeout can still hold its cwd for a moment after the kill returns. TheafterEachnow passesmaxRetries/retryDelaytofs.rminstead of failing teardown. I also dropped a second PowerShell case I had added — the existingcmdcases already cover nonzero exit codes — to keep the number of heavyweight shell spawns in the unit suite down.
Addresses review feedback on the environment tools. resolveAndValidatePath moves from tools/environment/utils.ts to utils/file_utils.ts, where the rest of the shared filesystem helpers live. Call sites in ReadFile, WriteFile and EditFile follow; no behaviour change and every existing test case is kept. ExecuteTool gains an optional `shell`, forwarded to child_process.exec and plumbed through EnvironmentToolset. Previously the shell was whatever exec defaults to -- /bin/sh on POSIX and %ComSpec% (cmd.exe) on Windows -- so cmd worked but PowerShell was unreachable. Node only special cases cmd (`/d /s /c`); any other shell is spawned as `<shell> -c <command>`, which covers pwsh and powershell.exe, whose `-c` is the short form of `-Command`. Tests: a stand-in shell script proves the configured shell, not the platform default, receives the command (both directly and through the toolset), and Windows-only cases exercise cmd.exe by default, cmd.exe selected explicitly, and powershell.exe for both success and a nonzero exit code.
The Windows job hit EBUSY tearing down the temp dir for the timeout case: a command killed on timeout can still hold its cwd for a moment after the kill returns. fs.rm now retries instead of failing teardown. Also drops the second PowerShell case (the cmd path already covers nonzero exit codes) to keep the number of heavyweight shell spawns down, and reports the whole tool result in the assertion message so a shell failure that only reproduces on a CI agent is diagnosable.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
1. Link to an existing issue (if applicable):
N/A — no existing issue tracks this work.
2. Or, if no issue exists, describe the change:
Problem:
The JS variant of ADK lacks parity with the
adk-pythonenvironment tools, missingnecessary functionality for agent local file manipulation and shell execution.
Solution:
Added Node.js parity environment tools (
ExecuteTool,ReadFileTool,WriteFileTool,EditFileTool) and a bundledEnvironmentToolsetincore/src/tools/environment/. The toolset also injects theENVIRONMENT_INSTRUCTIONinto the LLM system requests for bounded contextualusage.
One deliberate deviation from
adk-python, please read:ExecuteToolrequires an explicit tool confirmation before it runs a command.adk-python's execute tool has no such gate, but it also does not run anythingitself: it delegates to
BaseEnvironment.execute(), so "run on the host" is onebackend an embedder selects. This port collapses that seam into a hardcoded
child_processimplementation behindworkingDir: string, which makes hostexecution the only option — a model-authored string becomes arbitrary code
execution on the machine running the agent, and
cwdis not a boundary. The gateuses the same mechanism
RunSkillInlineScriptToolalready uses onmainformodel-provided scripts. Restoring the
BaseEnvironmentseam would be the betterlong-term answer and is a reasonable follow-up.
All four tool classes carry
@experimental, matchingadk-pythonand reflectingthat they became public API here via
core/src/common.tsandcore/src/index.ts.Each tool exports its result type (
ExecuteResult,ReadFileResult,WriteFileResult,EditFileResult) rather than returning the base class'sPromise<unknown>.There are no
any,as any,eslint-disable,@ts-ignoreor@ts-expect-erroruses anywhere in this diff, in
src/or in the tests.Testing Plan
Unit Tests:
Added
core/test/tools/environment/tools_test.ts, covering boundaries acrossreading, writing, editing and shell execution, plus the confirmation gate and
literal-text matching in
EditFile. Local run:Manual End-to-End (E2E) Tests:
npm run build.npx vitest run --project unit:core core/test/tools/environment/tools_test.tsto exercise the tools against a temporary working directory.
Checklist
Additional context
resolveAndValidatePathperforms a lexical containment check. It rejects..and absolute-path arguments, but
path.resolve/path.relativenever touch thefilesystem, so a symlink inside
workingDirthat points elsewhere still resolvesthrough. That is intentional — reading through symlinks is necessary for real
workspace layouts such as the package links npm creates under
node_modules— andthe docstring says so explicitly. It is not a sandbox; callers who need one should
point the tools at an isolated filesystem.
This branch also carries one small, independent fix commit:
fix(dev): output agent bundle in project .adk_build_cache for hoisted dependencies,which changes
dev/src/utils/agent_loader.tsto emit the compiled agent bundle intoa project-local
.adk_build_cachedirectory instead of the system temp directory, sothat hoisted
node_modulesdependencies resolve correctly. Happy to split this into aseparate PR if reviewers prefer.