From 3ac7e68c41a82586869c38df5835ffe06f3594cc Mon Sep 17 00:00:00 2001 From: Long Ho Date: Sun, 13 Sep 2026 18:40:32 +0000 Subject: [PATCH 1/4] test: prototype declared Chromium execution with actiond --- .github/workflows/actiond-prototype.yaml | 67 +++++++++++++++++++++++ docs/actiond-evaluation.md | 70 ++++++++++++++++++++++++ experiments/actiond/BUILD.bazel.template | 14 +++++ experiments/actiond/README.md | 66 ++++++++++++++++++++++ experiments/actiond/capture.bzl | 31 +++++++++++ experiments/actiond/capture.mjs | 48 ++++++++++++++++ experiments/actiond/package-runtime.sh | 28 ++++++++++ experiments/actiond/patch-interpreter.py | 29 ++++++++++ experiments/actiond/prepare.sh | 39 +++++++++++++ experiments/actiond/run-actiond.sh | 14 +++++ experiments/actiond/run-sandbox.sh | 24 ++++++++ experiments/actiond/sandbox-smoke.zig | 40 ++++++++++++++ 12 files changed, 470 insertions(+) create mode 100644 .github/workflows/actiond-prototype.yaml create mode 100644 docs/actiond-evaluation.md create mode 100644 experiments/actiond/BUILD.bazel.template create mode 100644 experiments/actiond/README.md create mode 100644 experiments/actiond/capture.bzl create mode 100644 experiments/actiond/capture.mjs create mode 100755 experiments/actiond/package-runtime.sh create mode 100644 experiments/actiond/patch-interpreter.py create mode 100755 experiments/actiond/prepare.sh create mode 100755 experiments/actiond/run-actiond.sh create mode 100755 experiments/actiond/run-sandbox.sh create mode 100644 experiments/actiond/sandbox-smoke.zig diff --git a/.github/workflows/actiond-prototype.yaml b/.github/workflows/actiond-prototype.yaml new file mode 100644 index 0000000..90a1cfa --- /dev/null +++ b/.github/workflows/actiond-prototype.yaml @@ -0,0 +1,67 @@ +name: actiond prototype + +on: + pull_request: + paths: + - 'experiments/actiond/**' + - '.github/workflows/actiond-prototype.yaml' + +permissions: + contents: read + +jobs: + chromium: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + USE_BAZEL_VERSION: '9.2.0' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '24' + - uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0 + with: + bazelisk-cache: true + repository-cache: true + - name: Prepare declared browser runtime + run: | + corepack enable pnpm + pnpm install --frozen-lockfile + docker pull --platform linux/amd64 mcr.microsoft.com/playwright:v1.63.0-noble@sha256:bc6ab0d6d44ff4826e4cb8c1e6d801e185bfc42bb0753f8e2a30efc70db054c7 + bash experiments/actiond/prepare.sh "$RUNNER_TEMP/actiond-prototype" + - name: Enable KVM and vhost-vsock + run: | + test -c /dev/kvm + sudo chmod a+rw /dev/kvm + if [[ ! -e /dev/vhost-vsock ]]; then sudo modprobe vhost_vsock; fi + test -c /dev/vhost-vsock + sudo chmod a+rw /dev/vhost-vsock + - name: Capture inside the actiond VM + env: + ACTIOND_BAZEL: bazelisk + run: | + work="$RUNNER_TEMP/actiond-prototype" + curl -fsSL https://github.com/hermeticbuild/actiond/releases/download/v0.0.6/linux-actiond_linux_x86_64 -o "$work/actiond-worker" + printf '006dc798d4363596fe8ab997606fc93766a0cc427c2d005cf4fc1765fa4c2052 %s\n' "$work/actiond-worker" | sha256sum -c - + chmod +x "$work/actiond-worker" + "$work/actiond-worker" serve-vm --root="$work/vm" --listen=127.0.0.1:8980 --memory-mib=3072 --cpus=2 --cas-image-size-mib=4096 > "$work/vm.log" 2>&1 & + worker_pid=$! + trap 'kill "$worker_pid" 2>/dev/null || true' EXIT + ready=false + for attempt in $(seq 1 90); do + kill -0 "$worker_pid" + if (echo > /dev/tcp/127.0.0.1/8980) 2>/dev/null; then ready=true; break; fi + sleep 1 + done + "$ready" + bash experiments/actiond/run-actiond.sh "$work" grpc://127.0.0.1:8980 + sha256sum "$work/results/first.png" "$work/results/second.png" + cmp "$work/results/first.png" "$work/results/second.png" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: actiond-chromium-prototype + path: | + ${{ runner.temp }}/actiond-prototype/results/ + ${{ runner.temp }}/actiond-prototype/vm.log diff --git a/docs/actiond-evaluation.md b/docs/actiond-evaluation.md new file mode 100644 index 0000000..28acbfe --- /dev/null +++ b/docs/actiond-evaluation.md @@ -0,0 +1,70 @@ +# actiond evaluation for VRT + +Source review on 2026-09-13 at upstream commit +[`8a42c3d`](https://github.com/hermeticbuild/actiond/tree/8a42c3d481df3a1bf1b80e95a9bb991a207fc035). +The initial review below is followed by a [runnable prototype](../experiments/actiond/README.md). +The prototype subsequently rendered under actiond's unmodified process runner; +this local environment has no `/dev/kvm`, so that result excludes the VM/CAS path. + +## Assessment + +Promising optional execution backend; not a drop-in replacement for the current +Testcontainers VRT implementation. Keep the current backend for AGI and FormatJS +while evaluating a general remote-execution-compatible VRT path. + +## What improves + +The entire test action could execute inside Linux: Node, fixture server, browser, +and comparison. actiond provides a Bazel REAPI executor/cache, immutable declared +inputs, a VM boundary, and per-action loopback-only networking. The VM has no +external network device. This would constrain consumer Node code as well as the +browser and remove Docker/Ryuk from that execution path. These are architectural +benefits, not a tested Chromium integration. +[Architecture](https://github.com/hermeticbuild/actiond/blob/8a42c3d481df3a1bf1b80e95a9bb991a207fc035/ARCHITECTURE.md) + +## Integration gaps + +- **Architecture:** Apple Silicon gets an ARM64 Linux guest; Windows/Linux guests + match their host architecture. Our current VRT browser uses Linux amd64 on all + hosts. Linux alone does not establish pixel equivalence between ARM64 and amd64; + a common architecture or verified separate baseline policy remains necessary. + [VM topology](https://github.com/hermeticbuild/actiond/blob/8a42c3d481df3a1bf1b80e95a9bb991a207fc035/ARCHITECTURE.md#topology) +- **Browser provisioning:** packaged runtimes contain selected glibc versions and + Bash. The executor selects those runtimes, rather than a caller-owned OCI image. + Chromium, additional shared libraries, fonts, and font configuration would need + declared packaging and a suitable launcher, or new runtime support. + [Runtime definitions](https://github.com/hermeticbuild/actiond/blob/8a42c3d481df3a1bf1b80e95a9bb991a207fc035/runtimes/BUILD.bazel), + [executor](https://github.com/hermeticbuild/actiond/blob/8a42c3d481df3a1bf1b80e95a9bb991a207fc035/src/action_executor.zig#L305-L331) +- **Rule changes:** our browser tests carry `no-remote` and `no-cache`; VRT starts + Testcontainers unconditionally. An executor backend must launch a declared + browser within the action and run the fixture server in that same action. + [Current rules](../internal/browser.bzl), [runner](../runtime/runner.ts) +- **Baseline updates:** `.update` currently copies into `BUILD_WORKSPACE_DIRECTORY`. + Remote capture must instead produce downloadable outputs, with a small local + wrapper applying them to the source workspace. Merely enabling remote execution + does not move the final `bazel run` executable into the executor. + [Baseline handling](../runtime/baselines.ts), [runner](../runtime/runner.ts) +- **Chromium compatibility:** the action sandbox rejects installing additional + seccomp filters. Chromium sandbox behavior needs a smoke test; do not assume its + normal sandbox works unchanged. Time and randomness still require test control. + [Sandbox implementation](https://github.com/hermeticbuild/actiond/blob/8a42c3d481df3a1bf1b80e95a9bb991a207fc035/src/action_runner.zig#L817-L854) +- **Provisioning:** actiond replaces the Docker prerequisite with its own VM worker. + Linux requires KVM/vhost-vsock access and io_uring; Windows requires Hyper-V. + Linux-compatible Bazel toolchains must also be configured. + [Setup](https://github.com/hermeticbuild/actiond/blob/8a42c3d481df3a1bf1b80e95a9bb991a207fc035/README.md#start-the-worker) + +## Prototype follow-up + +The prototype supplies its own ELF loader alongside Chromium and its libraries, +so no embedded glibc selection or OCI runtime support is needed for the smoke +action. It renders with the existing seccomp filter and no `/dev/shm` changes, +using `chromiumSandbox: false` and `--no-zygote`. See its README for exact limits. + +## Next experiment + +Build one existing editor screenshot case as an ordinary Linux action with a +fully declared Chromium runtime. Run capture and comparison inside actiond, +return a PNG through Bazel outputs, and test local baseline application. Verify +Chromium startup, fonts, isolation, cancellation, and repeated captures. Compare +ARM64 and amd64 outputs explicitly before considering shared baselines. Keep the +interface REAPI-compatible so callers can choose actiond or another executor. diff --git a/experiments/actiond/BUILD.bazel.template b/experiments/actiond/BUILD.bazel.template new file mode 100644 index 0000000..caff87e --- /dev/null +++ b/experiments/actiond/BUILD.bazel.template @@ -0,0 +1,14 @@ +load(":capture.bzl", "capture") + +platform( + name = "linux_amd64", + constraint_values = ["@platforms//os:linux", "@platforms//cpu:x86_64"], +) + +capture( + name = "capture", + node = "runtime/bin/node", + script = "capture.mjs", + runtime = ["ld.so"] + glob(["runtime/**"]), + playwright = glob(["playwright-core/**"]), +) diff --git a/experiments/actiond/README.md b/experiments/actiond/README.md new file mode 100644 index 0000000..d981b9b --- /dev/null +++ b/experiments/actiond/README.md @@ -0,0 +1,66 @@ +# actiond Chromium prototype + +Throwaway experiment, not a replacement for the production VRT backend. + +The action runs a Node HTTP fixture, Chromium, and screenshot comparison together. +Node, Playwright, Chromium, shared libraries, fonts, and even the ELF loader are +ordinary declared inputs. No OCI API, injected glibc runtime, Testcontainers, +Docker socket, or Ryuk is used inside the action. + +## Reproduce + +Setup requires Linux amd64, Python 3, curl, Git, Docker, and the repository's +installed pnpm dependencies. Preload the Playwright image pinned in `prepare.sh`. +Docker only extracts the image during preparation; the action consumes files. + +```sh +bash experiments/actiond/prepare.sh /tmp/actiond-prototype +# With an existing amd64 actiond worker: +bash experiments/actiond/run-actiond.sh /tmp/actiond-prototype grpc://127.0.0.1:8980 +``` + +`run-actiond.sh` invokes a standalone Bazel action with local fallback disabled. +It downloads declared PNG outputs into `/tmp/actiond-prototype/results`; source +baselines are untouched. `ACTIOND_BAZEL` may select the Bazel executable. + +Without KVM, test the actual actiond process runner separately: + +```sh +# Also preload the Ubuntu image pinned in run-sandbox.sh. +bash experiments/actiond/run-sandbox.sh /tmp/actiond-prototype +``` + +That diagnostic uses a disposable container to grant namespace/mount privileges. +Inside it, the unmodified actiond runner applies its own chroot, namespaces, +uid/gid drop, and seccomp filter. This does **not** exercise the VM, REAPI, CAS +input filesystem, or Bazel output collection. The Docker helper is a diagnostic, +not the proposed deployment architecture. + +## Local results + +- Unmodified actiond runner source at `8a42c3d481df3a1bf1b80e95a9bb991a207fc035`. +- Chromium `153.0.8010.12` / Playwright `1.63.0`, Linux amd64. +- `chromiumSandbox: false` and `--no-zygote` produced matching 640x360 PNGs across + fresh invocations. The default zygote path failed with child/GPU startup errors; + its root cause is not established. `--zygote` reproduces that diagnostic. +- `--sandbox` failed with “No usable sandbox” in the nested container harness; + this does not establish which restriction a real VM would hit first. +- Fixture HTTP and font loading succeeded; a direct external TCP connection + failed with `ENETUNREACH`. No `/dev/shm`, Docker socket, or extra device mounts. +- PNG: 11,389 bytes, SHA-256 + `498f5f17cc8af0437eeabf78db5c6079fd1d3f3009012537d5d0dc9ee9244362`. +- Bazel aquery resolves Linux amd64 and lists the loader, browser, libraries, + fonts, and Playwright files as inputs, with PNGs in a declared TreeArtifact. + +The separate prototype workflow is prepared to test the released actiond +`v0.0.6` VM on a KVM-capable GitHub runner. It has not run: publication requires +user approval. The local host has no `/dev/kvm`. + +## Decision + +The process-level proof requires **no actiond source changes**. Before adding OCI +runtime support or relaxing seccomp, validate the real VM/CAS path and then an +existing editor fixture. Production integration still needs an optional executor +backend, reviewed baseline-update handling, amd64 worker selection on Apple +Silicon, and cleanup/isolation coverage. One stable fixture is not evidence of +cross-architecture pixel equivalence or full Chromium compatibility. diff --git a/experiments/actiond/capture.bzl b/experiments/actiond/capture.bzl new file mode 100644 index 0000000..51f87fd --- /dev/null +++ b/experiments/actiond/capture.bzl @@ -0,0 +1,31 @@ +"""Throwaway REAPI-compatible screenshot action; no Docker or Testcontainers.""" + +def _capture_impl(ctx): + out = ctx.actions.declare_directory("screenshots") + ctx.actions.run( + executable = ctx.file.node, + arguments = [ctx.file.script.path], + inputs = depset(ctx.files.runtime + ctx.files.playwright + [ctx.file.script]), + outputs = [out], + env = { + "HOME": "/tmp", + "TMPDIR": "/tmp", + "LANG": "C.UTF-8", + "TZ": "UTC", + "FONTCONFIG_PATH": "/workspace/runtime/etc/fonts", + "LD_LIBRARY_PATH": "/workspace/runtime/lib", + "OUTPUT_DIR": out.path, + }, + mnemonic = "ActiondChromiumSmoke", + ) + return [DefaultInfo(files = depset([out]))] + +capture = rule( + implementation = _capture_impl, + attrs = { + "node": attr.label(allow_single_file = True), + "script": attr.label(allow_single_file = True), + "runtime": attr.label_list(allow_files = True), + "playwright": attr.label_list(allow_files = True), + }, +) diff --git a/experiments/actiond/capture.mjs b/experiments/actiond/capture.mjs new file mode 100644 index 0000000..60b5b70 --- /dev/null +++ b/experiments/actiond/capture.mjs @@ -0,0 +1,48 @@ +// Prototype: all HTTP traffic, browser work and screenshot outputs stay in one action. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import net from 'node:net'; +import { chromium } from './playwright-core/index.mjs'; + +assert.equal(process.platform, 'linux'); +assert.equal(process.arch, 'x64'); +assert.equal(fs.existsSync('/dev/shm'), false); +assert.equal(fs.existsSync('/var/run/docker.sock'), false); +const output = path.resolve(process.env.OUTPUT_DIR || '/workspace/outputs'); +fs.mkdirSync(output, { recursive: true }); +const html = `

