Skip to content

fix(bot-screen): bound RFB input and repair viewer recovery and ownership - #109446

Closed
whyyagswhy wants to merge 14 commits into
NousResearch:hermes/hermes-b802e898from
whyyagswhy:review/bot-screen-contribution
Closed

whyyagswhy wants to merge 14 commits into
NousResearch:hermes/hermes-b802e898from
whyyagswhy:review/bot-screen-contribution

Conversation

@whyyagswhy

Copy link
Copy Markdown

Scope

Follow-up to #108914, based on d947fc83a461. Targets the Bot Screen feature branch, not main. Converts six already-reported defects into four separately cherry-pickable commits. AI-assisted implementation and verification.

Fixes

  1. Reject positive and extended RFB clipboard lengths above 256 KiB at the header. Bound retained parser data without rejecting valid coalesced messages.
  2. Use the existing connection/profile predicate in the portal's lease listener.
  3. Do not match a legacy sidebar group to an unrelated remote bot with the same name.
  4. Reset preview state on bot ownership changes, including pending or rejected thumbnail requests.
  5. On intentional pane closure, send 1000 before noVNC's statusless close. Reconnect teardown remains separate and preserves the human lease.
  6. Make Linux CLI screen stop recover a stranded lease even after the desktop has exited. Do not add a Unix-only import on unsupported hosts.

Verification

  • 32 Python tests pass on native Linux, including the original nine Bot Desktop/display files and two new test files.
  • 11 focused renderer tests pass. Each repaired behavior was reproduced before its fix, with positive controls for legitimate input, matching routes, reconnection and stale responses.
  • 10 live checks pass using real Xvnc/Xfce, noVNC 1.7.0 and Chromium in an isolated Debian arm64 container: framebuffer streaming, server-side watcher gating, real terminal input, takeover/eviction, capture refusal, reconnect, statusless close 1005, intentional close 1000, abnormal drop 1006, oversized-header rejection 1003.
  • 1,000 randomized fragmented RFB streams pass, with holder and watcher gates.
  • tsc --noEmit, changed-file ESLint/Ruff, Windows-footgun scan of changed production Python, compatibility-pointer check, subprocess-stdin check and git diff --check pass.

The broad macOS Python run was not green: 19,581 passed, 95 failed, 340 skipped. Re-ran all failing files on the unmodified PR head: the same 95 exact test node IDs fail, with no additional failing node from these patches.

The full Desktop run had 9,789 passing tests, one failing test and six skips, plus two collection failures from an initially uninstalled Electron binary. Installing the pinned binary resolved both collection failures. The remaining managed-SSH test fails identically on the unmodified PR on this macOS host.

Reproduction script, live receipt, exact baseline comparison and verification scope.

Limits

The live fixture exercises the production display route and ticket store, but mints tickets directly on loopback. It is not a packaged Electron or authenticated main-gateway acceptance test. No real credentials, login accounts or external sites were used. Native Windows, managed Cloud, distro installers and the other reported P1s remain outside this patch. This is not a blanket approval of #108914.

Credit

The original findings came from @carlotestor, @Julientalbot, @MrD1az, @Xipong, @erosika, @BearHuddleston, @helix4u and the reviewers who corroborated them. This PR supplies patches and additional executed evidence rather than re-filing their findings.

teknium1 and others added 14 commits September 12, 2026 12:53
…with human take-over

A bot running on a headless Linux gateway now gets its own desktop (TigerVNC
Xvnc + a minimal Xfce session, one per profile) that Hermes Desktop streams
live. The user can watch the bot work, take over to type a login / 2FA code /
CAPTCHA, and hand control back; the bot refuses every computer_use action
(screenshots included) while a human holds the screen, then resumes with the
session cookies the human just created.

Why this shape:
- The screen lives on the machine Hermes runs on, not in a vendor cloud browser,
  so it works for any app the bot drives and keeps the session on the user's host.
