Files
2026-09-15 15:24:03 -04:00

367 lines
16 KiB
Python

"""Open a URL in the machine's own browser, in one call.
WHY THIS IS A VERB AND NOT SIX
Without it, reading one web page in GUI mode costs: a screenshot to find
the browser, a click to focus it, a click in the address bar, a `type`,
a `key` for Return, and a screenshot to see what happened. Six round
trips through a 27B model with thinking on, most of them spent operating
a text field. The address bar is not the part of the task anyone cares
about, so it is not the model's job.
Paired with `web_search`, which runs on the host against the same SearXNG
the main model uses, the whole "look something up and read it" loop is
two calls: search, then open the result.
THE URL IS THE ONLY THING THAT CROSSES
It is validated here rather than trusted from the model, and it is passed
to the guest as an ENVIRONMENT VARIABLE, not interpolated into the shell
script - qemu-ga takes the environment as a list, so there is no quoting
to get wrong and nothing a crafted URL can close and reopen.
"""
from __future__ import annotations
import re
import time
from guest import GuestError, GuestTimeout
# Deliberately narrow. Only http(s), no credentials in the authority, and
# none of the characters a shell or a PowerShell argument list could read
# as structure even though nothing here interpolates them.
URL_RE = re.compile(r"^https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]{3,2000}$")
# Chromium, Brave and Chrome all take these. Read in order:
# new-window do not reuse a tab the agent has not seen
# no-first-run / no-default- the onboarding tab and the "make me
# browser-check default" bar, which every session would
# otherwise meet for the first time
# password-store=basic stops the gnome-keyring unlock dialog
# appearing over the page on a fresh profile
# start-maximized a known window size, so the agent's clicks
# land where its screenshot says they do
CHROMIUM_FLAGS = (
"--new-window --no-first-run --no-default-browser-check "
"--password-store=basic --start-maximized"
)
# The transient unit the browser runs as. Named rather than left to
# systemd's random `run-uNNN`, because the name is how its state is read
# back afterwards - which is the only honest way to know it started.
UNIT = "owuivm-open-page"
LINUX_SCRIPT = r"""
set -u
url='__URL__'
uid=1000
user=$(id -nu "$uid" 2>/dev/null) || { echo "no desktop user (uid 1000)"; exit 90; }
browser=""
for candidate in chromium chromium-browser google-chrome brave-browser firefox; do
if command -v "$candidate" >/dev/null 2>&1; then browser="$candidate"; break; fi
done
[ -n "$browser" ] || { echo "no browser installed"; exit 91; }
case "$browser" in
firefox) flags="--new-window" ;;
*) flags="__FLAGS__" ;;
esac
runtime="/run/user/$uid"
[ -d "$runtime" ] || { echo "no session for $user"; exit 90; }
as_user() {
runuser -u "$user" -- env "XDG_RUNTIME_DIR=$runtime" \
"DBUS_SESSION_BUS_ADDRESS=unix:path=$runtime/bus" "$@"
}
# LAUNCH IT THE WAY THE DESKTOP LAUNCHES THINGS.
#
# Two earlier versions guessed at the display environment and both were
# wrong. DISPLAY=:0 made Chromium choose X11 on a Wayland session and die
# with "Missing X server". Reading the variables out of gnome-shell's
# /proc/environ was wrong in a subtler way: gnome-shell IS the
# compositor, so it never had WAYLAND_DISPLAY - it creates that socket
# rather than consuming one - and the launch fell back to X11 again.
#
# The user's systemd instance is where a desktop exports its environment
# (`systemctl --user import-environment`, which gnome-session runs at
# startup). Measured on this image: it holds WAYLAND_DISPLAY=wayland-0,
# DISPLAY=:0 and the mutter Xwayland XAUTHORITY. Starting the browser as
# a transient unit there gives it exactly what a .desktop launcher gets,
# and detaches by construction.
as_user systemctl --user reset-failed __UNIT__.service >/dev/null 2>&1 || true
as_user systemctl --user stop __UNIT__.service >/dev/null 2>&1 || true
as_user systemd-run --user --unit=__UNIT__ --quiet \
-- "$browser" $flags "$url" >/dev/null 2>&1 || {
echo "systemd-run refused to start $browser"; exit 92; }
# AND THEN CHECK, BECAUSE THE FIRST TWO VERSIONS DID NOT.
#
# The first asked `pgrep -f chromium` and believed it - a pattern that
# matches the launcher's own command line, so it reported success for a
# browser that never started. The second compared framebuffers, which the
# clock in the top bar defeats every sixty seconds.
#
# The unit has a name and a state, and neither is a guess.
for _ in $(seq 20); do
state=$(as_user systemctl --user is-active __UNIT__.service 2>/dev/null || true)
case "$state" in
active|activating) echo "$browser"; exit 0 ;;
failed) as_user journalctl --user -u __UNIT__.service -n 3 --no-pager 2>/dev/null | tail -2
echo "$browser failed to start"; exit 92 ;;
esac
sleep 0.5
done
echo "$browser did not start"
exit 92
"""
# WINDOWS, AND WHY IT LOOKS LIKE THIS.
#
# qemu-ga on Windows runs as LocalSystem, in session 0. A GUI process
# started from there appears on a desktop nobody is looking at - it does
# not fail, which is worse, because the agent then screenshots an
# unchanged screen and concludes the page did not load.
#
# The design's answer is the in-guest broker running in the interactive
# admin session (see vm/images/windows/build.md); it does not exist yet.
# Until it does, this uses the mechanism Windows itself provides for
# exactly this: a scheduled task marked INTERACTIVE, which the task
# service starts inside the logged-on user's session. Created, run and
# deleted in one go, so nothing persists in the image.
WINDOWS_SCRIPT = r"""
$ErrorActionPreference = 'Stop'
$url = '__URL__'
$candidates = @(
"$env:ProgramFiles\BraveSoftware\Brave-Browser\Application\brave.exe",
"${env:ProgramFiles(x86)}\BraveSoftware\Brave-Browser\Application\brave.exe",
"$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\Application\brave.exe",
"$env:ProgramFiles\Google\Chrome\Application\chrome.exe",
"$env:ProgramFiles\Mozilla Firefox\firefox.exe"
)
$exe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $exe) { Write-Output 'no browser installed'; exit 91 }
$user = (Get-CimInstance Win32_ComputerSystem).UserName
if (-not $user) { Write-Output 'nobody is logged on'; exit 90 }
$flags = if ($exe -like '*firefox.exe') { '--new-window' } else { '__FLAGS__' }
$name = 'owuivm-open-page'
schtasks /create /tn $name /tr "`"$exe`" $flags `"$url`"" /sc once /st 00:00 /ru $user /it /f | Out-Null
schtasks /run /tn $name | Out-Null
Start-Sleep -Milliseconds 700
schtasks /delete /tn $name /f | Out-Null
Write-Output (Split-Path $exe -Leaf)
"""
# ---------------------------------------------------------------------
# launching something that is not a browser
# ---------------------------------------------------------------------
#
# WHY THIS EXISTS, AND WHY IT IS HERE.
#
# `open_page` had the only working way to put something on the real
# desktop, and it was welded to browsers. So a session that built a GUI
# application had to rediscover the mechanism for itself - and one did:
# it wrote a 259-line WinForms toolkit in nine steps, then spent the
# remaining twenty of its sixty on "launch the GUI on the interactive
# desktop", and ran out. The knowledge was in this file the whole time.
#
# The same two facts apply as for a browser: qemu-ga is LocalSystem in
# session 0 on Windows, so anything started from `run` appears where
# nobody is looking; and on Linux the desktop launches things through the
# user's own systemd, not through a guessed DISPLAY.
# THE INTERPRETER IS NAMED IN FULL, AND THAT IS NOT PEDANTRY.
#
# Both launchers here pass `env` to guest-exec so the URL or the path
# arrives out of band. Passing `env` REPLACES the child's environment
# rather than adding to it - so PATH is gone, and a bare "powershell"
# cannot be resolved. Every call died with
# `guest-exec: Failed to execute child process (No such file or
# directory)`, which reads like a missing script and is a missing PATH.
#
# `run` never showed it because `shell()` passes no env at all. So this
# was latent in `open_page` on Windows from the day it was written, and
# only surfaced when a second tool used the same mechanism. Measured: the
# agent tried Hello.ps1, a .bat wrapper, calc.exe, cmd.exe, notepad.exe
# and mspaint.exe, and got the identical error for all six - then
# correctly reported a tooling defect rather than inventing a screenshot.
WINDOWS_POWERSHELL = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
APP_UNIT = "owuivm-launch-app"
# THE CHECK BELOW MUST NOT MATCH THE LAUNCHER ITSELF.
#
# It asks "is a process running whose command line mentions this path",
# and the launcher's own command line contains the path - so it excludes
# its own process id. That exact mistake was made once already in this
# file, with `pgrep -f chromium` matching its own launcher and reporting
# a browser that never started.
#
# The first version passed the path by environment instead, so the
# launcher's command line could not contain it. That looked cleaner and
# did not work: see the note above about what passing `env` does to PATH.
WINDOWS_APP_SCRIPT = r"""
$ErrorActionPreference = 'Stop'
$app = '__APP__'
if (-not (Test-Path -LiteralPath $app)) { Write-Output 'not found'; exit 92 }
$user = (Get-CimInstance Win32_ComputerSystem).UserName
if (-not $user) { Write-Output 'nobody is logged on'; exit 90 }
switch -Wildcard ($app) {
'*.ps1' { $tr = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$app`"" }
'*.bat' { $tr = "cmd.exe /c `"$app`"" }
'*.cmd' { $tr = "cmd.exe /c `"$app`"" }
default { $tr = "`"$app`"" }
}
$name = 'owuivm-launch-app'
schtasks /create /tn $name /tr $tr /sc once /st 00:00 /ru $user /it /f | Out-Null
schtasks /run /tn $name | Out-Null
Start-Sleep -Seconds 3
schtasks /delete /tn $name /f | Out-Null
# Is it actually running? Anything whose command line mentions the path
# and is not this process. The path is not in our own command line.
$live = Get-CimInstance Win32_Process |
Where-Object { $_.CommandLine -and $_.CommandLine.Contains($app) -and $_.ProcessId -ne $PID }
if ($live) { Write-Output ('running:' + ($live | Select-Object -First 1).ProcessId) }
else { Write-Output 'exited'; exit 93 }
"""
LINUX_APP_SCRIPT = r"""
set -u
uid=1000
user=$(id -nu "$uid" 2>/dev/null) || { echo "no desktop user (uid 1000)"; exit 90; }
runtime="/run/user/$uid"
[ -d "$runtime" ] || { echo "no session for $user"; exit 90; }
app='__APP__'
[ -e "$app" ] || { echo "not found"; exit 92; }
case "$app" in
*.sh) set -- /bin/bash "$app" ;;
*.py) set -- python3 "$app" ;;
*) set -- "$app" ;;
esac
runuser -u "$user" -- env "XDG_RUNTIME_DIR=$runtime" \
"DBUS_SESSION_BUS_ADDRESS=unix:path=$runtime/bus" \
systemd-run --user --collect --unit=__UNIT__ "$@" >/dev/null 2>&1
sleep 3
if runuser -u "$user" -- env "XDG_RUNTIME_DIR=$runtime" \
"DBUS_SESSION_BUS_ADDRESS=unix:path=$runtime/bus" \
systemctl --user is-active --quiet __UNIT__; then
echo "running"
else
echo "exited"; exit 93
fi
"""
def _ps_quote(value: str) -> str:
"""A value safe to drop inside single quotes in PowerShell or sh."""
return value.replace("'", "''")
def launch_app(session, path: str, timeout: float = 90.0) -> str:
"""Start a program on the machine's real desktop, where it can be seen.
Returns a short line saying it is running. Raises BrowserError with
something the agent can act on when it is not.
WHAT "IT WORKED" MEANS HERE: the process is alive three seconds after
being asked to start. That is deliberately weaker than "a window is on
screen" - only a screenshot answers that, and the tool says to take
one. But it is much stronger than "the launcher returned", which is
what a GUI app that dies on a syntax error also does.
"""
path = (path or "").strip()
if not path:
raise BrowserError("no path was given.")
windows = session.os_name == "windows"
script = ((WINDOWS_APP_SCRIPT if windows else LINUX_APP_SCRIPT)
.replace("__UNIT__", APP_UNIT).replace("__APP__", _ps_quote(path)))
argv = ([WINDOWS_POWERSHELL, "-NoProfile", "-NonInteractive", "-Command", script]
if windows else ["/bin/bash", "-lc", script])
try:
with session.guest(timeout=30.0) as agent:
result = agent.exec(argv, timeout_s=timeout)
except (GuestTimeout, GuestError, OSError) as exc:
raise BrowserError(f"the machine could not be reached: {exc}") from exc
output = (result["stdout"] or result["stderr"]).strip().splitlines()
last = output[-1] if output else ""
code = result["exit_code"]
if code == 92:
raise BrowserError(f"there is nothing at {path} to launch. Check the path first.")
if code == 90:
raise BrowserError("nobody is logged on to this machine, so there is no desktop "
"to put a window on.")
if code == 93:
raise BrowserError(
"it started and then stopped, which is what a program does when it fails on "
"startup. Run it from the command line to see the error - a GUI program still "
"writes its errors to stderr."
)
if code != 0:
detail = " / ".join(output[-2:]) if output else "no output"
raise BrowserError(f"it did not start: {detail}")
return f"started on the desktop ({last}). Take a screenshot to see it."
class BrowserError(RuntimeError):
"""Phrased for the model to read."""
def validate(url: str) -> str:
url = (url or "").strip()
if not url:
raise BrowserError("no URL was given.")
if not URL_RE.match(url):
raise BrowserError(
f"'{url[:120]}' is not a plain http(s) URL. Give the address exactly as "
"web_search returned it."
)
if "@" in url.split("/")[2]:
raise BrowserError("a URL with credentials in it will not be opened.")
return url
def open_url(session, url: str, timeout: float = 60.0) -> str:
"""Launch the guest's browser on `url`. Returns the browser's name.
WHAT "IT WORKED" MEANS HERE.
That the browser is running, read back from the transient systemd
unit it was started as - not that a window is on screen. Those are
different questions and only the first one can be answered quickly:
Chromium here is a snap, and its first launch after a boot takes the
better part of half a minute to paint. The agent's next screenshot is
what answers the second question, which is why the tool tells it to
take one.
"""
url = validate(url)
windows = session.os_name == "windows"
script = ((WINDOWS_SCRIPT if windows else LINUX_SCRIPT)
.replace("__FLAGS__", CHROMIUM_FLAGS).replace("__UNIT__", UNIT)
.replace("__URL__", _ps_quote(url)))
argv = ([WINDOWS_POWERSHELL, "-NoProfile", "-NonInteractive", "-Command", script]
if windows else ["/bin/bash", "-lc", script])
try:
with session.guest(timeout=30.0) as agent:
result = agent.exec(argv, timeout_s=timeout)
except (GuestTimeout, GuestError, OSError) as exc:
raise BrowserError(f"the machine could not be reached: {exc}") from exc
output = (result["stdout"] or result["stderr"]).strip().splitlines()
last = output[-1] if output else ""
if result["exit_code"] == 91:
raise BrowserError(
"there is no browser installed in this machine, so the page cannot be shown. "
"Read it with read_page instead."
)
if result["exit_code"] == 90:
raise BrowserError(
"there is no desktop session to show a page in. Read it with read_page instead."
)
if result["exit_code"] != 0:
detail = " / ".join(output[-2:]) if output else "no output"
raise BrowserError(f"the browser did not start: {detail}")
return last or "the browser"