58 Commits

Author SHA1 Message Date
ethernet
2be53ecd7b fix(encoding): read kernel pseudo-files as plain utf-8
utf-8-sig exists to tolerate BOMs that Windows tooling adds to files
users edit. /proc and /sys files are generated by the Linux kernel, never
BOM'd and absent on Windows, so -sig there only muddies the read/write
policy. Switch every literal /proc/ and /sys/ read to utf-8 and teach
the footgun read rule that string literals starting with /proc/ or
/sys/ are exempt (user-edited files keep utf-8-sig).
2026-09-24 11:50:30 -04:00
ethernet
66d54ebf51 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	.github/workflows/docker.yml
#	Dockerfile
#	apps/desktop/src/app/settings/about-settings.tsx
#	docker/stage2-hook.sh
2026-09-24 02:23:44 -04:00
ethernet
a5721b1de2 fix(bot-desktop): choose PM headed Chromium despite headless override 2026-09-24 01:40:31 -04:00
IAvecilla
686c34d3f6 feat(bot_desktop): ship Bot Screen on hosted images (-desktop tags)
The published image had no Xvnc/Xfce because nothing set the Dockerfile's
HERMES_BOT_DESKTOP argument, and a hosted instance (unprivileged, no sudo,
sealed /opt/hermes) cannot install at run time. The image layer is the only
delivery path.

- docker.yml: variant axis [slim, desktop]. :latest / :main / :v* stay the
  image they are today; :latest-desktop / :main-desktop / :v*-desktop carry
  the packages plus Playwright's headed Chromium. Slim owns the build cache
  scope; one manifest per variant so a desktop publish failure never skips
  slim's :latest.
- Dockerfile / stage2-hook.sh: XDG_RUNTIME_DIR=/tmp/hermes-runtime seeded
  0700 as hermes (containers have no logind; the $HOME/.cache fallback was
  the shared /opt/data volume), refused when foreign-owned; deterministic
  Chromium discovery exporting the headless shell for ordinary browsing.
- bot_desktop: memory gate reads the cgroup working set (usage minus
  inactive_file) so it cannot tighten over uptime and refuse to restart a
  screen idle-stop just stopped; installable() gives three distinct dead-end
  messages instead of a sudo line nobody there can run; env_for_agent
  replaces a headless-shell pin so agent and dock share one Chromium.

Squash of IAvecilla/hermes-agent:bot-desktop-cloud-image (#112381, 13
commits), which GitHub auto-closed when its base branch merged as #108914.
Review fixes from pefontana (cache scope, per-variant merge, red browser
test) are included.

