Skip to content

Feat: Port the environment abstraction (BaseEnvironment, ExecutionResult, LocalEnvironment) from adk-python - #582

Open
AmaadMartin wants to merge 6 commits into
google:mainfrom
AmaadMartin:feat/core-environment-abstraction
Open

Feat: Port the environment abstraction (BaseEnvironment, ExecutionResult, LocalEnvironment) from adk-python#582
AmaadMartin wants to merge 6 commits into
google:mainfrom
AmaadMartin:feat/core-environment-abstraction

Conversation

@AmaadMartin

Copy link
Copy Markdown
Collaborator

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

Problem: adk-python has a small, self-contained abstraction for "a place where an agent can run shell commands and read/write files" (src/google/adk/environment/). Everything file/shell related there is written against that interface, which is what lets the same tool run against a local subprocess or a remote sandbox. adk-js has no equivalent, so any future adk-js shell/file tool has to hardcode local child_process access and can never be pointed at a sandbox.

Solution: port the interface itself first, as an independent, cleanly landable change — the abstract contract, its result type, and the one concrete local implementation, nothing else. core/src/environment/ now exports:

Symbol Kind Parity source
ExecutionResult interface environment/_base_environment.py (dataclass)
BaseEnvironment abstract class environment/_base_environment.py
LocalEnvironment / LocalEnvironmentOptions class environment/_local_environment.py

All three are marked @experimental. BaseEnvironment and ExecutionResult import no Node built-ins, so they ship from the browser-safe barrel (core/src/common.ts); LocalEnvironment spawns child processes and is exported from the Node barrel (core/src/index.ts) only, matching how UnsafeLocalCodeExecutor is wired.

import {LocalEnvironment} from '@google/adk';

const env = new LocalEnvironment(); // temp dir created on initialize(), removed on close()
await env.initialize();
try {
  await env.writeFile('script.js', 'console.log("hi");');
  const result = await env.execute(`"${process.execPath}" script.js`, 30);
  // {exitCode: 0, stdout: 'hi\n', stderr: '', timedOut: false}
} finally {
  await env.close();
}