- Xfce components are launched individually (xfwm4, xfce4-panel, xfdesktop,
  xfsettingsd) under a private dbus session rather than xfce4-session/the
  metapackage: no screensaver, power manager or polkit agent to lock or prompt
  a headless desktop.
- Transport is raw RFB over a WebSocket beside /api/ws, authenticated with a
  one-shot ticket minted through the already-authenticated RPC channel; noVNC
  runs in the Electron renderer. The bridge parses the RFB client stream and
  drops keyboard/pointer/clipboard (incl. QEMU Extended KeyEvent, which noVNC
  switches to once Xvnc advertises it) from any viewer that does not hold the
  lease, so viewOnly is enforced server-side, not by the client.
- One lease per profile (agent | human viewer) is the single truth for the RFB
  bridge, the computer_use tool and the Desktop UI; taking control evicts other
  viewers' input with close code 4000 control-taken.
- Auto-start happens only at the computer_use tool boundary (headless host,
  packages present, bot_desktop.auto_start=true); env builders stay pure so
  status probes and tests never spawn X servers. tests/tools/conftest.py pins
  the binaries to "missing" for the same reason the browser-use fixture does.

Surfaces: Desktop (Bots → right-click → Open Screen; Take over / Hand back),
CLI (`hermes computer-use screen status|start|stop|install-deps`), tool
(`computer_use` actions request_handoff / wait_for_human), gateway RPCs
(display.status/start/stop/observe/lease.acquire/lease.release + display.lease
event), docs page user-guide/features/bot-screen.
After a server-side eviction (4000 control-taken) or stream loss noVNC has already
torn the client down; detach() then called disconnect() on it and noVNC logged
"Tried changing state of a disconnected RFB object". Clear the ref in the
disconnect listener so teardown only touches a live client.
The declaration lived in src/types/novnc.d.ts, which .gitignore drops
(apps/desktop/src/**/*.d.ts is ignored except for an allowlist), so the
pushed tree failed typecheck with TS7016 while the local worktree passed.
An ambient 'declare module' needs a script-scoped declaration file, so it
joins vite-env.d.ts rather than the module-scoped global.d.ts.
…kage install from Desktop

Three ways in, one install path, no shell required.

- Screen portal box: a compact card ("Screen · Live / Stopped / Not installed on
  host", who holds control) rendered at the top of a bot's Scheduled Jobs pane
  above the routines, and under each gateway/profile group header in the
  Sessions sidebar (new `sidebar.gatewayGroup.header` contribution area, so
  the plugin owns the box and core only exposes the slot). Clicking it opens
  the Screen pane; it reads the same display.status/lease events as the pane.
- Install from Desktop: `display.install` runs the distro package command on
  the gateway host (apt/dnf/pacman) as a supervised child, streams
  `display.install.log`, and finishes with `display.install.done`. When sudo
  needs a password the host raises `display.install.sudo.request` on the
  caller's own WebSocket; the renderer shows the existing masked SudoDialog
  (pointed at `display.install.sudo.respond`), so the password never touches
  the renderer store as plaintext beyond the field, is redacted from the
  gateway trace like `sudo.respond`, and an empty answer cancels without
  spawning the package manager. One install per profile at a time. The pane's
  "packages missing" state is now an install card with the command shown for
  the shell-inclined.
- Opt-in stays intact: nothing installs on `hermes update`; the only triggers
  are the Desktop button and `hermes computer-use screen install`.

Why the SudoDialog fallback to the app-level card: the install belongs to the
connection, not to a chat turn, so the request has no session id; the focused
chat's dialog now also shows the session-less card instead of it dying unseen.

Live (headless Electron via CDP, `hermes serve` with the packages hidden):
portal in Scheduled Jobs → click → Screen pane → Install on host → sudo card →
Cancel → "Install cancelled" and the card returns; with the packages present:
portal → Start → live stream, portal flips to "Live · bot in control"; the
same portal renders under the `default` profile in the Sessions sidebar.
…ed Jobs pane

The bot's computer is now the first thing in its pane: a big 16:10 box above the
title that shows an actual picture of the desktop, refreshed every 4 s while the
hero is on screen (paused when scrolled away or the window is hidden). Caption
carries who holds control; the chip reads Open live / Start / Install. Clicking
the picture expands into the live Screen pane where the user can take over.

- `display.thumbnail` RPC: one JPEG grab of the profile's Xvnc display via
  Pillow's ImageGrab (XAUTHORITY from the launcher's published env), scaled to
  <=960x600, returned as a data URL. Read-only: it never touches the lease, so a
  human in control is not disturbed and the bot is not blocked.