Co-authored-by: pefontana <pefontana@users.noreply.github.com>
2026-09-23 19:54:47 -07:00
ethernet
3f4b8840f1 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	pyproject.toml
#	tests/fixtures/resolution_allowlist.json
2026-09-23 07:36:31 -04:00
teknium1
59a81c49ca chore(bot-desktop): mark the X11 lock/socket paths for main's no-/tmp-literal guard
Main now rejects literal /tmp paths in favour of the scratch-dir resolver. The
X11 protocol fixes .X<n>-lock and .X11-unix/X<n> under /tmp; these lines detect
or document that location, they do not pick scratch space.
2026-09-19 23:13:10 -07:00
teknium1
56cf995545 docs(bot-screen): lease.py states the fence is tool-level, matching the threat model (#110040) 2026-09-18 21:07:18 -07:00
teknium1
9be0e710d5 fix(bot-screen): the orphan reaper finds a lock-less X server through the socket it binds
_reap_orphaned_server only knew the orphan via /tmp/.X<n>-lock, so a launcher
SIGKILLed while a /tmp cleaner also took the lock (case B of #109941) — or a
failed start() that dropped <sd>/display — left one live Xvnc leaked per
occurrence; the allocator merely moved to the next number. When the lock names
nothing alive, the reaper now scans for the Xvnc whose command line binds this
profile's rfb.sock (the same ownership proof it already required), and skips the
lock unlink when no number is recorded.
2026-09-18 19:20:01 -07:00
teknium1
b428634d63 fix(bot-screen): a watcher's SetDesktopSize is dropped, not forwarded to Xvnc
Framing client message 251 (so the stream no longer dies) is kept, but the
message resizes the bot's framebuffer under a working agent — Xvnc runs
-AcceptSetDesktopSize — so forwarding it from a viewer that does not hold the
lease was a fail-open flip for a server-mutating message. The shipped pane
sets resizeSession=false anyway; SetDesktopSize now sits in _INPUT_TYPES and
only the lease holder's resize reaches the server. Test covers both legs.
2026-09-18 19:20:01 -07:00
teknium1
ada0538199 fix(bot-screen): only a real headed browser command auto-starts the screen; the env builder stays pure
_build_browser_env() is shared by the npx cache warmer (hermes update / doctor
--fix), the lazy Chromium auto-installer, the Lightpanda engine's env and
browser_use_cli. Hooking bot_desktop.auto_start there made every one of those
block up to 15 s spawning Xvnc+Xfce with browser.headed on, against
desktop_env()'s own "never starts anything" contract.

The hook now sits where a headed Chromium is actually spawned for a tool
action, mirroring computer_use dispatch: _spawn_and_collect (the first
agent-browser command forks the daemon; Lightpanda engine excluded), the Chrome
fallback from Lightpanda, and the real-profile Chrome launch. The regression
test asserts both halves: the env builder never starts the screen, the headed
Chromium spawn does, a headless or Lightpanda spawn does not.
2026-09-18 19:20:01 -07:00
teknium1
3c53adbc1e fix(bot-screen): the RFB bridge forwards SetDesktopSize instead of dropping the viewer's stream
launcher.sh starts Xvnc with -AcceptSetDesktopSize so the viewer can fit the
screen to its pane, but rfb_filter.py had no frame for client message type 251
and raised "unknown RFB client message type 251", closing the WebSocket on the
first resize request.

SetDesktopSize (u8 type, pad, u16 width, u16 height, u8 nScreens, pad, then 16
bytes per screen) is framed like the other variable-length client messages and
forwarded intact; it carries no input, so watchers without the lease send it too.
A truncated message waits for the remaining bytes.

Closes #110039
2026-09-18 19:20:01 -07:00
teknium1
56613ebf2b fix(bot-screen): AGENT_BROWSER_PROFILE honours ~ and relative paths instead of silently ignoring them
profile_dir() accepted only an absolute AGENT_BROWSER_PROFILE; `~/pin` and `pin`
fell back to the default user-data-dir while the docs said setting the variable
pins your own. The dock's Browser icon and agent-browser both derive their
identity from profile_dir(), so the silent fallback was at least confusing and,
combined with any other consumer of the raw value, a way to end up on two jars.

`~` is expanded and a relative path is anchored at the profile's HERMES_HOME (the
same root runtime.state_dir() uses), so per-profile pins stay per-profile. The
docstring and the bot-screen docs state the resolution rule.

Contributor PR #112708 (@Rook-CodeVolt) proposed documenting "absolute only";
this takes the behavioural fix instead.

Closes #110029
2026-09-18 19:20:01 -07:00
teknium1
bead012b91 fix(bot-screen): computer-use screen status redacts the lease holder's viewer id like every RPC surface
The CLI printed lease.get().as_dict() (--json) and "human (<viewer_id>)" while
display.status, the display.lease broadcast and both lease mutations replaced the
id with its 12-hex sha256 prefix. The viewer id is a capability (whoever presents
it co-drives or releases the lease), and the CLI leaked the one string the rest of
the feature deliberately hides.

The redaction now has one owner, tools.bot_desktop.lease.public_view(); the RPC
handlers, the lease watcher and the CLI all call it, so there is no second hash
implementation to drift. The human-readable line shows "human (viewer <hash>)".

Closes #110006
2026-09-18 19:20:01 -07:00
teknium1
dea71ed925 fix(bot-screen): a live X server whose lock file was reaped no longer gets its display re-allocated
_display_in_use() keyed only on /tmp/.X<n>-lock naming a live pid. A /tmp reaper
removes that lock while Xvnc keeps running; the allocator then handed the number
out, the new launcher died with "server already running", and because the
profile's recorded `display` file was never cleared, _pick_display() returned the
same number on every retry — the profile wedged until someone killed Xvnc by hand.

- _x_socket_bound(): parse /proc/net/unix for a bound (@)/tmp/.X11-unix/X<n>
  socket, which the kernel holds for the server's whole life, and OR it into
  _display_in_use(). Module-level _X_UNIX_TABLE so tests point it at a fixture.
- start(): a failed launch (launcher exit / readiness timeout) unlinks the
  recorded `display` so the next attempt allocates afresh.

Live-verified with a real Xvnc :77: after `rm /tmp/.X77-lock` the base module
reported _display_in_use(77) False while Xvnc was alive; this head reports True,
and False again once Xvnc exits.

Closes #109941
2026-09-18 19:20:01 -07:00
teknium1
36e9ddeff2 fix(bot-desktop): re-admit secret writes after a prompt; recover pre-marker dock launchers
- browser_vault_enter_code blocks on the user's code INSIDE the handler-level
  fence, so a takeover during that (human-length) wait was only detected
  afterwards by the epoch check, which discards the result: the code had
  already been written into the page the human was typing into. The
  supervisor write path now re-admits against the lease immediately before
  evaluating; a human lease refuses the write itself.
- Profiles seeded before `.hermes-browser-launcher` existed never got their
  Browser Exec= rewritten after `hermes profile rename`. The launcher recovers
  the marker from its own Name=Browser entry (bash builtins only; the seed
  runs on a minimal PATH).
- Threat model: Chromium's DevTools port is reachable by any local user, not
  only the gateway's OS user; documented (a Chromium property, no per-UID
  switch exists).

