diff --git a/PLAN.md b/PLAN.md index 5e07d0f..43c3e66 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1875,6 +1875,8 @@ The first Phase 6 checkpoint implements AArch64 Linux `execve` as atomic process The next Phase 6 compatibility checkpoint is driven by an ignored, checksum-verified official AArch64 Linux Zig 0.16.0 bundle as a compact LLVM/LLD runtime probe; it is not the product's promised Clang distribution. `zig version` now completes, and `zig cc` discovers its installed executable/library tree, creates explicit project-local caches, and advances through 20 million guest instructions without a syscall or semantic trap before reaching the configured instruction limit. The observed path added scalar binary32/binary64 conversion, NEON `rev32`, Linux `O_PATH`/`O_NOCTTY`, directory-descriptor-relative `*at` path resolution, repeatable initial environment entries in the native CLI, and correct `ENOENT` versus `ENOTDIR` handling for missing filesystem parents. This measurement makes performance—not another guessed ABI surface—the current boundary. The next implementation step is to persist intermediate compiler snapshots and exercise the same workload through the browser's translated tier while pruning the eventual Clang/LLD/musl image. +Restartable filesystem checkpoints now cover the compiler workload in both hosts. The native CLI accepts `--filesystem-output` and exports completed project mutations even after instruction exhaustion; a regression stops a writer after 25 instructions and successfully consumes the emitted snapshot in a second process. Browser-uploaded programs accept explicit environment entries, large image imports transfer their buffers into the Worker, and an opt-in real Chromium regression imports the ignored 330 MB Zig image, completes `zig version` within three million instructions with translated blocks, drives `zig cc` to a five-million-instruction boundary, and exports a larger post-run snapshot. The measured browser probe completes in 2.2 minutes. This is filesystem/cache continuation, not suspended process-memory continuation. The remaining Phase 6 work is to prune and package the promised Clang/LLD/musl distribution, decide whether subprocess semantics beyond `execve` are required by its trace, and drive a source-to-runnable-ELF compile through completion. + Do not begin the full web IDE before item 30 passes. --- diff --git a/README.md b/README.md index 55c04b9..3a43d2d 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,11 @@ Pass repeatable `--env NAME=VALUE` options before the executable when a guest runtime needs an explicit environment; no host environment variables are inherited implicitly. +Use `--filesystem-output ` to persist the final `/project` state. +The CLI writes this snapshot after execution stops even when a resource limit +produces a diagnostic, so bounded compiler runs can retain their cache and +other completed filesystem mutations for a later run. + The runnable instruction/syscall profile remains fixture-driven. The current static Rust program adds single-thread atomics and barriers, 128-bit vector moves/stores, byte popcount and reduction, multiplication/division, and signed shifts to the earlier libc instruction path. The runtime implements `openat`, `close`, `lseek`, file `read`/`write`, polling, deterministic process/signal setup, static-ELF `execve`, anonymous memory management, terminal output, and exit. Invalid guest arguments return Linux errno values; instruction, syscall, committed-memory, ephemeral-filesystem, open-file, and combined-output limits are enforced before host side effects. The native CPython checkpoint is reproducible without committing its large diff --git a/crates/browser-runtime/src/lib.rs b/crates/browser-runtime/src/lib.rs index 3e425ff..edbd71f 100644 --- a/crates/browser-runtime/src/lib.rs +++ b/crates/browser-runtime/src/lib.rs @@ -607,6 +607,12 @@ pub struct BrowserGuestSession { finished: bool, } +struct GuestProgram<'a> { + elf: &'a [u8], + arguments: Vec>, + environment: Vec>, +} + #[wasm_bindgen] impl BrowserGuestSession { /// Continue execution with optional terminal bytes or an end-of-file marker. @@ -727,7 +733,11 @@ pub fn start_fixture_with_hot_threshold( hot_block_threshold: u64, ) -> BrowserGuestSession { let request = fixture(fixture_name) - .map(|(elf, argv0)| (elf, vec![argv0.to_vec()])) + .map(|(elf, argv0)| GuestProgram { + elf, + arguments: vec![argv0.to_vec()], + environment: Vec::new(), + }) .ok_or_else(|| { BrowserExecution::diagnostic( "request.unknown_fixture", @@ -756,6 +766,7 @@ pub fn start_program( elf: &[u8], argv0: &str, nul_separated_arguments: &str, + nul_separated_environment: &str, instruction_budget: u64, syscall_budget: u64, max_output_bytes: u64, @@ -764,7 +775,13 @@ pub fn start_program( filesystem_snapshot: &[u8], ) -> BrowserGuestSession { let request = program_arguments(argv0, nul_separated_arguments) - .map(|arguments| (elf, arguments)) + .and_then(|arguments| { + program_environment(nul_separated_environment).map(|environment| GuestProgram { + elf, + arguments, + environment, + }) + }) .map_err(|message| BrowserExecution::diagnostic("request.invalid_arguments", message)); start_guest_session( request, @@ -780,6 +797,24 @@ pub fn start_program( ) } +fn program_environment(nul_separated_environment: &str) -> Result>, String> { + if nul_separated_environment.is_empty() { + return Ok(Vec::new()); + } + nul_separated_environment + .split('\0') + .map(|entry| { + if entry + .split_once('=') + .is_none_or(|(name, _)| name.is_empty()) + { + return Err("environment entries must be name=value".to_owned()); + } + Ok(entry.as_bytes().to_vec()) + }) + .collect() +} + fn program_arguments(argv0: &str, nul_separated_arguments: &str) -> Result>, String> { if argv0.is_empty() || argv0.contains('\0') { return Err("argv[0] must be non-empty and must not contain a NUL byte".to_owned()); @@ -812,7 +847,7 @@ fn session_config( } fn start_guest_session( - request: Result<(&[u8], Vec>), BrowserExecution>, + request: Result, BrowserExecution>, config: ProcessConfig, filesystem_snapshot: &[u8], hot_block_threshold: u64, @@ -824,12 +859,12 @@ fn start_guest_session( MemoryFileSystem::from_snapshot(max_filesystem_bytes, filesystem_snapshot) }; let process = request - .and_then(|(elf, argv)| { + .and_then(|program| { load_process( - elf, + program.elf, &ProcessParameters { - argv, - envp: Vec::new(), + argv: program.arguments, + envp: program.environment, random_bytes: [0x42; 16], credentials: Credentials::default(), }, @@ -1214,8 +1249,8 @@ mod tests { use super::{ BrowserBlockExecutor, COMPILER_HELLO_ELF, Credentials, EXECVE_TOOLCHAIN_IMAGE, Interpreter, - ProcessConfig, ProcessParameters, execute_fixture, fixture, load_process, start_fixture, - start_program, translate_fixture_entry_inner, + ProcessConfig, ProcessParameters, execute_fixture, fixture, load_process, + program_environment, start_fixture, start_program, translate_fixture_entry_inner, }; const DEFAULT_INSTRUCTIONS: u64 = 10_000_000; @@ -1437,6 +1472,7 @@ mod tests { COMPILER_HELLO_ELF, "/uploaded-compiler-hello", "first\0second argument", + "BINARROW_PHASE=6\0CACHE=/project/.cache", DEFAULT_INSTRUCTIONS, DEFAULT_SYSCALLS, DEFAULT_OUTPUT, @@ -1459,6 +1495,7 @@ mod tests { COMPILER_HELLO_ELF, "", "", + "", DEFAULT_INSTRUCTIONS, DEFAULT_SYSCALLS, DEFAULT_OUTPUT, @@ -1472,6 +1509,41 @@ mod tests { assert_eq!(completed.diagnostic_code, "request.invalid_arguments"); } + #[test] + fn external_program_rejects_an_invalid_environment_entry() { + let mut session = start_program( + COMPILER_HELLO_ELF, + "/uploaded-compiler-hello", + "", + "MISSING_VALUE_SEPARATOR", + DEFAULT_INSTRUCTIONS, + DEFAULT_SYSCALLS, + DEFAULT_OUTPUT, + DEFAULT_MEMORY, + DEFAULT_FILESYSTEM, + &[], + ); + + let completed = session.resume(&[], false); + assert_eq!(completed.outcome, "diagnostic"); + assert_eq!(completed.diagnostic_code, "request.invalid_arguments"); + assert_eq!( + completed.diagnostic_message, + "environment entries must be name=value" + ); + } + + #[test] + fn parses_external_program_environment_entries() { + assert_eq!( + program_environment("BINARROW_PHASE=6\0CACHE=/project/.cache"), + Ok(vec![ + b"BINARROW_PHASE=6".to_vec(), + b"CACHE=/project/.cache".to_vec(), + ]) + ); + } + #[test] fn translates_a_real_state_mutating_loop_entry_block() { let (elf, argv0) = fixture("translated-loop").unwrap(); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 5231dae..53ef1cf 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -15,12 +15,13 @@ use binarrow_linux_runtime::Process; use binarrow_loader::{Credentials, ProcessConfig, ProcessParameters, load_process}; use binarrow_memory_fs::MemoryFileSystem; -const USAGE: &str = "usage: binarrow inspect \n binarrow run [run options] [guest arguments...]\n binarrow trace [run options] [guest arguments...]\n binarrow image pack [--guest-root ] \n\nrun options:\n --instruction-budget \n --filesystem-limit \n --filesystem-snapshot \n --filesystem-install (repeatable)\n --random-seed \n --argv0 \n --env (repeatable)"; +const USAGE: &str = "usage: binarrow inspect \n binarrow run [run options] [guest arguments...]\n binarrow trace [run options] [guest arguments...]\n binarrow image pack [--guest-root ] \n\nrun options:\n --instruction-budget \n --filesystem-limit \n --filesystem-snapshot \n --filesystem-output \n --filesystem-install (repeatable)\n --random-seed \n --argv0 \n --env (repeatable)"; struct RunOptions { instruction_budget: Option, filesystem_limit: Option, filesystem_snapshot: Option, + filesystem_output: Option, filesystem_installs: Vec, random_seed: Option, argv0: Option, @@ -69,6 +70,7 @@ fn parse_run_options(arguments: impl Iterator) -> Result) -> Result { filesystem_snapshot = Some(PathBuf::from(arguments.next().ok_or(USAGE)?)); } + Some("--filesystem-output") => { + filesystem_output = Some(PathBuf::from(arguments.next().ok_or(USAGE)?)); + } Some("--filesystem-install") => { filesystem_installs.push(PathBuf::from(arguments.next().ok_or(USAGE)?)); } @@ -123,6 +128,7 @@ fn parse_run_options(arguments: impl Iterator) -> Result Result { .map_err(|error| format!("could not write trace: {error}"))?; } } + if let Some(path) = options.filesystem_output { + let snapshot = filesystem + .export_snapshot() + .map_err(|error| format!("could not encode filesystem snapshot: {error}"))?; + fs::write(&path, snapshot) + .map_err(|error| format!("could not write {}: {error}", path.display()))?; + } let result = execution.map_err(|error| { let mut recent_pcs = Vec::new(); for address in process.recent_instruction_pcs() { diff --git a/crates/cli/tests/run.rs b/crates/cli/tests/run.rs index 811e627..96780c2 100644 --- a/crates/cli/tests/run.rs +++ b/crates/cli/tests/run.rs @@ -112,6 +112,46 @@ fn run_command_installs_project_images_over_a_base_snapshot() { fs::remove_file(image_path).unwrap(); } +#[test] +fn run_command_exports_project_mutations_after_instruction_exhaustion() { + let fixture_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../guest-tests/project-persistence"); + let writer = fixture_directory.join("project-persistence-write.aarch64.elf"); + let reader = fixture_directory.join("project-persistence-read.aarch64.elf"); + let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.tmp/tests"); + fs::create_dir_all(&directory).expect("workspace test directory should be writable"); + let snapshot_path = directory.join(format!( + "output-{}-{}.bnfs", + std::process::id(), + std::thread::current().name().unwrap_or("test") + )); + + let write = Command::new(env!("CARGO_BIN_EXE_binarrow")) + .arg("run") + .arg("--instruction-budget") + .arg("25") + .arg("--filesystem-output") + .arg(&snapshot_path) + .arg(writer) + .output() + .expect("binarrow should start"); + assert_eq!(write.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&write.stderr).contains("ResourceLimit(Instructions)")); + + let read = Command::new(env!("CARGO_BIN_EXE_binarrow")) + .arg("run") + .arg("--filesystem-snapshot") + .arg(&snapshot_path) + .arg(reader) + .output() + .expect("binarrow should start"); + + assert_eq!(read.status.code(), Some(0)); + assert_eq!(read.stdout, b"persistent project data\n"); + assert!(read.stderr.is_empty()); + fs::remove_file(snapshot_path).unwrap(); +} + #[test] fn run_command_replaces_the_guest_from_an_installed_image() { let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/docs/architecture.md b/docs/architecture.md index e088d04..2978414 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ Consumes a loaded process image, owns its architectural execution state and desc ### `binarrow-browser-runtime` -Provides the narrow wasm-bindgen boundary used by the Worker. It embeds deterministic C, Rust, process-replacement, and non-terminating fixtures, applies browser-supplied resource limits, invokes the production loader/runtime, captures the terminal host trait, and exposes output, exit status, counters, trace events, translation metrics, and stable structured diagnostics. The process-replacement fixture loads its second ELF from the same imported OPFS-backed snapshot path used by ordinary project files. Its Tier-1 executor compiles supported hot blocks, caches modules by guest address and instruction encodings, installs their exports in a bounded Worker-local WebAssembly function table, exchanges scalar architectural state through the backend ABI, and invokes the selected entry synchronously. The TypeScript controller starts one run at a time and implements unconditional cancellation by terminating and recreating the Worker; no AArch64 or Linux behavior is reimplemented in TypeScript. +Provides the narrow wasm-bindgen boundary used by the Worker. It embeds deterministic C, Rust, process-replacement, and non-terminating fixtures, validates uploaded ELF bytes plus explicit arguments and environment entries, applies browser-supplied resource limits, invokes the production loader/runtime, captures the terminal host trait, and exposes output, exit status, counters, trace events, translation metrics, stable structured diagnostics, and the mutated filesystem snapshot. The process-replacement fixture loads its second ELF from the same imported OPFS-backed snapshot path used by ordinary project files. The Worker persists returned snapshots after exits, input suspension, and diagnostics, so bounded compiler runs retain completed project and cache mutations after their process state is discarded. Its Tier-1 executor compiles supported hot blocks, caches modules by guest address and instruction encodings, installs their exports in a bounded Worker-local WebAssembly function table, exchanges scalar architectural state through the backend ABI, and invokes the selected entry synchronously. The TypeScript controller starts one run at a time and implements unconditional cancellation by terminating and recreating the Worker; no AArch64 or Linux behavior is reimplemented in TypeScript. ### `binarrow-wasm-backend` @@ -84,7 +84,7 @@ Consumes validated project-owned basic blocks and emits deterministic core WebAs ### `binarrow-cli` -Owns native filesystem/terminal access and stable human-readable rendering. Its `inspect` command feeds bytes into `binarrow-elf`; its `run` command constructs the process, accepts repeatable filesystem overlays and `name=value` guest environment entries, and supplies the native terminal implementation while loader, CPU, and Linux behavior stay in their respective crates. +Owns native filesystem/terminal access and stable human-readable rendering. Its `inspect` command feeds bytes into `binarrow-elf`; its `run` command constructs the process, accepts repeatable filesystem overlays and `name=value` guest environment entries, optionally exports the mutated project snapshot even after an execution diagnostic, and supplies the native terminal implementation while loader, CPU, and Linux behavior stay in their respective crates. ### `binarrow-wasm-probe` @@ -107,3 +107,5 @@ Phase 5 is complete. A shared native/browser CPython regression independently in Phase 6 is in progress. Its first checkpoint adds static `execve` replacement with bounded guest-vector ingestion and Linux-compatible close-on-exec handling. A checked-in launcher, child ELF, and installable filesystem image exercise the same path in native unit tests, the CLI, the browser-runtime native host, and Chromium. This supplies the first compiler-orchestration primitive without pretending to provide concurrent processes, pipes, or a complete toolchain yet. An official static AArch64 Linux Zig 0.16.0 distribution serves as an ignored LLVM/LLD compatibility probe while the final Clang package is selected and pruned. It now completes its version path and advances `zig cc` through a bounded 20-million-instruction startup/compilation run using project-local caches. The trace-derived additions are floating-point width conversion, NEON `rev32`, directory-relative `*at` operations, path-only descriptors, initial guest environment entries, and precise missing-parent errno behavior. The probe bundle and caches remain under `.tmp`; they are evidence and test input, not a shipped replacement for Clang/LLD. + +The compiler probe now crosses the browser boundary as an opt-in Playwright regression. Chromium imports the ignored 330 MB toolchain image without an extra structured-clone copy, completes `zig version` within a three-million-instruction budget with translated blocks, advances the real `zig cc` command to a five-million-instruction diagnostic boundary, and exports a larger snapshot containing the resulting cache/project state. The complete probe took 2.2 minutes in the measured Chromium run. The native CLI's matching `--filesystem-output` path is regression-tested after instruction exhaustion. This establishes restartable filesystem checkpoints in both hosts; process-memory continuation is intentionally not implied. diff --git a/web/index.html b/web/index.html index f09df7d..7e6c97b 100644 --- a/web/index.html +++ b/web/index.html @@ -54,6 +54,12 @@ rows="5" placeholder="-S -E -c print(6 * 7)" > + + diff --git a/web/src/main.ts b/web/src/main.ts index 8391e75..0586d81 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -53,6 +53,9 @@ const programControls = requiredElement("#program-controls" const programElf = requiredElement("#program-elf"); const programArgv0 = requiredElement("#program-argv0"); const programArguments = requiredElement("#program-arguments"); +const programEnvironment = requiredElement( + "#program-environment", +); const instructionBudget = requiredElement("#instruction-budget"); const syscallBudget = requiredElement("#syscall-budget"); const outputLimit = requiredElement("#output-limit"); @@ -84,6 +87,7 @@ function setControls(): void { programElf.disabled = busy; programArgv0.disabled = busy; programArguments.disabled = busy; + programEnvironment.disabled = busy; terminalInput.disabled = !waitingInput; sendInputButton.disabled = !waitingInput; projectImage.disabled = !workerReady || busy; @@ -300,12 +304,23 @@ async function createRunCommand( if (arguments_.some((argument) => argument.includes("\0"))) { throw new Error("arguments must not contain NUL bytes"); } + const environment = programEnvironment.value + .split(/\r?\n/) + .filter((entry) => entry.length > 0); + if ( + environment.some( + (entry) => entry.includes("\0") || !/^[^=]+=/.test(entry), + ) + ) { + throw new Error("environment entries must be name=value without NUL bytes"); + } return { kind: "run-program", runId: nextRunId, executable: new Uint8Array(await file.arrayBuffer()), argv0: programArgv0.value, arguments: arguments_, + environment, limits, }; } @@ -408,7 +423,7 @@ async function importProjectImage(mode: "replace" | "install"): Promise { mode, snapshot: new Uint8Array(await file.arrayBuffer()), }; - worker.postMessage(command); + worker.postMessage(command, [command.snapshot.buffer as ArrayBuffer]); } catch (error) { imageOperationPending = false; status.textContent = diff --git a/web/src/probe.ts b/web/src/probe.ts index e994ae9..dbb2e59 100644 --- a/web/src/probe.ts +++ b/web/src/probe.ts @@ -92,6 +92,7 @@ export type WorkerCommand = executable: Uint8Array; argv0: string; arguments: string[]; + environment: string[]; limits: ExecutionLimits; } | { diff --git a/web/src/probe.worker.ts b/web/src/probe.worker.ts index 0e7f1cc..7277ab3 100644 --- a/web/src/probe.worker.ts +++ b/web/src/probe.worker.ts @@ -299,6 +299,7 @@ async function execute( command.executable, command.argv0, command.arguments.join("\0"), + command.environment.join("\0"), command.limits.instructionBudget, command.limits.syscallBudget, command.limits.maxOutputBytes, diff --git a/web/tests/compiler-toolchain.spec.ts b/web/tests/compiler-toolchain.spec.ts new file mode 100644 index 0000000..2d51e08 --- /dev/null +++ b/web/tests/compiler-toolchain.spec.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; + +import { expect, test } from "@playwright/test"; + +const runtime = process.env.BINARROW_COMPILER_RUNTIME; +const toolchainImage = process.env.BINARROW_COMPILER_TOOLCHAIN_IMAGE; +const projectImage = process.env.BINARROW_COMPILER_PROJECT_IMAGE; + +test("runs and checkpoints the packaged compiler workload", async ({ page }) => { + test.skip( + !runtime || !toolchainImage || !projectImage, + "set all BINARROW_COMPILER runtime and image variables", + ); + if (!runtime || !toolchainImage || !projectImage) { + return; + } + test.setTimeout(20 * 60 * 1_000); + + await page.goto("/"); + await expect(page.getByRole("status")).toHaveText("Browser runtime ready"); + await page.getByLabel("Filesystem byte limit").fill("536870912"); + await page.getByLabel("Memory byte limit").fill("1073741824"); + await page + .getByLabel("Snapshot or package image") + .setInputFiles(toolchainImage); + await page.getByRole("button", { name: "Replace project" }).click(); + await expect(page.getByRole("status")).toHaveText( + "Project snapshot imported", + { timeout: 3 * 60 * 1_000 }, + ); + + await page.getByLabel("Fixture").selectOption("uploaded-program"); + await page.getByLabel("AArch64 ELF executable").setInputFiles(runtime); + await page.getByLabel("argv[0]").fill("/project/toolchain/zig"); + await page.getByLabel("Arguments (one per line)").fill("version"); + await page + .getByLabel("Environment (one name=value per line)") + .fill( + "ZIG_GLOBAL_CACHE_DIR=/project/.zig-global-cache\n" + + "ZIG_LOCAL_CACHE_DIR=/project/.zig-cache", + ); + await page.getByLabel("Instruction budget").fill("3000000"); + await page.getByRole("button", { name: "Start" }).click(); + + await expect(page.getByRole("status")).toHaveText("Guest exited", { + timeout: 10 * 60 * 1_000, + }); + await expect(page.getByLabel("Guest terminal output")).toContainText("0.16.0"); + const versionTranslated = await page + .locator("#translated-block-executions") + .textContent(); + expect(BigInt(versionTranslated ?? "0")).toBeGreaterThan(0n); + + await page.getByLabel("Snapshot or package image").setInputFiles(projectImage); + await page.getByRole("button", { name: "Install image" }).click(); + await expect(page.getByRole("status")).toHaveText("Project image installed", { + timeout: 3 * 60 * 1_000, + }); + await page.getByLabel("AArch64 ELF executable").setInputFiles(runtime); + await page + .getByLabel("Arguments (one per line)") + .fill( + "cc\n-target\naarch64-linux-musl\n-static\n-O0\n-o\n/project/hello\n/project/main.c", + ); + await page.getByLabel("Instruction budget").fill("5000000"); + await page.getByRole("button", { name: "Start" }).click(); + + await expect(page.locator("#diagnostic-code")).toHaveText( + "resource.instructions", + { timeout: 10 * 60 * 1_000 }, + ); + await expect(page.locator("#instruction-count")).toHaveText("5000000"); + const compilerTranslated = await page + .locator("#translated-block-executions") + .textContent(); + expect(BigInt(compilerTranslated ?? "0")).toBeGreaterThan(0n); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export project" }).click(); + const download = await downloadPromise; + const exportedPath = await download.path(); + expect(exportedPath).not.toBeNull(); + if (exportedPath) { + const inputBytes = + fs.statSync(toolchainImage).size + fs.statSync(projectImage).size; + expect(fs.statSync(exportedPath).size).toBeGreaterThan(inputBytes); + } +}); diff --git a/web/tests/probe.spec.ts b/web/tests/probe.spec.ts index 2d5e87a..3a53f3e 100644 --- a/web/tests/probe.spec.ts +++ b/web/tests/probe.spec.ts @@ -65,6 +65,9 @@ test("runs an uploaded AArch64 ELF in the Worker", async ({ page }) => { await page.getByLabel("Arguments (one per line)").fill( "first\nsecond argument", ); + await page + .getByLabel("Environment (one name=value per line)") + .fill("BINARROW_PHASE=6\nCACHE=/project/.cache"); await page.getByRole("button", { name: "Start" }).click(); await expect(page.getByRole("status")).toHaveText("Guest exited");