367 lines
16 KiB
Python
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"
|