Reported by @thelonewander3r (round 6, re-surfaced at e4f591d1b0).
2026-09-18 14:06:15 -07:00
teknium1
e8473ae5e4 fix(bot-desktop): cap the dock browser's disk cache
The per-profile browser profile is persistent on purpose (logins survive
handoffs); its HTTP cache is not worth a gateway's disk. Uncapped, Chromium
lets it grow for months toward a hosted instance's 6 GB. 256 MB.
2026-09-16 17:22:14 -07:00
teknium1
71971e9205 feat(bot-desktop): memory gate and idle auto-stop for small hosts
Hosted instances come in 4 GB and 8 GB packages; a headed desktop plus the
browser a human opens during a takeover costs 1.1-1.5 GB on top of the
gateway (measured in the official image: gateway 304 MiB, +Xvnc/Xfce 520,
+Chromium on one page 1,073). Left unguarded, the loser of that squeeze is
Chromium mid-login or the gateway itself.

- runtime.start() refuses while the host, or its cgroup when tighter, has
  less than bot_desktop.min_free_memory_mb free (default 1536); status()
  carries `blocker` plus memory_available_mb/memory_limit_mb so the pane
  shows the reason in place of Start and the CLI prints it. A running
  screen is never reported blocked: the gate guards the allocation.
- A screen nobody used for bot_desktop.idle_stop_minutes (default 30) is
  stopped by the gateway's display watcher and comes back on the next
  use. "Use" = a computer_use dispatch, a browser/cua spawn onto the
  screen, an attached viewer (stamped from the RFB bridge at most once a
  minute), or start itself; a human holding the lease is never timed out.

Both knobs are 0-disableable; the contract gains the three status fields.
2026-09-16 17:22:14 -07:00
teknium1
1b121d1b0b fix(bot-screen): install single-flight holds across processes
The gateway (Install card) and the CLI (`screen install`) are separate processes; the in-process
set let both drive dpkg for one profile at once. The slot is now also a non-blocking flock on
<state_dir>/install.lock held for the life of the install.
2026-09-14 17:35:23 -07:00
teknium1
855928a9bd fix(bot-screen): hold the host-wide display-allocation lock only until Xvnc claims the number
start() held _ALLOC_LOCK across the whole Xfce bring-up (up to wait_seconds), serializing every
profile's start behind one desktop launch and letting a hung launcher block them all. The lock
exists for the pick -> /tmp/.X<n>-lock window; it is dropped the moment the X lock appears.
2026-09-14 17:35:22 -07:00
teknium1
4ffa767127 fix(bot-screen): no-op lease transitions do not bump the epoch
release() on an agent-held lease and a same-viewer re-acquire rewrote the record and bumped the
epoch, so an admitted in-flight agent action looked overtaken and was voided by a double-clicked
Hand back or a stray CLI stop; the re-acquire also reset `since` and wiped the on-screen reason.
2026-09-14 17:35:21 -07:00
teknium1
069465f3b5 fix(bot-screen): dock Browser follows the agent-browser sandbox policy in containers too
dock_argv added the sandbox-bypass flags only for root, while agent-browser's policy also covers Docker
and AppArmor-userns hosts. In the official image (gateway as uid 10000 inside Docker) the agent's own
Chromium ran but the dock icon died with 'No usable sandbox!'. Same binary, same profile, same
container: one policy (_needs_chromium_sandbox_bypass) decides for both.
2026-09-13 20:15:03 -07:00
teknium1
25b5a4884c fix(bot-screen): a headless-shell AGENT_BROWSER_EXECUTABLE_PATH is not a headed browser
The official Docker image ships only Playwright's chrome-headless-shell and its boot hook exports that
path as AGENT_BROWSER_EXECUTABLE_PATH. executable() trusted the override, so in the Cloud shape
display.status.browser named a windowless binary and the dock's Browser icon pointed at something that
can never open a window. A headless shell is skipped; a real headed Chromium elsewhere still wins.
2026-09-13 20:11:34 -07:00
teknium1
807de3a43f fix(bot-screen): reap the launcher we kill so a later gateway's stop leaves no zombie
The launcher is Popen'd by the gateway that started it; a gateway started later holds no Popen for it,
so after stop() the dead leader sat as a zombie in our table and killpg(pgid, 0) kept reporting the
group alive. waitpid(WNOHANG) on the leader during and after the kill sequence.
2026-09-13 15:06:40 -07:00
teknium1
8b0774550a fix(bot-screen): process-group cleanup waits for the whole group, not the leader
_kill_group_then_wait (runtime) and _kill_group (install) returned once the leader had exited, so a
TERM-ignoring descendant in the same group (a stuck dpkg, a display client) survived the KILL round
with the display or the dpkg lock. Both now poll killpg(pgid, 0) until nobody is left. The installer
also kills the group when the drain raises (a failing output sink), since the slot is released either way.
2026-09-13 14:49:25 -07:00
teknium1
745f88ae49 fix(bot-screen): refusing a bare release/stop under a human is one lease transition
display.stop and a viewer-less display.lease.release read human_holds() and then released in a second
step; a takeover landing in between was acknowledged to the human and silently revoked. lease.release
gains unless_human, decided under the file lock inside the same transition; the handlers read the
returned holder instead of pre-checking.
2026-09-13 14:47:29 -07:00
teknium1
6c406fd53c fix(bot-screen): dock Browser entry follows the profile path after a rename
The panel layout is seeded once on purpose (a human may have rearranged it), but the Browser launcher's
Exec= line bound the bot's user-data-dir by absolute path, so after 'hermes profile rename' the dock
opened a browser with an empty jar while agent-browser used the renamed one. The launcher now records
which dock slot is the Browser and rewrites only that Exec= line on every start; the layout is untouched.
2026-09-13 14:45:28 -07:00
teknium1
88bd29213b refactor(bot-screen): drop the agent-side handoff actions; takeover is always human-initiated
`computer_use` carried two Bot Screen actions, `request_handoff` and `wait_for_human`, that let
the model ask for the screen and then block a tool call until the human handed it back. Both only
make sense when a person is guaranteed to be watching the Desktop pane; from Telegram, the CLI or
a cron worker the request lands nowhere and the wait burns minutes before returning. The blocking
wait also fought the sequential tool deadline (600 s default vs 420 s), so the model saw a timeout
before the wait returned while the thread stayed parked.

The agent now simply says what it needs in its reply and ends the turn; the user takes over from
the pane, does the step, hands back and tells it to continue. Take over / hand back and every fence
(actions refused with human_has_control, epoch-voided results, suppressed thumbnails) are
unchanged. Removes the handoff module, the schema entries and their host-conditional rewriter,
the lease's pending_handoff field and wait helpers, and the "Bot needs you" badge in the pane.
2026-09-13 09:30:43 -07:00
teknium1
f1df96b629 Merge branch 'bs5-c' into hermes/hermes-b802e898
# Conflicts:
#	tests/tools/test_bot_desktop_install.py
2026-09-13 06:07:11 -07:00
teknium1
36fbb370e6 Merge branch 'bs5-a' into hermes/hermes-b802e898 2026-09-13 06:06:56 -07:00
teknium1
7fd5f59ba2 fix(bot-screen): dock Browser entry quotes its Exec= line, so spaced paths work
launcher.sh received the dock browser as one shell line and recovered the
executable with `${3%% *}`: a Chromium under '/opt/Google Chrome/' or a
profile dir under a HERMES_HOME with a space split at the first blank, the
existence check failed or Exec= became garbage, and the dock had no working
Browser icon.

Python now hands the launcher the bare executable (HERMES_BD_BROWSER_EXEC, for
the `command -v` check) and a ready-made Exec= line
(HERMES_BD_BROWSER_EXEC_LINE) built by `browser.dock_exec_line`: each argument
double-quoted, reserved characters backslash-escaped inside the quotes and the
backslashes string-escaped once more, per the Desktop Entry spec. `dock_argv`
is the single source of the dock's arguments (incl. the root sandbox flags).

