AnyBot/src/vnc.rs

328 lines
13 KiB
Rust
Raw Normal View History

People watch and help on the bots' computer through VNC (noVNC), carried by the account server The core: the bot works the computer; a person watches it like a colleague, and takes over when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on phones), so the pictures sent one by one go, and with them their own input path. - vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and offers the viewer no password, so the password never leaves the brain. The person's clicks and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box (any language) is pasted in the box. - Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled); /relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to 160 ms more than needed. - Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu. - Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone. - Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window through accessibility and failed without 輔助使用 now and then. Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median; headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
// People watching and helping on the bots' computer: VNC, carried to them as a stream.
//
// The box already runs a VNC server (x11vnc, inside the container, with a password). A person's
// viewer (noVNC in a browser: the phone, or any computer through the account server) speaks VNC
// to this proxy, which reaches the box's server through one `docker exec` (a few lines of
// Python in the box joining its stdin/stdout to 127.0.0.1:5900). VNC sends only what changed on
// the screen and every mouse move and key as it happens, so watching is live and helping is
// like sitting at it — unlike pictures sent one by one.
//
// The proxy signs in to the box with its password itself and offers the viewer "no password",
// so the password never leaves this AnyBot; whoever reaches the proxy was already let in (an
// account session or one-time ticket at the account server, or the phone's password here).
// While the person clicks or types, the bot's own steps wait (HELD_BY_PERSON), and go on a
// little after they stop (person_active).
use std::io::{Read, Write};
use std::process::{Child, ChildStdin};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// The bot waits this long after the person's last click or key before it goes on.
const HELD_AFTER_INPUT: Duration = Duration::from_secs(8);
/// Joins the box's VNC server to stdin/stdout, and ends when either side does (a loop left in
/// the box would hold the connection open).
const BRIDGE: &str = r#"import os,select,socket
s=socket.create_connection(('127.0.0.1',5900))
while True:
r=select.select([s,0],[],[])[0]
if s in r:
d=s.recv(65536)
if not d: break
os.write(1,d)
if 0 in r:
d=os.read(0,65536)
if not d: break
s.sendall(d)
"#;
/// Viewers connected now (the box stays awake while there is one).
static OPEN: AtomicUsize = AtomicUsize::new(0);
/// When the person last clicked or typed through a viewer.
static LAST_INPUT: Mutex<Option<Instant>> = Mutex::new(None);
/// VNC authentication: the 16-byte challenge encrypted with DES, the key being the password's
/// first 8 bytes with each byte's bits reversed (RFC 6143 §7.2.2, as every VNC server does it).
pub fn vnc_response(password: &str, challenge: &[u8; 16]) -> [u8; 16] {
use des::cipher::{BlockEncrypt, KeyInit};
let mut key = [0u8; 8];
for (k, b) in key.iter_mut().zip(password.bytes()) {
*k = b.reverse_bits();
}
let c = des::Des::new_from_slice(&key).expect("8-byte key");
let mut out = *challenge;
for block in out.chunks_mut(8) {
c.encrypt_block(des::cipher::generic_array::GenericArray::from_mut_slice(block));
}
out
}
/// This proxy's first words to a viewer, as a VNC server: version 3.8 (then "no password"; see
/// Session::feed).
pub const GREETING: &[u8] = b"RFB 003.008\n";
/// One person's viewer joined to the box.
pub struct Session {
child: Child,
stdin: ChildStdin,
/// The viewer's side of the handshake still to read: its version (12 bytes) and its choice
/// of security type (1 byte).
greeting: usize,
/// Viewer → box messages not yet complete (to notice clicks and keys).
pending: Vec<u8>,
sniffing: bool,
/// The viewer's ClientInit (one byte) is still to come.
init: bool,
}
/// Open a viewer's way to the box: sign in to its VNC server, and start sending what the server
/// says to `out` (from a thread of its own; `out` returns false when the viewer is gone, and
/// gets an empty slice when the box's side ended). The caller then sends the viewer GREETING
/// (once it is ready for the viewer's answer).
pub fn open(out: impl Fn(&[u8]) -> bool + Send + 'static) -> Result<Session, String> {
crate::cloudbox::ensure_running()?;
let mut child = crate::cloudbox::exec_piped(&["python3", "-c", BRIDGE])?;
let mut stdin = child.stdin.take().ok_or("連不上這台電腦的畫面")?;
let mut stdout = child.stdout.take().ok_or("連不上這台電腦的畫面")?;
let fail = |child: &mut Child, why: &str| -> String {
let _ = child.kill();
let _ = child.wait();
format!("連不上這台電腦的畫面:{why}")
};
let mut read = |n: usize| -> Result<Vec<u8>, String> {
let mut b = vec![0u8; n];
stdout.read_exact(&mut b).map(|_| b).map_err(|_| "畫面伺服器沒有回應".to_string())
};
// The box's side, as a VNC client: version 3.8, then its password.
let signed_in = (|| -> Result<(), String> {
let v = read(12)?;
if !v.starts_with(b"RFB 003.") {
return Err("不是 VNC 伺服器".into());
}
stdin.write_all(b"RFB 003.008\n").map_err(|e| e.to_string())?;
let n = read(1)?[0] as usize;
if n == 0 {
return Err("畫面伺服器拒絕連線".into());
}
let types = read(n)?;
if types.contains(&2) {
stdin.write_all(&[2]).map_err(|e| e.to_string())?;
let c: [u8; 16] = read(16)?.try_into().map_err(|_| "認證失敗".to_string())?;
stdin.write_all(&vnc_response(&crate::cloudbox::box_vnc_password(), &c)).map_err(|e| e.to_string())?;
} else if types.contains(&1) {
stdin.write_all(&[1]).map_err(|e| e.to_string())?;
} else {
return Err("畫面伺服器要的登入方式不支援".into());
}
if read(4)? != [0, 0, 0, 0] {
return Err("畫面伺服器的密碼不對".into());
}
Ok(())
})();
if let Err(e) = signed_in {
return Err(fail(&mut child, &e));
}
OPEN.fetch_add(1, Ordering::SeqCst);
crate::cloudbox::set_viewer_open(true);
std::thread::spawn(move || {
let mut buf = vec![0u8; 64 * 1024];
loop {
match stdout.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if !out(&buf[..n]) {
return;
}
}
}
}
// The box's side ended: an empty send says so.
out(&[]);
});
Ok(Session { child, stdin, greeting: 13, pending: Vec::new(), sniffing: true, init: true })
}
impl Session {
/// Bytes from the viewer. False when the box's side is gone.
pub fn feed(&mut self, mut b: &[u8], reply: impl Fn(&[u8]) -> bool) -> bool {
if self.greeting > 0 {
let take = self.greeting.min(b.len());
let before = self.greeting;
self.greeting -= take;
b = &b[take..];
// After the version (12 bytes): the one security type, "None".
if before > 1 && self.greeting <= 1 && !reply(&[1, 1]) {
return false;
}
// After the choice: OK.
if self.greeting == 0 && !reply(&[0, 0, 0, 0]) {
return false;
}
if b.is_empty() {
return true;
}
}
self.notice_input(b);
self.stdin.write_all(b).and_then(|_| self.stdin.flush()).is_ok()
}
/// Follow the viewer's messages (RFC 6143 §7.5) to see clicks and keys. Anything it doesn't
/// know stops the watching, never the forwarding.
fn notice_input(&mut self, b: &[u8]) {
if !self.sniffing {
return;
}
self.pending.extend_from_slice(b);
// The first byte after the handshake is ClientInit (the shared flag).
if self.init && !self.pending.is_empty() {
self.pending.remove(0);
self.init = false;
}
let mut at = 0;
loop {
Whole control flow checked against real chats: what the bot sees is what is there, every step lands, nothing done twice Web - web_fetch kept pages with a <header> only up to it (taken for <head>): articles came back as their menus. - browser_open waits for the page to load and returns it like browser_read (one step fewer per page); actions wait for the page to hold still instead of fixed 1-2.5 s sleeps; one way to reach the tab. The bot's computer (box v6) - Numbers only on what can be seen: clipped to the window and the page's visible area, none under another app's window (a link scrolled under the toolbar was numbered on Back). - press_key with a typed character no longer swallows the keys after it (xdotool type --args 1). - The person's input through noVNC/TigerVNC is noticed again (fence, continuous updates, QEMU keys). - "Nothing changed" only after clicks, scrolls and drags; long text is pasted; one screen step at a time; one docker inspect per step when the box is known ready; poppler-utils for long PDFs. - One typing path and fewer round trips (stdin variant, unused picture path, extra batch execs gone). The person's computers - Linux: the window system's front app when it has one, and the reason when there is none (was 「」); hidden and off-screen controls get no number; wording no longer names a tool that doesn't exist. The loop - Without Docker, the bot's own computer runs no commands where there is no sandbox (Linux, Windows): it was a plain shell on the brain itself, keys in reach, asked nothing. - The bot's own computer is re-checked each step; unreachable computers are failures, not safety blocks; no direct-mode note on every result; an empty reply is asked again; scrolling and keys aren't counted as going in circles; steps aren't moved to a computer that can't do them. - One finish() for every step's result, keep_grant(), dead appctl list_computers/SERVING and the impossible server branches removed; duplicated prompt instructions merged. Tests: 162 (Mac) / 166 (Linux) unit; real box drills 6/6 on box v6; a brain without Chrome browses on its box; remote E2E 28/28. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 09:44:59 +00:00
match next_message(&self.pending[at..]) {
Msg::Need => break,
Msg::Unknown => {
People watch and help on the bots' computer through VNC (noVNC), carried by the account server The core: the bot works the computer; a person watches it like a colleague, and takes over when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on phones), so the pictures sent one by one go, and with them their own input path. - vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and offers the viewer no password, so the password never leaves the brain. The person's clicks and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box (any language) is pasted in the box. - Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled); /relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to 160 ms more than needed. - Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu. - Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone. - Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window through accessibility and failed without 輔助使用 now and then. Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median; headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
self.sniffing = false;
self.pending.clear();
return;
}
Whole control flow checked against real chats: what the bot sees is what is there, every step lands, nothing done twice Web - web_fetch kept pages with a <header> only up to it (taken for <head>): articles came back as their menus. - browser_open waits for the page to load and returns it like browser_read (one step fewer per page); actions wait for the page to hold still instead of fixed 1-2.5 s sleeps; one way to reach the tab. The bot's computer (box v6) - Numbers only on what can be seen: clipped to the window and the page's visible area, none under another app's window (a link scrolled under the toolbar was numbered on Back). - press_key with a typed character no longer swallows the keys after it (xdotool type --args 1). - The person's input through noVNC/TigerVNC is noticed again (fence, continuous updates, QEMU keys). - "Nothing changed" only after clicks, scrolls and drags; long text is pasted; one screen step at a time; one docker inspect per step when the box is known ready; poppler-utils for long PDFs. - One typing path and fewer round trips (stdin variant, unused picture path, extra batch execs gone). The person's computers - Linux: the window system's front app when it has one, and the reason when there is none (was 「」); hidden and off-screen controls get no number; wording no longer names a tool that doesn't exist. The loop - Without Docker, the bot's own computer runs no commands where there is no sandbox (Linux, Windows): it was a plain shell on the brain itself, keys in reach, asked nothing. - The bot's own computer is re-checked each step; unreachable computers are failures, not safety blocks; no direct-mode note on every result; an empty reply is asked again; scrolling and keys aren't counted as going in circles; steps aren't moved to a computer that can't do them. - One finish() for every step's result, keep_grant(), dead appctl list_computers/SERVING and the impossible server branches removed; duplicated prompt instructions merged. Tests: 162 (Mac) / 166 (Linux) unit; real box drills 6/6 on box v6; a brain without Chrome browses on its box; remote E2E 28/28. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 09:44:59 +00:00
Msg::Is { len, input } => {
if input {
person_acted();
}
at += len;
}
People watch and help on the bots' computer through VNC (noVNC), carried by the account server The core: the bot works the computer; a person watches it like a colleague, and takes over when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on phones), so the pictures sent one by one go, and with them their own input path. - vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and offers the viewer no password, so the password never leaves the brain. The person's clicks and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box (any language) is pasted in the box. - Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled); /relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to 160 ms more than needed. - Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu. - Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone. - Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window through accessibility and failed without 輔助使用 now and then. Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median; headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
}
}
self.pending.drain(..at);
}
}
Whole control flow checked against real chats: what the bot sees is what is there, every step lands, nothing done twice Web - web_fetch kept pages with a <header> only up to it (taken for <head>): articles came back as their menus. - browser_open waits for the page to load and returns it like browser_read (one step fewer per page); actions wait for the page to hold still instead of fixed 1-2.5 s sleeps; one way to reach the tab. The bot's computer (box v6) - Numbers only on what can be seen: clipped to the window and the page's visible area, none under another app's window (a link scrolled under the toolbar was numbered on Back). - press_key with a typed character no longer swallows the keys after it (xdotool type --args 1). - The person's input through noVNC/TigerVNC is noticed again (fence, continuous updates, QEMU keys). - "Nothing changed" only after clicks, scrolls and drags; long text is pasted; one screen step at a time; one docker inspect per step when the box is known ready; poppler-utils for long PDFs. - One typing path and fewer round trips (stdin variant, unused picture path, extra batch execs gone). The person's computers - Linux: the window system's front app when it has one, and the reason when there is none (was 「」); hidden and off-screen controls get no number; wording no longer names a tool that doesn't exist. The loop - Without Docker, the bot's own computer runs no commands where there is no sandbox (Linux, Windows): it was a plain shell on the brain itself, keys in reach, asked nothing. - The bot's own computer is re-checked each step; unreachable computers are failures, not safety blocks; no direct-mode note on every result; an empty reply is asked again; scrolling and keys aren't counted as going in circles; steps aren't moved to a computer that can't do them. - One finish() for every step's result, keep_grant(), dead appctl list_computers/SERVING and the impossible server branches removed; duplicated prompt instructions merged. Tests: 162 (Mac) / 166 (Linux) unit; real box drills 6/6 on box v6; a brain without Chrome browses on its box; remote E2E 28/28. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 09:44:59 +00:00
enum Msg {
/// Not all of it has arrived yet.
Need,
/// A message this doesn't know (watching stops; forwarding goes on).
Unknown,
/// A whole message of `len` bytes; `input` when it is the person pressing a key or button.
Is { len: usize, input: bool },
}
/// The viewer's next message (RFC 6143 §7.5 and the extensions noVNC uses with TigerVNC:
/// continuous updates, fences, desktop size, xvp and QEMU key events — without them the watching
/// stopped at the first fence, and the bot went on clicking while the person worked).
fn next_message(rest: &[u8]) -> Msg {
let Some(&kind) = rest.first() else { return Msg::Need };
let need = |n: usize| rest.len() >= n;
let len = match kind {
0 => 20,
2 if need(4) => 4 + 4 * u16::from_be_bytes([rest[2], rest[3]]) as usize,
3 => 10,
4 => 8,
5 => 6,
6 if need(8) => 8 + u32::from_be_bytes([rest[4], rest[5], rest[6], rest[7]]) as usize,
150 => 10,
248 if need(9) => 9 + rest[8] as usize,
250 => 4,
251 if need(7) => 8 + 16 * rest[6] as usize,
// QEMU extended key event (submessage 0).
255 if need(2) && rest[1] == 0 => 12,
2 | 6 | 248 | 251 | 255 if !need(9) => return Msg::Need,
_ => return Msg::Unknown,
};
if !need(len) {
return Msg::Need;
}
let input = match kind {
4 => rest[1] == 1,
5 => rest[1] != 0,
255 => u16::from_be_bytes([rest[2], rest[3]]) != 0,
_ => false,
};
Msg::Is { len, input }
}
People watch and help on the bots' computer through VNC (noVNC), carried by the account server The core: the bot works the computer; a person watches it like a colleague, and takes over when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on phones), so the pictures sent one by one go, and with them their own input path. - vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and offers the viewer no password, so the password never leaves the brain. The person's clicks and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box (any language) is pasted in the box. - Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled); /relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to 160 ms more than needed. - Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu. - Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone. - Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window through accessibility and failed without 輔助使用 now and then. Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median; headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
impl Drop for Session {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
if OPEN.fetch_sub(1, Ordering::SeqCst) == 1 {
crate::cloudbox::set_viewer_open(false);
}
}
}
/// The person clicked or typed: the bot's steps wait until a little after they stop.
pub fn person_acted() {
*LAST_INPUT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
}
/// The person is using the box through a viewer right now (input in the last few seconds).
pub fn person_active() -> bool {
LAST_INPUT.lock().unwrap_or_else(|e| e.into_inner()).map(|t| t.elapsed() < HELD_AFTER_INPUT).unwrap_or(false)
}
/// Text the person typed in the viewer's own box (any language): pasted into the box.
pub fn person_typed(text: &str) {
if !text.is_empty() {
person_acted();
crate::cloudbox::viewer_text(text);
}
}
#[cfg(test)]
mod tests {
use super::*;
Whole control flow checked against real chats: what the bot sees is what is there, every step lands, nothing done twice Web - web_fetch kept pages with a <header> only up to it (taken for <head>): articles came back as their menus. - browser_open waits for the page to load and returns it like browser_read (one step fewer per page); actions wait for the page to hold still instead of fixed 1-2.5 s sleeps; one way to reach the tab. The bot's computer (box v6) - Numbers only on what can be seen: clipped to the window and the page's visible area, none under another app's window (a link scrolled under the toolbar was numbered on Back). - press_key with a typed character no longer swallows the keys after it (xdotool type --args 1). - The person's input through noVNC/TigerVNC is noticed again (fence, continuous updates, QEMU keys). - "Nothing changed" only after clicks, scrolls and drags; long text is pasted; one screen step at a time; one docker inspect per step when the box is known ready; poppler-utils for long PDFs. - One typing path and fewer round trips (stdin variant, unused picture path, extra batch execs gone). The person's computers - Linux: the window system's front app when it has one, and the reason when there is none (was 「」); hidden and off-screen controls get no number; wording no longer names a tool that doesn't exist. The loop - Without Docker, the bot's own computer runs no commands where there is no sandbox (Linux, Windows): it was a plain shell on the brain itself, keys in reach, asked nothing. - The bot's own computer is re-checked each step; unreachable computers are failures, not safety blocks; no direct-mode note on every result; an empty reply is asked again; scrolling and keys aren't counted as going in circles; steps aren't moved to a computer that can't do them. - One finish() for every step's result, keep_grant(), dead appctl list_computers/SERVING and the impossible server branches removed; duplicated prompt instructions merged. Tests: 162 (Mac) / 166 (Linux) unit; real box drills 6/6 on box v6; a brain without Chrome browses on its box; remote E2E 28/28. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-06 09:44:59 +00:00
#[test]
fn a_persons_keys_are_seen_past_novncs_extra_messages() {
let walk = |b: &[u8]| -> (usize, bool, bool) {
let (mut at, mut input) = (0, false);
loop {
match next_message(&b[at..]) {
Msg::Need => return (at, input, false),
Msg::Unknown => return (at, input, true),
Msg::Is { len, input: i } => {
input |= i;
at += len;
}
}
}
};
let mut b = vec![150, 1, 0, 0, 0, 0, 5, 0, 3, 32]; // EnableContinuousUpdates
b.extend([248, 0, 0, 0, 0, 0, 0, 1, 2, 7, 7]); // ClientFence with 2 bytes of payload
b.extend([3, 1, 0, 0, 0, 0, 5, 0, 3, 32]); // FramebufferUpdateRequest
let (at, input, stopped) = walk(&b);
assert_eq!((at, input, stopped), (b.len(), false, false));
// A QEMU key press (noVNC's keys with TigerVNC), then its release.
let mut k = b.clone();
k.extend([255, 0, 0, 1, 0, 0, 0, 0x61, 0, 0, 0, 30]);
k.extend([255, 0, 0, 0, 0, 0, 0, 0x61, 0, 0, 0, 30]);
assert_eq!(walk(&k), (k.len(), true, false));
// A plain pointer click still counts; a half-arrived message waits for the rest.
assert_eq!(walk(&[5, 1, 0, 10, 0, 20]), (6, true, false));
assert_eq!(walk(&[248, 0, 0]), (0, false, false));
// Something unknown stops the watching.
assert!(walk(&[99, 0, 0, 0]).2);
}
People watch and help on the bots' computer through VNC (noVNC), carried by the account server The core: the bot works the computer; a person watches it like a colleague, and takes over when it needs them. VNC is made for that (only what changed, every mouse move and key, touch on phones), so the pictures sent one by one go, and with them their own input path. - vnc.rs: a VNC proxy to the box's x11vnc through one docker exec (a Python bridge that ends with either side). It signs in to the box with the box's own password (its VNC_PASSWORD) and offers the viewer no password, so the password never leaves the brain. The person's clicks and keys make the bot's steps wait until 8 s after they stop; text typed in the viewer's box (any language) is pasted in the box. - Account server: /screen (the viewer page) and /novnc.js (noVNC 1.6.0, MPL-2.0, bundled); /relay/vnc/<host> takes the browser's WebSocket (account session, or a 10-minute ticket the app asks for with POST /relay/screen-ticket; owners and admins) and carries it over that AnyBot's lasting connection (vopen / vdata / vtext / vclose). Both ends of that connection wait 2 ms instead of 40 ms to read while things move: a request and its answer took up to 160 ms more than needed. - Phone page: 看 Bot 的電腦 and 接管 open the viewer page (also on the local network: /api/vnc on the AnyBot's own phone server). This Mac's own screen moved to the ⋯ menu. - Mac app: 看畫面 opens the viewer in the browser (a screen of a brain: the brain's computer by ticket; else this computer's box, noVNC straight from it). The in-app picture viewer, the live JPEG stream, the person's click/move/scroll/key endpoints and the input shell are gone. - Opening a Mac app uses LaunchServices (`open -a`): agent-desktop's launch waited on the window through accessibility and failed without 輔助使用 now and then. Measured on this Mac (made-up account, local relay + headless brain + the real box): viewer signed in in 0.4–0.7 s, a request and its answer through relay + brain + box 25–58 ms median; headless Chrome shows the desktop live in /screen. remote_e2e 28/28 ×2, 149 unit tests, relay 11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:05:44 +00:00
#[test]
fn the_bridge_keeps_its_indentation() {
// Python needs its indentation (a Rust "\" line continuation would strip it).
assert!(BRIDGE.contains("\n r=select") && BRIDGE.contains("\n if not d: break"));
}
#[test]
fn vnc_password_answer_matches_the_standard() {
// Password "password", challenge 00..0f: DES-ECB with the bit-reversed key 0e86ceceeef64e26
// (worked out with `openssl enc -des-ecb -nopad`).
let c: [u8; 16] = core::array::from_fn(|i| i as u8);
let want = [0xb8, 0x66, 0x92, 0x41, 0x25, 0xc8, 0xee, 0xbb, 0x9d, 0xeb, 0xc1, 0xdb, 0x61, 0xc5, 0x38, 0xe2];
assert_eq!(vnc_response("password", &c), want);
// Only the first 8 bytes of a password count.
assert_eq!(vnc_response("password-and-more", &c), want);
}
}