- `ScreenHero` replaces the small portal row in the routines pane; the compact
  `ScreenPortal` stays for the Sessions sidebar group header where a 16:10 box
  would crowd the list.

Live: hero "Screen is off" → click → pane → Start → hero shows the Xfce desktop
with "Live · bot in control"; an xmessage window opened on the bot's display
appeared in the hero on the next refresh; clicking the hero opened the live pane
(canvas + Take over).
… theme, curated dock

The vendor Xfce panel layout (copied only to silence the first-run dialog) put a
light grey bar with dead launchers on every bot screen: File Manager and Text
Editor pointed at Thunar/Mousepad we deliberately do not install, Web Browser
opened exo's "pick a browser" dialog. It read as an unconfigured VM, and so did
the thumbnail in Desktop.

- Wallpaper: `tools/bot_desktop/wallpaper.png` (the Nous gradient), seeded via
  xfconf as a zoomed backdrop over the existing colour fallback.
- Dark theme: first dark GTK theme the host ships (Adwaita-dark, Breeze-Dark,
  Greybird-dark, Arc-Dark, else Adwaita), dark icon theme likewise, xfwm4 Default
  decorations, DejaVu fonts; both panels dark with slight translucency.
- Own panel layout: top bar = menu · task list · tray · clock; bottom dock =
  only launchers whose program exists on this host, the browser pinned to the
  one the bot drives (chrome → chromium → firefox) so a human who takes over
  lands in the bot's own browser profile. Anything the user installs still
  appears in the Applications menu; the dock is the only curated part.
- `launcher.sh` and the wallpaper declared as package data (the launcher was
  already missing from sealed wheels).
- `HERMES_BD_SEED_ONLY=1` stops the launcher after seeding so the config tree is
  testable on a fake PATH without an X server.

Live: fresh screen shows the gradient, dark panels, two dock icons; XTEST clicks
on the dock opened xfce4-terminal and Chrome, both dark-themed and listed in the
task bar; the Desktop hero and Screen pane show the same picture.
…ight actions, sudo reply pinned to origin, dock Browser is the bot's browser, safe display reuse, install keeps profile scope

Independent review of the PR head found the control boundary only held inside
one process and several claims the code did not back. Each item below was
reproduced, fixed, covered by an invariant test proven red without the fix, and
re-verified live on a real Xvnc/Xfce screen.

- Lease authority on disk. `lease.json` under an fcntl lock in the profile's
  bot-desktop dir; every read goes to the file. `hermes serve` (viewer bridge),
  the messaging gateway, a CLI turn and isolated workers now agree. Live: a
  takeover in process A made `computer_use capture` in process B return
  human_has_control; release in C made B work again.
- Takeover fences admitted actions. `handle_computer_use` re-checks the lease
  under the dispatch lock and discards a result produced after the lease epoch
  changed, so an action admitted before a takeover cannot picture what the human
  typed during approval / backend start-up waits.
- Sudo reply pinned to its origin. `SudoRequest.origin` records the
  (connection, profile) the card came from; SudoDialog answers through
  `requestGatewayForAgent` on that socket, never the foreground gateway. A
  password typed for host A can no longer reach host B. `sudo.expire` and
  `display.install.sudo.expire` now tear the card down (the Desktop never
  handled sudo.expire).