Test: tests/tools/test_bot_desktop_browser.py — a spaced executable and a
spaced, quote-bearing profile dir produce a correctly quoted Exec= line
(AttributeError on the previous commit); the launcher seed test keeps running
the real script (`bash -n` clean).
2026-09-13 06:04:43 -07:00
teknium1
dff8929194 fix(bot-screen): dock Browser picks a browser that can start, and status says when there is none
`browser.executable()` always preferred Playwright's bundled Chromium. Its
`chrome_sandbox` is not setuid, so for a non-root user on Ubuntu 23.10+
(`kernel.apparmor_restrict_unprivileged_userns=1`) the dock's Browser icon died
with `FATAL: No usable sandbox!` even when a distro chromium with the sandbox
helper was installed. And when the only bundle is chromium_headless_shell (the
official image) `executable()` is None and launcher.sh silently skipped the
dock entry — nothing anywhere said "no headed browser".

Now: non-root under the userns restriction tries a system chrome/chromium first
and falls back to the Playwright build (no --no-sandbox for non-root, by
ruling: a loud failure beats a sandbox-less browser). `DesktopStatus.browser`
carries the resolved executable (None = no headed browser) through `as_dict()`
so the pane and `hermes computer-use screen status --json` can show it.

The root sandbox-bypass flags are ONE list, `browser_tool_session.
CHROMIUM_SANDBOX_BYPASS_ARGS`: agent-browser gets it via AGENT_BROWSER_ARGS and
the dock command appends the same flags as root, so the human's click and the
agent's launch start the same binary the same way. The sysctl reader is shared
as `apparmor_restricts_unprivileged_userns()`.

Tests: tests/tools/test_bot_desktop_browser.py — restricted non-root prefers
the system chromium and keeps Playwright's when alone (AttributeError on
bc36ddb5f9); root dock args are a superset of the agent's (dock lacked
--no-sandbox); status exposes browser / None (field missing).
2026-09-13 06:02:35 -07:00
teknium1
a047156df7 fix(bot-screen): hygiene — private alloc lock, private lease files, cookie off argv, real Fedora packages
Four small exposures, none a same-UID boundary (which the design does not claim), all
about other local users and wrong install hints:

- The host-wide display-allocation lock sat at a predictable name in world-writable
  /tmp, where any local user could pre-create or squat it. It now lives under
  XDG_RUNTIME_DIR (else ~/.cache), created 0700; still host-wide, outside every profile
  home, because all profiles allocate from one band.
- lease.py hand-rolled mkdir + plain writes, so a takeover recorded before start() ever
  ran left bot-desktop/ 0755 and lease.json / lease.lock 0644 under the umask — who
  holds the screen and the lock the RFB bridge serialises on, readable by everyone. It
  now uses secure_parent_dir and creates every file 0600 via an opener; the fcntl-less
  (Windows) fallback is untouched.
- launcher.sh passed the X cookie on xauth's argv, visible in ps to other local UIDs.
  The cookie is now fed on stdin through `xauth source -`.
- The Fedora map named tigervnc-server-minimal (only a Provides of tigervnc-x11-server,
  which actually ships Xvnc) and dbus-x11 for dbus-run-session (dbus-daemon owns it;
  dbus-x11 ships dbus-launch). Both BINARY_PACKAGES and PACKAGES are corrected together.
2026-09-13 05:59:54 -07:00
teknium1
5b1089a19c fix(bot-screen): install works as root and names the host command when sudo is absent
Two dead ends on hosts without a sudo binary. As root (the official Docker
image is uid 0, no sudo): `runtime.install_command()` hardcoded `sudo <pm>`,
`_run` asserted argv[0] == "sudo", `sudo -n true` failed, so the password card
was raised for a user who already IS root and the install was cancelled. As an
unprivileged user in a minimal container the same card was raised although no
password could ever satisfy it.

`install_command()` now omits `sudo` when euid is 0 (one builder, so the pane's
shown command and the executed command stay the same line); `_run` runs the
package manager directly when the command carries no sudo, and when it does but
`shutil.which("sudo")` is None it streams the exact command to run on the host
as root and returns `install.NO_SUDO` instead of asking. `hermes computer-use
screen install` prints that command.

Tests: tests/tools/test_bot_desktop_install.py — euid 0: ask_password is never
called and argv[0] is the package manager; euid 1000 + no sudo: NO_SUDO, no
Popen, the apt-get line is in the stream (both raised the password card on
bc36ddb5f9).
2026-09-13 05:58:05 -07:00
teknium1
717e4c2fe7 fix(bot-screen): Xvnc cut-text cap matches the bridge, NOPASSWD install covered, bridge design notes
- launcher.sh passes -MaxCutText 262144 so Xvnc's own cut-text bound equals the
  bridge filter's _MAX_CUT_TEXT (rfb_filter.py); before, only the bridge capped
  and the two could drift apart silently. Comments on both sides name each
  other; the existing launcher argv test asserts the flag equals the constant.