Deliberately out of scope (queued separately, not touched here): the tools/environment/ toolset, any Daytona/E2B sandbox implementation, and any refactor of core/src/code_executors/* onto BaseEnvironment. Code executors are a different abstraction — they run a code snippet and return CodeExecutionResult {stdout, stderr, outputFiles}, with no working-dir scoping, no readFile/writeFile and no lifecycle — so the two result types are deliberately not unified. The ExecutionResult JSDoc says so explicitly to keep the two apart.

Why one PR and not a stack. 823 added lines, but 481 of them are the two test files and the source is a single cohesive module. The only natural split — BaseEnvironment in part 1, LocalEnvironment in part 2 — would make part 1 an exported abstract class with zero implementations and zero readers, which is worse to review, not better.

Parity deviations (all deliberate)

adk-python This port Why
is_initialized property with a setter isInitialized getter only, backed by protected initialized A public setter lets external code assert an initialized state that is false, defeating the guard. Subclasses set the protected field, which is what LocalEnvironment already does in Python (self._is_initialized = True).
guards on if self._working_dir is None guards on assertInitialized() The one place this port is deliberately stricter. In Python, passing an explicit working_dir sets the attribute in __init__, so execute/read_file/write_file work without initialize() ever being called — and keep working after close(), which only clears _working_dir for auto-created dirs. Here they fail loudly instead. Pinned by two tests.
working_dir -> Path workingDir: string Node has no Path object; string is the adk-js convention (FileArtifactService, materializeFiles).
execute(command, *, timeout) execute(command, timeoutSeconds?) Keyword-only maps to an optional trailing parameter. Renamed for unit clarity, matching UnsafeLocalCodeExecutorOptions.timeoutSeconds. No options bag for one optional parameter.
read_file -> bytes, write_file(str | bytes) readFile -> Promise<Uint8Array>, writeFile(string | Uint8Array) Uint8Array rather than Buffer keeps base_environment.ts free of Node types so it can live in the browser-safe barrel. Buffer is assignable to Uint8Array, so no cast is needed.
dataclass field defaults required fields Same call as CodeExecutionResult, which makes stdout/stderr/outputFiles required though the Python original defaults them. Optional fields would push | undefined onto every consumer. The default values still hold behaviourally and are pinned by a test asserting the exact zero-state object.
Path.resolve() containment (resolves symlinks) path.resolve + path.relative containment (lexical only) fs.realpath would fail for the not-yet-created file in writeFile. The JSDoc says plainly that this is a lexical check and not a sandbox: it does not survive symlinks, hardlinks, bind mounts or TOCTOU.

Parity rule applied where the two conflict: local TS convention wins for in-process concerns (naming, private/protected, module layout); parity wins for anything observable — hence exitCode reproduces Python's negative signal number (-9 for SIGKILL) rather than an invented sentinel, and the thrown message keeps Python's Path escapes working directory: wording.

Documented limitations (all stated in the JSDoc, all shared with the reference)

LocalEnvironment runs arbitrary shell strings on the host with no sandboxing and no sanitisation; stdout/stderr are buffered fully in memory with no cap; the child inherits the whole of process.env; and a timeout SIGKILLs the shell, so processes it forked may survive. It is a building block — gating execution behind an explicit confirmation is the job of the (out-of-scope) tools built on top of it.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

npx vitest run --project unit:core core/test/environment31 passed (2 files), and green on all three CI legs (ubuntu-latest, macos-latest, windows-latest). Commands are built from process.execPath with outer double quotes so they run under both sh and cmd.exe; the cwd assertion normalises fs.realpath on both sides, because macOS reaches os.tmpdir() through a symlink and Windows CI reports it as an 8.3 short path.

Coverage of the new module (--coverage.include='core/src/environment/**'):

File                    | % Stmts | % Branch | % Funcs | % Lines
base_environment.ts     |     100 |      100 |     100 |     100
local_environment.ts    |     100 |    96.77 |     100 |     100

The single uncovered branch is the ?? 0 fallback in
exitCode: signal === null ? (code ?? 0) : -os.constants.signals[signal].
It is unreachable by construction — Node guarantees exactly one of code/signal is non-null on 'close' — but TypeScript types both as nullable. Keeping it is the honest option: the alternative is a non-null assertion, and the fallback mirrors Python's own proc.returncode or 0. No coverage pragma was added to hide it.

A real defect the cross-platform CI caught

The first CI run was green on macOS and red on ubuntu-latest and windows-latest, both with expected 10054 to be less than 9000execute(cmd, 0.5) had blocked for the command's full 10s runtime. Root cause: spawn(…, {shell: true}) runs the command under /bin/sh (bash on my workstation, dash on ubuntu-latest, cmd.exe on Windows). bash execs a simple command, so the shell process is the command and SIGKILL reaches it; dash and cmd.exe fork, so the kill only reaches the shell and the surviving command keeps the stdio pipes open — and 'close' fires only once the process has exited and its stdio is closed. Reproduced locally by forcing the shell:

/bin/bash  close after 524ms    (SIGKILL)
/bin/dash  exit  after 516ms  ->  close after 10132ms

Fix: destroy the read ends alongside the kill, so the timeout is enforced regardless of what the shell did with the command. Output produced before the kill is still returned. The surviving process itself is the documented limitation this port shares with adk-python; the JSDoc now also records its Windows consequence (a survivor keeps the working directory locked, so a close() right after a timeout can fail to remove a temporary workspace). Actually killing the process tree — detached + a process-group kill on POSIX, taskkill /T on Windows — would remove the limitation entirely but goes beyond the reference implementation, so it is filed as follow-up work rather than smuggled into this port.

A regression test pins this on every platform without depending on which shell is installed: it writes a script that spawns a child inheriting stdio, so the survivor exists even under bash.

The second, Windows-only failure was in the test's own teardown — EBUSY: resource busy or locked, rmdir — the same survivor holding the workspace as its cwd. That was fixed in the tests only (fs.rm retries, shorter-lived commands, an explicit hook budget); no assertion was weakened to get CI green.

Proof the tests can fail. Each mutation was applied to the finished implementation, the suite re-run, then reverted:

Mutation Test that failed Failure message
Delete this.assertInitialized() from execute() rejects execute, readFile and writeFile before initialize() (+ the after-close() test) expected [Function] to throw error matching /not initialized/ but got 'spawn /bin/sh ENOENT'
Replace the path.relative guard with if (!resolved.startsWith(base)) rejects a sibling directory that merely shares the name prefix expected [Function] to throw error matching /escapes working directory/ but got 'ENOENT: no such file or directory…'
timedOut = false in the timeout handler kills the command and reports timedOut once the timeout elapses expected false to be true
Drop this.autoCreated && from close() creates a caller-supplied workspace and keeps it after close() promise rejected "Error: ENOENT: no such file or directory…" instead of resolving
if (false) in BaseEnvironment.assertInitialized() rejects operations until a subclass marks it initialized promise resolved "{ exitCode: +0, stdout: '', …(2) }" instead of rejecting
Drop child.stdout.destroy() / child.stderr.destroy() from the timeout handler times out even when the command leaves a child holding the pipes open expected 5296 to be less than 4000

Manual End-to-End (E2E) Tests:

Run against the built package (npm run build --workspace core), importing from @google/adk — no mocks, real child processes, real filesystem:

import {LocalEnvironment} from '@google/adk';
const env = new LocalEnvironment();
await env.initialize();
console.log(env.workingDir); // /tmp/adk_workspace_XXXXXX
await env.writeFile('script.js', 'console.log("hi");');
console.log(await env.execute(`"${process.execPath}" script.js`, 30));
console.log(
  await env.execute(
    `"${process.execPath}" -e "setTimeout(() => {}, 10000)"`,
    0.5,
  ),
);
await env.close(); // temp dir is gone

Observed:

workspace: /tmp/adk_workspace_XG45Vh
execute:   { exitCode: 0, stdout: 'hi\n', stderr: '', timedOut: false }
timeout:   { exitCode: -9, stdout: '', stderr: '', timedOut: true }
envVars:   { exitCode: 0, stdout: '1', stderr: '', timedOut: false }

i.e. the temp directory appears under the OS temp dir while open and is gone after close(), output round-trips, -9 matches Python's SIGKILL return code, envVars reach the child, readFile('../escape.txt') rejects, and a caller-supplied workingDir survives close().

Other CI gates run locally on the rebased commit: npm run build ✅, npm run lint ✅, scripts/check_license.sh ✅, npx secretlint ✅, npm run docs:check (typedoc --treatWarningsAsErrors) ✅, and tsc -p core --noEmit is clean. npm run format:check reports no issue in any file this PR touches (its 16 remaining warnings are pre-existing on main). npm run ts:check at the repo root fails identically before and after this change (745 pre-existing errors on a clean tree, 745 with it, none in the new files).

Simplicity review

An independent complexity audit returned "Lean. Faithful, tightly scoped port with no suppressions and no unrelated hunks. Nothing blocking." A second pass over the final diff, asked specifically to scrutinise the late CI fixes, returned "Lean already. Ship." with no findings. Two of the first pass's four nits were applied (trimmed a redundant JSDoc sentence and a comment). Three were declined, with reasons:

  • "resolve(code ?? -1) instead of the negative signal number." Declined: exitCode is an observable output of a ported API, so parity wins. Verified: the manual run returns -9 for SIGKILL, matching Python.
  • "Collapse the path guard to resolved !== base && !resolved.startsWith(base + path.sep)." Declined — it is 4 lines shorter but wrong on Windows, which CI runs. path.win32.relative('C:\Users\x\ws', 'c:\users\x\ws\a.txt') returns 'a.txt' (case-folded, correctly accepted) whereas the startsWith form returns false and would reject a legitimate in-workspace path on a case-insensitive filesystem. The path.isAbsolute(relative) arm is likewise load-bearing: path.win32.relative('C:\ws', 'D:\evil.txt') returns 'D:\evil.txt', which neither of the other two conditions catches. This also matches existing repo precedent in core/src/artifacts/file_artifact_service.ts.
  • "Let the base initialize() set the flag." Declined: Python's base initialize() is a pure no-op that leaves is_initialized false, and flipping the flag by default would let a subclass that failed to set itself up report as initialized — the opposite of the strictness this port exists to add.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.

Amaad Martin added 6 commits July 30, 2026 11:08
…ironment

Adds core/src/environment/, the adk-js counterpart of adk-python's
src/google/adk/environment/: the abstract contract for "a place where an
agent can run shell commands and read/write files", its result type, and
the one concrete local implementation.

BaseEnvironment and ExecutionResult are node-free so they ship from the
browser-safe barrel; LocalEnvironment spawns child processes and is
exported from the Node barrel only.

Deliberately stricter than the reference: execute/readFile/writeFile
guard on isInitialized rather than on the presence of a working
directory, so they fail loudly before initialize() instead of silently
working when a workingDir was supplied to the constructor.
…nd execute

Unit tests for the ported environment abstraction: base-class lifecycle
defaults and the initialization guard, plus LocalEnvironment's temporary
vs caller-supplied workspaces, lexical path containment (including the
sibling-prefix case a naive startsWith check would accept), file
round-trips, and real child-process execution covering stdout, stderr,
exit codes, cwd, env vars, timeout kill and spawn failure.
Follow-up from the simplicity review: drop the redundant "every field is
required" sentence (the type already says so) and compress the exit-code
mapping comment.
A shell that forks its command rather than exec'ing it (dash on
ubuntu-latest, cmd.exe on windows-latest) leaves the command running
after SIGKILL reaches the shell. The survivor keeps the stdio pipes
open, so 'close' did not fire until it exited on its own: execute()
blocked for the command's full runtime and the timeout was not
enforced. Destroy the read ends alongside the kill.

Also compare realpath on both sides of the cwd assertion; Windows CI
reports the temp directory as an 8.3 short path.
A command killed by a timeout outlives the test, and Windows refuses to
remove a directory that is a live process's cwd, so teardown failed with
EBUSY. Use fs.rm's built-in retry, and document the same consequence for
close() on the class.
… hook budget

The 10s sleepers left a survivor that Windows kept the workspace locked
on for the whole 10s, so the retrying afterEach blew vitest's 10s hook
timeout. Halve the command lifetime, assert against a named bound rather
than a bare number, and give the hook the same explicit budget as the
spawning tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant