io library

dart:io-backed execution environment for VM, desktop, and mobile.

Separate entry point so the core library (flutter_agent_harness.dart) stays pure Dart and web-compilable. Import this only from platform code that is allowed to touch dart:io.

Classes

AiinCallback
One OAuth proxy redirect caught by AiinCallbackServer.
AiinCallbackServer
Loopback HTTP server catching the AIIN OAuth proxy redirect.
CaffeinatePowerRunner
macOS runner: one caffeinate child per held assertion (per run by default, per session at power.hold: session), bound to the fa pid by -w. Kill-safe: ProcessPowerAssertionHandle.release terminates the child even though -w would also end it at process exit.
ChatGptOAuthCallback
ChatGptOAuthLocalCallbackServer
CliConfig
Persisted CLI configuration.
CodeMieSsoCallbackServer
HubJoin
One accepted join (spec § join).
HubSocket
One live hub connection.
HubTransport
Dials a hub URL (ws://host:port/ws) — the only network operation the hub fabric performs. Implementations throw when the hub is unreachable or rejects the upgrade (wrong pairing token); the repository treats any throw as "not connected" and retries with backoff.
IoHubSocket
One live hub connection over a dart:io WebSocket. Binary frames are dropped at the boundary (the hub speaks text frames only and answers them with a bad_frame error).
IoHubTransport
A HubTransport over a dart:io WebSocket.
IoLspTransport
An LspTransport over a spawned process's stdio.
IoMcpByteChannel
A McpByteChannel over a spawned process's stdio.
IsolateSessionParseExecutor
Parses every batch in a fresh short-lived isolate.
LocalExecutionEnv
Local ExecutionEnv: LocalFileSystem plus LocalShell.
LocalFileSystem
Local-disk FileSystem backed by dart:io.
LocalHub
A complete in-memory DAP/1 hub for local development and tests.
LocalShell
Minimal local shell backed by dart:io Process.
OpenRouterOAuthLocalCallbackServer
A one-shot HTTP server that captures the OpenRouter OAuth callback on localhost.
ProcessPowerAssertionHandle
One spawned helper process holding an assertion. release kills the child (dropping the assertion immediately) and waits for its exit — BOUNDED by killTimeout (5s default): a helper that ignores SIGTERM gets one follow-up SIGKILL and release proceeds anyway (issue #326: an unbounded await on a stuck helper would hang the run's settle). An unexpected helper death flips held so /power never reports a dead assertion as live.
QjsProcessRuntime
A JsrRuntime over a qjs subprocess using the stdio wire protocol.
SecureKeyRunResult
Result of one helper-process invocation.
SessionParseBatch
One bounded batch of raw JSONL entry lines (never the header line).
SessionParseExecutor
Where CPU-bound session JSONL parsing runs (issue #199).
SessionParseResult
Per-line parse outcomes, parallel to SessionParseBatch.lines.
Sqlite3Engine
A SqliteEngine opening databases through package:sqlite3 (FFI).
SystemdInhibitPowerRunner
Linux runner (best-effort): systemd-inhibit holding idle (and, at the system level, sleep) inhibition while its watchdog command lives — the watchdog polls the fa pid, so the assertion dies with the fa process even if fa is SIGKILLed.

Constants

envHubSecret → const String
The environment variable carrying the hub password for fa hub serve (distinct from DAP_MASTER_SECRET, which is the CLIENT credential).
headlessTextExtensions → const Set<String>
File extensions whose content is inlined as the headless prompt body.
maxConcurrentSessionParseBatches → const int
Max parse batches in flight on the executor path of parseSessionLines (issue #503): enough to keep the cores busy on a marathon walk, bounded so concurrent callers (windowed open + listing fan-out) never spawn an unbounded isolate storm.
secureKeyServiceName → const String
The service/account scope every backend namespaces its entries under.
sessionParseBatchMaxBytes → const int
Max chars per parse transfer.
sessionParseBatchMaxLines → const int
Max lines per parse transfer.

Properties

secureKeyProcessRunner SecureKeyRunner
Direct access to the default runner for timeout tests.
getter/setter pair
secureKeyProcessTimeout Duration
The per-invocation cap for helper processes (security, secret-tool, powershell.exe) — they can block on a system keychain modal on a broken keychain. Tests shorten it.
getter/setter pair

Functions

apiKeyEnvNames(String provider) List<String>
The env names that can hold provider's API key: the catalog spec's names (providerCatalog; copilot → COPILOT_GITHUB_TOKEN, kimi → KIMI_API_KEY, …), plus the two non-catalog slots; unknown kinds keep the historical OpenRouter/OpenAI pair.
attachPathReference(String path) String
The [attached file: <path> — read it with your tools] marker: shared by the positional file-as-prompt resolution and the --attach non-image passthrough (issue #196) so the wording cannot drift.
buildRedactionPipeline(RedactionConfig? config) RedactionPipeline?
Assembles the layered RedactionPipeline (issue #24) from the redact: config section and this process's well-known secrets. Returns null when the section disables redaction (enabled: false) so the hooks never attach. Secret registration happens through buildSecretRedactor's pipeline parameter — call that first with this pipeline, or register later via RedactionPipeline.registerSecret.
buildSecretRedactor({Map<String, String> roleSecrets = const {}, SecureKeyCache? keys, Map<String, String>? env, RedactionPipeline? pipeline}) SecretRedactor
Assembles the startup SecretRedactor: the API keys this CLI knows about (every catalog provider's env names, the web-search slots) are masked from tool results and the provider context so they cannot leak into the LLM conversation or the session files. The rotation stacks collected for the roles resolver and the values preloaded from the platform secure store (keychain values must never reach the transcript either) are redacted too. The spawned shell already inherits the process environment, so no env injection is needed here.
collectQueueSecrets(List<ProviderQueueEntry> entries, SecureKeyCache keys, {Map<String, String>? env}) Map<String, String>
Collects the secrets snapshot for the queue: each entry's apiKeyEnv resolves from the environment, else the secure store (env wins — the same precedence as the roles snapshot).
collectRoleSecrets(ModelRolesConfig rolesConfig, SecureKeyCache keys, {Map<String, String>? env}) Map<String, String>
Collects the secrets snapshot for the model-roles resolver: every provider catalog env name plus its rotation stack (NAME, NAME_2, NAME_3, ...), plus any base name referenced by an explicit apiKeyName in the roles config. The platform secure store backs up base names where the environment has none (env wins; rotation stacks stay env-only — secure storage holds base names only).
defaultHubStateFile({String? home, Map<String, String>? environment}) File
The default hub state file (~/.dap/hub.json) — the hub's master secret (the "hub password") and enrolled per-client secrets live here so fa hub serve restarts keep both. DAP_HUB_STATE_FILE (from environment) wins outright; home overrides ~ (test seam).
faProviderPreconfig(CliArgs parsed, CliConfig saved, {required Map<String, String> env}) EnvProviderPreconfig?
The explicit FA_PROVIDER_* env preconfig (Docker/headless): FA_PROVIDER_TYPE + FA_PROVIDER_NAME + FA_PROVIDER_CONFIG (a JSON object with required baseUrl/model and an optional apiKeyEnvVar) plus the key env var the config references. Every text input has a _BASE64 twin (FA_PROVIDER_CONFIG_BASE64, <apiKeyEnvVar>_BASE64) for platforms that mangle special characters; when both carry the same value the plain one is used. This is an explicit declaration, so it wins over the saved config restore too — a container that declares its provider in env vars runs on it, store or config notwithstanding. An explicit --provider flag means full manual control and disables the preconfig entirely (mixing the flag's provider with the env endpoint would be a silent misconfiguration).
handleRelayRequest(HttpRequest request, {required String? requireCredential(), required String? allowedOrigin}) Future<void>
One POST /relay call (issue #633): the desktop add-in taskpane has no extension to carry its provider HTTP, so the pane proxies it through the local hub, which fetches CORS-free by construction. The body is the SW-bridge request envelope {url, method?, headers?, bodyB64?}; the upstream answer is streamed back raw (status + content-type + body), so provider SSE flows through incrementally.
hidShiftPollingEnabled(Map<String, String> env) bool
Whether the session may poll the HID Shift state at all: the FA_TUI_SHIFT_HID kill switch wins, then SSH detection — an SSH session has no WindowServer attach, so the CoreGraphics call blocks instead of answering (issue #355).
homeDirectory() String?
Returns the user's home directory, or null if it cannot be determined.
hostPowerRunner({required int pid, String? os, PowerProcessLauncher? launcher}) PowerAssertionRunner
The platform runner: caffeinate on macOS, systemd-inhibit on Linux, a no-op with an explicit note everywhere else. os overrides the detected platform (tests); launcher overrides the spawn (tests).
hubAuthVerdict(String? credential, String? masterSecret, Iterable<String> enrolledSecrets) HubAuthVerdict
Pure auth decision for one upgrade attempt (unit-testable — the socket wrapper stays trivial so the CRAP ratchet holds).
hubEnrollDecision({required bool isProtected, required bool isMaster, required String newSecret()}) → ({String? issueSecret, Map<String, Object?> reply})
Pure enroll decision for one {"t":"enroll"} frame (the socket wrapper stays trivial so the CRAP ratchet holds): reply is the frame to send; issueSecret is the per-client credential to persist for the agent (null = nothing to persist — open hub or a refused enroll on a protected hub).
hubUpgradeCredential(String? authorizationHeader, Map<String, String> queryParameters) String?
The upgrade credential: the Authorization: Bearer header (native clients) or the dap_token query param (browser WebSocket cannot set headers — the loopback hub accepts the query form).
ioLspTransportFactory(LspServerConfig config, String cwd) Future<LspTransport>
The process-capable LspTransportFactory for CLI/desktop hosts: spawns config.command config.args... in the workspace root.
ioMcpTransportFactory(McpServerConfig server, String cwd) Future<McpTransport>
The process-capable McpTransportFactory for CLI/desktop hosts: spawns command args... in the workspace root. Remote (HTTP) servers are pure Dart and never come through here — the manager routes them itself.
isShiftPressedViaHid() bool
The live Shift state from the HID system; false when CoreGraphics is unavailable. Call only after resolveHidShiftPressed gated the session — see the library docs.
loadCliConfig(String homeDir) CliConfig
Loads CliConfig from ~/.fah/config.yaml.
loadHubIdentity(String path) Future<HubIdentity>
Loads the hub identity persisted at path, creating it (mode 0600, atomic temp+rename) on first use. File format — the SAME lines the CLI's fa_hub_client key files use — so a host may share one identity across surfaces when it wants to: ed25519:<seed b64>, x25519:<priv b64>, x25519pub:<pub b64>. The pub line is informational; keys are always re-derived from the private scalars, so a torn legacy write can never pair a mismatched pub.
loadProjectCompactionEngine(String projectDir) CompactionEngine?
Loads the PROJECT-level compaction: section from <projectDir>/.fah/config.yaml — the engine choice travels with the repo (issue #148). Project wins over the user-level compaction: section; the runtime flag wins over both. Null when the file or the section is absent/unreadable; a present-but-invalid section throws ConfigException (strict, like the user config).
loadProjectCubeSettings(String projectDir) CubeSettings?
Loads the PROJECT-level cube: section from <projectDir>/.fah/config.yaml — the git-backed sandbox default travels with the repo. Project wins over the user-level cube: section. Null when the file or the section is absent/unreadable; a present-but-invalid section throws ConfigException (strict, like the user config).
loadProjectMemoryConfig(String projectDir) MemoryConfig?
Loads the PROJECT-level memory: section from <projectDir>/.fah/config.yaml — the git-backed memory path travels with the repo (anyone cloning gets the pointer AND the memory). Project wins over the user-level memory: section. Null when the file or the section is absent/unreadable; a present-but-invalid section throws ConfigException (strict, like the user config).
loadProjectSpillsConfig(String projectDir) SpillsConfig?
Loads the PROJECT-level spills: section from <projectDir>/.fah/config.yaml (issue #678) — the spilling thresholds travel with the repo. Project wins wholesale over the user-level spills: section, like the other project sections. Null when the file or the section is absent; the parse itself is tolerant, so this never throws.
loadProjectToolsConfig(String projectDir) ToolsConfig?
Loads the PROJECT-level tools: section from <projectDir>/.fah/config.yaml — the git-backed availability policy travels with the repo. The PROJECT scope is consumed live (separate from the saved global CliConfig.tools) so the deepest-wins resolution can stack both. Null when the file or the section is absent/unreadable; a present-but-invalid section throws ConfigException (strict, like the user config).
loadProjectWireDump(String projectDir) bool?
Loads the PROJECT-level trajectory: section's wireDump flag from <projectDir>/.fah/config.yaml (issue #385). Null when the file or the section is absent/unreadable; a present-but-invalid section throws ConfigException (strict, like the user config).
loadPromptFile(String path, {required String homeDir, required String baseDir, required String source}) String
Reads path as a prompt override file: YAML frontmatter is stripped (so the prompts/** Markdown sources can be copied verbatim as overrides) and the body is trimmed. A missing or unreadable file throws ConfigException prefixed with source — never a silent fallback.
loadPromptOverrideSource(String value, {required String homeDir, required String baseDir, required String source}) String
Resolves one raw override value: a file path per looksLikePromptFilePath (read via loadPromptFile), anything else is inline text (trimmed).
logFileFromEnv(Map<String, String> env) String?
The FA_LOG_FILE env twin of the --log-file flag: the tee path, for Docker/headless hosts that cannot pass flags. Absent or blank yields null (no tee intent); the flag wins when both are set.
looksLikePromptFilePath(String value) bool
Whether a raw prompts: value names a file rather than inline text: an absolute path, a ~/ home-relative path, a .//../ relative path, or anything ending in a Markdown/text extension (.md, .markdown, .txt).
openBrowser(String url) Future<bool>
Opens url in the user's default browser.
optionalProviderApiKey(String provider, SecureKeyCache keys, {String? baseUrl, Iterable<String>? scopedKeyNames, Map<String, String>? env}) String?
Resolves provider's API key headlessly. On the catalog spec's DEFAULT endpoint: a genuine environment value of the catalog env names, then endpoint-scoped secure-store entries (FA_KEY_<HOST> — what /provider writes — plus any saved custom entry's name-scoped key for this endpoint), then legacy env-name store entries from older versions. On ANY OTHER endpoint only the endpoint-scoped entries resolve — the catalog env names describe the default endpoint and must never hijack a custom one (issue #40: the user's OPENROUTER_API_KEY environment key silently serving api.z.ai), mirroring the shared resolveEndpointKey chain. env overrides Platform.environment (tests).
parseImagesSection(Object? node) ImageRegistryConfig?
Parses the images: section (session image registry, issue #171): registry (kill switch) and maxPerRequest (per-request unique-image cap). Strict — a bad schema throws ConfigException instead of silently keeping the defaults. Public so the settings flow's reload-after-write (issue #395) reuses the same parser the boot runs.
parseProviderTimeouts(Object? node) ProviderTimeoutsOverride?
Parses the providerTimeouts: section: provider watchdog overrides (see ProviderTimeoutsOverride). Strict — a bad schema throws ConfigException instead of silently keeping the defaults. Public so the settings-hub resilience flow (issue #393) reloads the saved section with the SAME parser boot uses.
parseSessionEntryLinesSync(SessionParseBatch batch) SessionParseResult
Parses one batch right here — the inline executor's body and the isolate worker's entry point share it, so both paths run the exact same parse code (issue #199 AC4 parity).
parseSessionLines(List<String> lines, {required String filePath, required int firstLineNumber, SessionParseExecutor? executor, bool shallowGiantCustoms = false}) Future<List<SessionRecord?>>
Parses lines through executor — or inline, batch by batch, when it is null — and returns one slot per line in file order (null where a line was torn/foreign).
platformSecureKeyStore({SecureKeyRunner? runner, String? platform}) SecureKeyStore
Picks the SecureKeyStore for the host OS. platform and runner are test seams; production callers use the defaults.
probeHidShiftPolling({void entry(SendPort port)?, Duration timeout = const Duration(milliseconds: 300)}) Future<bool>
Runs entry in a fresh isolate and waits timeout for its completion ACK. True means the HID read COMPLETED in time — the session is healthy enough to poll; a hang (a GUI-less session where CoreGraphics blocks) answers false. Any message counts: the payload value is ignored. The isolate is killed unconditionally — a wedged probe never leaks.
readHubState(File file) HubState
Reads the hub state file; a missing or invalid file counts as empty.
readHubStateSecret(File file) String?
Reads just the master secret from a hub state file; a missing or invalid file counts as "no password".
relayAllowedOrigin(String? origin) String?
The CORS answer for a relay request's origin: the allowlisted taskpane origins get their origin echoed back (never * — a hostile page must not be able to read this proxy), anything else gets null (= no CORS headers = the browser blocks the read).
resolveEffectiveCliArgs(CliArgs parsed, CliConfig saved, {required Map<String, String> env}) → ({CliArgs args, EnvProviderPreconfig? faPreconfig, String provider})
Provider/model restoration. Precedence: an explicit --provider flag (full manual control, preconfigs disabled) > the FA_PROVIDER_* env declaration (faProviderPreconfig) > the saved provider: (the persisted /provider switch) > the parsed default. Only kinds the legacy single-model path can build are restored (chatgpt-codex keeps the openai-completions default; its OAuth flow re-establishes on demand).
resolveEnabledPlugins(List<String> argPlugins, Map<String, dynamic> config) Set<String>
'hub' ships default-on. A .fah/packages.yaml entry enables a plugin with a truthy value (inspect_image:/hub: {url: …}) and opts it OUT with a falsy one (hub: false, hub:) — so only the keys with truthy values join the enabled set.
resolveHeadlessPrompt({String? prompt, String? promptFile, List<String> positionals = const []}) String?
Resolves the headless prompt from parsed CLI arguments.
resolveHidShiftPressed({Map<String, String>? env, bool isMacOS = true, void probeEntry(SendPort port)?, Duration probeTimeout = const Duration(milliseconds: 300)}) Future<bool Function()?>
Resolves the TUI host callback for Shift detection: the HID poll when the session allows it, null when it must stay off — a non-macOS host, the kill switch, an SSH session, or a startup probe whose HID read failed to complete in time (issue #355). Wiring keys on probe COMPLETION, never on the live Shift value it happened to read. The probe runs ONCE, at startup, off the UI isolate; probeEntry is the test seam for the isolate payload.
resolveInteractiveFileReference(String text, {InputPromptFileFactory fileOf = _defaultFileOf}) String
Resolves an interactive prompt that starts with a pasted file path.
resolvePromptFilePath(String path, {required String homeDir, required String baseDir}) String
Expands a leading ~ to homeDir and resolves a relative path against baseDir.
resolvePromptOverrides(Map<String, String> raw, {required String homeDir, required String baseDir}) PromptOverrides
Resolves the validated raw prompts: map (see parsePromptOverrideMap) into a PromptOverrides. Relative file paths resolve against baseDir (the CLI passes the agent cwd); the system alias canonicalizes to cli/mode_code. Missing files throw ConfigException.
resolveProviderQueueAtBoot({required String projectDir, required String homeDir, Map<String, String>? env}) ProviderQueueResolution
Resolves the FA_PROVIDERS_QUEUE scope chain at boot: the env var, the project .fah/config.yaml providersQueue: section, the user ~/.fah/config.yaml one. Absent files/sections are not present; a present-but-invalid section throws ConfigException naming the file (strict, like every other config section).
resolveStartupCubeSource({String? flagConfigPath, String? flagName, CubeSettings? project, CubeSettings? user}) String?
The startup cube source (fa_cube): explicit flags win, then the project cube: section, then the user cube: section — each config section applies only when enabled. Null = start unsandboxed.
roleKeyNames(ModelRolesConfig rolesConfig) Set<String>
The explicit apiKeyNames referenced by a roles config (the secure-store preload set; the catalog names are always preloaded).
runAiinConnectCliFlow({required void onStatus(String), Future<bool> openBrowserFn(String) = openBrowser, Client? client, String authBaseUrl = aiinAuthBaseUrl, Duration timeout = const Duration(minutes: 5)}) Future<AiinConnectResult?>
Runs the full AIIN connect flow for CLI/desktop hosts:
runChatGptOAuthCliFlow({required void onStatus(String), Future<bool> openBrowserFn(String) = openBrowser, Future<ChatGptOAuthCredentials> exchangeFn({required String code, required String redirectUri, required String verifier}) = _defaultExchange, Duration timeout = const Duration(minutes: 5)}) Future<ChatGptOAuthCredentials?>
runCodeMieSsoCliFlow({required String codeMieUrl, required void onStatus(String), Future<bool> openBrowserFn(String) = openBrowser}) Future<CodeMieSsoCredentials?>
Runs the CodeMie SSO login: starts the callback server, opens (or prints) the organization's login URL, waits for the token, and decodes it into CodeMieSsoCredentials. Returns null on cancel/timeout/failure.
runOpenRouterOAuthCliFlow({required void onStatus(String), Future<bool> openBrowserFn(String url) = openBrowser, Future<OpenRouterOAuthKey> exchangeFn({required String code, required String codeVerifier, String? label}) = _defaultExchange, String keyLabel = openRouterDefaultKeyLabel}) Future<OpenRouterOAuthKey?>
Runs the full automatic OAuth flow for the CLI: starts a localhost server, opens the browser, waits for the callback, and exchanges the code.
saveCliConfig(String homeDir, CliConfig config) Future<void>
Saves CliConfig to ~/.fah/config.yaml.
saveHubIdentity(String path, HubIdentity identity) Future<void>
Saves identity's seeds to path (the host's rotation/rebind path).
secureKeyPreloadNames(CliConfig saved, {required String? baseUrl}) Set<String>
Every provider key name the startup snapshot must preload: each catalog provider's env names, the endpoint-scoped FA_KEY_<HOST> name for every catalog default endpoint plus the configured endpoint and every saved custom provider's, the two non-catalog media slots, and the explicit apiKeyNames referenced by the roles config.
splitServeA2aArgs(List<String> args) → ({List<String> cliArgs, bool serveA2a, bool serveBridge})
fa serve [--a2a|--bridge] [--port N] [--token T] interception: the args parser does not know the serve forms, so args is scanned for the bare serve invocation and the serve-specific flags (and their values) are stripped from the list that reaches parseCliArgs. Exactly one serve form must be selected: serveA2a/serveBridge say which, and the caller fails with the usage line when a serve invocation carries neither, both, or is missing its marker.
splitSessionParseBatches(List<String> lines, {required String filePath, required int firstLineNumber, bool shallowGiantCustoms = false}) List<SessionParseBatch>
Splits lines into bounded transfer batches. Issue #199 E5: an oversize line becomes its own batch, so one huge record can never stall a batch beyond its own parse.
startupApiKey(String provider, SecureKeyCache keys, {required String? baseUrl, required List<CustomProviderEntry> customProviders, required bool defaultRoleResolved, required bool interactive, Map<String, String>? env}) String
Headless startup API-key resolution: a base URL other than the catalog default (--base-url or config baseUrl) means a user-configured endpoint: local llama.cpp/Ollama/LM Studio servers need no key at all, so the key is optional there (the hosted presets keep requiring one; the config default IS the OpenRouter URL, so compare values, not nullness). Roles mode already tolerates a missing key; the openai-completions adapter omits the Authorization header entirely when the key is empty. The interactive REPL can start without a key: the user can switch providers, models, or base URLs with slash commands before the first run. Headless mode needs a key immediately because it performs a single run and exits.
toolsSpecFromEnv(Map<String, String> env) ToolsConfig?
Parses the FA_TOOLS env twin of the --tools flag: the same csv spec, for Docker/headless hosts that cannot pass flags. Absent or empty yields null (no runtime intent); a malformed value throws ConfigException naming the bad token.
webSearchSecrets({Map<String, String>? env}) InMemorySecretsStore
Web search works out of the box via keyless DuckDuckGo; keyed providers (Brave, Tavily) join the chain when their API key is in the environment.
writeHubState(File file, {required String? masterSecret, required Map<String, String> clients}) Future<void>
Persists {masterSecret, clients} (0600 — the file carries secrets). Best-effort: IO failures never take the hub down.

Typedefs

HubAuthVerdict = ({bool allowed, bool isMaster})
The upgrade auth verdict: allowed = the presented credential may connect at all; isMaster = it is the hub password itself (enroll is master-only on a protected hub). An open hub (no master secret) allows everything.
HubState = ({Map<String, String> clients, String? masterSecret})
The parsed hub state file (~/.dap/hub.json): the hub password and the enrolled per-client secrets.
InputPromptFileFactory = File Function(String path)
Creates the File probed by resolveInteractiveFileReference.
PowerProcessLauncher = Future<Process> Function(String executable, List<String> arguments)
Spawns a helper process; Process.start in production, a fake in tests.
SecureKeyRunner = Future<SecureKeyRunResult> Function(String executable, List<String> arguments, {Map<String, String>? environment, String? stdin})
Runs one helper process for a SecureKeyStore backend, optionally piping stdin and extending the child environment. Injectable so tests never spawn real processes.