diff --git a/changelog.d/fix-code-execution-child-env.breaking.md b/changelog.d/fix-code-execution-child-env.breaking.md new file mode 100644 index 000000000..43c3e6d40 --- /dev/null +++ b/changelog.d/fix-code-execution-child-env.breaking.md @@ -0,0 +1,6 @@ +- **Operators with `codeExecution` enabled:** the python interpreter that runs model-authored scripts no longer inherits the host's environment, and it starts in its own runtime directory instead of the host's working directory. It gets the allowlist stdio MCPL children get (#175): `PATH`, `HOME`, the locale, temp directories, CA bundles and proxy variables, whose URLs can carry credentials. Then come `codeExecution.env`, and last `PYTHONUNBUFFERED=1` and `PYTHONIOENCODING=utf-8`, which the runner sets over any declared value. + - **What stops arriving:** every host variable outside the allowlist, such as `PYTHONPATH`, `VIRTUAL_ENV`, `PYTHONHOME`, `LD_LIBRARY_PATH`, `PYTHONUTF8` and `ALL_PROXY`, and any credential a script read from `os.environ`. To get one back, list it in `codeExecution.env`, which a recipe can `${VAR}`-substitute from `.env`. `codeExecution.inheritEnv: true` restores the whole environment, and with it hands every host variable, provider keys included, to model code and to every subprocess a script starts. + - **Relative paths** now resolve in the runtime directory, which teardown removes, best effort, whenever the interpreter stops or is reclaimed: a script's relative reads and writes, a relative path in `codeExecution.env`, such as a `PYTHONPATH` entry, and a relative `PATH` entry that a bare `pythonPath` is looked up on. A file a script reads from the host, or writes to keep, needs an absolute path, as does a path in `codeExecution.env`. + - **Direct `PyRunner` users:** `PyRunnerOptions` gains `env` and `inheritEnv`, and a runner built without them gets the allowlist. Only `inheritEnv: true` passes the whole environment, and these options aren't checked as `create()` checks `codeExecution`'s. + - **A misread configuration is refused at create:** an `inheritEnv` that isn't `true` or `false`, such as a quoted `"true"`, which would quietly keep the allowlist; an `env` that isn't an object of strings; and a variable name that is empty or holds `=` or NUL, or a value that holds NUL. A `null` `inheritEnv` or `env` counts as left out. + - **Unchanged:** a `pythonPath` with a separator, which still resolves from the host's working directory, and which a venv's interpreter needs no `VIRTUAL_ENV` for, since it finds its prefix and site-packages itself. Everything the allowlist passes is unchanged too. The allowlist limits what model code inherits, not what it can read: on Linux a process running as the same user can still read the environment the host and its MCPL servers started with, through `/proc//environ`, and can open the host's files, `.env` included, by absolute path. diff --git a/changelog.d/fix-code-execution-child-env.fixed.md b/changelog.d/fix-code-execution-child-env.fixed.md new file mode 100644 index 000000000..cd3193841 --- /dev/null +++ b/changelog.d/fix-code-execution-child-env.fixed.md @@ -0,0 +1,2 @@ +- `USERNAME` joins the child-env allowlist, beside `USER` and `LOGNAME`, so a Windows child gets its user's name, as a POSIX one does. Python's `getpass.getuser()` tries `LOGNAME`, `USER` and `LNAME` before `USERNAME`, so without it, it raises on a Windows host that supplies the name only through `USERNAME`, in stdio MCPL children and the code-execution interpreter alike. +- A code-execution script's output that UTF-8 can't encode, a lone surrogate such as Python makes of a filename byte that isn't UTF-8 or of a `\ud800` JSON escape, reaches the host as U+FFFD. Before, the interpreter could fail to send the script's result, which then waited out the script's deadline. diff --git a/src/code-execution/py-runner.ts b/src/code-execution/py-runner.ts index 817e38343..85aa3525b 100644 --- a/src/code-execution/py-runner.ts +++ b/src/code-execution/py-runner.ts @@ -15,9 +15,10 @@ */ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { buildChildEnv } from '../mcpl/transport.js'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { createInterface, type Interface } from 'node:readline'; import { PYTHON_RUNTIME_SOURCE } from './runtime-py.js'; @@ -72,6 +73,19 @@ export interface PyRunnerOptions { onToolCall: ScriptToolCallHandler; /** Log prefix, typically the agent name. */ label?: string; + /** Variables for the interpreter beyond what it inherits, such as + * PYTHONPATH or a venv's. It inherits only what stdio MCPL children do + * (CHILD_ENV_ALLOWLIST and LC_*, CA bundles and proxy variables among + * them, and a proxy URL can carry credentials), then these, then + * PYTHONUNBUFFERED and PYTHONIOENCODING, which the runner sets last. + * Like the allowlist, this limits what the model's code inherits, not + * what a process running as the same user can read. */ + env?: Record; + /** Pass the host's whole environment instead of the allowlist, as before + * `env` existed. That hands every host variable, provider keys included, + * to the model's code and to every subprocess a script starts; listing + * what scripts need in `env` is the safer route. */ + inheritEnv?: boolean; } const DEFAULT_TOOL_CALL_TIMEOUT_MS = 270_000; @@ -103,6 +117,17 @@ interface PendingExec { deadlineMs: number | null; } +/** + * The interpreter to spawn. A bare name is looked up on PATH. A path with a + * separator is resolved against the host's working directory, where it + * resolved before the interpreter got a working directory of its own: spawn + * would resolve it against the runtime directory instead. + */ +function spawnablePath(pythonPath: string): string { + const hasSeparator = pythonPath.includes('/') || (process.platform === 'win32' && pythonPath.includes('\\')); + return hasSeparator ? resolve(pythonPath) : pythonPath; +} + export class PyRunner { private readonly pythonPath: string; private readonly toolCallTimeoutMs: number; @@ -111,6 +136,7 @@ export class PyRunner { private readonly idleReclaimMs: number; private readonly onToolCall: ScriptToolCallHandler; private readonly label: string; + private readonly envOptions: { env?: Record; inheritEnv?: boolean }; private onWake: ((line: number, payload: unknown) => Promise) | null = null; private child: ChildProcessWithoutNullStreams | null = null; @@ -132,6 +158,12 @@ export class PyRunner { this.idleReclaimMs = options.idleReclaimMs ?? DEFAULT_IDLE_RECLAIM_MS; this.onToolCall = options.onToolCall; this.label = options.label ?? 'pytc'; + // The interpreter runs model-authored code: the same env discipline as an + // MCPL connector child (#175), for a child that deserves it more. Stored + // as options, not a snapshot — the env is rebuilt at every spawn so a + // respawn after idle reclaim sees current host values, as the old + // per-spawn `{ ...process.env }` did. + this.envOptions = { env: options.env, inheritEnv: options.inheritEnv }; } get busy(): boolean { @@ -285,13 +317,23 @@ export class PyRunner { } this.teardownChild(); - this.runtimeDir = mkdtempSync(join(tmpdir(), 'af-pytc-')); + // Absolute, since the interpreter runs in it: from a relative TMPDIR, + // runtime.py's path would otherwise resolve against the directory itself. + this.runtimeDir = resolve(mkdtempSync(join(tmpdir(), 'af-pytc-'))); const runtimePath = join(this.runtimeDir, 'runtime.py'); writeFileSync(runtimePath, PYTHON_RUNTIME_SOURCE, 'utf8'); - const child = spawn(this.pythonPath, [runtimePath], { + const child = spawn(spawnablePath(this.pythonPath), [runtimePath], { stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, PYTHONUNBUFFERED: '1' }, + // The runtime directory, not the host's working directory: a script's + // relative paths land here, and go at teardown, rather than beside the + // host's .env and data. An absolute path still reaches the host's files. + cwd: this.runtimeDir, + // Set last, over a declared env: the protocol needs both. It writes + // non-ASCII JSON on piped stdio, which Python before 3.15 on Windows + // encodes in the ANSI code page unless told otherwise, and a host's + // own PYTHONUTF8 no longer reaches the interpreter. + env: { ...buildChildEnv(this.envOptions), PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8' }, }); this.child = child; @@ -501,8 +543,8 @@ export class PyRunner { this.reader.close(); this.reader = null; } - if (this.child) { - const child = this.child; + const child = this.child; + if (child) { this.child = null; this.childReady = null; this.readyResolver = null; @@ -516,13 +558,24 @@ export class PyRunner { child.kill('SIGKILL'); } } - if (this.runtimeDir) { - try { - rmSync(this.runtimeDir, { recursive: true, force: true }); - } catch { - // temp cleanup is best-effort - } - this.runtimeDir = null; + const dir = this.runtimeDir; + this.runtimeDir = null; + if (dir) { + const remove = (): void => { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // temp cleanup is best-effort + } + }; + // The directory is the interpreter's working directory, which Windows + // won't remove while the process lives, so there it goes once the + // process has. Elsewhere it goes now, so a host that exits right after + // this doesn't leave it behind. A child that never started has no exit + // to wait for. + const running = child !== null && child.pid !== undefined && child.exitCode === null && child.signalCode === null; + if (running && process.platform === 'win32') child.once('exit', remove); + else remove(); } } diff --git a/src/code-execution/runtime-py.ts b/src/code-execution/runtime-py.ts index e1304f557..5615b0f9b 100644 --- a/src/code-execution/runtime-py.ts +++ b/src/code-execution/runtime-py.ts @@ -53,6 +53,7 @@ import inspect import io import json import os +import re import signal import sys import threading @@ -70,9 +71,16 @@ TAIL_CHARS = 4000 _send_lock = threading.Lock() +# A str can hold a lone surrogate, which UTF-8 can't encode: on POSIX, +# os.fsdecode makes one of each byte in a filename that doesn't decode as +# UTF-8, and json.loads makes one of a "\ud800" escape. PYTHONIOENCODING makes +# the protocol channel strict UTF-8, so writing one would fail the whole +# message, and a script's result would never arrive. Each goes as U+FFFD. +_LONE_SURROGATE = re.compile(r"[\ud800-\udfff]") + def send(obj): - line = json.dumps(obj, ensure_ascii=False, default=str) + line = _LONE_SURROGATE.sub("\ufffd", json.dumps(obj, ensure_ascii=False, default=str)) with _send_lock: PROTO_OUT.write(line + "\n") PROTO_OUT.flush() diff --git a/src/code-execution/tool-definition.ts b/src/code-execution/tool-definition.ts index cab62de22..f8a8e934d 100644 --- a/src/code-execution/tool-definition.ts +++ b/src/code-execution/tool-definition.ts @@ -9,6 +9,7 @@ */ import type { ToolDefinition } from '../types/index.js'; +import { kindOf } from '../mcpl/transport.js'; import { formatLimit, MAX_TIMER_MS } from './py-runner.js'; export const CODE_EXECUTION_TOOL_NAME = 'code_execution'; @@ -39,7 +40,9 @@ export function buildCodeExecutionToolDefinition(opts?: { 'Only what you print() (plus stderr and the exit code) comes back to you — intermediate ' + 'tool results stay in the script, so filter or aggregate large data there and print only ' + 'what you need. Interpreter state (variables, imports) persists across code_execution ' + - `calls but is reclaimed after ~${idleMinutes} minutes idle. A tool call that receives no ` + + `calls but is reclaimed after ~${idleMinutes} minutes idle. Relative paths resolve in a ` + + 'scratch directory of the interpreter\'s, which goes whenever it stops or is reclaimed, so a file meant to ' + + 'outlast the interpreter needs an absolute path. A tool call that receives no ' + `response within ~${callTimeoutSeconds}s raises TimeoutError inside the script. ` + `A foreground script is stopped after ${formatLimit(limits.defaultMs)}; pass time_limit_ms to set this call's limit ` + `(at most ${formatLimit(limits.maxMs)}; a background script runs up to ${formatLimit(limits.backgroundMaxMs)}). ` + @@ -113,8 +116,20 @@ export function scriptTimeLimits(opts?: { }; } -/** Refuse a configuration whose time limits contradict each other. */ -export function validateCodeExecutionConfig(cfg: { scriptTimeoutMs?: number; maxScriptTimeoutMs?: number }): void { +/** + * Refuse a configuration whose time limits contradict each other, or whose + * interpreter environment would be misread. A recipe's `"inheritEnv": "true"`, + * or a `${VAR}` substituted as a string, arrives untyped, and the runner passes + * the host's environment only for `true`, so such a value would quietly keep + * the allowlist. Anything but true, false or null is refused, a quoted "false" + * included. + */ +export function validateCodeExecutionConfig(cfg: { + scriptTimeoutMs?: number; + maxScriptTimeoutMs?: number; + env?: unknown; + inheritEnv?: unknown; +}): void { const defaultMs = cfg.scriptTimeoutMs ?? 600_000; if (cfg.maxScriptTimeoutMs !== undefined && !(cfg.maxScriptTimeoutMs >= defaultMs)) { throw new Error( @@ -122,4 +137,43 @@ export function validateCodeExecutionConfig(cfg: { scriptTimeoutMs?: number; max 'it is the most an agent may ask for, and the default is always allowed', ); } + // None of the environment's refusals below shows the value it refuses, + // at most its kind: a substituted value can be a credential. null counts + // as not set, so a generated config that writes an unset setting as null + // keeps working. + if (cfg.inheritEnv !== undefined && cfg.inheritEnv !== null && typeof cfg.inheritEnv !== 'boolean') { + const wanted = typeof cfg.inheritEnv === 'string' ? 'true or false, unquoted,' : 'true or false,'; + throw new Error( + `codeExecution.inheritEnv must be ${wanted} not ${kindOf(cfg.inheritEnv)}: ` + + "true hands the interpreter the host's whole environment, and false or leaving it out keeps the allowlist", + ); + } + if (cfg.env !== undefined && cfg.env !== null) { + if (!isPlainObject(cfg.env)) { + throw new Error(`codeExecution.env must be an object of variable names and string values, not ${kindOf(cfg.env)}`); + } + for (const [name, value] of Object.entries(cfg.env)) { + // spawn writes each variable as name=value: a name that is empty or + // holds '=' doesn't reach the interpreter as written, and a NUL in a + // name or a value makes spawn throw at every start. + // Only what comes before an '=' or NUL is shown: what follows can be a + // value pasted into the name. + if (name === '' || name.includes('=') || name.includes('\0')) { + const shown = name === '' ? 'that is empty' : `starting ${JSON.stringify(name.split(/[=\0]/, 1)[0])} that holds '=' or NUL`; + throw new Error(`codeExecution.env has a variable name ${shown}, and a name can't be empty or hold '=' or NUL`); + } + if (typeof value !== 'string') { + throw new Error(`codeExecution.env.${name} must be a string, not ${kindOf(value)}: give its value as a string, or leave ${name} out`); + } + if (value.includes('\0')) { + throw new Error(`codeExecution.env.${name} holds a NUL character, which no environment variable can carry`); + } + } + } +} + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; } diff --git a/src/framework.ts b/src/framework.ts index 3455acb3b..cbf9e35b2 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -14787,6 +14787,8 @@ export class AgentFramework { toolCallTimeoutMs: cfg?.toolCallTimeoutMs, scriptTimeoutMs: cfg?.scriptTimeoutMs, idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline + env: cfg?.env, + inheritEnv: cfg?.inheritEnv, label: `${agentName}:${scriptId}`, onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args, origin), }); @@ -15218,6 +15220,8 @@ export class AgentFramework { toolCallTimeoutMs: cfg?.toolCallTimeoutMs, scriptTimeoutMs: cfg?.scriptTimeoutMs, idleReclaimMs: cfg?.idleReclaimMs, + env: cfg?.env, + inheritEnv: cfg?.inheritEnv, label: agentName, // The script that made the call is the one running when it arrives. onToolCall: (toolName, args) => diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 6109f3276..f52a3e557 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -136,7 +136,8 @@ export const CHILD_ENV_ALLOWLIST = [ 'http_proxy', 'https_proxy', 'no_proxy', '__CF_USER_TEXT_ENCODING', // Windows equivalents, harmless elsewhere. - 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE', + // USERNAME is Windows' USER, which Python's getpass.getuser() reads there. + 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'USERNAME', ]; /** Environment for a stdio child: allowlisted host vars (+ LC_*), then the @@ -518,9 +519,11 @@ export function assertServerConfigBooleans(config: McplServerConfig): void { } } -/** The kind of a value that isn't undefined, null or a boolean, as a refusal - * names it in place of the value. */ -function kindOf(value: unknown): string { +/** The kind of a value, as a refusal names it in place of the value, which + * can be a credential. Shared by the MCPL server and codeExecution checks. */ +export function kindOf(value: unknown): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; if (Array.isArray(value)) return 'an array'; if (typeof value !== 'object') return `a ${typeof value}`; const prototype = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null; diff --git a/src/types/framework.ts b/src/types/framework.ts index 5725aee5d..7b3fbc67a 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -74,6 +74,23 @@ export interface CodeExecutionConfig { /** Max wake_agent() calls per background script (default 100); further * calls raise RuntimeError inside the script. */ maxWakesPerScript?: number; + /** Variables for the python interpreter beyond the operating allowlist it + * inherits (see CHILD_ENV_ALLOWLIST), such as PYTHONPATH. A recipe can + * `${VAR}`-substitute them from .env. Other host variables aren't + * inherited, which limits what model-authored code inherits, not what a + * process running as the same user can read. The runner sets + * PYTHONUNBUFFERED and PYTHONIOENCODING last, over any value given here, + * since its protocol needs both. The interpreter's working directory is + * its own runtime directory, so a relative path given here, as in + * PYTHONPATH, resolves there: give absolute paths. */ + env?: Record; + /** The interpreter inherits the host's whole environment instead of the + * allowlist: every host variable, provider keys included, reaches + * model-authored code and every subprocess a script starts. Listing what + * scripts need in `env` is the safer route. With code execution enabled, + * `create()` refuses any value but true, false or null, which counts as + * not set. */ + inheritEnv?: boolean; } /** See `FrameworkConfig.providerHold`. */ diff --git a/test/code-execution-env.test.ts b/test/code-execution-env.test.ts new file mode 100644 index 000000000..7732f57a5 --- /dev/null +++ b/test/code-execution-env.test.ts @@ -0,0 +1,278 @@ +/** + * The code-execution child runs MODEL-AUTHORED code. It must not inherit the + * host's secrets any more than an MCPL connector child does (#175): on a + * multi-resident host the .env carries provider keys and every bot's token, + * and `os.environ` would hand all of them to any agent with code_execution. + * Same operating allowlist as stdio MCPL children, plus the runner's own + * declared env; `inheritEnv: true` restores full inheritance. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; + +import { PyRunner } from '../src/index.js'; +import { validateCodeExecutionConfig } from '../src/code-execution/tool-definition.js'; + +test('a codeExecution.inheritEnv set to anything but true, false or null is refused, naming its kind but not its value', () => { + const kinds: Array<[unknown, string]> = [ + ['false', 'true or false, unquoted, not a string'], ['true', 'true or false, unquoted, not a string'], + [0, 'true or false, not a number'], [1, 'true or false, not a number'], + [[], 'true or false, not an array'], [{}, 'true or false, not an object'], + ]; + for (const [inheritEnv, wanted] of kinds) { + assert.throws(() => validateCodeExecutionConfig({ inheritEnv }), (err: Error) => { + assert.equal(err.message, `codeExecution.inheritEnv must be ${wanted}: ` + + "true hands the interpreter the host's whole environment, and false or leaving it out keeps the allowlist"); + return true; + }, JSON.stringify(inheritEnv)); + } + // null counts as not set, so a generated config that writes an unset + // setting as null keeps working. + for (const inheritEnv of [undefined, null, true, false]) validateCodeExecutionConfig({ inheritEnv }); +}); + +test('a codeExecution.env that is not an object of strings is refused, naming the variable and its value\'s kind but not the value', () => { + const envKinds: Array<[unknown, string]> = [ + [[], 'an array'], ['PYTHONPATH=/opt/lib', 'a string'], [7, 'a number'], [new Map(), 'a Map'], + [Object.create(Object.create(null)), "an object that isn't plain"], + ]; + for (const [env, kind] of envKinds) { + assert.throws(() => validateCodeExecutionConfig({ env }), (err: Error) => { + assert.equal(err.message, `codeExecution.env must be an object of variable names and string values, not ${kind}`); + return true; + }, String(kind)); + } + const valueKinds: Array<[unknown, string]> = [[8080, 'a number'], [true, 'a boolean'], [null, 'null'], [undefined, 'undefined'], [['x'], 'an array'], [{ nested: 'y' }, 'an object']]; + for (const [value, kind] of valueKinds) { + assert.throws(() => validateCodeExecutionConfig({ env: { PYTHONPATH: '/opt/lib', BAD: value } }), (err: Error) => { + assert.equal(err.message, `codeExecution.env.BAD must be a string, not ${kind}: give its value as a string, or leave BAD out`); + return true; + }, String(kind)); + } + validateCodeExecutionConfig({ env: {} }); + validateCodeExecutionConfig({ env: null }); + validateCodeExecutionConfig({ env: { PYTHONPATH: '/opt/lib', EMPTY: '' } }); +}); + +test('a codeExecution.env name spawn could not pass as written, or a value holding NUL, is refused, showing nothing after an = or NUL', () => { + const names: Array<[string, string]> = [ + ['', 'that is empty'], + ['PYTHONPATH=/opt/lib', `starting "PYTHONPATH" that holds '=' or NUL`], + ['TOKEN=sk-live-secret', `starting "TOKEN" that holds '=' or NUL`], + ['NU\0L', `starting "NU" that holds '=' or NUL`], + ]; + for (const [name, shown] of names) { + assert.throws(() => validateCodeExecutionConfig({ env: { [name]: 'x' } }), (err: Error) => { + assert.equal(err.message, `codeExecution.env has a variable name ${shown}, and a name can't be empty or hold '=' or NUL`); + return true; + }, JSON.stringify(name)); + } + assert.throws(() => validateCodeExecutionConfig({ env: { SECRET: 'sk-\0live' } }), (err: Error) => { + assert.equal(err.message, 'codeExecution.env.SECRET holds a NUL character, which no environment variable can carry'); + return true; + }); +}); + +test('model-authored python does not inherit a host variable outside the allowlist', async () => { + const key = 'AF_CODE_EXEC_TEST_HOST_SECRET'; + process.env[key] = 'must-not-leak'; + const runner = new PyRunner({ onToolCall: async () => '' }); + try { + const r = await runner.exec(`import os; print(repr(os.environ.get(${JSON.stringify(key)})))`, []); + assert.equal(r.returnCode, 0, r.stderr); + assert.match(r.stdout, /None/, `the code-execution child inherited a host secret: ${r.stdout.trim()}`); + } finally { + runner.dispose(); + delete process.env[key]; + } +}); + +test('declared env reaches the interpreter; inheritEnv restores the host env', async () => { + const key = 'AF_CODE_EXEC_TEST_HOST_SECRET'; + process.env[key] = 'must-not-leak'; + const declared = new PyRunner({ onToolCall: async () => '', env: { AF_CODE_EXEC_DECLARED: 'yes' } }); + const inheriting = new PyRunner({ onToolCall: async () => '', inheritEnv: true }); + try { + const d = await declared.exec(`import os; print(os.environ.get('AF_CODE_EXEC_DECLARED'), repr(os.environ.get(${JSON.stringify(key)})))`, []); + assert.match(d.stdout, /^yes None/m, d.stdout + d.stderr); + const i = await inheriting.exec(`import os; print(repr(os.environ.get(${JSON.stringify(key)})))`, []); + assert.match(i.stdout, /must-not-leak/, i.stdout + i.stderr); + } finally { + declared.dispose(); inheriting.dispose(); + delete process.env[key]; + } +}); + +test('the child env is built at each spawn: a respawn after idle reclaim sees a value changed since', async () => { + const key = 'AF_CODE_EXEC_TEST_LATE_VAR'; + const read = `import os; print(repr(os.environ.get(${JSON.stringify(key)})))`; + process.env[key] = 'first'; + const runner = new PyRunner({ onToolCall: async () => '', inheritEnv: true, idleReclaimMs: 20, label: 'respawn' }); + // The runner reports its idle reclaim on stderr; wait for that line rather than a guessed delay. + const error = console.error; + let reclaimed = false; + console.error = (...args: unknown[]) => { + if (args.map(String).join(' ').includes('[pytc:respawn] reclaiming python interpreter (idle)')) reclaimed = true; + else error(...args); + }; + try { + const before = await runner.exec(read, []); + assert.equal(before.returnCode, 0, before.stderr); + assert.match(before.stdout, /'first'/, before.stdout); + const deadline = Date.now() + 10_000; + while (!reclaimed && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10)); + assert.ok(reclaimed, 'the idle interpreter should have been reclaimed'); + process.env[key] = 'second'; + const after = await runner.exec(read, []); + assert.equal(after.returnCode, 0, after.stderr); + assert.match(after.stdout, /'second'/, `the respawned interpreter must see the value set since the first spawn: ${after.stdout.trim()}`); + } finally { + console.error = error; + runner.dispose(); + delete process.env[key]; + } +}); + +test('PYTHONUNBUFFERED and PYTHONIOENCODING are set last, over a declared env, since the protocol needs both', async () => { + const runner = new PyRunner({ onToolCall: async () => '', env: { PYTHONUNBUFFERED: '0', PYTHONIOENCODING: 'latin-1' } }); + try { + const r = await runner.exec(`import os; print(os.environ.get('PYTHONUNBUFFERED'), os.environ.get('PYTHONIOENCODING'))`, []); + assert.equal(r.returnCode, 0, r.stderr); + assert.match(r.stdout, /^1 utf-8$/m, r.stdout); + } finally { + runner.dispose(); + } +}); + +test("a script's output that UTF-8 can't encode still returns, each lone surrogate as U+FFFD", async () => { + // A result the interpreter failed to send would wait out the script's + // deadline, so a short one makes that a failure here rather than a hang. + const runner = new PyRunner({ onToolCall: async () => '', scriptTimeoutMs: 15_000 }); + try { + // A filename byte that isn't UTF-8, as os.fsdecode gives it, and a lone + // surrogate from a JSON escape, as a tool's result can carry one. + const r = await runner.exec(String.raw`import json, os +print(os.fsdecode(b"\xff")) +print(json.loads('"\\ud800"')) +print("after")`, []); + assert.equal(r.returnCode, 0, r.stderr); + assert.equal(r.stdout, '\ufffd\n\ufffd\nafter\n'); + } finally { + runner.dispose(); + } +}); + +test("the interpreter runs in its own runtime directory, which goes at teardown, not the host's working directory", async () => { + const runner = new PyRunner({ onToolCall: async () => '' }); + try { + const r = await runner.exec(`import os; open('scratch.txt', 'w').write('x'); print(os.getcwd())`, []); + assert.equal(r.returnCode, 0, r.stderr); + const cwd = r.stdout.trim(); + assert.notEqual(cwd, process.cwd()); + assert.match(basename(cwd), /^af-pytc-/, cwd); + assert.ok(existsSync(join(cwd, 'scratch.txt')), 'a relative path lands in the runtime directory'); + runner.dispose(); + if (process.platform === 'win32') { + // There it goes once the killed interpreter has exited, which dispose() doesn't wait for. + const deadline = Date.now() + 10_000; + while (existsSync(cwd) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10)); + } + assert.ok(!existsSync(cwd), 'the runtime directory goes at teardown, with what the script wrote there'); + } finally { + runner.dispose(); + } +}); + +test("a relative pythonPath resolves from the host's working directory, not the interpreter's", async () => { + const python = execFileSync('python3', ['-c', 'import sys; print(sys.executable)'], { encoding: 'utf8' }).trim(); + // bin/python under a host directory of its own: a relative path that + // doesn't climb to the root, so it finds the interpreter from that + // directory and from no other. + const hostDir = mkdtempSync(join(tmpdir(), 'pytc-host-')); + const previousCwd = process.cwd(); + let runner: PyRunner | undefined; + try { + mkdirSync(join(hostDir, 'bin')); + symlinkSync(python, join(hostDir, 'bin', 'python')); + process.chdir(hostDir); + runner = new PyRunner({ onToolCall: async () => '', pythonPath: join('bin', 'python') }); + const r = await runner.exec('print("started")', []); + assert.equal(r.returnCode, 0, r.stderr); + assert.match(r.stdout, /started/); + } finally { + runner?.dispose(); + process.chdir(previousCwd); + rmSync(hostDir, { recursive: true, force: true }); + } +}); + +/** Run `fn` with os.tmpdir() reading `dir`, as TMPDIR sets it on POSIX. */ +async function withTmpdir(dir: string, fn: () => Promise): Promise { + const previous = process.env.TMPDIR; + process.env.TMPDIR = dir; + try { + return await fn(); + } finally { + if (previous === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = previous; + } +} + +test('an interpreter that never starts leaves no runtime directory behind', { skip: process.platform === 'win32' }, async () => { + const tmp = mkdtempSync(join(tmpdir(), 'pytc-tmp-')); + try { + await withTmpdir(tmp, async () => { + const runner = new PyRunner({ onToolCall: async () => '', pythonPath: join(tmp, 'no-such-python') }); + try { + const r = await runner.exec('print(1)', []); + assert.equal(r.returnCode, 1); + assert.match(r.stderr, /Failed to start python runtime/, r.stderr); + assert.deepEqual(readdirSync(tmp), [], 'the runtime directory went with the failed start'); + } finally { + runner.dispose(); + } + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('the interpreter starts from a relative TMPDIR, since its runtime directory is made absolute', { skip: process.platform === 'win32' }, async () => { + const hostDir = mkdtempSync(join(tmpdir(), 'pytc-host-')); + const previousCwd = process.cwd(); + try { + mkdirSync(join(hostDir, 'tmp')); + process.chdir(hostDir); + await withTmpdir('tmp', async () => { + const runner = new PyRunner({ onToolCall: async () => '' }); + try { + const r = await runner.exec('import os; print(os.getcwd())', []); + assert.equal(r.returnCode, 0, r.stderr); + assert.match(basename(r.stdout.trim()), /^af-pytc-/, r.stdout); + } finally { + runner.dispose(); + } + }); + } finally { + process.chdir(previousCwd); + rmSync(hostDir, { recursive: true, force: true }); + } +}); + +test('a PyRunner built directly passes the host env only for inheritEnv: true, whatever else its options hold', async () => { + const key = 'AF_CODE_EXEC_TEST_DIRECT_SECRET'; + let runner: PyRunner | undefined; + try { + process.env[key] = 'must-not-leak'; + runner = new PyRunner({ onToolCall: async () => '', inheritEnv: 'false' as unknown as boolean }); + const r = await runner.exec(`import os; print(repr(os.environ.get('${key}')))`, []); + assert.equal(r.returnCode, 0, r.stderr); + assert.match(r.stdout, /^None$/m, r.stdout); + } finally { + runner?.dispose(); + delete process.env[key]; + } +}); diff --git a/test/code-execution.test.ts b/test/code-execution.test.ts index 5deec59aa..291a896ac 100644 --- a/test/code-execution.test.ts +++ b/test/code-execution.test.ts @@ -426,7 +426,95 @@ async function createFrameworkWithCodeExecution(storePath: string, membrane: Moc }); } +/** A host variable no allowlist names, and a declared one, for the env tests below. */ +const HOST_ONLY = 'AF_CODE_EXEC_TEST_HOST_ONLY'; +const DECLARED = 'AF_CODE_EXEC_TEST_DECLARED'; + describe('framework code_execution integration (real python3)', () => { + it("a script gets codeExecution.env and not a host variable outside the allowlist", async () => { + const { tempDir, storePath } = tempStorePath('pytc-env-'); + const membrane = new MockMembrane(); + const code = `import os; print("declared:", os.environ.get("${DECLARED}"), "host:", repr(os.environ.get("${HOST_ONLY}")))`; + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'call_env_1', name: 'code_execution', input: { code } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); + const framework = await AgentFramework.create({ + storePath, + membrane: membrane.asMembrane(), + agents: [], + modules: [new ScriptToolModule()], + syncIntervalMs: 0, + codeExecution: { enabled: true, env: { [DECLARED]: 'from-config' } }, + }); + try { + process.env[HOST_ONLY] = 'host-value'; + const created = await framework.createEphemeralAgent({ + name: 'worker', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', + }); + created.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + const promise = framework.runEphemeralToCompletion(created.agent, created.contextManager); + framework.start(); + await promise; + const wireResult = membrane.lastStream!.receivedToolResults[0][0] as { content: string }; + assert.match(wireResult.content, /declared: from-config host: None/, wireResult.content); + } finally { + await framework.stop(); + delete process.env[HOST_ONLY]; + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("with codeExecution.inheritEnv: true, a script gets the host's variables beside codeExecution.env", async () => { + const { tempDir, storePath } = tempStorePath('pytc-envinherit-'); + const membrane = new MockMembrane(); + const code = `import os; print("declared:", os.environ.get("${DECLARED}"), "host:", repr(os.environ.get("${HOST_ONLY}")))`; + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'call_env_2', name: 'code_execution', input: { code } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); + const framework = await AgentFramework.create({ + storePath, + membrane: membrane.asMembrane(), + agents: [], + modules: [new ScriptToolModule()], + syncIntervalMs: 0, + codeExecution: { enabled: true, env: { [DECLARED]: 'from-config' }, inheritEnv: true }, + }); + try { + process.env[HOST_ONLY] = 'host-value'; + const created = await framework.createEphemeralAgent({ + name: 'worker', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', + }); + created.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + const promise = framework.runEphemeralToCompletion(created.agent, created.contextManager); + framework.start(); + await promise; + const wireResult = membrane.lastStream!.receivedToolResults[0][0] as { content: string }; + assert.match(wireResult.content, /declared: from-config host: 'host-value'/, wireResult.content); + } finally { + await framework.stop(); + delete process.env[HOST_ONLY]; + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('create() refuses a codeExecution.inheritEnv that is not a boolean', async () => { + const { tempDir, storePath } = tempStorePath('pytc-inherit-'); + try { + await assert.rejects(AgentFramework.create({ + storePath, + membrane: new MockMembrane().asMembrane(), + agents: [], + modules: [new ScriptToolModule()], + syncIntervalMs: 0, + codeExecution: { enabled: true, inheritEnv: 'false' as unknown as boolean }, + }), /codeExecution\.inheritEnv must be true or false, unquoted, not a string: /); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('synthesizes the tool only when enabled', async () => { const { tempDir, storePath } = tempStorePath('pytc-tools-'); const membrane = new MockMembrane(); @@ -805,6 +893,72 @@ describe('framework background scripts + spill (real python3)', () => { } }); + it('a background script gets codeExecution.env and not a host variable outside the allowlist', async () => { + const { tempDir, storePath } = tempStorePath('pytc-bgenv-'); + const membrane = new MockMembrane(); + const bgCode = `import os\nawait wake_agent({"declared": os.environ.get("${DECLARED}"), "host": os.environ.get("${HOST_ONLY}")})`; + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'call_bgenv', name: 'code_execution', input: { code: bgCode, background: true } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Armed.' }])); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Woke.' }])); + const framework = await createBgFramework(storePath, membrane, { codeExecution: { env: { [DECLARED]: 'from-config' } } }); + try { + process.env[HOST_ONLY] = 'host-value'; + framework.start(); + framework.pushEvent({ + type: 'external-message', source: 'test', content: [{ type: 'text', text: 'arm it' }], metadata: {}, triggerInference: true, + } as unknown as ProcessEvent); + const deadline = Date.now() + 20_000; + let wakeText = ''; + while (Date.now() < deadline && !wakeText) { + await new Promise((r) => setTimeout(r, 100)); + const msgs = (framework.getAgent('prime')?.getContextManager().queryMessages({}).messages ?? []) as unknown as Array<{ content: ContentBlockLike[]; metadata?: { source?: string } }>; + const wake = msgs.find((m) => m.metadata?.source === 'background-script'); + wakeText = (wake?.content[0] as { text?: string } | undefined)?.text ?? ''; + } + assert.match(wakeText, /"declared": "from-config"/, wakeText); + assert.match(wakeText, /"host": null/, wakeText); + } finally { + await framework.stop(); + delete process.env[HOST_ONLY]; + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("with codeExecution.inheritEnv: true, a background script gets the host's variables beside codeExecution.env", async () => { + const { tempDir, storePath } = tempStorePath('pytc-bgenvinherit-'); + const membrane = new MockMembrane(); + const bgCode = `import os\nawait wake_agent({"declared": os.environ.get("${DECLARED}"), "host": os.environ.get("${HOST_ONLY}")})`; + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'call_bgenv_inherit', name: 'code_execution', input: { code: bgCode, background: true } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Armed.' }])); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Woke.' }])); + const framework = await createBgFramework(storePath, membrane, { codeExecution: { env: { [DECLARED]: 'from-config' }, inheritEnv: true } }); + try { + process.env[HOST_ONLY] = 'host-value'; + framework.start(); + framework.pushEvent({ + type: 'external-message', source: 'test', content: [{ type: 'text', text: 'arm it' }], metadata: {}, triggerInference: true, + } as unknown as ProcessEvent); + const deadline = Date.now() + 20_000; + let wakeText = ''; + while (Date.now() < deadline && !wakeText) { + await new Promise((r) => setTimeout(r, 100)); + const msgs = (framework.getAgent('prime')?.getContextManager().queryMessages({}).messages ?? []) as unknown as Array<{ content: ContentBlockLike[]; metadata?: { source?: string } }>; + const wake = msgs.find((m) => m.metadata?.source === 'background-script'); + wakeText = (wake?.content[0] as { text?: string } | undefined)?.text ?? ''; + } + assert.match(wakeText, /"declared": "from-config"/, wakeText); + assert.match(wakeText, /"host": "host-value"/, wakeText); + } finally { + await framework.stop(); + delete process.env[HOST_ONLY]; + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('list and cancel manage the daemon fleet; cap enforced', async () => { const { tempDir, storePath } = tempStorePath('pytc-bglist-'); const membrane = new MockMembrane(); diff --git a/test/mcpl-child-env.test.ts b/test/mcpl-child-env.test.ts index cfc824dba..296380add 100644 --- a/test/mcpl-child-env.test.ts +++ b/test/mcpl-child-env.test.ts @@ -145,6 +145,13 @@ for (const inheritEnv of [false, true]) { }); } +test('win32: USERNAME passes, as USER and LOGNAME do on POSIX', () => { + const env = buildChildEnv({}, { + USERNAME: 'ada', USERDOMAIN: 'HOUSE', SystemRoot: 'C:\\Windows', OpenAI_Api_Key: 'host-secret', + }, 'win32'); + assert.deepEqual(env, { USERNAME: 'ada', SystemRoot: 'C:\\Windows' }); +}); + test('win32: operating allowlist matches native mixed-case spellings', () => { const env = buildChildEnv({}, { Path: 'C:\\Windows', SystemRoot: 'C:\\Windows', ComSpec: 'C:\\Windows\\System32\\cmd.exe',