- Install: a host with passwordless sudo must run the package command without
  popping the masked password card (it would block the install on an answer
  nobody needs to give). Covered with a fake Popen; sabotaging the
  _sudo_nopasswd() branch turns the test red.
- web_routers/display.py docstring records two deliberate rulings that read
  like defects: the display ticket rides in the query string because noVNC's
  Websock cannot negotiate subprotocols or headers and the ticket is
  single-use / 30 s; _LEASE_REFRESH_S (250 ms) bounds how long another
  process's takeover can go unseen by the sync input gate.
2026-09-13 05:55:19 -07:00
teknium1
004b43f15c fix(bot-screen): install timeout releases the profile slot even when a root child survives
The install runs `sudo <pm> ...` in its own session and drained stdout with a
blocking `for line in proc.stdout`; the timeout only `killpg(SIGKILL)`ed the
group. From an unprivileged Hermes that signal reaches the sudo leader but not
the root-owned apt/dnf child, which keeps the pipe's write end open: the drain
never saw EOF, `install_packages` never returned, and the per-profile install
slot stayed taken until the gateway restarted (every later Install click:
"an install is already running").

Now the drain is readiness-polled against the deadline, so it ends on time
regardless of what survived; the group gets SIGTERM (dpkg can finish its
transaction) then SIGKILL after a grace; `install timed out` is streamed to the
pane; the leader is reaped best effort and the slot is released by the
existing finally.

Test: tests/tools/test_bot_desktop_install.py — a stand-in leader whose
grandchild sits in its own session holding our stdout; install_packages must
return within 3 s, report the timeout, and leave the slot claimable (hung
3.0 s on bc36ddb5f9).
2026-09-13 05:55:06 -07:00
teknium1
85d0b15325 fix(bot-screen): a start() that times out takes its launcher down instead of leaving it to publish later
_spawn_and_wait raised on the readiness deadline and walked away from the launcher it
had just spawned. Xvnc and Xfce kept coming up; a moment later the launcher published
DISPLAY and rfb.sock, and a screen whose start() had reported failure was now "running"
under a pid the caller never learned about. A retry then saw it as already up and returned
it, so the failure message described a state that no longer existed.

On timeout the launch's process group (the launcher is its own session leader, so the
group is exactly this launch) gets SIGTERM, then SIGKILL after a grace period, the child
is reaped, and only that launch's launcher.pid / env / rfb.sock are removed before the
error propagates.
2026-09-13 05:53:59 -07:00
teknium1
809169b7d0 fix(bot-screen): reap the X server a dead launcher leaves behind instead of starting a second one
status() and stop() keyed only on the launcher pid. When the launcher was SIGKILLed
(OOM, a stray kill, a crashed supervisor) its Xvnc kept running in the launcher's
session, holding the display number and rfb.sock, while status() reported stopped.
The next start() then allocated a fresh display and spawned a second server whose
launcher unlinked and re-bound the same rfb.sock path — two X servers, one socket,
and the orphan leaked forever because stop() never reached it.

The X lock of the recorded display names that server. When no live launcher exists,
start() and stop() now check it: a process in the dead launcher's process group, or
an Xvnc whose command line binds THIS profile's rfb.sock, is ours and is killed
(SIGTERM, then SIGKILL) before the stale state is dropped and a new launch proceeds.
A server that merely reused our display number is never touched. _pid_alive also
stops treating a zombie as alive: the gateway reaps a killed launcher lazily, and the
zombie made status() claim a running screen whose server nobody owned.
2026-09-13 05:53:09 -07:00
teknium1
21d3c77300 fix(bot-screen): quiet xkbcomp keysym noise, log an ignored release, hide Chrome-for-Testing infobars
Fourth review round (@dadhalfdev): launcher.log opened with dozens of "Could not resolve keysym
XF86..." lines that read like a failure and buried real errors; a lease.release() by a non-holder
was silently ignored (correct, but invisible to a caller that drops the return value); the dock
browser showed the "Chrome for Testing" and unsupported-flag infobars in the human's takeover view.
2026-09-12 19:00:10 -07:00
teknium1
c4b304e7ea fix(bot-screen): Xvnc stops broadcasting the screen clipboard to watchers
TigerVNC sends every clipboard change on the desktop to ALL connected RFB clients by
default, so whatever the person in control copied (a password manager entry, a 2FA code)
landed in every watcher's clipboard too. -SendCutText=0 turns that off; -AcceptCutText
stays at its default so pasting INTO the screen keeps working.

