diff --git a/core/src/common.ts b/core/src/common.ts index d7f73718..bfb30da6 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -129,6 +129,8 @@ export {TrajectoryThoughtPruningCompactor} from './context/trajectory_thought_pr export type {TrajectoryThoughtPruningCompactorOptions} from './context/trajectory_thought_pruning_compactor.js'; export {TruncatingContextCompactor} from './context/truncating_context_compactor.js'; export type {TruncatingContextCompactorOptions} from './context/truncating_context_compactor.js'; +export {BaseEnvironment} from './environment/base_environment.js'; +export type {ExecutionResult} from './environment/base_environment.js'; export {isCompactedEvent, isScratchpadEvent} from './events/compacted_event.js'; export type {CompactedEvent} from './events/compacted_event.js'; export { diff --git a/core/src/environment/base_environment.ts b/core/src/environment/base_environment.ts new file mode 100644 index 00000000..e20e781d --- /dev/null +++ b/core/src/environment/base_environment.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {experimental} from '../utils/experimental.js'; + +/** + * Result of a shell command executed in a {@link BaseEnvironment}. + * + * This is distinct from `CodeExecutionResult`, which is produced by the code + * executors: a code executor runs a code *snippet* and reports the files it + * produced, whereas an environment runs a *shell command* inside a working + * directory and reports its process exit status. + * + * A command that succeeds with no output yields + * `{exitCode: 0, stdout: '', stderr: '', timedOut: false}`. + */ +export interface ExecutionResult { + /** The exit code of the process. `0` on success. */ + exitCode: number; + /** Standard output captured from the process. `''` when nothing was written. */ + stdout: string; + /** Standard error captured from the process. `''` when nothing was written. */ + stderr: string; + /** Whether the execution exceeded the timeout. `false` when it completed. */ + timedOut: boolean; +} + +/** + * Abstract base class for code execution environments. + * + * An environment provides the ability to execute shell commands, read files, + * and write files within a working directory. Concrete implementations include + * local subprocess execution, sandboxed execution, container environments, and + * cloud-hosted environments. + * + * Lifecycle: + * 1. Construct the environment. + * 2. Call {@link initialize} before first use. + * 3. Use {@link execute}, {@link readFile}, {@link writeFile}. + * 4. Call {@link close} when done. + */ +@experimental +export abstract class BaseEnvironment { + /** + * Backing flag for {@link isInitialized}. + * + * Subclasses own this flag: set it in {@link initialize} and clear it in + * {@link close}. + */ + protected initialized = false; + + /** Whether the environment has been initialized. */ + get isInitialized(): boolean { + return this.initialized; + } + + /** + * Initializes the environment (e.g. creates the working directory). + * + * Called before first use. The default implementation is a no-op and leaves + * {@link isInitialized} `false`. Subclasses must be idempotent and must set + * {@link initialized}. + */ + async initialize(): Promise {} + + /** + * Releases resources held by the environment. + * + * The default implementation is a no-op. Subclasses must be idempotent and + * must clear {@link initialized}. + */ + async close(): Promise {} + + /** The absolute path to the environment's working directory. */ + abstract get workingDir(): string; + + /** + * Executes a shell command in the working directory. + * + * @param command The shell command string to execute. + * @param timeoutSeconds Maximum execution time in seconds. `undefined` means + * no limit. + * @returns The exit code, stdout, stderr, and timeout status. A non-zero exit + * code is reported in the result, not thrown. + */ + abstract execute( + command: string, + timeoutSeconds?: number, + ): Promise; + + /** + * Reads a file from the environment's filesystem. + * + * @param filePath Absolute or working-dir-relative path to the file. + * @returns The raw file contents. + */ + abstract readFile(filePath: string): Promise; + + /** + * Writes content to a file in the environment's filesystem. + * + * Parent directories are created automatically if they do not exist. + * + * @param filePath Absolute or working-dir-relative path to the file. + * @param content The string or raw bytes to write. + */ + abstract writeFile( + filePath: string, + content: string | Uint8Array, + ): Promise; + + /** + * Throws if {@link initialize} has not been called. + * + * Implementations should call this at the start of every operation that needs + * a live working directory. + */ + protected assertInitialized(): void { + if (!this.initialized) { + throw new Error( + 'Environment is not initialized. Call initialize() first.', + ); + } + } +} diff --git a/core/src/environment/local_environment.ts b/core/src/environment/local_environment.ts new file mode 100644 index 00000000..f062d18e --- /dev/null +++ b/core/src/environment/local_environment.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {spawn} from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {experimental} from '../utils/experimental.js'; +import {logger} from '../utils/logger.js'; +import {BaseEnvironment, ExecutionResult} from './base_environment.js'; + +/** Prefix for the temporary workspace created when no `workingDir` is given. */ +const TEMP_WORKSPACE_PREFIX = 'adk_workspace_'; + +/** Options for {@link LocalEnvironment}. */ +export interface LocalEnvironmentOptions { + /** + * Absolute path to the workspace directory. Created by + * {@link LocalEnvironment.initialize} if it does not exist, and never deleted + * by {@link LocalEnvironment.close}. If omitted, a temporary directory is + * created on `initialize()` and removed on `close()`. + */ + workingDir?: string; + /** Extra variables merged over `process.env` for every executed command. */ + envVars?: Record; +} + +/** + * Resolves `filePath` against `workingDir` and rejects anything outside it. + * + * This is a **lexical** containment check on the resolved path strings, not a + * sandbox: it does not survive symlinks, hardlinks, bind mounts, or TOCTOU + * races. It is a guard against accidental traversal, not a security boundary. + * + * @throws If the resolved path is not inside `workingDir`. + */ +function resolvePathInWorkingDir(workingDir: string, filePath: string): string { + const base = path.resolve(workingDir); + const resolved = path.resolve(base, filePath); + const relative = path.relative(base, resolved); + if ( + relative === '..' || + relative.startsWith(`..${path.sep}`) || + // `path.relative` returns an absolute path across Windows drives. + path.isAbsolute(relative) + ) { + throw new Error(`Path escapes working directory: ${filePath}`); + } + return resolved; +} + +/** + * Executes commands via local child processes, scoped to a working directory. + * + * When `workingDir` is not specified, a temporary directory is created on + * {@link initialize} and removed on {@link close}. + * + * WARNING: this class runs arbitrary shell strings on the host with **no + * sandboxing** and no sanitisation — the caller is responsible for trusting the + * command. It is a building block; tools built on top of it are responsible for + * gating execution behind an explicit user confirmation. + * + * Further limitations, all shared with the adk-python reference implementation: + * - stdout and stderr are buffered fully in memory with no cap, so a command + * producing unbounded output will grow the heap until it fails. + * - The child inherits the whole of `process.env`, so any secret in the parent + * environment is visible to the command. + * - A timeout sends `SIGKILL` to the spawned shell; processes it forked itself + * may survive, and anything they write after the kill is not captured. On + * Windows such a survivor also keeps the working directory locked, so a + * {@link close} following a timeout can fail to remove a temporary workspace. + * - File paths are confined to the working directory by a lexical check only + * (see {@link readFile} and {@link writeFile}). + */ +@experimental +export class LocalEnvironment extends BaseEnvironment { + private currentWorkingDir?: string; + private readonly envVars?: Record; + private autoCreated = false; + + constructor(options: LocalEnvironmentOptions = {}) { + super(); + this.currentWorkingDir = options.workingDir; + this.envVars = options.envVars; + } + + override get workingDir(): string { + if (this.currentWorkingDir === undefined) { + throw new Error('`workingDir` is not set. Call initialize() first.'); + } + return this.currentWorkingDir; + } + + override async initialize(): Promise { + if (this.currentWorkingDir === undefined) { + this.currentWorkingDir = await fs.mkdtemp( + path.join(os.tmpdir(), TEMP_WORKSPACE_PREFIX), + ); + this.autoCreated = true; + logger.debug(`Created temporary workspace: ${this.currentWorkingDir}`); + } else { + await fs.mkdir(this.currentWorkingDir, {recursive: true}); + } + this.initialized = true; + } + + override async close(): Promise { + if (this.autoCreated && this.currentWorkingDir !== undefined) { + await fs.rm(this.currentWorkingDir, {recursive: true, force: true}); + logger.debug(`Removed temporary workspace: ${this.currentWorkingDir}`); + this.currentWorkingDir = undefined; + } + this.initialized = false; + } + + override async execute( + command: string, + timeoutSeconds?: number, + ): Promise { + this.assertInitialized(); + + const child = spawn(command, { + shell: true, + cwd: this.workingDir, + env: {...process.env, ...this.envVars}, + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); + + let timedOut = false; + let timer: ReturnType | undefined; + if (timeoutSeconds !== undefined) { + timer = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + // Killing the shell does not kill a command it forked rather than + // exec'd, and that survivor keeps the pipes open, which would hold + // 'close' back until it exits on its own. Release the read ends so + // the timeout is actually enforced. + child.stdout.destroy(); + child.stderr.destroy(); + }, timeoutSeconds * 1000); + } + + try { + const exitCode = await new Promise((resolve, reject) => { + // 'close' rather than 'exit': the stdio streams are drained by then. + child.on('close', (code, signal) => { + // Node reports either an exit code or the terminating signal; Python + // reports the negative signal number (`-9` for SIGKILL), so map back. + resolve( + signal === null ? (code ?? 0) : -os.constants.signals[signal], + ); + }); + child.on('error', reject); + }); + return { + exitCode, + // Decode once, so a multi-byte character split across two chunks is + // not corrupted. Invalid bytes become U+FFFD, matching Python's + // `errors='replace'`. + stdout: Buffer.concat(stdoutChunks).toString('utf-8'), + stderr: Buffer.concat(stderrChunks).toString('utf-8'), + timedOut, + }; + } finally { + clearTimeout(timer); + } + } + + /** + * Reads a file from the working directory. + * + * `filePath` is confined to the working directory by a lexical check on the + * resolved path, which is not a sandbox. + * + * @throws If the environment is not initialized, if the path escapes the + * working directory, or — as `ENOENT` — if the file does not exist. + */ + override async readFile(filePath: string): Promise { + this.assertInitialized(); + return fs.readFile(resolvePathInWorkingDir(this.workingDir, filePath)); + } + + /** + * Writes a file in the working directory, creating parent directories. + * + * `filePath` is confined to the working directory by a lexical check on the + * resolved path, which is not a sandbox. No newline translation is applied, + * so explicit CRLF sequences are preserved. + * + * @throws If the environment is not initialized or the path escapes the + * working directory. + */ + override async writeFile( + filePath: string, + content: string | Uint8Array, + ): Promise { + this.assertInitialized(); + const resolved = resolvePathInWorkingDir(this.workingDir, filePath); + await fs.mkdir(path.dirname(resolved), {recursive: true}); + await fs.writeFile(resolved, content); + } +} diff --git a/core/src/index.ts b/core/src/index.ts index 21479d8e..2a28db1b 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -37,6 +37,8 @@ export { type UnsafeLocalCodeExecutorOptions, } from './code_executors/unsafe_local_code_executor.js'; export * from './common.js'; +export {LocalEnvironment} from './environment/local_environment.js'; +export type {LocalEnvironmentOptions} from './environment/local_environment.js'; export {DatabaseSessionService} from './sessions/database_session_service.js'; export {getSessionServiceFromUri} from './sessions/registry.js'; export {VertexAiSessionService} from './sessions/vertex_ai_session_service.js'; diff --git a/core/test/environment/base_environment_test.ts b/core/test/environment/base_environment_test.ts new file mode 100644 index 00000000..b4c57cd3 --- /dev/null +++ b/core/test/environment/base_environment_test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BaseEnvironment, ExecutionResult} from '@google/adk'; +import {describe, expect, it} from 'vitest'; + +const EMPTY_RESULT: ExecutionResult = { + exitCode: 0, + stdout: '', + stderr: '', + timedOut: false, +}; + +/** Minimal concrete environment that relies on the base lifecycle defaults. */ +class TestEnvironment extends BaseEnvironment { + override get workingDir(): string { + return '/test'; + } + + override async execute(): Promise { + this.assertInitialized(); + return EMPTY_RESULT; + } + + override async readFile(): Promise { + this.assertInitialized(); + return new Uint8Array(); + } + + override async writeFile(): Promise { + this.assertInitialized(); + } +} + +/** An environment that owns the initialized flag, as real subclasses do. */ +class InitializingTestEnvironment extends TestEnvironment { + override async initialize(): Promise { + this.initialized = true; + } +} + +describe('BaseEnvironment', () => { + it('is not initialized when constructed', () => { + expect(new TestEnvironment().isInitialized).toBe(false); + }); + + it('leaves isInitialized false when the default initialize() runs', async () => { + const env = new TestEnvironment(); + + await env.initialize(); + + expect(env.isInitialized).toBe(false); + }); + + it('resolves the default close() without throwing', async () => { + await expect(new TestEnvironment().close()).resolves.toBeUndefined(); + }); + + it('rejects operations until a subclass marks it initialized', async () => { + const env = new InitializingTestEnvironment(); + + await expect(env.execute()).rejects.toThrow( + 'Environment is not initialized. Call initialize() first.', + ); + + await env.initialize(); + + expect(env.isInitialized).toBe(true); + await expect(env.execute()).resolves.toEqual(EMPTY_RESULT); + }); +}); diff --git a/core/test/environment/local_environment_test.ts b/core/test/environment/local_environment_test.ts new file mode 100644 index 00000000..950743f4 --- /dev/null +++ b/core/test/environment/local_environment_test.ts @@ -0,0 +1,407 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {LocalEnvironment} from '@google/adk'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +/** + * Commands are built from the Node binary running the tests so that they work + * under both `sh` and `cmd.exe`. The outer double quotes survive both shells; + * the inner JavaScript string literals stay single-quoted. + */ +const NODE = `"${process.execPath}"`; + +/** Spawning a child process is slow on Windows CI runners. */ +const SPAWN_TIMEOUT_MS = 30_000; + +/** + * How long the commands used by the timeout tests run for. A killed shell can + * leave the command running, so this also bounds how long cleanup has to wait. + */ +const SURVIVOR_LIFETIME_MS = 5_000; + +/** Upper bound on a timed-out call: comfortably short of the command itself. */ +const TIMED_OUT_BY_MS = 4_000; + +const decoder = new TextDecoder(); + +describe('LocalEnvironment', () => { + let tmpRoot: string; + + beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'adk-local-env-test-')); + }); + + afterEach(async () => { + // A command killed by a timeout outlives the test by up to + // SURVIVOR_LIFETIME_MS, and Windows refuses to remove a directory that is + // a live process's cwd; retry until that process exits. + await fs.rm(tmpRoot, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 500, + }); + }, SPAWN_TIMEOUT_MS); + + describe('lifecycle', () => { + it('reports isInitialized across initialize() and close()', async () => { + const env = new LocalEnvironment({workingDir: path.join(tmpRoot, 'ws')}); + expect(env.isInitialized).toBe(false); + + await env.initialize(); + expect(env.isInitialized).toBe(true); + + await env.close(); + expect(env.isInitialized).toBe(false); + }); + + it('creates a temporary workspace and removes it on close()', async () => { + const env = new LocalEnvironment(); + expect(() => env.workingDir).toThrow( + '`workingDir` is not set. Call initialize() first.', + ); + + await env.initialize(); + const workingDir = env.workingDir; + expect(path.basename(workingDir)).toMatch(/^adk_workspace_/); + await expect(fs.access(workingDir)).resolves.toBeUndefined(); + + await env.close(); + await expect(fs.access(workingDir)).rejects.toThrow(/ENOENT/); + }); + + it('creates a caller-supplied workspace and keeps it after close()', async () => { + const workingDir = path.join(tmpRoot, 'nested', 'workspace'); + const env = new LocalEnvironment({workingDir}); + + await env.initialize(); + await expect(fs.access(workingDir)).resolves.toBeUndefined(); + + await env.close(); + await expect(fs.access(workingDir)).resolves.toBeUndefined(); + }); + + it('tolerates close() being called twice', async () => { + const env = new LocalEnvironment(); + await env.initialize(); + await env.close(); + + await expect(env.close()).resolves.toBeUndefined(); + }); + + it('keeps the same workspace when initialize() is called twice', async () => { + const env = new LocalEnvironment(); + await env.initialize(); + const workingDir = env.workingDir; + + await env.initialize(); + + expect(env.workingDir).toBe(workingDir); + await env.close(); + }); + + it('creates a fresh temporary workspace when re-initialized', async () => { + const env = new LocalEnvironment(); + await env.initialize(); + const first = env.workingDir; + await env.close(); + + await env.initialize(); + + expect(env.workingDir).not.toBe(first); + await env.close(); + }); + + it('rejects execute, readFile and writeFile before initialize()', async () => { + const env = new LocalEnvironment({workingDir: path.join(tmpRoot, 'ws')}); + + await expect(env.execute(`${NODE} -e ""`)).rejects.toThrow( + /not initialized/, + ); + await expect(env.readFile('a.txt')).rejects.toThrow(/not initialized/); + await expect(env.writeFile('a.txt', 'x')).rejects.toThrow( + /not initialized/, + ); + }); + + it('rejects execute() after close() even for a caller-supplied workspace', async () => { + const env = new LocalEnvironment({workingDir: path.join(tmpRoot, 'ws')}); + await env.initialize(); + await env.close(); + + await expect(env.execute(`${NODE} -e ""`)).rejects.toThrow( + /not initialized/, + ); + }); + }); + + describe('files', () => { + let env: LocalEnvironment; + let workingDir: string; + + beforeEach(async () => { + workingDir = path.join(tmpRoot, 'ws'); + env = new LocalEnvironment({workingDir}); + await env.initialize(); + }); + + afterEach(async () => { + await env.close(); + }); + + it('round-trips string content', async () => { + await env.writeFile('hello.txt', 'hello world'); + + expect(decoder.decode(await env.readFile('hello.txt'))).toBe( + 'hello world', + ); + }); + + it('round-trips binary content', async () => { + const raw = Uint8Array.from([0, 1, 2, 255]); + + await env.writeFile('binary.bin', raw); + + expect(Uint8Array.from(await env.readFile('binary.bin'))).toEqual(raw); + }); + + it('preserves explicit CRLF sequences', async () => { + await env.writeFile('crlf.txt', 'first\r\nsecond\r\n'); + + expect(decoder.decode(await env.readFile('crlf.txt'))).toBe( + 'first\r\nsecond\r\n', + ); + }); + + it('creates parent directories when writing', async () => { + await env.writeFile(path.join('sub', 'dir', 'file.txt'), 'nested'); + + expect(decoder.decode(await env.readFile('sub/dir/file.txt'))).toBe( + 'nested', + ); + }); + + it('accepts an absolute path inside the working directory', async () => { + const absolute = path.join(workingDir, 'absolute.txt'); + + await env.writeFile(absolute, 'absolute'); + + expect(decoder.decode(await env.readFile(absolute))).toBe('absolute'); + }); + + it('rejects parent traversal on both read and write', async () => { + await fs.writeFile(path.join(tmpRoot, 'outside.txt'), 'secret'); + + await expect( + env.readFile(path.join('..', 'outside.txt')), + ).rejects.toThrow(/escapes working directory/); + await expect( + env.writeFile(path.join('..', 'write-outside.txt'), 'nope'), + ).rejects.toThrow(/escapes working directory/); + await expect( + fs.access(path.join(tmpRoot, 'write-outside.txt')), + ).rejects.toThrow(/ENOENT/); + }); + + it('rejects the working directory parent itself', async () => { + await expect(env.readFile('..')).rejects.toThrow( + /escapes working directory/, + ); + }); + + it('rejects an absolute path outside the working directory', async () => { + const outside = path.join(tmpRoot, 'outside-absolute.txt'); + await fs.writeFile(outside, 'secret'); + + await expect(env.readFile(outside)).rejects.toThrow( + /escapes working directory/, + ); + }); + + it('rejects a sibling directory that merely shares the name prefix', async () => { + const sibling = path.join(`${workingDir}-evil`, 'x.txt'); + + await expect(env.readFile(sibling)).rejects.toThrow( + /escapes working directory/, + ); + }); + + it('propagates ENOENT when reading a missing file', async () => { + await expect(env.readFile('does_not_exist.txt')).rejects.toThrow( + /ENOENT/, + ); + }); + }); + + describe('execute', () => { + let env: LocalEnvironment; + + beforeEach(async () => { + env = new LocalEnvironment({workingDir: path.join(tmpRoot, 'ws')}); + await env.initialize(); + }); + + afterEach(async () => { + await env.close(); + }); + + it( + 'captures stdout and reports the documented zero-state result', + async () => { + const result = await env.execute( + `${NODE} -e "process.stdout.write('hello')"`, + ); + + expect(result).toEqual({ + exitCode: 0, + stdout: 'hello', + stderr: '', + timedOut: false, + }); + }, + SPAWN_TIMEOUT_MS, + ); + + it( + 'captures stderr', + async () => { + const result = await env.execute( + `${NODE} -e "process.stderr.write('boom')"`, + ); + + expect(result.stderr).toBe('boom'); + expect(result.exitCode).toBe(0); + }, + SPAWN_TIMEOUT_MS, + ); + + it( + 'propagates a non-zero exit code', + async () => { + const result = await env.execute(`${NODE} -e "process.exit(3)"`); + + expect(result.exitCode).toBe(3); + expect(result.timedOut).toBe(false); + }, + SPAWN_TIMEOUT_MS, + ); + + it( + 'runs the command in the working directory', + async () => { + const result = await env.execute( + `${NODE} -e "process.stdout.write(process.cwd())"`, + ); + + // Normalise both sides: macOS reaches the temp dir through a symlink, + // and Windows CI reports it as an 8.3 short path. + expect(await fs.realpath(result.stdout.trim())).toBe( + await fs.realpath(env.workingDir), + ); + }, + SPAWN_TIMEOUT_MS, + ); + + it( + 'merges envVars into the command environment', + async () => { + const scoped = new LocalEnvironment({ + workingDir: path.join(tmpRoot, 'env-ws'), + envVars: {ADK_TEST_VAR: 'abc'}, + }); + await scoped.initialize(); + + try { + const result = await scoped.execute( + `${NODE} -e "process.stdout.write(process.env.ADK_TEST_VAR || '')"`, + ); + + expect(result.stdout.trim()).toBe('abc'); + } finally { + await scoped.close(); + } + }, + SPAWN_TIMEOUT_MS, + ); + + it( + 'kills the command and reports timedOut once the timeout elapses', + async () => { + const startedAt = Date.now(); + + const result = await env.execute( + `${NODE} -e "setTimeout(() => {}, ${SURVIVOR_LIFETIME_MS})"`, + 0.5, + ); + + expect(result.timedOut).toBe(true); + expect(result.exitCode).not.toBe(0); + expect(Date.now() - startedAt).toBeLessThan(TIMED_OUT_BY_MS); + }, + SPAWN_TIMEOUT_MS, + ); + + it( + 'times out even when the command leaves a child holding the pipes open', + async () => { + // A shell that forks rather than exec's its command leaves a survivor + // that keeps stdout/stderr open after the kill. Reproduce that with a + // script file so no shell-specific syntax is needed. + await env.writeFile( + 'spawn_survivor.cjs', + [ + "const {spawn} = require('node:child_process');", + `spawn(process.execPath, ['-e', 'setTimeout(() => {}, ${SURVIVOR_LIFETIME_MS})'], {`, + " stdio: 'inherit',", + '});', + `setTimeout(() => {}, ${SURVIVOR_LIFETIME_MS});`, + ].join('\n'), + ); + const startedAt = Date.now(); + + const result = await env.execute(`${NODE} spawn_survivor.cjs`, 0.5); + + expect(result.timedOut).toBe(true); + expect(Date.now() - startedAt).toBeLessThan(TIMED_OUT_BY_MS); + }, + SPAWN_TIMEOUT_MS, + ); + + it( + 'clears the timer when the command finishes before the timeout', + async () => { + const result = await env.execute( + `${NODE} -e "process.stdout.write('quick')"`, + SPAWN_TIMEOUT_MS / 1000, + ); + + expect(result.stdout).toBe('quick'); + expect(result.timedOut).toBe(false); + }, + SPAWN_TIMEOUT_MS, + ); + + it( + 'rejects when the shell cannot be spawned', + async () => { + const removed = new LocalEnvironment({ + workingDir: path.join(tmpRoot, 'gone'), + }); + await removed.initialize(); + await fs.rm(removed.workingDir, {recursive: true, force: true}); + + await expect(removed.execute(`${NODE} -e ""`)).rejects.toThrow(); + + await removed.close(); + }, + SPAWN_TIMEOUT_MS, + ); + }); +});