Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/actiond-prototype.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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: 30
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: 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
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 --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
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
${{ runner.temp }}/actiond-prototype/kernel-build.log
74 changes: 74 additions & 0 deletions docs/actiond-evaluation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# 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.
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

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`. 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

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.
19 changes: 19 additions & 0 deletions experiments/actiond/BUILD.bazel.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
load(":capture.bzl", "capture", "kernel_probe")

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/**"]),
)

kernel_probe(
name = "kernel_probe",
binary = "kernel-probe",
)
81 changes: 81 additions & 0 deletions experiments/actiond/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# 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.

## 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 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.
22 changes: 22 additions & 0 deletions experiments/actiond/actiond-advice.patch
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions experiments/actiond/capture.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""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),
},
)

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)},
)
48 changes: 48 additions & 0 deletions experiments/actiond/capture.mjs
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html><meta charset="utf-8"><style>
@font-face { font-family: Fixture; src: url('/font.ttf'); }
body { margin: 0; font-family: Fixture; background: #f5f5f5; }
main { margin: 24px; padding: 24px; background: white; border: 1px solid #ccc; }
h1 { font-size: 28px; color: #123; } button { font: inherit; background: #2463eb; color: white; padding: 12px; }
</style><main><h1>Declared Linux browser</h1><p>Same-action HTTP fixture · 12345</p><button>Save translation</button></main>`;
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));
}
25 changes: 25 additions & 0 deletions experiments/actiond/kernel-probe.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Minimal regression probe for V8's failing DiscardSystemPages call.
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

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;
}
Loading
Loading