The comment above the Xvnc line claimed the 0600 socket is reachable "solely by the gateway
process"; it is reachable by any process running as this user, which is the actual boundary.

Live: runtime.start() on this host (Xtigervnc 1.15) comes up with the flag, xdpyinfo answers,
the launcher log shows no option error.

(cherry picked from commit 45cd0ce36a55ea648bac479a43547637b547911b)
2026-09-12 18:59:17 -07:00
teknium1
7a55ef4b6b fix(bot-screen): agent attaches to a human-started dock Browser instead of dying on the profile singleton
The dock's Browser launched raw Chromium on the shared user-data-dir with no automation
endpoint. When the human opened it first and handed back, agent-browser's own launch was
forwarded into their instance by Chromium's ProcessSingleton and exited 21 without a
DevToolsActivePort, so every browser_navigate failed until the human closed their window.
The reverse order worked, which is why it slipped through.

- The dock command carries --remote-debugging-port=0, so a human-started instance advertises a
  port in <user-data-dir>/DevToolsActivePort (browser.dock_command; runtime.start reads it).
- browser.running_instance_cdp_port() trusts that file only when SingletonLock's pid is alive
  AND the port accepts a connection (both files outlive a closed Chromium), and never for the
  instance the calling agent-browser session launched itself: handing that daemon --cdp makes
  it treat the launch as a config change, close its browser and attach to the port that just
  died with it (seen live).
- The local argv builder appends --cdp <port> to the --session launch when such an instance
  exists, so the same daemon (and its snapshot refs) drives the human's window.

Live, HERMES_HOME=/tmp/bs-f-home on this host: human-first — dock instance up, navigate x2
succeeded, one Chromium main process (same pid) throughout; agent-first — navigate, dock
click, navigate x2 succeeded, one process throughout. Before the fix human-first returned
"Chrome exited early (exit code: 21) ... Failed to create SingletonLock".

(cherry picked from commit d732fad0af005ffd38eca153dd29c0f7e9dd6bc7)
2026-09-12 18:59:14 -07:00
teknium1
04f490c17e fix(bot-screen): install timeout kills the package manager's group; atomic slot claim
The timeout Timer called proc.kill, which only reaches sudo: apt/dnf kept
running as root in the new session, holding the dpkg lock, while the
per-profile install slot was released and a retry would collide with it.
The timer now SIGKILLs the whole process group start_new_session created.

install.claim() atomically takes the slot (raises InstallBusy when held)
and install_packages(claimed=True) skips re-claiming but still releases,
so the gateway handler can claim before spawning its worker thread instead
of a read-only assert_not_running that two Install clicks both pass.

(cherry picked from commit 31cce2b634fc6b84dd6422abec4ecee7b770af46)
2026-09-12 18:59:02 -07:00
teknium1
a7fd83d688 fix(bot-screen): launcher stays the supervisor so a dead Xfce session takes Xvnc with it
`exec dbus-run-session ...` replaced the launcher process, discarding the
`trap 'kill $XVNC_PID' EXIT`. When xfce4-panel (the session's foreground
process) exited on its own, dbus-run-session ended, launcher.pid pointed
at nothing and Xvnc lived on as an orphan that runtime.stop() could no
longer find (live-reproduced: panel killed -> launcher gone, Xvnc :20
still serving). Without exec the script waits for the session, reaps
Xvnc and exits with the session's status; the process group runtime.stop()
signals is unchanged (launcher is still the session leader), verified
live: 16 group members before stop, none after, no /tmp/.X20-lock left.

(cherry picked from commit 6e975f900f06ed2e85b2ffe0e462378ea6dd2e83)
2026-09-12 18:59:00 -07:00
teknium1
aae797bc27 fix(bot-screen): truncate launcher.log on each start
The log was opened in append mode and nothing rotated it; a long-lived
gateway restarting its screen grew it without bound and the tail
start() reports on failure mixed launches. Each start now owns the file.

(cherry picked from commit ea1e3ac737b3d0b76afc5ec5373c5d3248207820)
2026-09-12 18:58:57 -07:00
teknium1
ed5d2dc4f1 fix(bot-screen): hold the display-allocation and a per-profile start lock across spawn
_allocate_display released the host-wide flock as soon as it picked a
number, but Xvnc writes /tmp/.X<n>-lock only once it is up. Two profiles
cold-starting together both picked n; the loser's Xvnc failed and its
launcher's stale-lock cleanup could unlink the winner's socket. There was
also no per-profile lock at all: two start() calls for one profile
spawned two launchers, the second overwrote launcher.pid and the first was
orphaned.