- Dock Browser IS the bot's browser. `tools/bot_desktop/browser.py` resolves one
  identity — the Chromium agent-browser drives + a persistent per-profile
  user-data-dir (`bot-desktop/browser-profile`) — and both sides use it: the
  agent env gets AGENT_BROWSER_EXECUTABLE_PATH / AGENT_BROWSER_PROFILE, the
  dock launcher gets the same exe + --user-data-dir. Live: the bot wrote
  localStorage on http://127.0.0.1:8765 through agent-browser; a dock click and
  a typed URL on the screen showed BOT-WROTE-THIS in Chrome for Testing.
- Safe display reuse. Allocation under a host-wide lock; a recorded number is
  reused only when no live server holds it; the launcher never unlinks a lock
  whose pid is alive. Live: A stopped, B took :20, A restarted on :21, B kept
  running.
- Install worker keeps the caller's profile scope (copy_context carries the
  HERMES_HOME override and the transport); the done event carries the requested
  profile's status.
- Honest scope: `bot_desktop.auto_start` defaults to false (opt-in; Start lives
  in the Screen pane); request_handoff no longer claims a Telegram/Discord
  message was sent — the model relays the ask in its reply; docs match.
…ames marked, lease fails closed

Second review round (@Julientalbot):

- Desktop `display.*` listeners (lease, install log/done) match on (connectionId, profile_key),
  not the profile path alone: two hosts with the same ~/.hermes path no longer repaint each
  other's screen pane or install card. One predicate, `isEventForBotScreen`.
- Hero thumbnail: three consecutive failed refreshes dim the last frame and caption it
  "Last seen — screen unreachable"; a dead gateway never keeps looking live.
- Lease file present but unparsable reads as HUMAN holds (fail closed); only a missing file is
  a fresh agent-held profile. The next successful write repairs it.
- Taking over after `request_handoff` keeps the agent's reason on the lease and the pane shows
  it while the human acts, not only before they clicked Take over.
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 NousResearch#108914.
Use the connection/profile event predicate in the portal, never match a legacy group to a remote namesake, and reset thumbnail state on owner changes. Component tests cover cross-host events, click routing, pending/rejected thumbnails, and stale responses.

Addresses findings from Julientalbot, erosika, and BearHuddleston on NousResearch#108914.
Send close code 1000 before noVNC can send its statusless close. Keep reconnect teardown statusless so it does not release the human lease. Cover both lifecycle paths with component tests; verify the ordering against real noVNC and Xvnc separately.

Addresses the close-code finding discussed by MrD1az, Xipong, and other reviewers on NousResearch#108914.
Match the existing gateway stop contract on supported hosts, including when the desktop has already exited. Keep the Linux lease import off unsupported hosts. Native Linux test exercises the CLI parser and real persisted lease.

Addresses the CLI recovery finding from MrD1az and subsequent reviewers on NousResearch#108914.

@gaoanze888 gaoanze888 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clipboard-header rejection, viewer close ordering, and route-scoped renderer ownership changes are useful, but three high-risk lifecycle/resource gaps remain at exact head e38eb76c6fefb64a9de094a17c6d8b4b243d7731:

  1. CLI recovery releases human exclusion before confirming Desktop stopped. screen stop calls lease.release() before runtime.stop(). If stop raises (for example killpg returns PermissionError), the command exits with the Desktop still live but the persisted holder changed to agent, allowing automated screen actions while a human may still be interacting. I reproduced that state transition with a raising stop stub. Release only after a successful stop, while preserving the already-exited recovery case.

  2. The persisted PID is not identity-bound before killing its process group. Any numeric live PID from launcher.pid is accepted, then SIGTERM/SIGKILL targets its entire process group. After crash/PID reuse—or same-user state-file modification—screen stop can terminate an unrelated group. Persist and verify a process identity (at minimum start time plus expected launcher/session characteristics) and reject symlink/non-regular PID state before signaling. Add stale/reused PID tests.

  3. The advertised RFB bound does not bound frame/connection memory. feed() consumes input in _MAX_BUFFER slices but appends every accepted message to one unbounded output bytearray; the display WebSocket route accepts frames up to 384 MiB and passes the complete frame into feed(). A coalesced frame of valid small RFB messages therefore retains the original frame plus an equivalently large output allocation. A 20 MiB local valid-message probe returned a 20 MiB result despite _MAX_BUFFER being ~256 KiB. Stream bounded output to the bridge or impose a suitably small display-route frame cap, and test peak/batch behavior rather than only parser-buffer size.

