Skip to content

Commit d9cbd17

Browse files
authored
Merge pull request #991 from finitecomputer/claude/playwright
Playwright on the system Chromium, and unzip, zip, jq, sqlite3
2 parents 28daf85 + 7eef266 commit d9cbd17

7 files changed

Lines changed: 394 additions & 10 deletions

File tree

‎docs/computers.md‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -781,10 +781,45 @@ install` work, and what they install is kept.
781781
with `requirements.txt` listing what it held for `pip install -r`, and a
782782
new one is made; the agent's page says so.
783783
- **The image's own packages**, in `/opt/fragment/python` (the document
784-
skills' libraries; never Hermes' store, whose Python its PM records a
785-
digest of), are named in it by a `.pth` the boot writes at each start,
786-
after its own, so an agent's install of one wins. Hermes' venv names
787-
them by the same `.pth`.
784+
skills' libraries and Playwright; never Hermes' store, whose Python its
785+
PM records a digest of), are named in it by a `.pth` the boot writes at
786+
each start, after its own, so an agent's install of one wins. Hermes'
787+
venv names them by the same `.pth`.
788+
789+
### Playwright and small tools in our Hermes image
790+
791+
Paul, 2026-11-02: an agent's own code drives a browser with Playwright,
792+
on the system Chromium, which passes Cloudflare's bot check where Chrome
793+
for Testing fails.
794+
795+
- **One release for Python and Node**, pinned in
796+
`images/hermes/playwright-requirements.txt`: Python's in the image's own
797+
packages (above), its driver on the image's Node
798+
(`PLAYWRIGHT_NODEJS_PATH`; the wheel's own is dropped), Node's global
799+
(`require('playwright')` through the login shell's `NODE_PATH`), and the
800+
`playwright` CLI. A plain
801+
`chromium.launch()` runs `/opt/fragment/bin/chromium`, the desktop's:
802+
the image puts it in `PLAYWRIGHT_BROWSERS_PATH` under the names that
803+
release looks for, marked installed, beside the image's FFmpeg under
804+
its name (a page's video), so `sudo playwright install chromium`
805+
downloads nothing. sudo keeps `PLAYWRIGHT_BROWSERS_PATH`, so a browser
806+
`sudo playwright install` adds is where an agent's own launch looks,
807+
and `PLAYWRIGHT_SKIP_BROWSER_GC`: that directory is Hermes' tool store,
808+
and Playwright's install would collect Hermes' own Chrome for Testing
809+
and FFmpeg from it (`uninstall`, which always collects, is refused
810+
there). As
811+
the agent's user, `playwright install` (either runtime's) answers at
812+
once (`playwright-node`, the Node both run Playwright's CLI on):
813+
Chromium, or no browser named, is installed already; another browser is
814+
root's to install. Playwright's own would wait ten minutes on its lock
815+
in that directory, root's.
816+
Another release an agent installs looks for its own names, and finds
817+
none.
818+
- **`unzip`, `zip`, `jq` and `sqlite3`**, Debian's. `sqlite3` runs with
819+
Debian's SQLite library, not the newer one upstream puts first.
820+
- The image still carries Hermes' Chrome for Testing
821+
(docs/technical-debt-ledger.md, "The Hermes image carries two
822+
Chromiums").
788823

789824
### Claude Code in our Hermes image
790825

‎docs/technical-debt-ledger.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,21 @@ unfinished design, not debt.
139139
`a_regular_browser_is_shared_by_the_desktop_and_tools` and
140140
`two_agents_two_desktops` pass against it.
141141

142+
## The Hermes image carries two Chromiums
143+
144+
- **Shortcut:** Hermes v0.21.7's browser tools bring Chrome for Testing
145+
r1208 (`/opt/hermes/tools/chromium-1208`, 364 MB) beside the system
146+
Chromium (`/usr/bin/chromium`, 376 MB), which the desktop, Hermes'
147+
browser (desktop_browser.py) and an agent's own Playwright run
148+
(images/hermes/Dockerfile). r1208 fails Cloudflare's bot check where
149+
the system Chromium passes (Paul's prod agent, 2026-10-28).
150+
- **Risk:** 364 MB of every image, and a browser an agent could still
151+
reach by its path.
152+
- **Delete when:** the image moves to Hermes v0.22, which drops the
153+
browser tools (FIN-305): then the image removes r1208, and the
154+
build's Playwright check (both runtimes print a page with the system
155+
Chromium) still passes.
156+
142157
## Each agent's Hermes profile config is rewritten at every boot
143158

144159
- **Shortcut:** Hermes' managed scope cannot carry per-profile settings,

‎images/bridge/tests/docker.rs‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4949,3 +4949,93 @@ async fn the_computers_python_keeps_what_its_agents_install() {
49494949
assert_eq!(python_env_said(&c, 5).await["plan"], "Keep", "the start after keeps the new one");
49504950
assert_eq!(c.exec_out(&["ls", "/data/work"]).lines().filter(|l| l.starts_with("python")).collect::<Vec<_>>(), ["python", "python.3.13"], "set aside once");
49514951
}
4952+
4953+
/// An agent's own Playwright, Python's: a screenshot and a PDF of a page,
4954+
/// with a plain `chromium.launch()`.
4955+
const PLAYWRIGHT_PY: &str = r#"from playwright.sync_api import sync_playwright
4956+
with sync_playwright() as p:
4957+
b = p.chromium.launch()
4958+
page = b.new_page()
4959+
page.set_content("<h1>hello from python</h1>")
4960+
page.screenshot(path="/tmp/pw/py.png")
4961+
page.pdf(path="/tmp/pw/py.pdf")
4962+
print("python-chromium", b.version)
4963+
# a page's video, through the FFmpeg Playwright finds
4964+
context = b.new_context(record_video_dir="/tmp/pw/video")
4965+
context.new_page().set_content("<h1>a video</h1>")
4966+
context.close()
4967+
import pathlib
4968+
print("videos", sum(1 for v in pathlib.Path("/tmp/pw/video").glob("*.webm") if v.stat().st_size > 0))
4969+
b.close()
4970+
"#;
4971+
4972+
/// The same, Node's.
4973+
const PLAYWRIGHT_JS: &str = r#"const { chromium } = require('playwright');
4974+
chromium.launch().then(async b => {
4975+
const page = await b.newPage();
4976+
await page.setContent('<h1>hello from node</h1>');
4977+
await page.screenshot({ path: '/tmp/pw/js.png' });
4978+
await page.pdf({ path: '/tmp/pw/js.pdf' });
4979+
console.log('node-chromium', b.version());
4980+
await b.close();
4981+
}).catch(e => { console.error(e); process.exit(1); });
4982+
"#;
4983+
4984+
/// Goal (Paul, 2026-11-02): an agent's own Python and Node code run
4985+
/// Playwright with a plain `chromium.launch()` on the system Chromium, the
4986+
/// desktop's (Chrome for Testing fails Cloudflare's bot check where it
4987+
/// passes), with no browser downloaded; and unzip, zip, jq and sqlite3
4988+
/// run. Method: through the agent's terminal, a Python script (in the
4989+
/// computer's Python environment) and a Node script each take a screenshot
4990+
/// and print a PDF, saying their browser's version, which is the system
4991+
/// Chromium's; each tool does its work; and no browser was downloaded.
4992+
/// Invalid: `playwright install`, by either runtime as the agent's user,
4993+
/// answers at once, never waiting on its lock in a directory of root's.
4994+
#[tokio::test]
4995+
#[ignore = "needs Docker: cargo test -p fragment-bridge --test docker -- --ignored"]
4996+
async fn playwright_runs_the_system_chromium_and_the_small_tools_run() {
4997+
let (fake, _model, chat, c) = hermes_running().await;
4998+
python_env_said(&c, 1).await;
4999+
let system = c.exec_out(&["/usr/bin/chromium", "--version"]);
5000+
let version = system.split_whitespace().nth(1).unwrap_or_else(|| panic!("the system Chromium's version: {system:?}")).to_string();
5001+
for (path, text) in [("/tmp/pw/pw.py", PLAYWRIGHT_PY), ("/tmp/pw/pw.js", PLAYWRIGHT_JS)] {
5002+
let (code, said) = c.exec_code(&["/command/s6-setuidgid", "hermes", "sh", "-c", "mkdir -p /tmp/pw && printf '%s' \"$1\" > \"$2\"", "sh", text, path]);
5003+
assert_eq!(code, 0, "{path}: {said}");
5004+
}
5005+
let said = tool_said(&fake, &chat, &c, "run: cd /tmp/pw && python pw.py && node pw.js && python -c 'import sys; [print(f, open(f, \"rb\").read(5)) for f in sys.argv[1:]]' py.png py.pdf js.png js.pdf").await;
5006+
for seen in [format!("python-chromium {version}"), format!("node-chromium {version}"), "videos 1".into(), r"py.png b'\\x89PNG\\r'".into(), "py.pdf b'%PDF-'".into(), r"js.png b'\\x89PNG\\r'".into(), "js.pdf b'%PDF-'".into()] {
5007+
assert!(said.contains(&seen), "{seen:?} in what the terminal said: {said}");
5008+
}
5009+
let said = tool_said(&fake, &chat, &c, "run: cd /tmp/pw && zip -q t.zip pw.py && unzip -l t.zip | grep -c pw.py && echo '{\"n\": 42}' | jq .n && sqlite3 :memory: 'select 6 * 7' && playwright --version").await;
5010+
let lines: Vec<&str> = said.split("\\n").map(str::trim).collect();
5011+
let pinned = include_str!("../../hermes/playwright-requirements.txt").lines().find_map(|l| l.strip_prefix("playwright==")).and_then(|v| v.split_whitespace().next()).expect("the lock pins playwright");
5012+
let release = format!("Version {pinned}");
5013+
for seen in ["1", "42", release.as_str()] {
5014+
assert!(lines.iter().any(|l| l.contains(seen)), "{seen:?} in what the terminal said: {said}");
5015+
}
5016+
assert!(said.contains("\"exit_code\": 0"), "each ran: {said}");
5017+
// `playwright install`, as the agent's user, answers at once (Codex on
5018+
// #991: Playwright's own waits ten minutes on a lock in a directory of
5019+
// root's): Chromium, or no browser named, is installed already; another
5020+
// is root's
5021+
let said = tool_said(&fake, &chat, &c, "run: cd /tmp && s=$(date +%s) && timeout 60 playwright install && timeout 60 playwright install chromium && timeout 60 python -m playwright install --with-deps chromium && { timeout 60 playwright install firefox; echo \"firefox=$?\"; } && echo \"took=$(( $(date +%s) - s ))\"").await;
5022+
assert_eq!(said.matches("Playwright's Chromium is installed already").count(), 3, "each install of Chromium says so: {said}");
5023+
assert!(said.contains("firefox=1") && said.contains("firefox can only be installed as root here (sudo playwright install firefox)"), "another browser is root's: {said}");
5024+
let took: u64 = said.split("took=").nth(1).and_then(|r| r.split(|ch: char| !ch.is_ascii_digit()).next()).and_then(|n| n.parse().ok()).unwrap_or_else(|| panic!("how long: {said}"));
5025+
assert!(took < 10, "at once, not after Playwright's lock: {took} s");
5026+
// what it suggests (Codex on #991): `sudo playwright install chromium`
5027+
// fetches nothing, and a browser `sudo` installs goes where the agent's
5028+
// own launch looks (sudo keeps PLAYWRIGHT_BROWSERS_PATH): its dry run's
5029+
// place is the one the agent's Playwright resolves
5030+
let said = tool_said(&fake, &chat, &c, "run: cd /tmp/pw && sudo playwright install chromium && echo \"sudo-chromium=$?\" && at=$(sudo playwright install --dry-run firefox | sed -n 's|^ *Install location: *\\(.*/firefox-[0-9]*\\)$|\\1|p') && mine=$(python -c 'from playwright.sync_api import sync_playwright; p = sync_playwright().start(); print(p.firefox.executable_path); p.stop()') && case \"$mine\" in \"$at\"/*) echo \"firefox-where-$((6 * 7))\" ;; *) echo \"sudo $at, the agent $mine\" ;; esac").await;
5031+
assert!(said.contains("sudo-chromium=0") && !said.contains("MiB") && !said.contains("downloaded to"), "sudo installs no Chromium, nor its FFmpeg: {said}");
5032+
assert!(!said.contains("Removing unused browser"), "nor collects Hermes' tools: {said}");
5033+
assert!(said.contains("firefox-where-42"), "a browser sudo installs is where the agent looks: {said}");
5034+
// `uninstall`, which collects regardless, is refused in Hermes' store
5035+
let said = tool_said(&fake, &chat, &c, "run: cd /tmp/pw && { sudo playwright uninstall --all; echo \"uninstall=$?\"; } && { timeout 60 playwright uninstall; echo \"mine=$?\"; }").await;
5036+
assert!(said.contains("uninstall=1") && said.contains("mine=1") && said.contains("would remove Hermes' own tools"), "{said}");
5037+
let store = c.exec_out(&["ls", "/opt/hermes/tools"]);
5038+
assert!(store.lines().any(|l| l == "chromium-1208") && store.lines().any(|l| l.starts_with("ffmpeg-9")), "Hermes' Chrome for Testing and FFmpeg are kept: {store}");
5039+
let browsers = c.exec_out(&["sh", "-c", "ls /opt/hermes/tools | grep chromium"]);
5040+
assert_eq!(browsers.lines().count(), 3, "Chrome for Testing's, and Playwright's two names for the system Chromium; none downloaded: {browsers}");
5041+
}

0 commit comments

Comments
 (0)