start() now takes <state_dir>/start.lock (per profile) around the
running-check, and the allocation lock around pick + spawn + publish
wait (bounded by wait_seconds, default 15s, so a stuck launcher cannot
wedge other profiles for long). stop() takes the same per-profile lock so
a stop cannot interleave with a start. The launcher does not inherit the
lock fds (close_fds=True), so the locks fall with the caller.

(cherry picked from commit 1eee1a331e7ac6a8a2860dd7ff2df0150fe12a8b)
2026-09-12 18:58:54 -07:00
teknium1
7a36c99bba fix(bot-screen): launcher.pid carries the process birth time, not just a pid
pid_exists alone trusts a recycled pid: after a reboot or a long-lived
gateway, an unrelated process can own the recorded number and status()
reports the screen running while stop() SIGTERMs that stranger's whole
process group. The pid file now stores "<pid> <psutil create_time>" and
_launcher_pid() requires both to match; the pre-identity single-number
format, a dead pid and an out-of-range digit string all read as not
running (the safe direction: worst case is a spurious start()).

(cherry picked from commit 2f9e0e40a764f5a6bb38c02194081dc34c4c8f0b)
2026-09-12 18:58:51 -07:00
teknium1
e342c3e216 fix(bot-screen): Fedora per-binary package names, xprop required, binary->package map
Fedora split xorg-x11-server-utils and xorg-x11-utils into per-binary
packages (F35) and retired the umbrellas; dnf5 refuses the whole
transaction on one unknown name, so `hermes computer-use screen install`
was a no-op on Fedora. The dnf list now names xsetroot/xset/xdpyinfo/
xprop/setxkbmap directly. xprop (the launcher's WM-ready wait) joins
REQUIRED_BINARIES and every distro list; apt gains x11-xkb-utils
(setxkbmap) and pacman gains dbus (dbus-run-session), both previously
reached only via transitive deps. BINARY_PACKAGES documents which package
ships each binary per manager so a test can hold the lists to it.

(cherry picked from commit dc5c4d70f7a0fd355fe21c96071513d8b5ae3744)
2026-09-12 18:58:48 -07:00
teknium1
c1d996a84d fix(bot-screen): serialise the XAUTHORITY swap around thumbnail grabs
thumbnail_data_url swaps the process-wide os.environ["XAUTHORITY"] around
ImageGrab.grab; a multiplexed gateway thumbnails several profiles from worker
threads at once, so one grab could run with another profile's cookie (Xlib
auth failure or the wrong screen) and the restore left the wrong value
behind. A module lock serialises swap+grab+restore.

(cherry picked from commit be789c85fdbae869a31d63f30a3434f03d350a50)
2026-09-12 18:58:39 -07:00
teknium1
6f0d7ff42e refactor(bot-screen): keep only the RFB clipboard header cap
The header check alone removes the unbounded buffering: every other client
message type is fixed-size or bounded by a 16-bit count / one byte, so once
ClientCutText is capped nothing can grow _buf past ~256 KiB + a partial frame.
The feed()/_feed() slicing wrapper was a second layer over the same fact and
its coalesced-frame test exercised behaviour the base parser already had.
Rename the test file into the rfb_filter family.

(cherry picked from commit b9bfa7de4c7e968347923d109c3ae68a66ceb37d)
2026-09-12 18:58:28 -07:00
Yags
0672f1db0b fix(bot-screen): bound RFB clipboard buffering
Reject oversized positive and extended clipboard lengths at the header. Process coalesced WebSocket data in bounded slices without rejecting valid multi-message frames. Includes fragmented-header and boundary regressions.

Addresses the clipboard finding reported by carlotestor and corroborated by helix4u and other reviewers on #108914.

(cherry picked from commit b1fbe363266e6d6fa3d73d2605fe1ca383607859)
2026-09-12 18:58:25 -07:00
teknium1
4d886fa904 fix(computer-use): wait_for_human returns no_takeover when nobody answers the handoff
wait_for_release polled until the full timeout (10 min by default) even
when the holder never left AGENT, so an unseen request_handoff blocked the
turn instead of letting the model chase the user in chat. After `grace`
seconds (param, default 60, capped at the timeout) with the handoff still
pending and no human holding, return {ok: false, code: no_takeover}. Once
a human holds the screen the full timeout still applies to the hand-back.

(cherry picked from commit 1402af036044a7191ec885fc8fa5c24206880909)
2026-09-12 18:58:19 -07:00