Declared Linux browser

Same-action HTTP fixture · 12345

`; +const font = fs.readFileSync('/workspace/runtime/fonts/truetype/freefont/FreeSans.ttf'); +const server = http.createServer((req, res) => { + res.setHeader('Content-Type', req.url === '/font.ttf' ? 'font/ttf' : 'text/html'); + res.end(req.url === '/font.ttf' ? font : html); +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +let browser; +try { + browser = await chromium.launch({ executablePath: '/workspace/runtime/chromium/chrome-headless-shell', chromiumSandbox: process.argv.includes('--sandbox'), headless: true, timeout: 15000, args: (process.argv.includes('--zygote') || process.argv.includes('--sandbox')) ? [] : ['--no-zygote'] }); + const page = await browser.newPage({ viewport: { width: 640, height: 360 }, deviceScaleFactor: 1, locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' }); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.evaluate(() => document.fonts.ready); + assert.equal(await page.evaluate(() => document.fonts.check('28px Fixture')), true); + assert.equal(await page.locator('h1').textContent(), 'Declared Linux browser'); + const first = await page.screenshot({ path: path.join(output, 'first.png'), animations: 'disabled' }); + const second = await page.screenshot({ path: path.join(output, 'second.png'), animations: 'disabled' }); + assert.deepEqual(first, second); + await assert.rejects(new Promise((resolve, reject) => { + const socket = net.connect({ host: '1.1.1.1', port: 443 }); + socket.setTimeout(1000, () => { socket.destroy(); reject(new Error('timeout')); }); + socket.once('error', reject); + socket.once('connect', () => { socket.destroy(); resolve(); }); + }), error => error.code === 'ENETUNREACH'); + console.log(JSON.stringify({ browser: browser.version(), platform: process.platform, arch: process.arch, identical: true, bytes: first.length, sandbox: process.argv.includes('--sandbox') })); +} finally { + await browser?.close(); + await new Promise(resolve => server.close(resolve)); +} diff --git a/experiments/actiond/package-runtime.sh b/experiments/actiond/package-runtime.sh new file mode 100755 index 0000000..138e0be --- /dev/null +++ b/experiments/actiond/package-runtime.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Setup-only prototype: run inside the pinned Playwright image. +set -euo pipefail +out=${1:-/output} +mkdir -p "$out"/{bin,lib,chromium,etc/fonts,fonts} +cp /usr/bin/node /bin/bash "$out/bin/" +cp -a /ms-playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/. "$out/chromium/" +for binary in /usr/bin/node /bin/bash /ms-playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell; do + ldd "$binary" | awk '/=> \// {print $3} /^\s*\/lib/ {print $1}' | while read -r lib; do + cp -L "$lib" "$out/lib/" + done +done +cp -L /lib64/ld-linux-x86-64.so.2 "$out/lib/" +cp -a /usr/share/fonts/. "$out/fonts/" +cat > "$out/etc/fonts/fonts.conf" <<'XML' + + + + /workspace/runtime/fonts + /tmp/fontconfig + +XML +cat > "$out/chrome" <<'SH' +#!/workspace/runtime/bin/bash +exec /workspace/runtime/chromium/chrome-headless-shell "$@" +SH +chmod +x "$out/chrome" +tar -C "$out" -cf - . diff --git a/experiments/actiond/patch-interpreter.py b/experiments/actiond/patch-interpreter.py new file mode 100644 index 0000000..e19447b --- /dev/null +++ b/experiments/actiond/patch-interpreter.py @@ -0,0 +1,29 @@ +"""Point this amd64 prototype's ELF executables at a declared loader. + +The replacement fits inside the existing PT_INTERP segment; no offsets move. +Unlike invoking ld.so directly, this preserves Chromium's /proc/self/exe lookup. +""" +import pathlib +import struct +import sys + +interpreter = b"/workspace/ld.so\0" +for filename in sys.argv[1:]: + path = pathlib.Path(filename) + data = bytearray(path.read_bytes()) + assert data[:6] == b"\x7fELF\x02\x01", "expected ELF64 little endian" + assert struct.unpack_from("&2; exit 1; } +mkdir -p "$work"/{build,workspace} +actiond_commit=8a42c3d481df3a1bf1b80e95a9bb991a207fc035 +zig_sha=70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 +if [[ ! -d $work/actiond/.git ]]; then + git clone https://github.com/hermeticbuild/actiond.git "$work/actiond" +fi +git -C "$work/actiond" checkout --detach "$actiond_commit" +if [[ ! -f $work/zig.tar.xz ]]; then + curl -fsSL https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz -o "$work/zig.tar.xz" +fi +printf '%s %s\n' "$zig_sha" "$work/zig.tar.xz" | sha256sum -c - +if [[ ! -d $work/zig-x86_64-linux-0.16.0 ]]; then tar -xf "$work/zig.tar.xz" -C "$work"; fi +for file in action_runner cas reapi protobuf_wire; do cp "$work/actiond/src/$file.zig" "$work/build/"; done +cp "$here/sandbox-smoke.zig" "$work/build/" +printf 'pub const executor_timing_logs = false;\npub const actiondfs_fstype: [:0]const u8 = "actiondfs";\n' > "$work/build/options.zig" +ZIG_GLOBAL_CACHE_DIR="$work/zig-cache" "$work/zig-x86_64-linux-0.16.0/zig" build-exe -O ReleaseFast -target x86_64-linux-musl \ + --dep actiond_build_options -Mroot="$work/build/sandbox-smoke.zig" \ + -Mactiond_build_options="$work/build/options.zig" -femit-bin="$work/build/sandbox-smoke" +if [[ ! -f $work/runtime.tar ]]; then + docker run --rm -i --network none --pull=never \ + mcr.microsoft.com/playwright:v1.63.0-noble@sha256:bc6ab0d6d44ff4826e4cb8c1e6d801e185bfc42bb0753f8e2a30efc70db054c7 \ + bash -s /output < "$here/package-runtime.sh" > "$work/runtime.tar.tmp" + mv "$work/runtime.tar.tmp" "$work/runtime.tar" +fi +mkdir -p "$work/workspace/runtime" +tar -xf "$work/runtime.tar" -C "$work/workspace/runtime" +cp "$work/workspace/runtime/lib/ld-linux-x86-64.so.2" "$work/workspace/ld.so" +python3 "$here/patch-interpreter.py" "$work/workspace/runtime/bin/node" "$work/workspace/runtime/chromium/chrome-headless-shell" +cp -RL "$here/../../node_modules/playwright-core" "$work/workspace/" +cp "$here/capture.mjs" "$here/capture.bzl" "$work/workspace/" +cp "$here/BUILD.bazel.template" "$work/workspace/BUILD.bazel" +printf 'module(name = "actiond_chromium_prototype")\nbazel_dep(name = "platforms", version = "1.1.0")\n' > "$work/workspace/MODULE.bazel" +printf 'Prepared %s\n' "$work" diff --git a/experiments/actiond/run-actiond.sh b/experiments/actiond/run-actiond.sh new file mode 100755 index 0000000..a5c1466 --- /dev/null +++ b/experiments/actiond/run-actiond.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Requires an already running amd64 actiond worker. No local execution fallback. +set -euo pipefail +work=${1:?usage: run-actiond.sh ABSOLUTE_WORK_DIRECTORY GRPC_ENDPOINT} +endpoint=${2:?supply the actiond GRPC endpoint} +cd "$work/workspace" +"${ACTIOND_BAZEL:-bazel}" --output_base="$work/bazel-output" build //:capture \ + --host_platform=//:linux_amd64 --platforms=//:linux_amd64 \ + --remote_executor="$endpoint" --remote_cache="$endpoint" \ + --spawn_strategy=remote --remote_local_fallback=false \ + --remote_upload_local_results=false --noremote_cache_compression \ + --remote_download_outputs=all +mkdir -p "$work/results" +cp bazel-bin/screenshots/*.png "$work/results/" diff --git a/experiments/actiond/run-sandbox.sh b/experiments/actiond/run-sandbox.sh new file mode 100755 index 0000000..f80e700 --- /dev/null +++ b/experiments/actiond/run-sandbox.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Tests the real actiond runner; DOES NOT test its VM, REAPI or actiondfs. +set -euo pipefail +work=${1:?usage: run-sandbox.sh ABSOLUTE_WORK_DIRECTORY [CHROMIUM_FLAGS]} +shift +root="$work/root" +mkdir -p "$root"/{dev,proc,tmp,var/tmp,workspace/outputs} "$work/results" +touch "$root/dev/null" +chmod 1777 "$root/tmp" "$root/var/tmp" "$root/workspace/outputs" +cp -a "$work/workspace/runtime" "$work/workspace/playwright-core" "$work/workspace/capture.mjs" "$work/workspace/ld.so" "$root/workspace/" +rm -f "$root/workspace/outputs/"*.png +container=$(docker create --network none --cap-add SYS_ADMIN --cap-add NET_ADMIN \ + --security-opt seccomp=unconfined --security-opt apparmor=unconfined \ + --pull=never ubuntu:24.04@sha256:224a1869083a311ef3f13648a154ba79832fbef6364d31493642ca03082da254 \ + /bin/bash -c 'mkdir /cas; exec /smoke /action-root /cas /workspace/runtime/bin/node /workspace/capture.mjs "$@"' prototype "$@") +trap 'docker rm -f "$container" >/dev/null' EXIT +docker cp "$root" "$container:/action-root" +docker cp "$work/build/sandbox-smoke" "$container:/smoke" +set +e +docker start -a "$container" 2>&1 | tee "$work/results/sandbox.log" +smoke_exit=${PIPESTATUS[0]} +set -e +docker cp "$container:/action-root/workspace/outputs/." "$work/results/" +exit "$smoke_exit" diff --git a/experiments/actiond/sandbox-smoke.zig b/experiments/actiond/sandbox-smoke.zig new file mode 100644 index 0000000..f86587c --- /dev/null +++ b/experiments/actiond/sandbox-smoke.zig @@ -0,0 +1,40 @@ +// Prototype: invokes actiond's real runner without its VM, REAPI or actiondfs. +const std = @import("std"); +const runner = @import("action_runner.zig"); +const cas = @import("cas.zig"); + +pub fn main(init: std.process.Init) !void { + const io = init.io; + const allocator = init.arena.allocator(); + const args = try init.minimal.args.toSlice(allocator); + if (args.len < 4) return error.ExpectedRootCasAndCommand; + var dir = try std.Io.Dir.openDirAbsolute(io, args[2], .{}); + defer dir.close(io); + const mounts = [_]runner.BindMount{.{ + .source = try allocator.dupeZ(u8, "/dev/null"), + .target = try std.fmt.allocPrintSentinel(allocator, "{s}/dev/null", .{args[1]}, 0), + .read_only = false, + }}; + var outcome = try runner.runCommandWithOptions(io, allocator, cas.Store.init(dir), .{ + .arguments = args[3..], + .environment_variables = &.{ + .{ .name = "HOME", .value = "/tmp" }, + .{ .name = "TMPDIR", .value = "/tmp" }, + .{ .name = "LANG", .value = "C.UTF-8" }, + .{ .name = "TZ", .value = "UTC" }, + .{ .name = "FONTCONFIG_PATH", .value = "/workspace/runtime/etc/fonts" }, + .{ .name = "LD_LIBRARY_PATH", .value = "/workspace/runtime/lib" }, + }, + }, .{ + .chroot_dir = args[1], + .chroot_cwd = "/workspace", + .bind_mounts = &mounts, + .timeout_ns = 90 * std.time.ns_per_s, + }); + defer outcome.deinit(allocator); + std.debug.print("stdout:\n{s}\nstderr:\n{s}\nstatus: {any}\n", .{ outcome.stdout, outcome.stderr, outcome.status }); + switch (outcome.status) { + .exited => |code| if (code != 0) std.process.exit(code), + else => return error.CommandFailed, + } +} From c67966def8da541754fd1c93e2630bce695391ff Mon Sep 17 00:00:00 2001 From: Long Ho Date: Sun, 13 Sep 2026 18:47:39 +0000 Subject: [PATCH 2/4] test: isolate actiond guest memory-advice failure --- experiments/actiond/BUILD.bazel.template | 7 ++++++- experiments/actiond/README.md | 3 +-- experiments/actiond/capture.bzl | 15 ++++++++++++++ experiments/actiond/kernel-probe.c | 25 ++++++++++++++++++++++++ experiments/actiond/prepare.sh | 1 + experiments/actiond/run-actiond.sh | 2 +- 6 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 experiments/actiond/kernel-probe.c diff --git a/experiments/actiond/BUILD.bazel.template b/experiments/actiond/BUILD.bazel.template index caff87e..9f6771f 100644 --- a/experiments/actiond/BUILD.bazel.template +++ b/experiments/actiond/BUILD.bazel.template @@ -1,4 +1,4 @@ -load(":capture.bzl", "capture") +load(":capture.bzl", "capture", "kernel_probe") platform( name = "linux_amd64", @@ -12,3 +12,8 @@ capture( runtime = ["ld.so"] + glob(["runtime/**"]), playwright = glob(["playwright-core/**"]), ) + +kernel_probe( + name = "kernel_probe", + binary = "kernel-probe", +) diff --git a/experiments/actiond/README.md b/experiments/actiond/README.md index d981b9b..dc44e51 100644 --- a/experiments/actiond/README.md +++ b/experiments/actiond/README.md @@ -53,8 +53,7 @@ not the proposed deployment architecture. fonts, and Playwright files as inputs, with PNGs in a declared TreeArtifact. The separate prototype workflow is prepared to test the released actiond -`v0.0.6` VM on a KVM-capable GitHub runner. It has not run: publication requires -user approval. The local host has no `/dev/kvm`. +`v0.0.6` VM on a KVM-capable GitHub runner. The local host has no `/dev/kvm`. ## Decision diff --git a/experiments/actiond/capture.bzl b/experiments/actiond/capture.bzl index 51f87fd..8c82d7d 100644 --- a/experiments/actiond/capture.bzl +++ b/experiments/actiond/capture.bzl @@ -29,3 +29,18 @@ capture = rule( "playwright": attr.label_list(allow_files = True), }, ) + +def _kernel_probe_impl(ctx): + out = ctx.actions.declare_file("kernel-probe.txt") + ctx.actions.run( + executable = ctx.file.binary, + arguments = [out.path], + outputs = [out], + mnemonic = "ActiondKernelProbe", + ) + return [DefaultInfo(files = depset([out]))] + +kernel_probe = rule( + implementation = _kernel_probe_impl, + attrs = {"binary": attr.label(allow_single_file = True)}, +) diff --git a/experiments/actiond/kernel-probe.c b/experiments/actiond/kernel-probe.c new file mode 100644 index 0000000..2701a74 --- /dev/null +++ b/experiments/actiond/kernel-probe.c @@ -0,0 +1,25 @@ +// Minimal regression probe for V8's failing DiscardSystemPages call. +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + if (argc != 2) return 2; + long size = sysconf(_SC_PAGESIZE); + void *memory = mmap(NULL, size, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (memory == MAP_FAILED) { perror("mmap"); return 1; } + int result = madvise(memory, size, MADV_DONTNEED); + int failure = errno; + munmap(memory, size); + if (result != 0) { + fprintf(stderr, "madvise(MADV_DONTNEED): errno=%d (%s)\n", failure, strerror(failure)); + return 1; + } + FILE *output = fopen(argv[1], "w"); + if (!output) { perror("output"); return 1; } + fputs("madvise(MADV_DONTNEED) succeeded\n", output); + return fclose(output) != 0; +} diff --git a/experiments/actiond/prepare.sh b/experiments/actiond/prepare.sh index 4357b47..4528a8e 100755 --- a/experiments/actiond/prepare.sh +++ b/experiments/actiond/prepare.sh @@ -33,6 +33,7 @@ tar -xf "$work/runtime.tar" -C "$work/workspace/runtime" cp "$work/workspace/runtime/lib/ld-linux-x86-64.so.2" "$work/workspace/ld.so" python3 "$here/patch-interpreter.py" "$work/workspace/runtime/bin/node" "$work/workspace/runtime/chromium/chrome-headless-shell" cp -RL "$here/../../node_modules/playwright-core" "$work/workspace/" +ZIG_GLOBAL_CACHE_DIR="$work/zig-cache" "$work/zig-x86_64-linux-0.16.0/zig" cc -target x86_64-linux-musl -O2 "$here/kernel-probe.c" -o "$work/workspace/kernel-probe" cp "$here/capture.mjs" "$here/capture.bzl" "$work/workspace/" cp "$here/BUILD.bazel.template" "$work/workspace/BUILD.bazel" printf 'module(name = "actiond_chromium_prototype")\nbazel_dep(name = "platforms", version = "1.1.0")\n' > "$work/workspace/MODULE.bazel" diff --git a/experiments/actiond/run-actiond.sh b/experiments/actiond/run-actiond.sh index a5c1466..ba4e104 100755 --- a/experiments/actiond/run-actiond.sh +++ b/experiments/actiond/run-actiond.sh @@ -4,7 +4,7 @@ set -euo pipefail work=${1:?usage: run-actiond.sh ABSOLUTE_WORK_DIRECTORY GRPC_ENDPOINT} endpoint=${2:?supply the actiond GRPC endpoint} cd "$work/workspace" -"${ACTIOND_BAZEL:-bazel}" --output_base="$work/bazel-output" build //:capture \ +"${ACTIOND_BAZEL:-bazel}" --output_base="$work/bazel-output" build //:kernel_probe //:capture --keep_going \ --host_platform=//:linux_amd64 --platforms=//:linux_amd64 \ --remote_executor="$endpoint" --remote_cache="$endpoint" \ --spawn_strategy=remote --remote_local_fallback=false \ From 4b3d38dff4d36ec60942bbe2bb3e08517b55a067 Mon Sep 17 00:00:00 2001 From: Long Ho Date: Sun, 13 Sep 2026 18:52:25 +0000 Subject: [PATCH 3/4] test: validate actiond with memory-advice syscalls enabled --- .github/workflows/actiond-prototype.yaml | 14 ++++++++++++-- experiments/actiond/actiond-advice.patch | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 experiments/actiond/actiond-advice.patch diff --git a/.github/workflows/actiond-prototype.yaml b/.github/workflows/actiond-prototype.yaml index 90a1cfa..ea1aac4 100644 --- a/.github/workflows/actiond-prototype.yaml +++ b/.github/workflows/actiond-prototype.yaml @@ -12,7 +12,7 @@ permissions: jobs: chromium: runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 30 env: USE_BAZEL_VERSION: '9.2.0' steps: @@ -30,6 +30,15 @@ jobs: pnpm install --frozen-lockfile docker pull --platform linux/amd64 mcr.microsoft.com/playwright:v1.63.0-noble@sha256:bc6ab0d6d44ff4826e4cb8c1e6d801e185bfc42bb0753f8e2a30efc70db054c7 bash experiments/actiond/prepare.sh "$RUNNER_TEMP/actiond-prototype" + - name: Build kernel with memory-advice syscalls + run: | + work="$RUNNER_TEMP/actiond-prototype" + git -C "$work/actiond" worktree add --detach "$work/kernel-source" c99b0cafc824faa59c2b04498eb1a6e5245b53e7 + git -C "$work/kernel-source" apply "$GITHUB_WORKSPACE/experiments/actiond/actiond-advice.patch" + cd "$work/kernel-source" + bazelisk --output_base="$work/kernel-output" build --bes_backend= --remote_executor= --remote_cache= --spawn_strategy=local --jobs=2 --platforms=//platforms:linux_x86_64_musl //vm:linux_kernel.image > "$work/kernel-build.log" 2>&1 + kernel=$(bazelisk --output_base="$work/kernel-output" cquery --bes_backend= --platforms=//platforms:linux_x86_64_musl //vm:linux_kernel.image --output=files) + cp "$kernel" "$work/patched-kernel" - name: Enable KVM and vhost-vsock run: | test -c /dev/kvm @@ -45,7 +54,7 @@ jobs: curl -fsSL https://github.com/hermeticbuild/actiond/releases/download/v0.0.6/linux-actiond_linux_x86_64 -o "$work/actiond-worker" printf '006dc798d4363596fe8ab997606fc93766a0cc427c2d005cf4fc1765fa4c2052 %s\n' "$work/actiond-worker" | sha256sum -c - chmod +x "$work/actiond-worker" - "$work/actiond-worker" serve-vm --root="$work/vm" --listen=127.0.0.1:8980 --memory-mib=3072 --cpus=2 --cas-image-size-mib=4096 > "$work/vm.log" 2>&1 & + "$work/actiond-worker" serve-vm --kernel="$work/patched-kernel" --root="$work/vm" --listen=127.0.0.1:8980 --memory-mib=3072 --cpus=2 --cas-image-size-mib=4096 > "$work/vm.log" 2>&1 & worker_pid=$! trap 'kill "$worker_pid" 2>/dev/null || true' EXIT ready=false @@ -65,3 +74,4 @@ jobs: path: | ${{ runner.temp }}/actiond-prototype/results/ ${{ runner.temp }}/actiond-prototype/vm.log + ${{ runner.temp }}/actiond-prototype/kernel-build.log diff --git a/experiments/actiond/actiond-advice.patch b/experiments/actiond/actiond-advice.patch new file mode 100644 index 0000000..48c833c --- /dev/null +++ b/experiments/actiond/actiond-advice.patch @@ -0,0 +1,22 @@ +diff --git a/vm/linux.config b/vm/linux.config +index 69abcd9..1b6bffe 100644 +--- a/vm/linux.config ++++ b/vm/linux.config +@@ -1,5 +1,6 @@ + CONFIG_64BIT=y + CONFIG_ACTIONDFS_FS=y ++CONFIG_ADVISE_SYSCALLS=y + CONFIG_ACPI=y + CONFIG_ARM64=y + CONFIG_SMP=y +diff --git a/vm/linux_x86_64.config b/vm/linux_x86_64.config +index 278a71f..8bcd5a7 100644 +--- a/vm/linux_x86_64.config ++++ b/vm/linux_x86_64.config +@@ -1,5 +1,6 @@ + CONFIG_64BIT=y + CONFIG_ACTIONDFS_FS=y ++CONFIG_ADVISE_SYSCALLS=y + CONFIG_ACPI=y + CONFIG_HPET_TIMER=y + CONFIG_X86_PM_TIMER=y From eeb04d9cd4489e17e2d2d897759a40f06d9de37f Mon Sep 17 00:00:00 2001 From: Long Ho Date: Sun, 13 Sep 2026 19:03:12 +0000 Subject: [PATCH 4/4] docs: record successful actiond VM validation --- docs/actiond-evaluation.md | 10 +++++++--- experiments/actiond/README.md | 26 +++++++++++++++++++++----- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/actiond-evaluation.md b/docs/actiond-evaluation.md index 28acbfe..5d1a50a 100644 --- a/docs/actiond-evaluation.md +++ b/docs/actiond-evaluation.md @@ -3,8 +3,9 @@ Source review on 2026-09-13 at upstream commit [`8a42c3d`](https://github.com/hermeticbuild/actiond/tree/8a42c3d481df3a1bf1b80e95a9bb991a207fc035). The initial review below is followed by a [runnable prototype](../experiments/actiond/README.md). -The prototype subsequently rendered under actiond's unmodified process runner; -this local environment has no `/dev/kvm`, so that result excludes the VM/CAS path. +The prototype subsequently rendered under actiond's unmodified process runner. +Full VM/REAPI execution also passed on a GitHub KVM runner after enabling +`CONFIG_ADVISE_SYSCALLS` in the guest kernel; see the prototype's linked CI evidence. ## Assessment @@ -58,7 +59,10 @@ benefits, not a tested Chromium integration. The prototype supplies its own ELF loader alongside Chromium and its libraries, so no embedded glibc selection or OCI runtime support is needed for the smoke action. It renders with the existing seccomp filter and no `/dev/shm` changes, -using `chromiumSandbox: false` and `--no-zygote`. See its README for exact limits. +using `chromiumSandbox: false` and `--no-zygote`. The real VM initially failed +because `madvise` was absent (`ENOSYS`). Enabling `CONFIG_ADVISE_SYSCALLS=y` +resolved the syscall probe and screenshot failure; downloaded VM PNGs matched +the local result byte-for-byte. See its README for exact limits. ## Next experiment diff --git a/experiments/actiond/README.md b/experiments/actiond/README.md index dc44e51..42b1569 100644 --- a/experiments/actiond/README.md +++ b/experiments/actiond/README.md @@ -52,14 +52,30 @@ not the proposed deployment architecture. - Bazel aquery resolves Linux amd64 and lists the loader, browser, libraries, fonts, and Playwright files as inputs, with PNGs in a declared TreeArtifact. -The separate prototype workflow is prepared to test the released actiond -`v0.0.6` VM on a KVM-capable GitHub runner. The local host has no `/dev/kvm`. +## VM kernel finding + +The released actiond `v0.0.6` VM boots and accepts the Bazel action, but Node aborts +in V8's `DiscardSystemPages`. The minimal `kernel-probe.c` action independently +returns `madvise(MADV_DONTNEED): errno=38 (Function not implemented)`. +[Failing VM run](https://github.com/perplexityai/rules_web_e2e/actions/runs/34775725126). + +Both actiond kernel configs use `allnoconfig` and omit `CONFIG_ADVISE_SYSCALLS`. +`actiond-advice.patch` enables it for ARM64 and amd64. The prototype workflow +rebuilds the exact `v0.0.6` kernel source with this patch and passes it to the +released worker through `--kernel`. It performs all compilation locally on the +GitHub runner; no BuildBuddy upload or remote build service is used. + +The patched main-branch kernel also builds locally. The patched release kernel +passed the syscall probe and screenshot action through real Bazel REAPI execution +with local fallback disabled. Both downloaded PNGs match the local hash above. +[Passing VM run](https://github.com/perplexityai/rules_web_e2e/actions/runs/34775967325). +The local host has no `/dev/kvm`; KVM validation ran on GitHub's Ubuntu runner. ## Decision -The process-level proof requires **no actiond source changes**. Before adding OCI -runtime support or relaxing seccomp, validate the real VM/CAS path and then an -existing editor fixture. Production integration still needs an optional executor +The process-level proof requires **no actiond userspace changes**. The VM proof +requires the memory-advice kernel fix above. Before adding OCI runtime support +or relaxing seccomp, validate an existing editor fixture on the patched VM. Production integration still needs an optional executor backend, reviewed baseline-update handling, amd64 worker selection on Apple Silicon, and cleanup/isolation coverage. One stable fixture is not evidence of cross-architecture pixel equivalence or full Chromium compatibility.