Residual concurrency risk: runtime.start() checks live PID before publishing its own PID without a lifecycle lock, so concurrent starts can both pass and overwrite launcher.pid.

Focused Python runtime/RFB/lease/CLI tests pass 19/19 with one platform skip; Ruff and diff checks are clean.

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/desktop Electron desktop app (apps/desktop/*) comp/cli CLI entry point, hermes_cli/, setup wizard labels Sep 12, 2026
@teknium1
teknium1 force-pushed the hermes/hermes-b802e898 branch from d947fc8 to d65af42 Compare September 13, 2026 01:15
@teknium1

Copy link
Copy Markdown
Collaborator

Thanks — all four commits (f96333d, 7bc75c0, b9ccd54, e38eb76) are cherry-picked into #108914 with your authorship preserved (the _MAX_BUFFER chunking wrapper was trimmed to the header cap, which alone bounds the buffer). Leaving this open until #108914 lands, then I'll close it with the landed SHAs.

@whyyagswhy

Copy link
Copy Markdown
Author

Follow-up on the review findings against e38eb76:

  1. Release-before-stop: confirmed and fixed on fix/bot-screen-review-gaps (reorder + red-proven test that a raising stop keeps the lease). Ready to cherry-pick wherever these lines land.

  2. PID identity before killpg: agreed. The natural shape is persisting the launcher start time next to launcher.pid at publish and verifying liveness-plus-start match before signaling, with non-regular/symlink state rejected. Needs the launcher write path, so flagging rather than guessing at it here.

  3. Unbounded RFB output: confirmed reading feed(), the accepted-message accumulator has no cap while the parser does. Fix direction is a bounded output with drop/compact policy plus a display-route frame cap well under 384 MiB, with a peak-allocation test like the 20 MiB probe. Same treatment: say the word and I will implement it against the landing tree.

Residual start/start race: agreed, the check-then-publish on launcher.pid wants the lifecycle lock.

Thanks for the precise review, each finding reproduced or confirmed in code before answering.

@whyyagswhy

Copy link
Copy Markdown
Author

All three findings addressed on the rebased branch (now on current main), each red-proven:\n\n1. Lease-before-stop: CLI path reordered (raising stop stub keeps the lease, already-exited still releases). The gateway twin had the identical bug and is fixed the same way with 2 new tests.\n2. PID identity: launcher now records pid plus create_time, session id, and cmdline; stop signals only on exact match including session-leader check, rejects symlink/non-regular state, cleans stale records. 6 new tests (reused-PID killpg booby-trapped, real sleep child for the live path).\n3. Frame memory: feed() caps per-call output at 1 MiB, display route refuses single frames over 1 MiB with close 1009. 5 MB batch raises, chunked 5 MB streams with peak under 1 MiB byte-identical, oversize WS frame dropped unforwarded.\n\n46 Python tests green, ruff and diff checks clean, tsc clean, screen-connection plus isolation suites 7/7 green. The residual concurrent-starts lock is deferred: serializing needs a cross-process lock whose test requires multiprocess orchestration, fcntl won't exclude same-process threads.

@teknium1
teknium1 deleted the branch NousResearch:hermes/hermes-b802e898 September 23, 2026 11:03
@teknium1 teknium1 closed this Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants