AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
// AnyBot 帳號伺服器 (the thin backend).
//
// What it keeps: accounts (name + PBKDF2 password hash), signed-in phones (session tokens, stored
// hashed), the user's AnyBots ("hosts", tokens stored hashed), and one encrypted backup per account
// (sealed on the user's computer with a key made from the password; this server can't open it).
//
// What it does: each AnyBot keeps asking `GET /relay/host/next` (an outgoing connection, so the
// computer needs no open port). A phone signed in to the account calls `/h/<host>/api/...`; the
// request is handed to that AnyBot and its answer comes back. AnyBots of the same account can
// also reach each other (裝置連結) through `/relay/hosts/<host>/api/device/run`.
//
// Run: anybot-relay [--port 8080] [--data ./relay-data] (put HTTPS in front, e.g. Caddy)
// Sign-ups: the first account is free; after that ANYBOT_RELAY_INVITE must be set and given.
#[ path = " ../../src/httpd.rs " ]
mod httpd ;
2026-11-04 11:30:32 +00:00
#[ path = " ../../src/wire.rs " ]
mod wire ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
mod push ;
use httpd ::{ Req , Resp } ;
use serde ::{ Deserialize , Serialize } ;
use serde_json ::{ json , Value } ;
use std ::collections ::{ HashMap , VecDeque } ;
use std ::path ::PathBuf ;
use std ::sync ::mpsc ::{ channel , Sender } ;
2026-11-04 11:30:32 +00:00
use std ::sync ::atomic ::{ AtomicU64 , Ordering } ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
use std ::sync ::{ Arc , Condvar , Mutex } ;
use std ::time ::{ Duration , Instant } ;
const PAGE : & str = include_str! ( " ../../mobile/index.html " ) ;
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
/// The bots' computer, watched and used in a browser (noVNC).
const SCREEN_PAGE : & str = include_str! ( " ../../mobile/screen.html " ) ;
const NOVNC : & str = include_str! ( " ../../mobile/novnc.js " ) ;
2026-11-05 11:20:29 +00:00
const MANIFEST : & str = r ## "{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"},{"src":"icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}"## ;
/// AnyBot's icon (the same drawing as the app's), and PNGs for the iPhone home screen and Android.
const ICON : & str = include_str! ( " ../../mobile/icon.svg " ) ;
const ICON_PNGS : [ ( & str , & [ u8 ] ) ; 3 ] = [ ( " icon-180.png " , include_bytes! ( " ../../mobile/icon-180.png " ) ) , ( " icon-192.png " , include_bytes! ( " ../../mobile/icon-192.png " ) ) , ( " icon-512.png " , include_bytes! ( " ../../mobile/icon-512.png " ) ) ] ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
/// How long a host's "next request?" waits before answering "nothing".
const HOST_POLL_SECS : u64 = 25 ;
/// How long a phone's request waits for the AnyBot to answer (its own long-poll is 20 s).
const FORWARD_SECS : u64 = 35 ;
2026-11-05 07:48:20 +00:00
/// Model requests relayed to a person's computer (model_fetch) are ordinary LLM latency:
/// the asking side waits minutes, so the relay must not answer first with a timeout.
const LONG_FORWARD_SECS : u64 = 610 ;
2026-11-04 12:56:06 +00:00
/// How long a 配對碼 lasts.
const PAIR_SECS : u64 = 600 ;
2026-11-04 11:07:55 +00:00
/// How long a request may wait to be picked up before the host counts as gone.
const PICKUP_SECS : u64 = 8 ;
2026-11-04 11:30:32 +00:00
/// The lasting connection: a ping this often, and given up after this long without a word.
const WS_PING_SECS : u64 = 20 ;
const WS_DEAD_SECS : u64 = 60 ;
const GONE : & str = " 那台 AnyBot 斷線了(網路中斷,或 AnyBot 關掉了) " ;
const OFFLINE : & str = " 那台 AnyBot 目前離線(電腦可能關機、睡眠、斷網,或 AnyBot 沒開) " ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
/// A host counts as online when it asked within this many seconds.
const ONLINE_SECS : u64 = 60 ;
/// Sessions last this long without use.
const SESSION_DAYS : i64 = 90 ;
const MAX_FAILS : u32 = 8 ;
#[ derive(Clone, Serialize, Deserialize, Default) ]
struct User {
name : String ,
pass : String ,
created : i64 ,
}
#[ derive(Clone, Serialize, Deserialize, Default) ]
struct Session {
hash : String ,
user : String ,
device : String ,
created : i64 ,
last : i64 ,
}
#[ derive(Clone, Serialize, Deserialize, Default) ]
struct Host {
id : String ,
name : String ,
user : String ,
token_hash : String ,
created : i64 ,
#[ serde(default) ]
last_seen : i64 ,
/// 團隊: other accounts this AnyBot is shared with, and what they may do.
#[ serde(default) ]
members : Vec < Member > ,
}
#[ derive(Clone, Serialize, Deserialize, Default) ]
struct Member {
user : String ,
/// "admin": everything the owner can do on the phone (approve, screens);
/// "member": chat with the bots and attach files only.
role : String ,
}
impl Host {
/// The account's role on this AnyBot (None = no access).
fn role_of ( & self , user : & str ) -> Option < String > {
if self . user = = user {
return Some ( " owner " . into ( ) ) ;
}
self . members . iter ( ) . find ( | m | m . user = = user ) . map ( | m | m . role . clone ( ) )
}
}
#[ derive(Clone, Serialize, Deserialize, Default) ]
struct Sub {
user : String ,
sub : Value ,
created : i64 ,
}
#[ derive(Serialize, Deserialize, Default) ]
struct Data {
users : Vec < User > ,
sessions : Vec < Session > ,
hosts : Vec < Host > ,
/// Phones that turned notifications on.
#[ serde(default) ]
subs : Vec < Sub > ,
}
const SW : & str = r #" self.addEventListener( " install " , () => self.skipWaiting());
self . addEventListener ( " activate " , ( e ) = > e . waitUntil ( self . clients . claim ( ) ) ) ;
self . addEventListener ( " push " , ( e ) = > {
let d = { } ;
try { d = e . data ? e . data . json ( ) : { } ; } catch ( _ ) { d = { body : e . data ? e . data . text ( ) : " " } ; }
2026-11-05 11:20:29 +00:00
e . waitUntil ( self . registration . showNotification ( d . title | | " AnyBot " , { body : d . body | | " " , tag : " bot- " + ( d . bot | | 0 ) , renotify : true , data : d , icon : " icon-192.png " , badge : " icon-192.png " } ) ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
} ) ;
self . addEventListener ( " notificationclick " , ( e ) = > {
e . notification . close ( ) ;
const d = e . notification . data | | { } ;
const url = " ./#host= " + encodeURIComponent ( d . host | | " " ) + " &bot= " + ( d . bot | | " " ) ;
e . waitUntil ( self . clients . matchAll ( { type : " window " , includeUncontrolled : true } ) . then ( ( cs ) = > {
for ( const c of cs ) { c . postMessage ( { open : d } ) ; return c . focus ( ) ; }
return self . clients . openWindow ( url ) ;
} ) ) ;
} ) ;
" #;
2026-11-04 11:30:32 +00:00
/// A forwarded request's answer.
struct Answer {
status : u16 ,
ctype : String ,
headers : Vec < ( String , String ) > ,
body : Vec < u8 > ,
/// The body is deflate-compressed (only when the asker said it can read that).
z : bool ,
}
impl Answer {
fn error ( status : u16 , msg : & str ) -> Answer {
Answer { status , ctype : " application/json; charset=utf-8 " . into ( ) , headers : vec ! [ ] , body : json ! ( { " error " : msg } ) . to_string ( ) . into_bytes ( ) , z : false }
}
fn from_json ( v : & Value ) -> Answer {
use base64 ::Engine ;
Answer {
status : v [ " status " ] . as_u64 ( ) . unwrap_or ( 502 ) as u16 ,
ctype : v [ " ctype " ] . as_str ( ) . unwrap_or ( " application/octet-stream " ) . to_string ( ) ,
headers : v [ " headers " ] . as_array ( ) . into_iter ( ) . flatten ( ) . filter_map ( | h | Some ( ( h [ 0 ] . as_str ( ) ? . to_string ( ) , h [ 1 ] . as_str ( ) ? . to_string ( ) ) ) ) . collect ( ) ,
body : base64 ::engine ::general_purpose ::STANDARD . decode ( v [ " body " ] . as_str ( ) . unwrap_or ( " " ) ) . unwrap_or_default ( ) ,
z : v [ " z " ] . as_bool ( ) . unwrap_or ( false ) ,
}
}
fn into_resp ( self ) -> Resp {
let mut resp = Resp ::bytes ( & self . ctype , self . body ) ;
resp . status = self . status ;
for ( k , v ) in self . headers {
// Only harmless headers pass through.
if k . eq_ignore_ascii_case ( " x-size " ) {
resp . headers . push ( ( k , v ) ) ;
}
}
resp
}
}
/// An AnyBot's lasting connection: frames to write to it.
struct Sock {
tx : Sender < Vec < u8 > > ,
serial : u64 ,
}
static SERIAL : AtomicU64 = AtomicU64 ::new ( 1 ) ;
Clippy is clean on macOS and Linux, for the app and the relay
Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
is_multiple_of, while let, from_ref, next_back, struct init, no casts to
the same type.
Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-07 02:10:28 +00:00
/// A screen being watched: where the host's side goes, and that host.
type Stream = ( Sender < Vec < u8 > > , String ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
struct Relay {
dir : PathBuf ,
data : Mutex < Data > ,
/// Requests waiting for each host.
queues : Mutex < HashMap < String , VecDeque < Value > > > ,
arrived : Condvar ,
/// Where each forwarded request's answer goes.
2026-11-05 07:48:20 +00:00
/// Requests waiting for an answer: job id → (its answer, the host it was sent to —
/// so only that host may answer it).
waiting : Mutex < HashMap < String , ( Sender < Answer > , String ) > > ,
2026-11-04 11:30:32 +00:00
/// AnyBots connected the lasting way (WebSocket), and which requests went over which.
sockets : Mutex < HashMap < String , Sock > > ,
flights : Mutex < HashMap < String , ( String , u64 ) > > ,
2026-11-04 12:56:06 +00:00
/// The brain each AnyBot shows (when it is a screen of another), and which lend their hands.
2026-11-04 11:30:32 +00:00
uses : Mutex < HashMap < String , String > > ,
2026-11-04 12:56:06 +00:00
hands : Mutex < std ::collections ::HashSet < String > > ,
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 a bots' computer: stream id → (where the host's side goes, that host).
Clippy is clean on macOS and Linux, for the app and the relay
Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
is_multiple_of, while let, from_ref, next_back, struct init, no casts to
the same type.
Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-07 02:10:28 +00:00
streams : Mutex < HashMap < String , Stream > > ,
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
/// One-time links for a browser to watch a host's computer: ticket → (account, host, made).
tickets : Mutex < HashMap < String , ( String , String , Instant ) > > ,
2026-11-04 12:56:06 +00:00
/// 配對碼: code → (account, the AnyBot it is for, when made). Ten minutes, once.
pairs : Mutex < HashMap < String , ( String , String , Instant ) > > ,
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
/// When each host last asked (in memory; saved now and then).
seen : Mutex < HashMap < String , Instant > > ,
fails : Mutex < HashMap < String , ( u32 , Instant ) > > ,
invite : String ,
vapid : push ::Vapid ,
contact : String ,
}
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
/// How long a link the app opens for a browser works (reconnects included).
const TICKET_LIFE : Duration = Duration ::from_secs ( 600 ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
fn now ( ) -> i64 {
chrono ::Utc ::now ( ) . timestamp ( )
}
impl Relay {
fn load ( dir : PathBuf ) -> Relay {
let _ = std ::fs ::create_dir_all ( dir . join ( " backups " ) ) ;
let data : Data = std ::fs ::read ( dir . join ( " data.json " ) ) . ok ( ) . and_then ( | b | serde_json ::from_slice ( & b ) . ok ( ) ) . unwrap_or_default ( ) ;
let vapid = push ::Vapid ::load ( & dir ) ;
Relay {
dir ,
data : Mutex ::new ( data ) ,
queues : Mutex ::new ( HashMap ::new ( ) ) ,
arrived : Condvar ::new ( ) ,
waiting : Mutex ::new ( HashMap ::new ( ) ) ,
2026-11-04 11:30:32 +00:00
sockets : Mutex ::new ( HashMap ::new ( ) ) ,
flights : Mutex ::new ( HashMap ::new ( ) ) ,
uses : Mutex ::new ( HashMap ::new ( ) ) ,
2026-11-04 12:56:06 +00:00
hands : Mutex ::new ( std ::collections ::HashSet ::new ( ) ) ,
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
streams : Mutex ::new ( HashMap ::new ( ) ) ,
tickets : Mutex ::new ( HashMap ::new ( ) ) ,
2026-11-04 12:56:06 +00:00
pairs : Mutex ::new ( HashMap ::new ( ) ) ,
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
seen : Mutex ::new ( HashMap ::new ( ) ) ,
fails : Mutex ::new ( HashMap ::new ( ) ) ,
invite : std ::env ::var ( " ANYBOT_RELAY_INVITE " ) . unwrap_or_default ( ) ,
vapid ,
// Push services want a way to reach whoever runs the server.
contact : std ::env ::var ( " ANYBOT_RELAY_CONTACT " ) . ok ( ) . filter ( | c | c . starts_with ( " mailto: " ) | | c . starts_with ( " https:// " ) ) . unwrap_or_else ( | | " mailto:anybot-relay@example.com " . into ( ) ) ,
}
}
fn save ( & self , d : & Data ) {
let tmp = self . dir . join ( " data.json.tmp " ) ;
if std ::fs ::write ( & tmp , serde_json ::to_vec_pretty ( d ) . unwrap_or_default ( ) ) . is_ok ( ) {
let _ = std ::fs ::rename ( & tmp , self . dir . join ( " data.json " ) ) ;
}
}
fn session ( & self , req : & Req ) -> Option < String > {
let t = req . bearer ( ) ;
if t . is_empty ( ) {
return None ;
}
let h = httpd ::sha256_hex ( & t ) ;
let mut d = self . data . lock ( ) . unwrap ( ) ;
let cutoff = now ( ) - SESSION_DAYS * 86_400 ;
let s = d . sessions . iter_mut ( ) . find ( | s | s . hash = = h & & s . last > cutoff ) ? ;
// Count use at most once a minute (saves writing on every request).
if now ( ) - s . last > 60 {
s . last = now ( ) ;
let user = s . user . clone ( ) ;
self . save ( & d ) ;
return Some ( user ) ;
}
Some ( s . user . clone ( ) )
}
fn host ( & self , req : & Req ) -> Option < Host > {
let t = req . bearer ( ) ;
if t . is_empty ( ) {
return None ;
}
let h = httpd ::sha256_hex ( & t ) ;
let d = self . data . lock ( ) . unwrap ( ) ;
d . hosts . iter ( ) . find ( | x | x . token_hash = = h ) . cloned ( )
}
fn online ( & self , id : & str ) -> bool {
2026-11-04 11:30:32 +00:00
self . sockets . lock ( ) . unwrap ( ) . contains_key ( id ) | | self . seen . lock ( ) . unwrap ( ) . get ( id ) . map ( | t | t . elapsed ( ) . as_secs ( ) < ONLINE_SECS ) . unwrap_or ( false )
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
}
fn too_many_fails ( & self , key : & str ) -> bool {
let f = self . fails . lock ( ) . unwrap ( ) ;
matches! ( f . get ( key ) , Some ( ( n , t ) ) if * n > = MAX_FAILS & & t . elapsed ( ) < Duration ::from_secs ( 300 ) )
}
fn failed ( & self , key : & str ) {
let mut f = self . fails . lock ( ) . unwrap ( ) ;
let e = f . entry ( key . to_string ( ) ) . or_insert ( ( 0 , Instant ::now ( ) ) ) ;
if e . 1. elapsed ( ) > Duration ::from_secs ( 300 ) {
* e = ( 0 , Instant ::now ( ) ) ;
}
e . 0 + = 1 ;
e . 1 = Instant ::now ( ) ;
}
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
/// A person's browser watching (and maybe using) an AnyBot's computer: its VNC stream goes
/// to that AnyBot over its lasting connection, and back. Signed in by the account session
/// (?t=, the phone page) or a ticket the account's app asked for (?ticket=); owners and
/// admins only.
fn vnc_viewer ( self : & Arc < Self > , req : Req , mut c : std ::net ::TcpStream , host : & str ) {
use tungstenite ::{ protocol ::Role , Message , WebSocket } ;
let user = match req . query . get ( " ticket " ) . filter ( | t | ! t . is_empty ( ) ) {
Some ( t ) = > self . tickets . lock ( ) . unwrap ( ) . get ( t ) . filter ( | ( _ , h , made ) | h = = host & & made . elapsed ( ) < TICKET_LIFE ) . map ( | ( u , _ , _ ) | u . clone ( ) ) ,
None = > self . session ( & req ) ,
} ;
let Some ( user ) = user else { return httpd ::write_response ( & mut c , & Resp ::error ( 401 , " 請先登入 " ) ) } ;
let allowed = self . data . lock ( ) . unwrap ( ) . hosts . iter ( ) . any ( | x | x . id = = host & & matches! ( x . role_of ( & user ) . as_deref ( ) , Some ( " owner " ) | Some ( " admin " ) ) ) ;
if ! allowed {
return httpd ::write_response ( & mut c , & Resp ::error ( 404 , " 找不到這台 AnyBot " ) ) ;
}
let Some ( to_host ) = self . sockets . lock ( ) . unwrap ( ) . get ( host ) . map ( | s | s . tx . clone ( ) ) else {
return httpd ::write_response ( & mut c , & Resp ::error ( 503 , " 這台 AnyBot 現在沒有連線 " ) ) ;
} ;
Remove what nothing uses and keep one copy of what was written several times
Unused (found by taking away the dead-code allowances; nothing else used them, tests aside):
computer::specs, quiet_button, the never-made Poke::Spin, ten icons and their SVGs, the
theme's chrome/bubble_agent colours, ImportRow.line, voice's multipart/transcribe/speak
wrappers, and the old keychain writers vault::save/remove/index_add with their live test
(the vault store replaced them; reading old keychain entries for the import stays).
One copy each: percent-encoding (httpd::url_encode, was four), tool arguments (tools::args_of,
was three), the WebSocket handshake (httpd::accept_websocket, was three), the read pacing
(wire::Pace, was two: relay and app), docker with input and a time limit (dk_with_input, was
two). Docs: FEATURES §看畫面 (VNC), protocol (v* frames, /screen, tickets), architecture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:21:47 +00:00
if ! httpd ::accept_websocket ( & req , & mut c ) {
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
return ;
}
let mut ws = WebSocket ::from_raw_socket ( c , Role ::Server , None ) ;
let sid = httpd ::random_hex ( 8 ) ;
let ( tx , rx ) = channel ::< Vec < u8 > > ( ) ;
self . streams . lock ( ) . unwrap ( ) . insert ( sid . clone ( ) , ( tx , host . to_string ( ) ) ) ;
if to_host . send ( wire ::pack ( & json! ( { " t " : " vopen " , " sid " : sid } ) , & [ ] ) ) . is_err ( ) {
self . streams . lock ( ) . unwrap ( ) . remove ( & sid ) ;
return ;
}
let ( mut heard , mut pinged ) = ( Instant ::now ( ) , Instant ::now ( ) ) ;
Remove what nothing uses and keep one copy of what was written several times
Unused (found by taking away the dead-code allowances; nothing else used them, tests aside):
computer::specs, quiet_button, the never-made Poke::Spin, ten icons and their SVGs, the
theme's chrome/bubble_agent colours, ImportRow.line, voice's multipart/transcribe/speak
wrappers, and the old keychain writers vault::save/remove/index_add with their live test
(the vault store replaced them; reading old keychain entries for the import stays).
One copy each: percent-encoding (httpd::url_encode, was four), tool arguments (tools::args_of,
was three), the WebSocket handshake (httpd::accept_websocket, was three), the read pacing
(wire::Pace, was two: relay and app), docker with input and a time limit (dk_with_input, was
two). Docs: FEATURES §看畫面 (VNC), protocol (v* frames, /screen, tickets), architecture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:21:47 +00:00
let mut pace = wire ::Pace ::new ( ) ;
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
' live : loop {
pace . tune ( ws . get_ref ( ) ) ;
let up = match ws . read ( ) {
Ok ( Message ::Binary ( b ) ) = > Some ( ( " vdata " , b . to_vec ( ) ) ) ,
Ok ( Message ::Text ( t ) ) = > Some ( ( " vtext " , t . as_bytes ( ) . to_vec ( ) ) ) ,
Ok ( Message ::Close ( _ ) ) = > break ,
Ok ( _ ) = > None ,
Err ( tungstenite ::Error ::Io ( e ) ) if matches! ( e . kind ( ) , std ::io ::ErrorKind ::WouldBlock | std ::io ::ErrorKind ::TimedOut ) = > None ,
Err ( _ ) = > break ,
} ;
if let Some ( ( t , b ) ) = up {
heard = Instant ::now ( ) ;
pace . moved ( ) ;
if to_host . send ( wire ::pack ( & json! ( { " t " : t , " sid " : sid } ) , & b ) ) . is_err ( ) {
break ;
}
}
loop {
match rx . try_recv ( ) {
Ok ( b ) = > {
pace . moved ( ) ;
if ws . write ( Message ::Binary ( b . into ( ) ) ) . is_err ( ) {
break 'live ;
}
}
Err ( std ::sync ::mpsc ::TryRecvError ::Empty ) = > break ,
// The host closed it (or went away).
Err ( std ::sync ::mpsc ::TryRecvError ::Disconnected ) = > break 'live ,
}
}
if pinged . elapsed ( ) . as_secs ( ) > = WS_PING_SECS {
pinged = Instant ::now ( ) ;
if ws . write ( Message ::Ping ( Vec ::new ( ) . into ( ) ) ) . is_err ( ) {
break ;
}
}
match ws . flush ( ) {
Err ( tungstenite ::Error ::Io ( e ) ) if matches! ( e . kind ( ) , std ::io ::ErrorKind ::WouldBlock | std ::io ::ErrorKind ::TimedOut ) = > { }
Err ( _ ) = > break ,
Ok ( ( ) ) = > { }
}
if heard . elapsed ( ) . as_secs ( ) > = WS_DEAD_SECS {
break ;
}
}
let _ = ws . close ( None ) ;
let _ = ws . flush ( ) ;
self . streams . lock ( ) . unwrap ( ) . remove ( & sid ) ;
let _ = to_host . send ( wire ::pack ( & json! ( { " t " : " vclose " , " sid " : sid } ) , & [ ] ) ) ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
/// Hand a request to a host and wait for its answer.
fn forward ( & self , host_id : & str , method : & str , path : & str , query : & HashMap < String , String > , body : & [ u8 ] , who : ( & str , & str ) ) -> Resp {
2026-11-04 12:38:45 +00:00
self . forward_raw ( host_id , method , path , query , body , false , who , false , " " ) . into_resp ( )
2026-11-04 11:30:32 +00:00
}
/// `z`: the body is compressed; `z_ok`: the asker reads a compressed answer.
#[ allow(clippy::too_many_arguments) ]
2026-11-04 12:38:45 +00:00
/// `from`: the AnyBot of the account asking (empty for the phone).
fn forward_raw ( & self , host_id : & str , method : & str , path : & str , query : & HashMap < String , String > , body : & [ u8 ] , z : bool , who : ( & str , & str ) , z_ok : bool , from : & str ) -> Answer {
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
use base64 ::Engine ;
if ! self . online ( host_id ) {
2026-11-04 11:30:32 +00:00
return Answer ::error ( 503 , OFFLINE ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
}
let id = httpd ::random_hex ( 12 ) ;
2026-11-05 07:48:20 +00:00
let secs = if path = = " /api/device/run " { LONG_FORWARD_SECS } else { FORWARD_SECS } ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
let ( tx , rx ) = channel ( ) ;
2026-11-05 07:48:20 +00:00
self . waiting . lock ( ) . unwrap ( ) . insert ( id . clone ( ) , ( tx , host_id . to_string ( ) ) ) ;
2026-11-04 12:38:45 +00:00
let head = json! ( { " t " : " job " , " id " : id , " method " : method , " path " : path , " query " : query , " user " : who . 0 , " role " : who . 1 , " z " : z , " z_ok " : z_ok , " from " : from } ) ;
2026-11-04 11:30:32 +00:00
// Connected the lasting way: straight down that connection.
let sock = self . sockets . lock ( ) . unwrap ( ) . get ( host_id ) . map ( | s | ( s . tx . clone ( ) , s . serial ) ) ;
if let Some ( ( stx , serial ) ) = sock {
self . flights . lock ( ) . unwrap ( ) . insert ( id . clone ( ) , ( host_id . to_string ( ) , serial ) ) ;
let sent = stx . send ( wire ::pack ( & head , body ) ) . is_ok ( ) ;
2026-11-05 07:48:20 +00:00
let r = if sent { rx . recv_timeout ( Duration ::from_secs ( secs ) ) . ok ( ) } else { None } ;
2026-11-04 11:30:32 +00:00
self . waiting . lock ( ) . unwrap ( ) . remove ( & id ) ;
self . flights . lock ( ) . unwrap ( ) . remove ( & id ) ;
return match r {
Some ( a ) = > a ,
None if ! sent = > Answer ::error ( 503 , GONE ) ,
None = > Answer ::error ( 504 , " 那台 AnyBot 收到了,但太久沒有回應(可能太忙,或網路很慢) " ) ,
} ;
}
let mut job = head ;
job [ " body " ] = json! ( base64 ::engine ::general_purpose ::STANDARD . encode ( body ) ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
self . queues . lock ( ) . unwrap ( ) . entry ( host_id . to_string ( ) ) . or_default ( ) . push_back ( job ) ;
self . arrived . notify_all ( ) ;
2026-11-04 11:07:55 +00:00
// A running AnyBot always has a worker waiting, so it picks a request up within a moment.
// Still in the queue after a few seconds means it went away (closed, asleep, offline).
let mut r = rx . recv_timeout ( Duration ::from_secs ( PICKUP_SECS ) ) ;
if r . is_err ( ) {
let queued = self . queues . lock ( ) . unwrap ( ) . get ( host_id ) . map ( | q | q . iter ( ) . any ( | j | j [ " id " ] . as_str ( ) = = Some ( id . as_str ( ) ) ) ) . unwrap_or ( false ) ;
2026-11-04 11:30:32 +00:00
if ! queued {
2026-11-05 07:48:20 +00:00
r = rx . recv_timeout ( Duration ::from_secs ( secs . max ( PICKUP_SECS + 1 ) - PICKUP_SECS ) ) ;
2026-11-04 11:07:55 +00:00
}
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
self . waiting . lock ( ) . unwrap ( ) . remove ( & id ) ;
// Not picked up in time: drop it so it doesn't run late.
2026-11-04 11:30:32 +00:00
let was_queued = self . queues . lock ( ) . unwrap ( ) . get_mut ( host_id ) . map ( | q | {
let n = q . len ( ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
q . retain ( | j | j [ " id " ] . as_str ( ) ! = Some ( id . as_str ( ) ) ) ;
2026-11-04 11:30:32 +00:00
n ! = q . len ( )
} ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
match r {
2026-11-04 11:30:32 +00:00
Ok ( a ) = > a ,
Err ( _ ) if was_queued = = Some ( true ) = > Answer ::error ( 503 , OFFLINE ) ,
Err ( _ ) = > Answer ::error ( 504 , " 那台 AnyBot 收到了,但太久沒有回應(可能太忙,或網路很慢) " ) ,
}
}
/// 長連線: an AnyBot keeps one WebSocket open; requests for it come down it at once, and its
/// own requests to the account's other AnyBots go up it.
fn ws ( self : & Arc < Self > , req : Req , mut c : std ::net ::TcpStream ) {
use tungstenite ::{ protocol ::Role , Message , WebSocket } ;
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
if let Some ( host ) = req . path . strip_prefix ( " /relay/vnc/ " ) {
let host = host . to_string ( ) ;
return self . vnc_viewer ( req , c , & host ) ;
}
2026-11-04 11:30:32 +00:00
if req . path ! = " /relay/host/ws " {
return httpd ::write_response ( & mut c , & Resp ::error ( 404 , " 沒有這個網址 " ) ) ;
}
let Some ( h ) = self . host ( & req ) else { return httpd ::write_response ( & mut c , & Resp ::error ( 401 , " 這台電腦的金鑰無效 " ) ) } ;
Remove what nothing uses and keep one copy of what was written several times
Unused (found by taking away the dead-code allowances; nothing else used them, tests aside):
computer::specs, quiet_button, the never-made Poke::Spin, ten icons and their SVGs, the
theme's chrome/bubble_agent colours, ImportRow.line, voice's multipart/transcribe/speak
wrappers, and the old keychain writers vault::save/remove/index_add with their live test
(the vault store replaced them; reading old keychain entries for the import stays).
One copy each: percent-encoding (httpd::url_encode, was four), tool arguments (tools::args_of,
was three), the WebSocket handshake (httpd::accept_websocket, was three), the read pacing
(wire::Pace, was two: relay and app), docker with input and a time limit (dk_with_input, was
two). Docs: FEATURES §看畫面 (VNC), protocol (v* frames, /screen, tickets), architecture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:21:47 +00:00
if ! httpd ::accept_websocket ( & req , & mut c ) {
2026-11-04 11:30:32 +00:00
return ;
}
let cfg = tungstenite ::protocol ::WebSocketConfig ::default ( ) . max_message_size ( Some ( httpd ::MAX_BODY * 2 ) ) . max_frame_size ( Some ( httpd ::MAX_BODY * 2 ) ) ;
let mut ws = WebSocket ::from_raw_socket ( c , Role ::Server , Some ( cfg ) ) ;
let serial = SERIAL . fetch_add ( 1 , Ordering ::SeqCst ) ;
let ( tx , rx ) = channel ::< Vec < u8 > > ( ) ;
self . sockets . lock ( ) . unwrap ( ) . insert ( h . id . clone ( ) , Sock { tx : tx . clone ( ) , serial } ) ;
self . seen . lock ( ) . unwrap ( ) . insert ( h . id . clone ( ) , Instant ::now ( ) ) ;
let ( mut heard , mut pinged , mut marked ) = ( Instant ::now ( ) , Instant ::now ( ) , Instant ::now ( ) ) ;
Remove what nothing uses and keep one copy of what was written several times
Unused (found by taking away the dead-code allowances; nothing else used them, tests aside):
computer::specs, quiet_button, the never-made Poke::Spin, ten icons and their SVGs, the
theme's chrome/bubble_agent colours, ImportRow.line, voice's multipart/transcribe/speak
wrappers, and the old keychain writers vault::save/remove/index_add with their live test
(the vault store replaced them; reading old keychain entries for the import stays).
One copy each: percent-encoding (httpd::url_encode, was four), tool arguments (tools::args_of,
was three), the WebSocket handshake (httpd::accept_websocket, was three), the read pacing
(wire::Pace, was two: relay and app), docker with input and a time limit (dk_with_input, was
two). Docs: FEATURES §看畫面 (VNC), protocol (v* frames, /screen, tickets), architecture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:21:47 +00:00
let mut pace = wire ::Pace ::new ( ) ;
2026-11-04 11:30:32 +00:00
' live : loop {
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
pace . tune ( ws . get_ref ( ) ) ;
2026-11-04 11:30:32 +00:00
match ws . read ( ) {
Ok ( Message ::Binary ( b ) ) = > {
heard = Instant ::now ( ) ;
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
pace . moved ( ) ;
2026-11-04 11:30:32 +00:00
self . on_frame ( & h , & b , & tx ) ;
}
Ok ( Message ::Close ( _ ) ) = > break ,
Ok ( _ ) = > heard = Instant ::now ( ) ,
Err ( tungstenite ::Error ::Io ( e ) ) if matches! ( e . kind ( ) , std ::io ::ErrorKind ::WouldBlock | std ::io ::ErrorKind ::TimedOut ) = > { }
Err ( _ ) = > break ,
}
while let Ok ( f ) = rx . try_recv ( ) {
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
pace . moved ( ) ;
2026-11-04 11:30:32 +00:00
if ws . write ( Message ::Binary ( f . into ( ) ) ) . is_err ( ) {
break 'live ;
}
}
if pinged . elapsed ( ) . as_secs ( ) > = WS_PING_SECS {
pinged = Instant ::now ( ) ;
if ws . write ( Message ::Ping ( Vec ::new ( ) . into ( ) ) ) . is_err ( ) {
break ;
}
}
match ws . flush ( ) {
Err ( tungstenite ::Error ::Io ( e ) ) if matches! ( e . kind ( ) , std ::io ::ErrorKind ::WouldBlock | std ::io ::ErrorKind ::TimedOut ) = > { }
Err ( _ ) = > break ,
Ok ( ( ) ) = > { }
}
if heard . elapsed ( ) . as_secs ( ) > = WS_DEAD_SECS {
break ;
}
if marked . elapsed ( ) . as_secs ( ) > = 10 {
marked = Instant ::now ( ) ;
self . seen . lock ( ) . unwrap ( ) . insert ( h . id . clone ( ) , Instant ::now ( ) ) ;
}
}
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
// Gone: its viewers' streams end, and whoever waits on it hears so now, not after a timeout.
self . streams . lock ( ) . unwrap ( ) . retain ( | _ , ( _ , host ) | * host ! = h . id ) ;
2026-11-04 11:30:32 +00:00
{
let mut socks = self . sockets . lock ( ) . unwrap ( ) ;
if socks . get ( & h . id ) . map ( | s | s . serial ) = = Some ( serial ) {
socks . remove ( & h . id ) ;
self . seen . lock ( ) . unwrap ( ) . remove ( & h . id ) ;
}
}
let lost : Vec < String > = self . flights . lock ( ) . unwrap ( ) . iter ( ) . filter ( | ( _ , ( host , s ) ) | * host = = h . id & & * s = = serial ) . map ( | ( id , _ ) | id . clone ( ) ) . collect ( ) ;
for id in lost {
2026-11-05 07:48:20 +00:00
if let Some ( ( w , _ ) ) = self . waiting . lock ( ) . unwrap ( ) . remove ( & id ) {
2026-11-04 11:30:32 +00:00
let _ = w . send ( Answer ::error ( 503 , GONE ) ) ;
}
}
}
fn on_frame ( self : & Arc < Self > , h : & Host , b : & [ u8 ] , tx : & Sender < Vec < u8 > > ) {
let Some ( ( head , body ) ) = wire ::unpack ( b ) else { return } ;
let s = | k : & str | head [ k ] . as_str ( ) . unwrap_or ( " " ) . to_string ( ) ;
match head [ " t " ] . as_str ( ) . unwrap_or ( " " ) {
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
// A viewer's stream: only the host it was opened on may write to it.
" vdata " = > {
if let Some ( ( v , host ) ) = self . streams . lock ( ) . unwrap ( ) . get ( & s ( " sid " ) ) {
if * host = = h . id {
let _ = v . send ( body ) ;
}
}
}
" vclose " = > {
let mut all = self . streams . lock ( ) . unwrap ( ) ;
if all . get ( & s ( " sid " ) ) . map ( | ( _ , host ) | * host = = h . id ) . unwrap_or ( false ) {
all . remove ( & s ( " sid " ) ) ;
}
}
2026-11-04 11:30:32 +00:00
" reply " = > {
2026-11-05 07:48:20 +00:00
// Only the host the job went to may answer it.
let w = {
let mut waiting = self . waiting . lock ( ) . unwrap ( ) ;
match waiting . get ( & s ( " id " ) ) {
Some ( ( _ , target ) ) if * target = = h . id = > waiting . remove ( & s ( " id " ) ) . map ( | ( w , _ ) | w ) ,
_ = > None ,
}
} ;
let Some ( w ) = w else { return } ;
let headers = head [ " headers " ] . as_array ( ) . into_iter ( ) . flatten ( ) . filter_map ( | x | Some ( ( x [ 0 ] . as_str ( ) ? . to_string ( ) , x [ 1 ] . as_str ( ) ? . to_string ( ) ) ) ) . collect ( ) ;
let _ = w . send ( Answer { status : head [ " status " ] . as_u64 ( ) . unwrap_or ( 502 ) as u16 , ctype : s ( " ctype " ) , headers , body , z : head [ " z " ] . as_bool ( ) . unwrap_or ( false ) } ) ;
2026-11-04 11:30:32 +00:00
}
// One AnyBot asks another of the same account (大腦 sync, 裝置連結).
" call " = > {
let ( id , to , path ) = ( s ( " id " ) , s ( " to " ) , s ( " path " ) ) ;
let role = if path = = " /api/device/run " {
" device "
} else if path = = " /api/sync " | | path . starts_with ( " /api/sync/ " ) {
" mirror "
} else {
let a = Answer ::error ( 404 , " 沒有這個網址 " ) ;
let _ = tx . send ( wire ::pack ( & json! ( { " t " : " ret " , " id " : id , " status " : a . status , " ctype " : a . ctype } ) , & a . body ) ) ;
return ;
} ;
let same = self . data . lock ( ) . unwrap ( ) . hosts . iter ( ) . any ( | x | x . id = = to & & x . user = = h . user ) ;
if ! same | | to = = h . id {
let a = Answer ::error ( 403 , " 不是同一個帳號的電腦 " ) ;
let _ = tx . send ( wire ::pack ( & json! ( { " t " : " ret " , " id " : id , " status " : a . status , " ctype " : a . ctype } ) , & a . body ) ) ;
return ;
}
let query : HashMap < String , String > = head [ " query " ] . as_object ( ) . map ( | o | o . iter ( ) . map ( | ( k , v ) | ( k . clone ( ) , v . as_str ( ) . unwrap_or ( " " ) . to_string ( ) ) ) . collect ( ) ) . unwrap_or_default ( ) ;
2026-11-04 12:38:45 +00:00
let ( me , tx , user , method , z , from ) = ( self . clone ( ) , tx . clone ( ) , h . user . clone ( ) , s ( " method " ) , head [ " z " ] . as_bool ( ) . unwrap_or ( false ) , h . id . clone ( ) ) ;
2026-11-04 11:30:32 +00:00
std ::thread ::spawn ( move | | {
2026-11-04 12:38:45 +00:00
let a = me . forward_raw ( & to , & method , & path , & query , & body , z , ( & user , role ) , true , & from ) ;
2026-11-05 14:34:36 +00:00
// The answer's headers too (a live picture's number, a screen's size).
let headers : Vec < [ & str ; 2 ] > = a . headers . iter ( ) . map ( | ( k , v ) | [ k . as_str ( ) , v . as_str ( ) ] ) . collect ( ) ;
let _ = tx . send ( wire ::pack ( & json! ( { " t " : " ret " , " id " : id , " status " : a . status , " ctype " : a . ctype , " z " : a . z , " headers " : headers } ) , & a . body ) ) ;
2026-11-04 11:30:32 +00:00
} ) ;
}
" hello " = > {
2026-11-04 12:56:06 +00:00
if head [ " hands " ] . as_bool ( ) . unwrap_or ( false ) {
self . hands . lock ( ) . unwrap ( ) . insert ( h . id . clone ( ) ) ;
} else {
self . hands . lock ( ) . unwrap ( ) . remove ( & h . id ) ;
}
2026-11-04 11:30:32 +00:00
let brain = s ( " brain " ) ;
let mut u = self . uses . lock ( ) . unwrap ( ) ;
if brain . is_empty ( ) {
u . remove ( & h . id ) ;
} else {
u . insert ( h . id . clone ( ) , brain ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
}
}
2026-11-04 11:30:32 +00:00
_ = > { }
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
}
}
fn handle ( & self , req : Req ) -> Resp {
let parts : Vec < String > = req . parts ( ) . iter ( ) . map ( | s | s . to_string ( ) ) . collect ( ) ;
let p : Vec < & str > = parts . iter ( ) . map ( | s | s . as_str ( ) ) . collect ( ) ;
match ( req . method . as_str ( ) , p . as_slice ( ) ) {
( " GET " , [ ] ) | ( " GET " , [ " index.html " ] ) = > Resp ::bytes ( " text/html; charset=utf-8 " , PAGE . as_bytes ( ) . to_vec ( ) )
. with_header ( " Content-Security-Policy " , " default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' " )
. with_header ( " Strict-Transport-Security " , " max-age=31536000 " ) ,
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
( " GET " , [ " screen " ] ) = > Resp ::bytes ( " text/html; charset=utf-8 " , SCREEN_PAGE . as_bytes ( ) . to_vec ( ) )
. with_header ( " Content-Security-Policy " , " default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws: " )
. with_header ( " Strict-Transport-Security " , " max-age=31536000 " ) ,
( " GET " , [ " novnc.js " ] ) = > Resp ::bytes ( " text/javascript; charset=utf-8 " , NOVNC . as_bytes ( ) . to_vec ( ) ) . with_header ( " Cache-Control " , " public, max-age=86400 " ) ,
// A link for this account's browser to watch one of its AnyBots' computer (the app
// opens it; the browser has no session of its own).
( " POST " , [ " relay " , " screen-ticket " ] ) = > {
let Some ( h ) = self . host ( & req ) else { return Resp ::error ( 401 , " 這台電腦的金鑰無效 " ) } ;
let want = req . json ( ) [ " host " ] . as_str ( ) . unwrap_or ( " " ) . to_string ( ) ;
let ok = self . data . lock ( ) . unwrap ( ) . hosts . iter ( ) . any ( | x | x . id = = want & & matches! ( x . role_of ( & h . user ) . as_deref ( ) , Some ( " owner " ) | Some ( " admin " ) ) ) ;
if ! ok {
return Resp ::error ( 404 , " 找不到這台 AnyBot " ) ;
}
let t = httpd ::random_hex ( 24 ) ;
let mut all = self . tickets . lock ( ) . unwrap ( ) ;
all . retain ( | _ , ( _ , _ , made ) | made . elapsed ( ) < TICKET_LIFE ) ;
all . insert ( t . clone ( ) , ( h . user . clone ( ) , want , Instant ::now ( ) ) ) ;
Resp ::json ( & json! ( { " ticket " : t } ) )
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
( " GET " , [ " manifest.webmanifest " ] ) = > Resp ::bytes ( " application/manifest+json " , MANIFEST . as_bytes ( ) . to_vec ( ) ) ,
( " GET " , [ " icon.svg " ] ) = > Resp ::bytes ( " image/svg+xml " , ICON . as_bytes ( ) . to_vec ( ) ) ,
2026-11-05 11:20:29 +00:00
( " GET " , [ name ] ) if ICON_PNGS . iter ( ) . any ( | ( n , _ ) | n = = name ) = > Resp ::bytes ( " image/png " , ICON_PNGS . iter ( ) . find ( | ( n , _ ) | n = = name ) . map ( | ( _ , b ) | b . to_vec ( ) ) . unwrap_or_default ( ) ) ,
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
( " GET " , [ " api " , " hello " ] ) = > Resp ::json ( & json! ( { " app " : " anybot-relay " } ) ) ,
( " GET " , [ " sw.js " ] ) = > Resp ::bytes ( " text/javascript; charset=utf-8 " , SW . as_bytes ( ) . to_vec ( ) ) . with_header ( " Service-Worker-Allowed " , " / " ) ,
( " GET " , [ " relay " , " push " , " key " ] ) = > Resp ::json ( & json! ( { " key " : self . vapid . public_b64 } ) ) ,
( " POST " , [ " relay " , " push " , " subscribe " ] ) = > {
let Some ( user ) = self . session ( & req ) else { return Resp ::error ( 401 , " 請先登入 " ) } ;
let sub = req . json ( ) ;
let endpoint = sub [ " endpoint " ] . as_str ( ) . unwrap_or ( " " ) . to_string ( ) ;
if ! endpoint . starts_with ( " https:// " ) | | sub [ " keys " ] [ " p256dh " ] . as_str ( ) . is_none ( ) | | sub [ " keys " ] [ " auth " ] . as_str ( ) . is_none ( ) {
return Resp ::error ( 400 , " 通知訂閱資料不完整 " ) ;
}
let mut d = self . data . lock ( ) . unwrap ( ) ;
d . subs . retain ( | s | s . sub [ " endpoint " ] . as_str ( ) ! = Some ( endpoint . as_str ( ) ) ) ;
d . subs . push ( Sub { user , sub , created : now ( ) } ) ;
self . save ( & d ) ;
Resp ::json ( & json! ( { " ok " : true } ) )
}
( " POST " , [ " relay " , " push " , " unsubscribe " ] ) = > {
let endpoint = req . json ( ) [ " endpoint " ] . as_str ( ) . unwrap_or ( " " ) . to_string ( ) ;
let mut d = self . data . lock ( ) . unwrap ( ) ;
d . subs . retain ( | s | s . sub [ " endpoint " ] . as_str ( ) ! = Some ( endpoint . as_str ( ) ) ) ;
self . save ( & d ) ;
Resp ::json ( & json! ( { " ok " : true } ) )
}
// An AnyBot tells its person something (a bot needs them, or finished while they were away).
( " POST " , [ " relay " , " host " , " notify " ] ) = > {
let Some ( h ) = self . host ( & req ) else { return Resp ::error ( 401 , " 這台電腦的金鑰無效 " ) } ;
let v = req . json ( ) ;
let payload = json! ( { " title " : v [ " title " ] . as_str ( ) . unwrap_or ( " AnyBot " ) . chars ( ) . take ( 80 ) . collect ::< String > ( ) , " body " : v [ " body " ] . as_str ( ) . unwrap_or ( " " ) . chars ( ) . take ( 200 ) . collect ::< String > ( ) , " bot " : v [ " bot " ] , " host " : h . id } ) ;
let subs : Vec < Sub > = self . data . lock ( ) . unwrap ( ) . subs . iter ( ) . filter ( | s | matches! ( h . role_of ( & s . user ) . as_deref ( ) , Some ( " owner " ) | Some ( " admin " ) ) ) . cloned ( ) . collect ( ) ;
let mut gone = vec! [ ] ;
let mut sent = 0 ;
for s in & subs {
match push ::send ( & self . vapid , & s . sub , & payload , & self . contact ) {
Ok ( ( ) ) = > sent + = 1 ,
Err ( true ) = > gone . push ( s . sub [ " endpoint " ] . as_str ( ) . unwrap_or ( " " ) . to_string ( ) ) ,
Err ( false ) = > { }
}
}
if ! gone . is_empty ( ) {
let mut d = self . data . lock ( ) . unwrap ( ) ;
d . subs . retain ( | s | ! gone . iter ( ) . any ( | g | s . sub [ " endpoint " ] . as_str ( ) = = Some ( g . as_str ( ) ) ) ) ;
self . save ( & d ) ;
}
Resp ::json ( & json! ( { " sent " : sent } ) )
}
( " POST " , [ " relay " , " signup " ] ) = > self . signup ( & req ) ,
( " POST " , [ " relay " , " login " ] ) = > self . login ( & req ) ,
( " POST " , [ " relay " , " logout " ] ) = > {
let h = httpd ::sha256_hex ( & req . bearer ( ) ) ;
let mut d = self . data . lock ( ) . unwrap ( ) ;
d . sessions . retain ( | s | s . hash ! = h ) ;
self . save ( & d ) ;
Resp ::json ( & json! ( { " ok " : true } ) )
}
( " GET " , [ " relay " , " me " ] ) = > {
let Some ( user ) = self . session ( & req ) else { return Resp ::error ( 401 , " 請先登入 " ) } ;
let d = self . data . lock ( ) . unwrap ( ) ;
let hosts : Vec < Value > = d
. hosts
. iter ( )
. filter_map ( | h | h . role_of ( & user ) . map ( | r | ( h , r ) ) )
. map ( | ( h , r ) | {
let mut v = self . host_json ( h ) ;
v [ " role " ] = json! ( r ) ;
if r ! = " owner " {
v [ " name " ] = json! ( format! ( " {} ( {} 的) " , h . name , h . user ) ) ;
}
v
} )
. collect ( ) ;
let sessions : Vec < Value > = d . sessions . iter ( ) . filter ( | s | s . user = = user ) . map ( | s | json! ( { " device " : s . device , " created " : s . created , " last " : s . last } ) ) . collect ( ) ;
Resp ::json ( & json! ( { " user " : user , " hosts " : hosts , " sessions " : sessions , " backup " : self . dir . join ( " backups " ) . join ( format! ( " {} .bin " , httpd ::sha256_hex ( & user ) ) ) . exists ( ) } ) )
}
( " POST " , [ " relay " , " hosts " ] ) = > {
let Some ( user ) = self . session ( & req ) else { return Resp ::error ( 401 , " 請先登入 " ) } ;
let name : String = req . json ( ) [ " name " ] . as_str ( ) . unwrap_or ( " AnyBot " ) . chars ( ) . take ( 60 ) . collect ( ) ;
2026-11-04 12:56:06 +00:00
let ( h , token ) = self . add_host ( & user , & name ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
Resp ::json ( & json! ( { " id " : h . id , " token " : token } ) )
}
2026-11-04 12:56:06 +00:00
// 加一台電腦: a signed-in phone or AnyBot asks for a code; the new computer gives it back.
( " POST " , [ " relay " , " pair " ] ) = > {
let Some ( user ) = self . session ( & req ) . or_else ( | | self . host ( & req ) . map ( | h | h . user ) ) else { return Resp ::error ( 401 , " 請先登入 " ) } ;
let want = req . json ( ) [ " for " ] . as_str ( ) . unwrap_or ( " " ) . to_string ( ) ;
let for_host = if self . data . lock ( ) . unwrap ( ) . hosts . iter ( ) . any ( | h | h . id = = want & & h . user = = user ) { want } else { String ::new ( ) } ;
let mut pairs = self . pairs . lock ( ) . unwrap ( ) ;
pairs . retain ( | _ , p | p . 2. elapsed ( ) . as_secs ( ) < PAIR_SECS ) ;
let code = loop {
let n = u32 ::from_str_radix ( & httpd ::random_hex ( 4 ) , 16 ) . unwrap_or ( 0 ) % 1_000_000 ;
let c = format! ( " {n:06} " ) ;
if ! pairs . contains_key ( & c ) {
break c ;
}
} ;
pairs . insert ( code . clone ( ) , ( user , for_host , Instant ::now ( ) ) ) ;
Resp ::json ( & json! ( { " code " : code , " expires_in " : PAIR_SECS } ) )
}
( " POST " , [ " relay " , " pair " , " claim " ] ) = > {
let key = format! ( " pair: {} " , req . peer ) ;
if self . too_many_fails ( & key ) {
return Resp ::error ( 429 , " 配對碼錯太多次,請 5 分鐘後再試 " ) ;
}
let v = req . json ( ) ;
let code : String = v [ " code " ] . as_str ( ) . unwrap_or ( " " ) . chars ( ) . filter ( | c | c . is_ascii_digit ( ) ) . collect ( ) ;
let got = {
let mut pairs = self . pairs . lock ( ) . unwrap ( ) ;
pairs . retain ( | _ , p | p . 2. elapsed ( ) . as_secs ( ) < PAIR_SECS ) ;
pairs . remove ( & code )
} ;
let Some ( ( user , for_host , _ ) ) = got else {
self . failed ( & key ) ;
return Resp ::error ( 404 , " 配對碼不對, 或已經過期( 10 分鐘內有效,只能用一次) " ) ;
} ;
let name : String = v [ " name " ] . as_str ( ) . unwrap_or ( " AnyBot " ) . chars ( ) . take ( 60 ) . collect ( ) ;
let ( h , token ) = self . add_host ( & user , & name ) ;
let for_name = self . data . lock ( ) . unwrap ( ) . hosts . iter ( ) . find ( | x | x . id = = for_host ) . map ( | x | x . name . clone ( ) ) . unwrap_or_default ( ) ;
Resp ::json ( & json! ( { " id " : h . id , " token " : token , " user " : user , " for " : for_host , " for_name " : for_name } ) )
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
( " POST " , [ " relay " , " hosts " , id , " remove " ] ) = > {
let Some ( user ) = self . session ( & req ) else { return Resp ::error ( 401 , " 請先登入 " ) } ;
let mut d = self . data . lock ( ) . unwrap ( ) ;
d . hosts . retain ( | x | ! ( x . user = = user & & x . id = = * id ) ) ;
self . save ( & d ) ;
Resp ::json ( & json! ( { " ok " : true } ) )
}
( " GET " , [ " relay " , " host " , " next " ] ) = > {
let Some ( h ) = self . host ( & req ) else { return Resp ::error ( 401 , " 這台電腦的金鑰無效 " ) } ;
self . seen . lock ( ) . unwrap ( ) . insert ( h . id . clone ( ) , Instant ::now ( ) ) ;
let q = self . queues . lock ( ) . unwrap ( ) ;
let ( mut q , _ ) = self . arrived . wait_timeout_while ( q , Duration ::from_secs ( HOST_POLL_SECS ) , | q | q . get ( & h . id ) . map ( | x | x . is_empty ( ) ) . unwrap_or ( true ) ) . unwrap ( ) ;
self . seen . lock ( ) . unwrap ( ) . insert ( h . id . clone ( ) , Instant ::now ( ) ) ;
match q . get_mut ( & h . id ) . and_then ( | x | x . pop_front ( ) ) {
Some ( job ) = > Resp ::json ( & job ) ,
None = > Resp ::empty ( 204 ) ,
}
}
( " POST " , [ " relay " , " host " , " reply " , id ] ) = > {
2026-11-05 07:48:20 +00:00
let Some ( h ) = self . host ( & req ) else { return Resp ::error ( 401 , " 這台電腦的金鑰無效 " ) } ;
// Only the host the job was sent to may answer it.
let tx = {
let mut waiting = self . waiting . lock ( ) . unwrap ( ) ;
match waiting . get ( * id ) {
Some ( ( _ , target ) ) if * target = = h . id = > waiting . remove ( * id ) . map ( | ( w , _ ) | w ) ,
_ = > None ,
}
} ;
if let Some ( tx ) = tx {
2026-11-04 11:30:32 +00:00
let _ = tx . send ( Answer ::from_json ( & req . json ( ) ) ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
}
Resp ::json ( & json! ( { " ok " : true } ) )
}
( " GET " , [ " relay " , " host " , " members " ] ) = > {
let Some ( h ) = self . host ( & req ) else { return Resp ::error ( 401 , " 這台電腦的金鑰無效 " ) } ;
Resp ::json ( & json! ( { " members " : h . members . iter ( ) . map ( | m | json! ( { " user " : m . user , " role " : m . role } ) ) . collect ::< Vec < _ > > ( ) } ) )
}
( " POST " , [ " relay " , " host " , " members " ] ) = > {
let Some ( h ) = self . host ( & req ) else { return Resp ::error ( 401 , " 這台電腦的金鑰無效 " ) } ;
let v = req . json ( ) ;
let user = v [ " user " ] . as_str ( ) . unwrap_or ( " " ) . trim ( ) . to_lowercase ( ) ;
let role = v [ " role " ] . as_str ( ) . unwrap_or ( " member " ) . to_string ( ) ;
let mut d = self . data . lock ( ) . unwrap ( ) ;
if role ! = " remove " & & ! d . users . iter ( ) . any ( | u | u . name = = user ) {
return Resp ::error ( 404 , " 這台伺服器上沒有這個帳號(請對方先建立帳號) " ) ;
}
if user = = h . user {
return Resp ::error ( 400 , " 這是你自己的帳號 " ) ;
}
if ! matches! ( role . as_str ( ) , " member " | " admin " | " remove " ) {
return Resp ::error ( 400 , " 角色只能是 member 或 admin " ) ;
}
if let Some ( x ) = d . hosts . iter_mut ( ) . find ( | x | x . id = = h . id ) {
x . members . retain ( | m | m . user ! = user ) ;
if role ! = " remove " {
x . members . push ( Member { user , role } ) ;
}
}
self . save ( & d ) ;
Resp ::json ( & json! ( { " ok " : true } ) )
}
// 發布網站: an AnyBot publishes a folder; it is served at /s/<user>-<name>/.
( " POST " , [ " relay " , " host " , " sites " , name ] ) = > {
let Some ( h ) = self . host ( & req ) else { return Resp ::error ( 401 , " 這台電腦的金鑰無效 " ) } ;
let name = name . to_lowercase ( ) ;
if name . is_empty ( ) | | name . len ( ) > 40 | | ! name . chars ( ) . all ( | c | c . is_ascii_alphanumeric ( ) | | c = = '-' ) {
return Resp ::error ( 400 , " 網站名稱只能用英文字母、數字和 -(最多 40 個字) " ) ;
}
let site = format! ( " {} - {name} " , h . user . replace ( | c : char | ! c . is_ascii_alphanumeric ( ) , " - " ) ) ;
let dir = self . dir . join ( " sites " ) . join ( & site ) ;
let v = req . json ( ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
if v [ " remove " ] . as_bool ( ) = = Some ( true ) {
return Resp ::json ( & json! ( { " ok " : true } ) ) ;
}
let mut n = 0 ;
for f in v [ " files " ] . as_array ( ) . into_iter ( ) . flatten ( ) {
let rel = f [ " path " ] . as_str ( ) . unwrap_or ( " " ) ;
if rel . is_empty ( ) | | rel . split ( '/' ) . any ( | c | c . is_empty ( ) | | c = = " . " | | c = = " .. " ) {
continue ;
}
let Ok ( bytes ) = base64 ::Engine ::decode ( & base64 ::engine ::general_purpose ::STANDARD , f [ " data " ] . as_str ( ) . unwrap_or ( " " ) ) else { continue } ;
let path = dir . join ( rel ) ;
let _ = std ::fs ::create_dir_all ( path . parent ( ) . unwrap_or ( & dir ) ) ;
if std ::fs ::write ( & path , bytes ) . is_ok ( ) {
n + = 1 ;
}
}
let public = std ::env ::var ( " ANYBOT_RELAY_PUBLIC_URL " ) . unwrap_or_default ( ) ;
Resp ::json ( & json! ( { " files " : n , " path " : format ! ( " /s/{site}/ " ) , " url " : if public . is_empty ( ) { String ::new ( ) } else { format! ( " {} /s/ {site} / " , public . trim_end_matches ( '/' ) ) } } ) )
}
( " GET " , [ " s " , site , rest @ .. ] ) = > {
if site . contains ( " .. " ) | | rest . iter ( ) . any ( | c | c . is_empty ( ) | | * c = = " . " | | * c = = " .. " ) {
return Resp ::error ( 404 , " 找不到 " ) ;
}
let mut path = self . dir . join ( " sites " ) . join ( site ) . join ( rest . join ( " / " ) ) ;
if path . is_dir ( ) | | rest . is_empty ( ) {
path = path . join ( " index.html " ) ;
}
let ext = path . extension ( ) . map ( | e | e . to_string_lossy ( ) . to_lowercase ( ) ) . unwrap_or_default ( ) ;
let ctype = match ext . as_str ( ) {
" html " | " htm " = > " text/html; charset=utf-8 " ,
" css " = > " text/css; charset=utf-8 " ,
" js " | " mjs " = > " text/javascript; charset=utf-8 " ,
" json " = > " application/json " ,
" svg " = > " image/svg+xml " ,
" png " = > " image/png " ,
" jpg " | " jpeg " = > " image/jpeg " ,
" gif " = > " image/gif " ,
" webp " = > " image/webp " ,
" ico " = > " image/x-icon " ,
" txt " | " md " = > " text/plain; charset=utf-8 " ,
" pdf " = > " application/pdf " ,
" woff2 " = > " font/woff2 " ,
" mp4 " = > " video/mp4 " ,
_ = > " application/octet-stream " ,
} ;
match std ::fs ::read ( & path ) {
// Sandboxed: a published page runs in its own opaque origin, so it can never
// read the phone app's sign-in on this same address.
Ok ( b ) = > Resp ::bytes ( ctype , b ) . with_header ( " Content-Security-Policy " , " sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads " ) ,
Err ( _ ) = > Resp ::error ( 404 , " 找不到這個網頁 " ) ,
}
}
( " GET " , [ " relay " , " host " , " peers " ] ) = > {
let Some ( me ) = self . host ( & req ) else { return Resp ::error ( 401 , " 這台電腦的金鑰無效 " ) } ;
let d = self . data . lock ( ) . unwrap ( ) ;
Resp ::json ( & json! ( { " hosts " : d . hosts . iter ( ) . filter ( | h | h . user = = me . user ) . map ( | h | self . host_json ( h ) ) . collect ::< Vec < _ > > ( ) } ) )
}
2026-11-05 07:48:20 +00:00
// Signing out of the app unregisters this machine: its own key removes its own
// registration, so a key left on a borrowed computer dies with the sign-out.
( " POST " , [ " relay " , " host " , " remove " ] ) = > {
let Some ( h ) = self . host ( & req ) else { return Resp ::error ( 401 , " 這台電腦的金鑰無效 " ) } ;
{
let mut d = self . data . lock ( ) . unwrap ( ) ;
d . hosts . retain ( | x | x . id ! = h . id ) ;
self . save ( & d ) ;
}
// Its open connection is closed, so it hears "the key is gone" at once.
if let Some ( s ) = self . sockets . lock ( ) . unwrap ( ) . remove ( & h . id ) {
let _ = s . tx . send ( wire ::pack ( & json! ( { " t " : " bye " } ) , & [ ] ) ) ;
}
Resp ::json ( & json! ( { " ok " : true } ) )
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
( " PUT " , [ " relay " , " backup " ] ) = > {
let Some ( h ) = self . host ( & req ) else { return Resp ::error ( 401 , " 這台電腦的金鑰無效 " ) } ;
2026-11-05 07:48:20 +00:00
if req . body . len ( ) > 200 * 1024 * 1024 {
return Resp ::error ( 413 , " 備份太大了(最多 200 MB) " ) ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
let path = self . dir . join ( " backups " ) . join ( format! ( " {} .bin " , httpd ::sha256_hex ( & h . user ) ) ) ;
let tmp = path . with_extension ( " tmp " ) ;
match std ::fs ::write ( & tmp , & req . body ) . and_then ( | _ | std ::fs ::rename ( & tmp , & path ) ) {
Ok ( ( ) ) = > Resp ::json ( & json! ( { " ok " : true , " bytes " : req . body . len ( ) } ) ) ,
Err ( e ) = > Resp ::error ( 500 , & e . to_string ( ) ) ,
}
}
( " GET " , [ " relay " , " backup " ] ) = > {
let Some ( user ) = self . session ( & req ) else { return Resp ::error ( 401 , " 請先登入 " ) } ;
match std ::fs ::read ( self . dir . join ( " backups " ) . join ( format! ( " {} .bin " , httpd ::sha256_hex ( & user ) ) ) ) {
Ok ( b ) = > Resp ::bytes ( " application/octet-stream " , b ) ,
Err ( _ ) = > Resp ::error ( 404 , " 還沒有備份 " ) ,
}
}
// 裝置連結: one AnyBot asks another AnyBot of the same account.
( " POST " , [ " relay " , " hosts " , target , " api " , " device " , " run " ] ) = > {
let Some ( me ) = self . host ( & req ) else { return Resp ::error ( 401 , " 這台電腦的金鑰無效 " ) } ;
let same = self . data . lock ( ) . unwrap ( ) . hosts . iter ( ) . any ( | h | h . id = = * target & & h . user = = me . user ) ;
if ! same | | * target = = me . id {
return Resp ::error ( 403 , " 不是同一個帳號的電腦 " ) ;
}
2026-11-04 12:38:45 +00:00
self . forward_raw ( target , " POST " , " /api/device/run " , & HashMap ::new ( ) , & req . body , false , ( & me . user , " device " ) , false , & me . id ) . into_resp ( )
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
}
2026-11-04 09:46:43 +00:00
// 大腦: one AnyBot of the account shows (and edits) another one's bots and chats.
( m , [ " relay " , " hosts " , target , " api " , " sync " , rest @ .. ] ) = > {
let Some ( me ) = self . host ( & req ) else { return Resp ::error ( 401 , " 這台電腦的金鑰無效 " ) } ;
let same = self . data . lock ( ) . unwrap ( ) . hosts . iter ( ) . any ( | h | h . id = = * target & & h . user = = me . user ) ;
if ! same | | * target = = me . id {
return Resp ::error ( 403 , " 不是同一個帳號的電腦 " ) ;
}
let path = if rest . is_empty ( ) { " /api/sync " . to_string ( ) } else { format! ( " /api/sync/ {} " , rest . join ( " / " ) ) } ;
2026-11-04 12:38:45 +00:00
self . forward_raw ( target , m , & path , & req . query , & req . body , false , ( & me . user , " mirror " ) , false , & me . id ) . into_resp ( )
2026-11-04 09:46:43 +00:00
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
// The phone, through to one of the account's AnyBots.
( m , [ " h " , host , rest @ .. ] ) = > {
let Some ( user ) = self . session ( & req ) else { return Resp ::error ( 401 , " 請先登入 " ) } ;
let role = self . data . lock ( ) . unwrap ( ) . hosts . iter ( ) . find ( | h | h . id = = * host ) . and_then ( | h | h . role_of ( & user ) ) ;
let Some ( role ) = role else { return Resp ::error ( 404 , " 找不到這台 AnyBot " ) } ;
if rest . first ( ) ! = Some ( & " api " ) | | rest . get ( 1 ) = = Some ( & " device " ) {
return Resp ::error ( 404 , " 沒有這個網址 " ) ;
}
let mut q = req . query . clone ( ) ;
q . remove ( " t " ) ;
self . forward ( host , m , & format! ( " / {} " , rest . join ( " / " ) ) , & q , & req . body , ( & user , & role ) )
}
_ = > Resp ::error ( 404 , " 沒有這個網址 " ) ,
}
}
2026-11-04 12:56:06 +00:00
/// Register an AnyBot of `user`. The same name again replaces its old key (keeping who it's
/// shared with).
fn add_host ( & self , user : & str , name : & str ) -> ( Host , String ) {
let token = httpd ::random_hex ( 32 ) ;
let mut h = Host { id : httpd ::random_hex ( 8 ) , name : name . to_string ( ) , user : user . to_string ( ) , token_hash : httpd ::sha256_hex ( & token ) , created : now ( ) , last_seen : 0 , members : vec ! [ ] } ;
let mut d = self . data . lock ( ) . unwrap ( ) ;
if let Some ( old ) = d . hosts . iter ( ) . find ( | x | x . user = = user & & x . name = = name ) {
h . members = old . members . clone ( ) ;
}
d . hosts . retain ( | x | ! ( x . user = = user & & x . name = = name ) ) ;
d . hosts . push ( h . clone ( ) ) ;
self . save ( & d ) ;
( h , token )
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
fn host_json ( & self , h : & Host ) -> Value {
let online = self . online ( & h . id ) ;
2026-11-04 12:56:06 +00:00
json! ( { " id " : h . id , " name " : h . name , " online " : online , " uses " : self . uses . lock ( ) . unwrap ( ) . get ( & h . id ) . cloned ( ) . unwrap_or_default ( ) , " hands " : self . hands . lock ( ) . unwrap ( ) . contains ( & h . id ) , " last_seen_text " : if online { " 在線上 " . to_string ( ) } else if h . last_seen > 0 { chrono ::DateTime ::from_timestamp ( h . last_seen , 0 ) . map ( | t | t . format ( " %m/%d %H:%M UTC " ) . to_string ( ) ) . unwrap_or_default ( ) } else { " 從未連線 " . into ( ) } } )
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
}
fn signup ( & self , req : & Req ) -> Resp {
let v = req . json ( ) ;
let user = v [ " user " ] . as_str ( ) . unwrap_or ( " " ) . trim ( ) . to_lowercase ( ) ;
let pw = v [ " password " ] . as_str ( ) . unwrap_or ( " " ) ;
if user . len ( ) < 2 | | user . len ( ) > 64 | | ! user . chars ( ) . all ( | c | c . is_ascii_alphanumeric ( ) | | " ._@- " . contains ( c ) ) {
return Resp ::error ( 400 , " 帳號只能用英文字母、數字和 . _ @ -( 2– 64 個字) " ) ;
}
if pw . chars ( ) . count ( ) < 8 {
return Resp ::error ( 400 , " 密碼至少 8 個字 " ) ;
}
let mut d = self . data . lock ( ) . unwrap ( ) ;
if ! d . users . is_empty ( ) & & ( self . invite . is_empty ( ) | | v [ " invite " ] . as_str ( ) ! = Some ( self . invite . as_str ( ) ) ) {
return Resp ::error ( 403 , if self . invite . is_empty ( ) { " 這台伺服器不開放註冊 " } else { " 邀請碼不對 " } ) ;
}
if d . users . iter ( ) . any ( | u | u . name = = user ) {
return Resp ::error ( 409 , " 這個帳號已經有人用了 " ) ;
}
d . users . push ( User { name : user , pass : httpd ::hash_password ( pw ) , created : now ( ) } ) ;
self . save ( & d ) ;
Resp ::json ( & json! ( { " ok " : true } ) )
}
fn login ( & self , req : & Req ) -> Resp {
let v = req . json ( ) ;
let user = v [ " user " ] . as_str ( ) . unwrap_or ( " " ) . trim ( ) . to_lowercase ( ) ;
2026-11-05 07:48:20 +00:00
// Rate-limit by the real peer. A client writes its own first X-Forwarded-For entry, so
// taking the first value lets anyone reset the counter by changing the header. Only when
// RELAY_TRUST_PROXY=1 (behind a reverse proxy) use the proxy's appended (last) entry.
let ip = match ( std ::env ::var ( " RELAY_TRUST_PROXY " ) . is_ok ( ) , req . header ( " x-forwarded-for " ) ) {
( true , xff ) if ! xff . is_empty ( ) = > xff . rsplit ( ',' ) . next ( ) . unwrap_or ( xff ) . trim ( ) . to_string ( ) ,
_ = > req . peer . clone ( ) ,
} ;
let key = format! ( " {ip} | {user} " ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
if self . too_many_fails ( & key ) {
return Resp ::error ( 429 , " 密碼錯太多次,請 5 分鐘後再試 " ) ;
}
let stored = self . data . lock ( ) . unwrap ( ) . users . iter ( ) . find ( | u | u . name = = user ) . map ( | u | u . pass . clone ( ) ) ;
let ok = stored . map ( | p | httpd ::check_password ( v [ " password " ] . as_str ( ) . unwrap_or ( " " ) , & p ) ) . unwrap_or ( false ) ;
if ! ok {
self . failed ( & key ) ;
std ::thread ::sleep ( Duration ::from_millis ( 500 ) ) ;
return Resp ::error ( 401 , " 帳號或密碼不對 " ) ;
}
let token = httpd ::random_hex ( 32 ) ;
let mut d = self . data . lock ( ) . unwrap ( ) ;
d . sessions . push ( Session { hash : httpd ::sha256_hex ( & token ) , user , device : v [ " device " ] . as_str ( ) . unwrap_or ( " " ) . chars ( ) . take ( 40 ) . collect ( ) , created : now ( ) , last : now ( ) } ) ;
let cutoff = now ( ) - SESSION_DAYS * 86_400 ;
d . sessions . retain ( | s | s . last > cutoff ) ;
self . save ( & d ) ;
Resp ::json ( & json! ( { " token " : token } ) )
}
/// Remember when hosts were last seen (for "離線(上次…)").
fn save_seen ( & self ) {
let seen : Vec < ( String , u64 ) > = self . seen . lock ( ) . unwrap ( ) . iter ( ) . map ( | ( k , v ) | ( k . clone ( ) , v . elapsed ( ) . as_secs ( ) ) ) . collect ( ) ;
let mut d = self . data . lock ( ) . unwrap ( ) ;
let mut changed = false ;
for ( id , ago ) in seen {
if let Some ( h ) = d . hosts . iter_mut ( ) . find ( | h | h . id = = id ) {
let t = now ( ) - ago as i64 ;
if t - h . last_seen > 60 {
h . last_seen = t ;
changed = true ;
}
}
}
if changed {
self . save ( & d ) ;
}
}
}
fn main ( ) {
let args : Vec < String > = std ::env ::args ( ) . collect ( ) ;
let arg = | name : & str | args . iter ( ) . position ( | a | a = = name ) . and_then ( | i | args . get ( i + 1 ) ) . cloned ( ) ;
let port : u16 = arg ( " --port " ) . and_then ( | p | p . parse ( ) . ok ( ) ) . or_else ( | | std ::env ::var ( " PORT " ) . ok ( ) . and_then ( | p | p . parse ( ) . ok ( ) ) ) . unwrap_or ( 8080 ) ;
let bind = arg ( " --bind " ) . unwrap_or_else ( | | " 0.0.0.0 " . into ( ) ) ;
let dir = PathBuf ::from ( arg ( " --data " ) . unwrap_or_else ( | | " relay-data " . into ( ) ) ) ;
2026-11-04 13:34:15 +00:00
// Forgot the password: only someone on this computer can set a new one.
if let Some ( user ) = arg ( " --reset-password " ) {
std ::process ::exit ( reset_password ( & dir , & user . trim ( ) . to_lowercase ( ) , port ) ) ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
let relay = Arc ::new ( Relay ::load ( dir . clone ( ) ) ) ;
let listener = std ::net ::TcpListener ::bind ( ( bind . as_str ( ) , port ) ) . unwrap_or_else ( | e | {
eprintln! ( " 無法使用連接埠 {port} : {e} " ) ;
std ::process ::exit ( 1 )
} ) ;
eprintln! ( " AnyBot 帳號伺服器: http:// {bind} : {port} (資料在 {} ) " , dir . display ( ) ) ;
if relay . data . lock ( ) . unwrap ( ) . users . is_empty ( ) {
eprintln! ( " 還沒有帳號:第一個註冊的人會成為這台伺服器的使用者。 " ) ;
}
let r2 = relay . clone ( ) ;
std ::thread ::spawn ( move | | loop {
std ::thread ::sleep ( Duration ::from_secs ( 60 ) ) ;
r2 . save_seen ( ) ;
} ) ;
2026-11-04 11:30:32 +00:00
let ( r , w ) = ( relay . clone ( ) , relay . clone ( ) ) ;
httpd ::serve_upgrade ( listener , Arc ::new ( move | req | r . handle ( req ) ) , Some ( Arc ::new ( move | req , c | w . ws ( req , c ) ) ) , Arc ::new ( | | true ) ) ;
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
}
2026-11-04 13:34:15 +00:00
/// `anybot-relay --data DIR --reset-password NAME`: a new password for an account (asked twice,
/// not shown). The server must be stopped first, or it would write its own copy back.
fn reset_password ( dir : & std ::path ::Path , user : & str , port : u16 ) -> i32 {
if std ::net ::TcpStream ::connect_timeout ( & std ::net ::SocketAddr ::from ( ( [ 127 , 0 , 0 , 1 ] , port ) ) , Duration ::from_millis ( 500 ) ) . is_ok ( ) {
eprintln! ( " 帳號伺服器還在執行(埠 {port} )。先停掉它再重設密碼,之後再啟動。 " ) ;
return 1 ;
}
let relay = Relay ::load ( dir . to_path_buf ( ) ) ;
if ! relay . data . lock ( ) . unwrap ( ) . users . iter ( ) . any ( | u | u . name = = user ) {
eprintln! ( " {} 裡沒有帳號「 {user} 」。 " , dir . display ( ) ) ;
return 1 ;
}
let ask = | prompt : & str | -> String {
use std ::io ::Write ;
eprint! ( " {prompt} " ) ;
let _ = std ::io ::stderr ( ) . flush ( ) ;
let hide = std ::process ::Command ::new ( " stty " ) . arg ( " -echo " ) . stdin ( std ::process ::Stdio ::inherit ( ) ) . stderr ( std ::process ::Stdio ::null ( ) ) . status ( ) . map ( | s | s . success ( ) ) . unwrap_or ( false ) ;
let mut s = String ::new ( ) ;
let _ = std ::io ::stdin ( ) . read_line ( & mut s ) ;
if hide {
let _ = std ::process ::Command ::new ( " stty " ) . arg ( " echo " ) . stdin ( std ::process ::Stdio ::inherit ( ) ) . stderr ( std ::process ::Stdio ::null ( ) ) . status ( ) ;
}
eprintln! ( ) ;
s . trim_end_matches ( [ '\n' , '\r' ] ) . to_string ( )
} ;
let pw = ask ( & format! ( " 「 {user} 」的新密碼(至少 8 個字): " ) ) ;
if pw . chars ( ) . count ( ) < 8 {
eprintln! ( " 密碼至少 8 個字,沒有改。 " ) ;
return 1 ;
}
if ask ( " 再輸入一次: " ) ! = pw {
eprintln! ( " 兩次不一樣,沒有改。 " ) ;
return 1 ;
}
let mut d = relay . data . lock ( ) . unwrap ( ) ;
if let Some ( u ) = d . users . iter_mut ( ) . find ( | u | u . name = = user ) {
u . pass = httpd ::hash_password ( & pw ) ;
}
// Phones signed in with the old password sign in again.
d . sessions . retain ( | s | s . user ! = user ) ;
relay . save ( & d ) ;
eprintln! ( " 已改好「 {user} 」的密碼。啟動帳號伺服器後,用新密碼登入。 " ) ;
eprintln! ( " (帳號備份是用舊密碼加密的,之後要先在電腦上按一次「立刻備份」。) " ) ;
0
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
#[ cfg(test) ]
mod tests {
use super ::* ;
fn post ( port : u16 , path : & str , token : & str , body : Value ) -> ( u16 , Value ) {
let agent : ureq ::Agent = ureq ::Agent ::config_builder ( ) . http_status_as_error ( false ) . build ( ) . into ( ) ;
let mut r = agent . post ( & format! ( " http://127.0.0.1: {port} {path} " ) ) . header ( " Authorization " , & format! ( " Bearer {token} " ) ) . send ( body . to_string ( ) . as_bytes ( ) ) . unwrap ( ) ;
let st = r . status ( ) . as_u16 ( ) ;
( st , serde_json ::from_str ( & r . body_mut ( ) . read_to_string ( ) . unwrap_or_default ( ) ) . unwrap_or ( Value ::Null ) )
}
fn get ( port : u16 , path : & str , token : & str ) -> ( u16 , Vec < u8 > ) {
let agent : ureq ::Agent = ureq ::Agent ::config_builder ( ) . http_status_as_error ( false ) . build ( ) . into ( ) ;
let mut r = agent . get ( & format! ( " http://127.0.0.1: {port} {path} " ) ) . header ( " Authorization " , & format! ( " Bearer {token} " ) ) . call ( ) . unwrap ( ) ;
( r . status ( ) . as_u16 ( ) , r . body_mut ( ) . read_to_vec ( ) . unwrap_or_default ( ) )
}
#[ test ]
fn account_host_and_phone_round_trip ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " anybot-relay-test- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
let relay = Arc ::new ( Relay ::load ( dir . clone ( ) ) ) ;
let l = std ::net ::TcpListener ::bind ( " 127.0.0.1:0 " ) . unwrap ( ) ;
let port = l . local_addr ( ) . unwrap ( ) . port ( ) ;
let r = relay . clone ( ) ;
std ::thread ::spawn ( move | | httpd ::serve ( l , Arc ::new ( move | req | r . handle ( req ) ) , Arc ::new ( | | true ) ) ) ;
assert_eq! ( post ( port , " /relay/signup " , " " , json! ( { " user " : " Daniel " , " password " : " 12345678 " } ) ) . 0 , 200 ) ;
// Second sign-up without an invite: refused.
assert_eq! ( post ( port , " /relay/signup " , " " , json! ( { " user " : " eve " , " password " : " 12345678 " } ) ) . 0 , 403 ) ;
assert_eq! ( post ( port , " /relay/login " , " " , json! ( { " user " : " daniel " , " password " : " wrong-pw " } ) ) . 0 , 401 ) ;
let ( _ , v ) = post ( port , " /relay/login " , " " , json! ( { " user " : " daniel " , " password " : " 12345678 " , " device " : " Mac " } ) ) ;
let session = v [ " token " ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
let ( _ , h ) = post ( port , " /relay/hosts " , & session , json! ( { " name " : " 工作室 Mac " } ) ) ;
let ( host_id , host_token ) = ( h [ " id " ] . as_str ( ) . unwrap ( ) . to_string ( ) , h [ " token " ] . as_str ( ) . unwrap ( ) . to_string ( ) ) ;
// Offline host: the phone is told so.
let ( st , _ ) = get ( port , & format! ( " /h/ {host_id} /api/state " ) , & session ) ;
assert_eq! ( st , 503 ) ;
// The "AnyBot": answers whatever comes in with the path it got.
let ht = host_token . clone ( ) ;
std ::thread ::spawn ( move | | loop {
let ( st , body ) = get ( port , " /relay/host/next " , & ht ) ;
if st = = 200 {
let job : Value = serde_json ::from_slice ( & body ) . unwrap ( ) ;
let answer = json! ( { " status " : 200 , " ctype " : " application/json " , " body " : base64 ::Engine ::encode ( & base64 ::engine ::general_purpose ::STANDARD , json! ( { " path " : job [ " path " ] , " q " : job [ " query " ] } ) . to_string ( ) ) , " headers " : [ [ " X-Size " , " 10x20 " ] , [ " Set-Cookie " , " no " ] ] } ) ;
post ( port , & format! ( " /relay/host/reply/ {} " , job [ " id " ] . as_str ( ) . unwrap ( ) ) , & ht , answer ) ;
}
} ) ;
std ::thread ::sleep ( Duration ::from_millis ( 300 ) ) ;
let agent : ureq ::Agent = ureq ::Agent ::config_builder ( ) . http_status_as_error ( false ) . build ( ) . into ( ) ;
let mut resp = agent . get ( & format! ( " http://127.0.0.1: {port} /h/ {host_id} /api/state?since=3 " ) ) . header ( " Authorization " , & format! ( " Bearer {session} " ) ) . call ( ) . unwrap ( ) ;
assert_eq! ( resp . status ( ) . as_u16 ( ) , 200 ) ;
assert_eq! ( resp . headers ( ) . get ( " x-size " ) . unwrap ( ) , " 10x20 " ) ;
assert! ( resp . headers ( ) . get ( " set-cookie " ) . is_none ( ) ) ;
let v : Value = serde_json ::from_str ( & resp . body_mut ( ) . read_to_string ( ) . unwrap ( ) ) . unwrap ( ) ;
assert_eq! ( v [ " path " ] , " /api/state " ) ;
assert_eq! ( v [ " q " ] [ " since " ] , " 3 " ) ;
// The device endpoint is only for the account's other AnyBots, never the phone.
assert_eq! ( get ( port , & format! ( " /h/ {host_id} /api/device/run " ) , & session ) . 0 , 404 ) ;
// Without a session: refused.
assert_eq! ( get ( port , & format! ( " /h/ {host_id} /api/state " ) , " nope " ) . 0 , 401 ) ;
// Backup: the host uploads, the person downloads after signing in.
let agent2 : ureq ::Agent = ureq ::Agent ::config_builder ( ) . http_status_as_error ( false ) . build ( ) . into ( ) ;
let st = agent2 . put ( & format! ( " http://127.0.0.1: {port} /relay/backup " ) ) . header ( " Authorization " , & format! ( " Bearer {host_token} " ) ) . send ( & [ 1 u8 , 2 , 3 ] [ .. ] ) . unwrap ( ) . status ( ) . as_u16 ( ) ;
assert_eq! ( st , 200 ) ;
assert_eq! ( get ( port , " /relay/backup " , & session ) . 1 , vec! [ 1 , 2 , 3 ] ) ;
let ( _ , me ) = get ( port , " /relay/me " , & session ) ;
let me : Value = serde_json ::from_slice ( & me ) . unwrap ( ) ;
assert_eq! ( me [ " hosts " ] [ 0 ] [ " online " ] , true ) ;
// Data survives a restart; tokens are stored hashed.
let saved = std ::fs ::read_to_string ( dir . join ( " data.json " ) ) . unwrap ( ) ;
assert! ( ! saved . contains ( & session ) & & ! saved . contains ( & host_token ) & & ! saved . contains ( " 12345678 " ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
#[ test ]
fn team_members_get_their_role ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " anybot-relay-team- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
let mut relay = Relay ::load ( dir . clone ( ) ) ;
relay . invite = " join-us " . into ( ) ;
let relay = Arc ::new ( relay ) ;
let l = std ::net ::TcpListener ::bind ( " 127.0.0.1:0 " ) . unwrap ( ) ;
let port = l . local_addr ( ) . unwrap ( ) . port ( ) ;
let r = relay . clone ( ) ;
std ::thread ::spawn ( move | | httpd ::serve ( l , Arc ::new ( move | req | r . handle ( req ) ) , Arc ::new ( | | true ) ) ) ;
post ( port , " /relay/signup " , " " , json! ( { " user " : " boss " , " password " : " 12345678 " } ) ) ;
assert_eq! ( post ( port , " /relay/signup " , " " , json! ( { " user " : " amy " , " password " : " 12345678 " , " invite " : " nope " } ) ) . 0 , 403 ) ;
assert_eq! ( post ( port , " /relay/signup " , " " , json! ( { " user " : " amy " , " password " : " 12345678 " , " invite " : " join-us " } ) ) . 0 , 200 ) ;
let boss = post ( port , " /relay/login " , " " , json! ( { " user " : " boss " , " password " : " 12345678 " } ) ) . 1 [ " token " ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
let amy = post ( port , " /relay/login " , " " , json! ( { " user " : " amy " , " password " : " 12345678 " } ) ) . 1 [ " token " ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
let h = post ( port , " /relay/hosts " , & boss , json! ( { " name " : " 辦公室 " } ) ) . 1 ;
let ( host_id , host_token ) = ( h [ " id " ] . as_str ( ) . unwrap ( ) . to_string ( ) , h [ " token " ] . as_str ( ) . unwrap ( ) . to_string ( ) ) ;
// Not shared yet: Amy can't see it.
assert_eq! ( get ( port , & format! ( " /h/ {host_id} /api/state " ) , & amy ) . 0 , 404 ) ;
assert_eq! ( post ( port , " /relay/host/members " , & host_token , json! ( { " user " : " ghost " , " role " : " member " } ) ) . 0 , 404 ) ;
assert_eq! ( post ( port , " /relay/host/members " , & host_token , json! ( { " user " : " amy " , " role " : " member " } ) ) . 0 , 200 ) ;
let me : Value = serde_json ::from_slice ( & get ( port , " /relay/me " , & amy ) . 1 ) . unwrap ( ) ;
assert_eq! ( me [ " hosts " ] [ 0 ] [ " role " ] , " member " ) ;
// The host hears who asked and in which role.
let ht = host_token . clone ( ) ;
std ::thread ::spawn ( move | | loop {
let ( st , body ) = get ( port , " /relay/host/next " , & ht ) ;
if st = = 200 {
let job : Value = serde_json ::from_slice ( & body ) . unwrap ( ) ;
let answer = json! ( { " status " : 200 , " ctype " : " application/json " , " body " : base64 ::Engine ::encode ( & base64 ::engine ::general_purpose ::STANDARD , json! ( { " user " : job [ " user " ] , " role " : job [ " role " ] } ) . to_string ( ) ) , " headers " : [ ] } ) ;
post ( port , & format! ( " /relay/host/reply/ {} " , job [ " id " ] . as_str ( ) . unwrap ( ) ) , & ht , answer ) ;
}
} ) ;
std ::thread ::sleep ( Duration ::from_millis ( 300 ) ) ;
let v : Value = serde_json ::from_slice ( & get ( port , & format! ( " /h/ {host_id} /api/state " ) , & amy ) . 1 ) . unwrap ( ) ;
assert_eq! ( ( v [ " user " ] . as_str ( ) , v [ " role " ] . as_str ( ) ) , ( Some ( " amy " ) , Some ( " member " ) ) ) ;
let v : Value = serde_json ::from_slice ( & get ( port , & format! ( " /h/ {host_id} /api/state " ) , & boss ) . 1 ) . unwrap ( ) ;
assert_eq! ( v [ " role " ] , " owner " ) ;
// Removing ends access.
post ( port , " /relay/host/members " , & host_token , json! ( { " user " : " amy " , " role " : " remove " } ) ) ;
assert_eq! ( get ( port , & format! ( " /h/ {host_id} /api/state " ) , & amy ) . 0 , 404 ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
#[ test ]
fn published_sites_are_sandboxed ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " anybot-relay-sites- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
let relay = Arc ::new ( Relay ::load ( dir . clone ( ) ) ) ;
let l = std ::net ::TcpListener ::bind ( " 127.0.0.1:0 " ) . unwrap ( ) ;
let port = l . local_addr ( ) . unwrap ( ) . port ( ) ;
let r = relay . clone ( ) ;
std ::thread ::spawn ( move | | httpd ::serve ( l , Arc ::new ( move | req | r . handle ( req ) ) , Arc ::new ( | | true ) ) ) ;
post ( port , " /relay/signup " , " " , json! ( { " user " : " boss " , " password " : " 12345678 " } ) ) ;
let s = post ( port , " /relay/login " , " " , json! ( { " user " : " boss " , " password " : " 12345678 " } ) ) . 1 [ " token " ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
let ht = post ( port , " /relay/hosts " , & s , json! ( { " name " : " m " } ) ) . 1 [ " token " ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
let b = | t : & str | base64 ::Engine ::encode ( & base64 ::engine ::general_purpose ::STANDARD , t ) ;
let ( st , v ) = post ( port , " /relay/host/sites/trip " , & ht , json! ( { " files " : [ { " path " : " index.html " , " data " : b ( " <h1>台南</h1> " ) } , { " path " : " css/a.css " , " data " : b ( " h1{} " ) } , { " path " : " ../evil " , " data " : b ( " x " ) } ] } ) ) ;
assert_eq! ( ( st , v [ " files " ] . as_u64 ( ) ) , ( 200 , Some ( 2 ) ) ) ;
assert_eq! ( v [ " path " ] , " /s/boss-trip/ " ) ;
let agent : ureq ::Agent = ureq ::Agent ::config_builder ( ) . http_status_as_error ( false ) . build ( ) . into ( ) ;
let mut r = agent . get ( & format! ( " http://127.0.0.1: {port} /s/boss-trip/ " ) ) . call ( ) . unwrap ( ) ;
assert! ( r . headers ( ) . get ( " content-security-policy " ) . unwrap ( ) . to_str ( ) . unwrap ( ) . starts_with ( " sandbox " ) ) ;
assert_eq! ( r . body_mut ( ) . read_to_string ( ) . unwrap ( ) , " <h1>台南</h1> " ) ;
assert_eq! ( get ( port , " /s/boss-trip/css/a.css " , " " ) . 0 , 200 ) ;
assert_eq! ( get ( port , " /s/boss-trip/../../data.json " , " " ) . 0 , 404 ) ;
assert! ( ! dir . join ( " evil " ) . exists ( ) & & ! dir . join ( " sites " ) . join ( " evil " ) . exists ( ) ) ;
assert_eq! ( post ( port , " /relay/host/sites/Bad_Name " , & ht , json! ( { } ) ) . 0 , 400 ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
2026-11-04 12:56:06 +00:00
#[ test ]
fn a_pairing_code_adds_a_computer_once ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " anybot-relay-pair- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
let relay = Arc ::new ( Relay ::load ( dir . clone ( ) ) ) ;
let l = std ::net ::TcpListener ::bind ( " 127.0.0.1:0 " ) . unwrap ( ) ;
let port = l . local_addr ( ) . unwrap ( ) . port ( ) ;
let r = relay . clone ( ) ;
std ::thread ::spawn ( move | | httpd ::serve ( l , Arc ::new ( move | req | r . handle ( req ) ) , Arc ::new ( | | true ) ) ) ;
post ( port , " /relay/signup " , " " , json! ( { " user " : " amy " , " password " : " 12345678 " } ) ) ;
let ( _ , v ) = post ( port , " /relay/login " , " " , json! ( { " user " : " amy " , " password " : " 12345678 " , " device " : " phone " } ) ) ;
let session = v [ " token " ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
let ( _ , brain ) = post ( port , " /relay/hosts " , & session , json! ( { " name " : " 雲端大腦 " } ) ) ;
// Asked from the phone, for the brain.
let ( st , p ) = post ( port , " /relay/pair " , & session , json! ( { " for " : brain [ " id " ] } ) ) ;
assert_eq! ( st , 200 ) ;
let code = p [ " code " ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
assert_eq! ( code . len ( ) , 6 ) ;
// Without signing in: no code.
assert_eq! ( post ( port , " /relay/pair " , " " , json! ( { } ) ) . 0 , 401 ) ;
let ( st , h ) = post ( port , " /relay/pair/claim " , " " , json! ( { " code " : format ! ( " {} {} " , & code [ .. 3 ] , & code [ 3 .. ] ) , " name " : " 書房電腦 " } ) ) ;
assert_eq! ( st , 200 , " {h} " ) ;
assert_eq! ( h [ " user " ] , " amy " ) ;
assert_eq! ( h [ " for_name " ] , " 雲端大腦 " ) ;
// The new computer's key works.
let ( st , peers ) = get ( port , " /relay/host/peers " , h [ " token " ] . as_str ( ) . unwrap ( ) ) ;
assert_eq! ( st , 200 ) ;
assert! ( String ::from_utf8_lossy ( & peers ) . contains ( " 雲端大腦 " ) ) ;
// Once only; a wrong code is refused, and guessing is stopped.
assert_eq! ( post ( port , " /relay/pair/claim " , " " , json! ( { " code " : code , " name " : " x " } ) ) . 0 , 404 ) ;
for _ in 0 .. 10 {
post ( port , " /relay/pair/claim " , " " , json! ( { " code " : " 000000 " , " name " : " x " } ) ) ;
}
assert_eq! ( post ( port , " /relay/pair/claim " , " " , json! ( { " code " : " 000000 " , " name " : " x " } ) ) . 0 , 429 ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
2026-11-05 07:48:20 +00:00
#[ test ]
fn brain_mirror_link_answers_only_from_the_right_machine ( ) {
// One account with a brain, a hands machine, and a nosy third machine: the sync
// channel (brain → relay → hands → relay → brain) must carry the right roles, must
// take answers only from the machine a job went to, and a signing-out machine's key
// must die with its registration.
let dir = std ::env ::temp_dir ( ) . join ( format! ( " anybot-relay-brain- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
let relay = Arc ::new ( Relay ::load ( dir . clone ( ) ) ) ;
let l = std ::net ::TcpListener ::bind ( " 127.0.0.1:0 " ) . unwrap ( ) ;
let port = l . local_addr ( ) . unwrap ( ) . port ( ) ;
let r = relay . clone ( ) ;
std ::thread ::spawn ( move | | httpd ::serve ( l , Arc ::new ( move | req | r . handle ( req ) ) , Arc ::new ( | | true ) ) ) ;
post ( port , " /relay/signup " , " " , json! ( { " user " : " boss " , " password " : " 12345678 " } ) ) ;
let s = post ( port , " /relay/login " , " " , json! ( { " user " : " boss " , " password " : " 12345678 " , " device " : " phone " } ) ) . 1 [ " token " ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
let hb = post ( port , " /relay/hosts " , & s , json! ( { " name " : " 大腦 " } ) ) . 1 ;
let btoken = hb [ " token " ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
let hh = post ( port , " /relay/hosts " , & s , json! ( { " name " : " 手腳 " } ) ) . 1 ;
let ( hid , htoken ) = ( hh [ " id " ] . as_str ( ) . unwrap ( ) . to_string ( ) , hh [ " token " ] . as_str ( ) . unwrap ( ) . to_string ( ) ) ;
let hm = post ( port , " /relay/hosts " , & s , json! ( { " name " : " 旁聽 " } ) ) . 1 ;
let mtoken = hm [ " token " ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
// The hands machine: takes its jobs, lets the snooper see the id, answers a beat later.
let ( tx , rx ) = channel ::< String > ( ) ;
let ht = htoken . clone ( ) ;
std ::thread ::spawn ( move | | loop {
let ( st , body ) = get ( port , " /relay/host/next " , & ht ) ;
if st = = 200 {
let job : Value = serde_json ::from_slice ( & body ) . unwrap ( ) ;
let id = job [ " id " ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
let _ = tx . send ( id . clone ( ) ) ;
std ::thread ::sleep ( Duration ::from_millis ( 400 ) ) ;
let answer = json! ( { " status " : 200 , " ctype " : " application/json " , " body " : base64 ::Engine ::encode ( & base64 ::engine ::general_purpose ::STANDARD , json! ( { " from " : " hands " , " role " : job [ " role " ] , " path " : job [ " path " ] } ) . to_string ( ) ) , " headers " : [ ] } ) ;
post ( port , & format! ( " /relay/host/reply/ {id} " ) , & ht , answer ) ;
}
} ) ;
// The snooper answers with a lie as soon as it learns a job id.
let sn = mtoken . clone ( ) ;
std ::thread ::spawn ( move | | {
let id = rx . recv ( ) . unwrap ( ) ;
let lie = json! ( { " status " : 200 , " ctype " : " application/json " , " body " : base64 ::Engine ::encode ( & base64 ::engine ::general_purpose ::STANDARD , json! ( { " from " : " snooper " } ) . to_string ( ) ) , " headers " : [ ] } ) ;
post ( port , & format! ( " /relay/host/reply/ {id} " ) , & sn , lie ) ;
} ) ;
std ::thread ::sleep ( Duration ::from_millis ( 200 ) ) ;
// 大腦 syncs 手腳's chats: the answer must come from the hands, with the mirror role.
let v : Value = serde_json ::from_slice ( & get ( port , & format! ( " /relay/hosts/ {hid} /api/sync?since=0 " ) , & btoken ) . 1 ) . unwrap ( ) ;
assert_eq! ( ( v [ " from " ] . as_str ( ) , v [ " role " ] . as_str ( ) , v [ " path " ] . as_str ( ) ) , ( Some ( " hands " ) , Some ( " mirror " ) , Some ( " /api/sync " ) ) , " the brain's sync answer must be the hands' own " ) ;
// 裝置連結: running a tool on the other machine carries the device role.
let v : Value = post ( port , & format! ( " /relay/hosts/ {hid} /api/device/run " ) , & btoken , json! ( { " tool " : " shell " , " args " : " {} " , " who " : " 大腦 " } ) ) . 1 ;
assert_eq! ( ( v [ " from " ] . as_str ( ) , v [ " role " ] . as_str ( ) ) , ( Some ( " hands " ) , Some ( " device " ) ) ) ;
// Signing out: the machine's own key removes its registration; others can't reach it.
assert_eq! ( post ( port , " /relay/host/remove " , & mtoken , json! ( { } ) ) . 0 , 200 ) ;
let hosts : Value = serde_json ::from_slice ( & get ( port , " /relay/host/peers " , & btoken ) . 1 ) . unwrap ( ) ;
assert_eq! ( hosts [ " hosts " ] . as_array ( ) . map ( | a | a . len ( ) ) , Some ( 2 ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
#[ test ]
fn forged_forwarded_for_cannot_outrun_the_login_lock ( ) {
// The login throttle follows the real peer: rewriting the first X-Forwarded-For
// entry on every try used to reset the counter, so passwords could be guessed forever.
let dir = std ::env ::temp_dir ( ) . join ( format! ( " anybot-relay-throttle- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
let relay = Arc ::new ( Relay ::load ( dir . clone ( ) ) ) ;
let l = std ::net ::TcpListener ::bind ( " 127.0.0.1:0 " ) . unwrap ( ) ;
let port = l . local_addr ( ) . unwrap ( ) . port ( ) ;
let r = relay . clone ( ) ;
std ::thread ::spawn ( move | | httpd ::serve ( l , Arc ::new ( move | req | r . handle ( req ) ) , Arc ::new ( | | true ) ) ) ;
post ( port , " /relay/signup " , " " , json! ( { " user " : " boss " , " password " : " 12345678 " } ) ) ;
let agent : ureq ::Agent = ureq ::Agent ::config_builder ( ) . http_status_as_error ( false ) . build ( ) . into ( ) ;
for i in 0 .. 8 {
let mut rr = agent
. post ( & format! ( " http://127.0.0.1: {port} /relay/login " ) )
. header ( " X-Forwarded-For " , & format! ( " 10.9. {i} . {i} " ) )
. send ( json! ( { " user " : " boss " , " password " : " wrong-password " } ) . to_string ( ) . as_bytes ( ) )
. unwrap ( ) ;
let _ = rr . body_mut ( ) . read_to_string ( ) ;
}
// Every fake IP was new; the one real peer is locked out — even for the right password.
let rr = agent
. post ( & format! ( " http://127.0.0.1: {port} /relay/login " ) )
. header ( " X-Forwarded-For " , " 10.9.99.99 " )
. send ( json! ( { " user " : " boss " , " password " : " 12345678 " } ) . to_string ( ) . as_bytes ( ) )
. unwrap ( ) ;
assert_eq! ( rr . status ( ) . as_u16 ( ) , 429 ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
}