2026-09-14 09:08:35 +00:00
//! Agent's own Linux computer (Grok Bot box), Docker-backed.
//! User-facing name is always "my computer". Viewer is noVNC on 127.0.0.1:6080.
use crate ::computer ::{
settle_before , steps_for , validate_bounds , ComputerAction , ComputerStep , ScreenSize ,
} ;
use anyhow ::{ anyhow , Context , Result } ;
use serde_json ::{ json , Value } ;
use std ::path ::{ Path , PathBuf } ;
use std ::process ::Stdio ;
use std ::sync ::Arc ;
use std ::time ::Duration ;
use tokio ::process ::Command ;
use tokio ::sync ::Mutex ;
const IMAGE : & str = " grokboy-box:local " ;
const CONTAINER : & str = " grokboy-box " ;
const VIEWER_PORT : u16 = 6080 ;
const BOX_REVISION : & str = " computer-use-2 " ;
const BROWSER_PROFILE : & str = " /home/box/chrome-profile " ;
/// Per-stream cap on shell output returned to the model; the middle is elided.
const JOB_OUTPUT_HEAD_CHARS : usize = 16_000 ;
const JOB_OUTPUT_TAIL_CHARS : usize = 32_000 ;
pub struct BoxHub {
inner : Mutex < BoxState > ,
}
struct BoxState {
ready : bool ,
}
impl BoxHub {
pub fn new ( ) -> Arc < Self > {
Arc ::new ( Self {
inner : Mutex ::new ( BoxState { ready : false } ) ,
} )
}
pub fn viewer_url ( ) -> String {
format! ( " http://127.0.0.1: {VIEWER_PORT} /vnc.html?autoconnect=true&resize=scale " )
}
2026-09-15 03:20:42 +00:00
fn ready_payload ( ) -> Value {
json! ( {
" ready " : true ,
" viewer_url " : Self ::viewer_url ( ) ,
" browser_surface " : " docker " ,
" workspace " : " /workspace " ,
" browser_profile " : BROWSER_PROFILE ,
" revision " : BOX_REVISION ,
} )
}
2026-09-14 09:08:35 +00:00
pub async fn ensure_ready ( & self ) -> Result < Value > {
let mut state = self . inner . lock ( ) . await ;
if state . ready & & docker_running ( CONTAINER ) . await ? {
// One cheap probe on the hot path; only a restarted container pays
// for the full readiness wait.
if ! desktop_up ( ) . await {
wait_desktop ( ) . await ? ;
}
2026-09-15 03:20:42 +00:00
return Ok ( Self ::ready_payload ( ) ) ;
2026-09-14 09:08:35 +00:00
}
docker_info ( ) . await ? ;
// Several processes (CLI, browser helper spawn, tests) each own a
// BoxHub. Without a host-wide lock they build the image twice and
// race each other removing the stale container.
let _provision = ProvisionLock ::acquire ( ) . await ? ;
ensure_image ( ) . await ? ;
ensure_container ( ) . await ? ;
wait_desktop ( ) . await ? ;
state . ready = true ;
2026-09-15 03:20:42 +00:00
let mut payload = Self ::ready_payload ( ) ;
payload [ " instruction " ] = json! (
" This is my computer. Paths here are /workspace and /home/box, not the user's machine. "
) ;
Ok ( payload )
}
/// Reboot the existing container. Volumes (workspace + Chrome profile) stay.
pub async fn restart ( & self ) -> Result < Value > {
let mut state = self . inner . lock ( ) . await ;
docker_info ( ) . await ? ;
let _provision = ProvisionLock ::acquire ( ) . await ? ;
state . ready = false ;
if docker ( [ " container " , " inspect " , CONTAINER ] ) . await . is_ok ( ) {
docker ( [ " restart " , " -t " , " 20 " , CONTAINER ] ) . await ? ;
} else {
ensure_image ( ) . await ? ;
ensure_container ( ) . await ? ;
}
wait_desktop ( ) . await ? ;
state . ready = true ;
let mut payload = Self ::ready_payload ( ) ;
payload [ " action " ] = json! ( " restart " ) ;
Ok ( payload )
}
/// Rebuild the image from the repo Dockerfile. Recreate the container only
/// when the image id actually changed. Volumes stay.
pub async fn update ( & self ) -> Result < Value > {
let mut state = self . inner . lock ( ) . await ;
docker_info ( ) . await ? ;
let _provision = ProvisionLock ::acquire ( ) . await ? ;
state . ready = false ;
let before = local_image_id ( ) . await ;
build_image ( ) . await ? ;
let after = local_image_id ( ) . await ;
let image_changed = before ! = after ;
if image_changed {
if docker ( [ " container " , " inspect " , CONTAINER ] ) . await . is_ok ( ) {
remove_stale_container ( ) . await ? ;
}
ensure_container ( ) . await ? ;
} else if ! docker_running ( CONTAINER ) . await ? {
ensure_container ( ) . await ? ;
}
wait_desktop ( ) . await ? ;
state . ready = true ;
let mut payload = Self ::ready_payload ( ) ;
payload [ " action " ] = json! ( " update " ) ;
payload [ " updated " ] = json! ( image_changed ) ;
Ok ( payload )
2026-09-14 09:08:35 +00:00
}
pub async fn ensure_browser_ready ( & self ) -> Result < ( ) > {
self . ensure_ready ( ) . await ? ;
let probe = [ " python3 " , " -c " , " import urllib.request; urllib.request.urlopen('http://127.0.0.1:9222/json/version', timeout=1).close() " ] ;
if docker_exec ( & probe ) . await ? . status = = 0 { return Ok ( ( ) ) ; }
// The desktop autostart may already be launching Chromium; a second
// launcher would race it for the profile. box-chrome serialises
// launches, but skipping the launch entirely is cheaper.
let already_launching = docker_exec ( & [
" pgrep " , " -f " , " -- " , & format! ( " --user-data-dir= {BROWSER_PROFILE} " ) ,
] ) . await ? . status = = 0 ;
if ! already_launching {
docker_exec ( & [ " bash " , " -lc " , " nohup box-chrome </dev/null >/tmp/grokboy-browser.log 2>&1 & " ] ) . await ? ;
}
for _ in 0 .. 80 {
if docker_exec ( & probe ) . await ? . status = = 0 { return Ok ( ( ) ) ; }
tokio ::time ::sleep ( Duration ::from_millis ( 250 ) ) . await ;
}
Err ( anyhow! ( " Box Chromium did not start. Inspect /tmp/grokboy-browser.log on the box; no local browser was opened. " ) )
}
pub async fn shell ( & self , cmd : & str , block_until_ms : u64 ) -> Result < Value > {
self . ensure_ready ( ) . await ? ;
let id = uuid ::Uuid ::new_v4 ( ) . to_string ( ) ;
// All commands run detached in the box. A foreground wait expiring must
// never kill an install or silently move it onto the user's computer.
// The exit file is renamed into place so a poller never sees it empty.
docker_exec ( & [ " bash " , " -lc " , & format! (
" mkdir -p /tmp/gb-jobs/{id} && {{ nohup bash -lc {q} </dev/null >/tmp/gb-jobs/{id}/stdout 2>/tmp/gb-jobs/{id}/stderr & }} " ,
q = shell_quote ( & format! (
" cd /workspace && bash -lc {}; code=$?; printf '%s \\ n' \" $code \" >/tmp/gb-jobs/{id}/exit.tmp && mv -f /tmp/gb-jobs/{id}/exit.tmp /tmp/gb-jobs/{id}/exit " ,
shell_quote ( cmd )
) )
) ] ) . await ? ;
self . wait_job ( & id , block_until_ms ) . await
}
async fn wait_job ( & self , id : & str , block_until_ms : u64 ) -> Result < Value > {
let deadline = tokio ::time ::Instant ::now ( ) + Duration ::from_millis ( block_until_ms . min ( 30_000 ) ) ;
loop {
let mut status = self . read_job_status ( id ) . await ? ;
status [ " session_id " ] = json! ( id ) ;
status [ " surface " ] = json! ( " box " ) ;
if status [ " running " ] ! = true | | tokio ::time ::Instant ::now ( ) > = deadline {
if status [ " running " ] = = true {
status [ " instruction " ] = json! ( " Still running on my computer. Use await_shell with this session_id when the result is needed. Do not rerun the command locally. " ) ;
}
return Ok ( status ) ;
}
tokio ::time ::sleep ( Duration ::from_millis ( 200 ) ) . await ;
}
}
pub async fn await_job ( & self , session_id : & str ) -> Result < Value > {
self . ensure_ready ( ) . await ? ;
validate_job_id ( session_id ) ? ;
self . wait_job ( session_id , 30_000 ) . await
}
async fn read_job_status ( & self , session_id : & str ) -> Result < Value > {
validate_job_id ( session_id ) ? ;
let py = r #"
import json , pathlib , sys
root = pathlib . Path ( ' / tmp / gb - jobs ' ) / sys . argv [ 1 ]
head , tail = int ( sys . argv [ 2 ] ) , int ( sys . argv [ 3 ] )
def stream ( name ) :
p = root / name
if not p . exists ( ) :
return ' ' , False
text = p . read_text ( errors = ' replace ' )
if len ( text ) < = head + tail :
return text , False
dropped = len ( text ) - head - tail
return text [ :head ] + f ' \ n .. . [ { dropped } chars elided ] .. . \ n ' + text [ - tail :] , True
if not root . exists ( ) :
print ( json . dumps ( { ' error ' : ' unknown session ' } ) )
else :
exitp = root / ' exit '
raw = exitp . read_text ( errors = ' replace ' ) . strip ( ) if exitp . exists ( ) else ' '
code = int ( raw ) if raw . lstrip ( '-' ) . isdigit ( ) else None
out , out_cut = stream ( ' stdout ' )
err , err_cut = stream ( ' stderr ' )
status = { ' running ' : code is None , ' exit_code ' : code , ' stdout ' : out , ' stderr ' : err }
if out_cut or err_cut :
status [ ' truncated ' ] = True
status [ ' full_output ' ] = { ' stdout ' : str ( root / ' stdout ' ) , ' stderr ' : str ( root / ' stderr ' ) }
print ( json . dumps ( status ) )
" #;
let out = docker_exec ( & [
" python3 " ,
" -c " ,
py ,
session_id ,
& JOB_OUTPUT_HEAD_CHARS . to_string ( ) ,
& JOB_OUTPUT_TAIL_CHARS . to_string ( ) ,
] )
. await ? ;
if out . status ! = 0 {
return Err ( anyhow! ( " box job status failed: {} " , out . stderr . trim ( ) ) ) ;
}
serde_json ::from_str ( out . stdout . trim ( ) ) . context ( " parse box_await json " )
}
pub async fn read ( & self , path : & str , offset : Option < i64 > , limit : Option < i64 > ) -> Result < Value > {
self . ensure_ready ( ) . await ? ;
let path = resolve_box_path ( path ) ? ;
let offset = offset . unwrap_or ( 1 ) ;
let limit = limit . unwrap_or ( 400 ) ;
let py = format! (
r #"
import json , pathlib , sys
p = pathlib . Path ( sys . argv [ 1 ] )
lines = p . read_text ( errors = ' replace ' ) . splitlines ( )
start = max ( { offset } - 1 , 0 )
chunk = lines [ start :start + { limit } ]
print ( json . dumps ( { { ' path ' : str ( p ) , ' offset ' : start + 1 , ' lines ' : len ( lines ) , ' content ' : chr ( 10 ) . join ( chunk ) } } ) )
" #
) ;
let out = docker_exec ( & [ " python3 " , " -c " , & py , & path ] ) . await ? ;
if out . status ! = 0 {
return Err ( anyhow! ( out . stderr . trim ( ) . to_string ( ) ) ) ;
}
serde_json ::from_str ( out . stdout . trim ( ) ) . context ( " parse box_read " )
}
pub async fn copy_to_box ( & self , host_path : & Path , box_path : Option < & str > ) -> Result < Value > {
self . ensure_ready ( ) . await ? ;
if ! host_path . is_file ( ) {
return Err ( anyhow! ( " source is not a file: {} " , host_path . display ( ) ) ) ;
}
let dest = match box_path {
Some ( p ) = > resolve_box_path ( p ) ? ,
None = > format! (
" /workspace/uploads/{} " ,
host_path
. file_name ( )
. and_then ( | s | s . to_str ( ) )
. unwrap_or ( " file " )
) ,
} ;
docker ( [ " exec " , CONTAINER , " mkdir " , " -p " , parent_dir ( & dest ) ] ) . await ? ;
docker ( [
" cp " ,
& host_path . to_string_lossy ( ) ,
& format! ( " {CONTAINER} : {dest} " ) ,
] )
. await ? ;
Ok ( json! ( { " box_path " : dest , " bytes " : std ::fs ::metadata ( host_path ) ? . len ( ) } ) )
}
pub async fn copy_from_box ( & self , box_path : & str , host_path : & Path ) -> Result < Value > {
self . ensure_ready ( ) . await ? ;
let src = resolve_box_path ( box_path ) ? ;
if let Some ( parent ) = host_path . parent ( ) {
std ::fs ::create_dir_all ( parent ) ? ;
}
docker ( [
" cp " ,
& format! ( " {CONTAINER} : {src} " ) ,
& host_path . to_string_lossy ( ) ,
] )
. await ? ;
let is_dir = host_path . is_dir ( ) ;
Ok ( json! ( {
" computer_path " : host_path ,
" is_dir " : is_dir ,
" bytes " : if is_dir { dir_size ( host_path ) } else { std ::fs ::metadata ( host_path ) . map ( | m | m . len ( ) ) . unwrap_or ( 0 ) } ,
} ) )
}
pub async fn screenshot ( & self , dest : & Path ) -> Result < Value > {
self . ensure_ready ( ) . await ? ;
let shot = ShotFile ::new ( ) ;
self . capture_root_png ( & shot ) . await ? ;
shot . fetch ( dest ) . await ? ;
Ok ( json! ( {
" path " : dest ,
" viewer_url " : Self ::viewer_url ( ) ,
} ) )
}
pub async fn computer ( & self , actions : & [ ComputerAction ] , dest : & Path ) -> Result < Value > {
self . ensure_ready ( ) . await ? ;
let screen = self . display_geometry ( ) . await ? ;
validate_bounds ( actions , screen ) ? ;
let names : Vec < & str > = actions . iter ( ) . map ( ComputerAction ::name ) . collect ( ) ;
let shot = ShotFile ::new ( ) ;
for ( index , action ) in actions . iter ( ) . enumerate ( ) {
let settle = settle_before ( actions , index ) ;
if settle > 0 {
tokio ::time ::sleep ( Duration ::from_millis ( u64 ::from ( settle ) ) ) . await ;
}
self . run_computer_action ( action , & shot ) . await ? ;
}
if ! actions . iter ( ) . any ( ComputerAction ::is_screenshot ) {
self . capture_root_png ( & shot ) . await ? ;
}
shot . fetch ( dest ) . await ? ;
let cursor = self . cursor_position ( ) . await . ok ( ) ;
Ok ( json! ( {
" ok " : true ,
" actions " : names ,
" path " : dest ,
" viewer_url " : Self ::viewer_url ( ) ,
" cursor_position " : cursor ,
" screen " : { " width " : screen . width , " height " : screen . height } ,
" instruction " : " Computer action ran on my computer. Read this screenshot before the next action. A batched then sequence returns only this final screen. "
} ) )
}
async fn run_computer_action ( & self , action : & ComputerAction , shot : & ShotFile ) -> Result < ( ) > {
for step in steps_for ( action ) ? {
match step {
ComputerStep ::Screenshot = > self . capture_root_png ( shot ) . await ? ,
ComputerStep ::SleepMs ( 0 ) = > { }
ComputerStep ::SleepMs ( ms ) = > {
let secs = format! ( " {:.3} " , f64 ::from ( ms ) / 1000.0 ) ;
let out = docker_exec ( & [
" python3 " ,
" -c " ,
" import sys,time; time.sleep(float(sys.argv[1])) " ,
& secs ,
] )
. await ? ;
if out . status ! = 0 {
return Err ( anyhow! ( " computer wait failed: {} " , out . stderr . trim ( ) ) ) ;
}
}
ComputerStep ::Xdotool ( args ) = > {
let mut argv : Vec < & str > = vec! [ " xdotool " ] ;
argv . extend ( args . iter ( ) . map ( String ::as_str ) ) ;
let out = docker_exec_env ( & [ ( " DISPLAY " , " :1 " ) ] , & argv ) . await ? ;
if out . status ! = 0 {
let detail = out . stderr . trim ( ) ;
if detail . contains ( " xdotool " ) & & detail . contains ( " not found " ) {
return Err ( anyhow! (
" My computer image is missing xdotool. Rebuild the box image and retry. "
) ) ;
}
return Err ( anyhow! (
" computer {} failed: {} " ,
action . name ( ) ,
if detail . is_empty ( ) {
out . stdout . trim ( )
} else {
detail
}
) ) ;
}
}
}
}
Ok ( ( ) )
}
async fn display_geometry ( & self ) -> Result < ScreenSize > {
let out = docker_exec_env ( & [ ( " DISPLAY " , " :1 " ) ] , & [ " xdotool " , " getdisplaygeometry " ] ) . await ? ;
if out . status = = 0 {
if let Ok ( size ) = ScreenSize ::parse ( & out . stdout ) {
return Ok ( size ) ;
}
}
let info = docker_exec ( & [ " bash " , " -lc " , " xdpyinfo -display :1 | awk '/dimensions:/{print $2}' " ] ) . await ? ;
let raw = info . stdout . trim ( ) . replace ( 'x' , " " ) ;
ScreenSize ::parse ( & raw ) . map_err ( | e | {
anyhow! ( " could not read my computer's display size ({e}). Is the desktop up? " )
} )
}
async fn cursor_position ( & self ) -> Result < Value > {
let out = docker_exec_env ( & [ ( " DISPLAY " , " :1 " ) ] , & [ " xdotool " , " getmouselocation " , " --shell " ] ) . await ? ;
if out . status ! = 0 {
return Err ( anyhow! ( out . stderr . trim ( ) . to_string ( ) ) ) ;
}
let mut x = 0 i32 ;
let mut y = 0 i32 ;
for line in out . stdout . lines ( ) {
if let Some ( v ) = line . strip_prefix ( " X= " ) {
x = v . trim ( ) . parse ( ) . unwrap_or ( 0 ) ;
}
if let Some ( v ) = line . strip_prefix ( " Y= " ) {
y = v . trim ( ) . parse ( ) . unwrap_or ( 0 ) ;
}
}
Ok ( json! ( { " x " : x , " y " : y } ) )
}
async fn capture_root_png ( & self , shot : & ShotFile ) -> Result < ( ) > {
let out = docker_exec ( & [
" import " ,
" -display " ,
" :1 " ,
" -window " ,
" root " ,
& shot . box_path ,
] )
. await ? ;
if out . status ! = 0 {
return Err ( anyhow! (
" Could not capture my computer's screen (desktop not up yet). {} " ,
out . stderr . trim ( )
) ) ;
}
Ok ( ( ) )
}
}
/// A per-call screenshot file on the box. Concurrent callers (a parent
/// checking in on a computerUse child) must never read each other's frame.
struct ShotFile {
box_path : String ,
}
impl ShotFile {
fn new ( ) -> Self {
Self {
box_path : format ! ( " /tmp/grokboy-shot-{}.png " , uuid ::Uuid ::new_v4 ( ) ) ,
}
}
async fn fetch ( & self , dest : & Path ) -> Result < ( ) > {
if let Some ( parent ) = dest . parent ( ) {
std ::fs ::create_dir_all ( parent ) ? ;
}
let copied = docker ( [
" cp " ,
& format! ( " {CONTAINER} : {} " , self . box_path ) ,
& dest . to_string_lossy ( ) ,
] )
. await ;
let _ = docker_exec ( & [ " rm " , " -f " , & self . box_path ] ) . await ;
copied . map ( | _ | ( ) )
}
}
/// Host-wide advisory lock held while the image/container are provisioned.
/// Released when dropped (the fd closes).
struct ProvisionLock {
_file : std ::fs ::File ,
}
impl ProvisionLock {
async fn acquire ( ) -> Result < Self > {
let path = std ::env ::temp_dir ( ) . join ( " grokboy-box-provision.lock " ) ;
let file = tokio ::task ::spawn_blocking ( move | | -> Result < std ::fs ::File > {
let file = std ::fs ::OpenOptions ::new ( )
. create ( true )
. truncate ( false )
. write ( true )
. open ( & path )
. with_context ( | | format! ( " open {} " , path . display ( ) ) ) ? ;
#[ cfg(unix) ]
{
use std ::os ::fd ::AsRawFd ;
if unsafe { libc ::flock ( file . as_raw_fd ( ) , libc ::LOCK_EX ) } ! = 0 {
return Err ( std ::io ::Error ::last_os_error ( ) )
. context ( " lock box provisioning " ) ;
}
}
Ok ( file )
} )
. await
. context ( " box provisioning lock task " ) ? ? ;
Ok ( Self { _file : file } )
}
}
fn dir_size ( path : & Path ) -> u64 {
let Ok ( entries ) = std ::fs ::read_dir ( path ) else {
return 0 ;
} ;
entries
. flatten ( )
. map ( | entry | {
let path = entry . path ( ) ;
if path . is_dir ( ) {
dir_size ( & path )
} else {
std ::fs ::metadata ( & path ) . map ( | m | m . len ( ) ) . unwrap_or ( 0 )
}
} )
. sum ( )
}
pub fn resolve_box_path ( path : & str ) -> Result < String > {
let path = path . trim ( ) ;
if path . is_empty ( ) {
return Err ( anyhow! ( " box path is empty " ) ) ;
}
let abs = if path . starts_with ( '/' ) {
path . to_string ( )
} else {
format! ( " /workspace/ {path} " )
} ;
// Normalise by component so `a//b` and `./x` are accepted while only a
// real `..` segment (not a name like `report..final.txt`) is rejected.
let mut parts : Vec < & str > = Vec ::new ( ) ;
for part in abs . split ( '/' ) {
match part {
" " | " . " = > { }
" .. " = > return Err ( anyhow! ( " box path must not contain .. " ) ) ,
other = > parts . push ( other ) ,
}
}
let clean = format! ( " / {} " , parts . join ( " / " ) ) ;
let allowed = [ " /workspace " , " /home/box " , " /tmp/gb-jobs " ] ;
if ! allowed
. iter ( )
. any ( | root | clean = = * root | | clean . starts_with ( & format! ( " {root} / " ) ) )
{
return Err ( anyhow! (
" box paths must be under /workspace or /home/box (got {clean}). This is my computer, not the user's. "
) ) ;
}
Ok ( clean )
}
fn parent_dir ( path : & str ) -> & str {
path . rsplit_once ( '/' )
. map ( | ( p , _ ) | if p . is_empty ( ) { " / " } else { p } )
. unwrap_or ( " /workspace " )
}
fn shell_quote ( s : & str ) -> String {
format! ( " ' {} ' " , s . replace ( '\'' , r # "'"'"'"# ) )
}
fn validate_job_id ( id : & str ) -> Result < ( ) > {
if id . chars ( ) . all ( | c | c . is_ascii_hexdigit ( ) | | c = = '-' ) & & ( 8 .. 80 ) . contains ( & id . len ( ) ) {
Ok ( ( ) )
} else {
Err ( anyhow! ( " invalid session_id " ) )
}
}
fn box_context_dir ( ) -> PathBuf {
if let Some ( dir ) = std ::env ::var_os ( " GROKBOY_BOX_DIR " ) . filter ( | s | ! s . is_empty ( ) ) {
return PathBuf ::from ( dir ) ;
}
PathBuf ::from ( env! ( " CARGO_MANIFEST_DIR " ) )
. join ( " ../.. " )
. join ( " box " )
}
async fn docker_info ( ) -> Result < ( ) > {
if docker ( [ " info " ] ) . await . is_err ( ) {
return Err ( anyhow! (
" My computer needs Docker. Start Docker Desktop (or the docker daemon) and try again. I will not run this on your computer. "
) ) ;
}
Ok ( ( ) )
}
async fn image_revision ( ) -> Option < String > {
docker ( [
" inspect " ,
" -f " ,
" {{index .Config.Labels \" grokboy.box.revision \" }} " ,
IMAGE ,
] )
. await
. ok ( )
. map ( | out | out . stdout . trim ( ) . to_string ( ) )
. filter ( | s | ! s . is_empty ( ) & & s ! = " <no value> " )
}
async fn ensure_image ( ) -> Result < ( ) > {
if image_revision ( ) . await . as_deref ( ) = = Some ( BOX_REVISION ) {
return Ok ( ( ) ) ;
}
2026-09-15 03:20:42 +00:00
build_image ( ) . await
}
async fn build_image ( ) -> Result < ( ) > {
2026-09-14 09:08:35 +00:00
let ctx = box_context_dir ( ) ;
if ! ctx . join ( " Dockerfile " ) . is_file ( ) {
return Err ( anyhow! (
" Box image {IMAGE} is missing and Dockerfile was not found at {} " ,
ctx . display ( )
) ) ;
}
// Stage the helper in the build context; never mount the user's workspace.
let helper_dir = ctx . join ( " playwright " ) ;
tokio ::fs ::create_dir_all ( & helper_dir ) . await ? ;
for file in [ " package.json " , " browser_helper.mjs " ] {
let src = ctx . join ( " ../tools/playwright " ) . join ( file ) ;
tokio ::fs ::copy ( & src , helper_dir . join ( file ) )
. await
. with_context ( | | format! ( " stage browser helper {} " , src . display ( ) ) ) ? ;
}
eprintln! (
" box: building image {IMAGE} (revision {BOX_REVISION}) from {}; this can take several minutes on first run " ,
ctx . display ( )
) ;
let started = std ::time ::Instant ::now ( ) ;
docker ( [ " build " , " -t " , IMAGE , & ctx . to_string_lossy ( ) ] ) . await ? ;
eprintln! ( " box: image {IMAGE} ready in {:?} " , started . elapsed ( ) ) ;
Ok ( ( ) )
}
async fn container_image_id ( ) -> Option < String > {
docker ( [ " inspect " , " -f " , " {{.Image}} " , CONTAINER ] )
. await
. ok ( )
. map ( | out | out . stdout . trim ( ) . to_string ( ) )
. filter ( | s | ! s . is_empty ( ) )
}
async fn local_image_id ( ) -> Option < String > {
docker ( [ " inspect " , " -f " , " {{.Id}} " , IMAGE ] )
. await
. ok ( )
. map ( | out | out . stdout . trim ( ) . to_string ( ) )
. filter ( | s | ! s . is_empty ( ) )
}
async fn ensure_container ( ) -> Result < ( ) > {
if docker_running ( CONTAINER ) . await ?
& & container_image_id ( ) . await = = local_image_id ( ) . await
{
return Ok ( ( ) ) ;
}
if docker ( [ " container " , " inspect " , CONTAINER ] ) . await . is_ok ( )
& & container_image_id ( ) . await ! = local_image_id ( ) . await
{
remove_stale_container ( ) . await ? ;
}
if docker_running ( CONTAINER ) . await ? {
return Ok ( ( ) ) ;
}
// The container name is Docker's cross-process creation lock. Losing a
// create race must reuse the winner, never remove its container.
let created = docker ( [
" create " ,
" --name " ,
CONTAINER ,
" --shm-size=2g " ,
" -p " ,
& format! ( " 127.0.0.1: {VIEWER_PORT} :6080 " ) ,
" --stop-timeout=20 " ,
" -v " ,
" grokboy-box-workspace:/workspace " ,
" -v " ,
" grokboy-box-home:/home/box " ,
IMAGE ,
] )
. await ;
if let Err ( error ) = created {
// Docker reserves the name before inspect can see the new container.
let mut visible = false ;
for _ in 0 .. 50 {
if docker ( [ " container " , " inspect " , CONTAINER ] ) . await . is_ok ( ) {
visible = true ;
break ;
}
tokio ::time ::sleep ( Duration ::from_millis ( 100 ) ) . await ;
}
if ! visible {
return Err ( error ) ;
}
}
// Starting an existing container also preserves its writable layer.
docker ( [ " start " , CONTAINER ] ) . await ? ;
Ok ( ( ) )
}
/// X server, window manager, VNC and noVNC all answering. The WM check
/// matters: xdotool against a bare Xvfb has no focus or window stacking.
const DESKTOP_PROBE : & str = " xdpyinfo -display :1 >/dev/null 2>&1 \
& & xprop - display :1 - root _NET_SUPPORTING_WM_CHECK 2 > / dev / null | grep - q ' window id ' \
& & python3 - c ' import urllib . request , socket ; urllib . request . urlopen ( \ " http://127.0.0.1:6080/vnc.html \" , timeout=1).close(); socket.create_connection(( \" 127.0.0.1 \" , 5900), timeout=1).close()' " ;
async fn desktop_up ( ) -> bool {
docker_exec ( & [ " bash " , " -c " , DESKTOP_PROBE ] )
. await
. map ( | o | o . status = = 0 )
. unwrap_or ( false )
}
/// Stop and remove the container built from an older image. Another process
/// (outside our provisioning lock, e.g. an older binary) may be doing the
/// same; "already in progress" or "no such container" both mean it is gone.
async fn remove_stale_container ( ) -> Result < ( ) > {
if docker_running ( CONTAINER ) . await ? {
if let Err ( error ) = docker ( [ " stop " , " -t " , " 20 " , CONTAINER ] ) . await {
if ! is_concurrent_removal ( & error ) {
return Err ( error ) ;
}
}
}
if let Err ( error ) = docker ( [ " rm " , CONTAINER ] ) . await {
if ! is_concurrent_removal ( & error ) {
return Err ( error ) ;
}
}
for _ in 0 .. 100 {
// Gone, or already replaced by a container on the current image.
if docker ( [ " container " , " inspect " , CONTAINER ] ) . await . is_err ( )
| | container_image_id ( ) . await = = local_image_id ( ) . await
{
return Ok ( ( ) ) ;
}
tokio ::time ::sleep ( Duration ::from_millis ( 200 ) ) . await ;
}
Err ( anyhow! (
" stale container {CONTAINER} is still being removed; retry in a moment "
) )
}
fn is_concurrent_removal ( error : & anyhow ::Error ) -> bool {
let text = error . to_string ( ) ;
text . contains ( " already in progress " )
| | text . contains ( " No such container " )
| | text . contains ( " is already stopped " )
}
async fn wait_desktop ( ) -> Result < ( ) > {
for _ in 0 .. 120 {
if desktop_up ( ) . await {
return Ok ( ( ) ) ;
}
tokio ::time ::sleep ( Duration ::from_millis ( 250 ) ) . await ;
}
Err ( anyhow! ( " My computer did not become ready: desktop or viewer startup timed out. Check the container desktop logs and try again. " ) )
}
async fn docker_running ( name : & str ) -> Result < bool > {
let out = docker ( [ " inspect " , " -f " , " {{.State.Running}} " , name ] ) . await ;
Ok ( matches! ( out , Ok ( s ) if s . stdout . trim ( ) = = " true " ) )
}
struct CmdOut {
status : i32 ,
stdout : String ,
stderr : String ,
}
async fn docker < I , S > ( args : I ) -> Result < CmdOut >
where
I : IntoIterator < Item = S > ,
S : AsRef < std ::ffi ::OsStr > ,
{
let mut cmd = Command ::new ( " docker " ) ;
cmd . args ( args )
. stdout ( Stdio ::piped ( ) )
. stderr ( Stdio ::piped ( ) )
. kill_on_drop ( true ) ;
let out = cmd . output ( ) . await . context ( " run docker " ) ? ;
let result = CmdOut {
status : out . status . code ( ) . unwrap_or ( - 1 ) ,
stdout : String ::from_utf8_lossy ( & out . stdout ) . into_owned ( ) ,
stderr : String ::from_utf8_lossy ( & out . stderr ) . into_owned ( ) ,
} ;
if result . status ! = 0 {
return Err ( anyhow! (
" docker failed ({}): {} " ,
result . status ,
result . stderr . trim ( )
) ) ;
}
Ok ( result )
}
async fn docker_exec ( args : & [ & str ] ) -> Result < CmdOut > {
docker_exec_env ( & [ ] , args ) . await
}
async fn docker_exec_env ( env : & [ ( & str , & str ) ] , args : & [ & str ] ) -> Result < CmdOut > {
let mut all = vec! [ " exec " . to_string ( ) ] ;
for ( key , value ) in env {
all . push ( " -e " . into ( ) ) ;
all . push ( format! ( " {key} = {value} " ) ) ;
}
all . push ( CONTAINER . to_string ( ) ) ;
all . extend ( args . iter ( ) . map ( | s | ( * s ) . to_string ( ) ) ) ;
let mut cmd = Command ::new ( " docker " ) ;
cmd . args ( & all )
. stdout ( Stdio ::piped ( ) )
. stderr ( Stdio ::piped ( ) )
. kill_on_drop ( true ) ;
let out = cmd . output ( ) . await . context ( " docker exec " ) ? ;
Ok ( CmdOut {
status : out . status . code ( ) . unwrap_or ( - 1 ) ,
stdout : String ::from_utf8_lossy ( & out . stdout ) . into_owned ( ) ,
stderr : String ::from_utf8_lossy ( & out . stderr ) . into_owned ( ) ,
} )
}
#[ cfg(test) ]
mod tests {
use super ::* ;
#[ test ]
fn box_paths_stay_on_my_computer ( ) {
assert_eq! (
resolve_box_path ( " notes.txt " ) . unwrap ( ) ,
" /workspace/notes.txt "
) ;
assert_eq! ( resolve_box_path ( " /workspace/a " ) . unwrap ( ) , " /workspace/a " ) ;
assert! ( resolve_box_path ( " /etc/passwd " ) . is_err ( ) ) ;
assert! ( resolve_box_path ( " ../secret " ) . is_err ( ) ) ;
assert! ( resolve_box_path ( " /workspace/../home " ) . is_err ( ) ) ;
assert! ( resolve_box_path ( " /workspace/../../etc/passwd " ) . is_err ( ) ) ;
assert! ( resolve_box_path ( " /workspaceX/a " ) . is_err ( ) ) ;
assert! ( resolve_box_path ( " /tmp/other " ) . is_err ( ) ) ;
}
#[ test ]
fn box_paths_normalise_without_false_positives ( ) {
assert_eq! (
resolve_box_path ( " /workspace///a/./b " ) . unwrap ( ) ,
" /workspace/a/b "
) ;
assert_eq! (
resolve_box_path ( " report..final.txt " ) . unwrap ( ) ,
" /workspace/report..final.txt "
) ;
assert_eq! ( resolve_box_path ( " /home/box/ " ) . unwrap ( ) , " /home/box " ) ;
assert_eq! (
resolve_box_path ( " /tmp/gb-jobs/abc/stdout " ) . unwrap ( ) ,
" /tmp/gb-jobs/abc/stdout "
) ;
}
#[ test ]
fn dir_size_sums_nested_files ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " gb-dirsize- {} " , uuid ::Uuid ::new_v4 ( ) ) ) ;
std ::fs ::create_dir_all ( dir . join ( " inner " ) ) . unwrap ( ) ;
std ::fs ::write ( dir . join ( " a.bin " ) , [ 0 u8 ; 10 ] ) . unwrap ( ) ;
std ::fs ::write ( dir . join ( " inner/b.bin " ) , [ 0 u8 ; 5 ] ) . unwrap ( ) ;
assert_eq! ( dir_size ( & dir ) , 15 ) ;
std ::fs ::remove_dir_all ( & dir ) . unwrap ( ) ;
}
#[ tokio::test ]
async fn missing_docker_is_explicit ( ) {
if docker ( [ " info " ] ) . await . is_ok ( ) {
return ;
}
let hub = BoxHub ::new ( ) ;
let err = hub . ensure_ready ( ) . await . unwrap_err ( ) . to_string ( ) ;
assert! ( err . contains ( " Docker " ) , " {err} " ) ;
assert! (
err . contains ( " your computer " ) | | err . contains ( " My computer " ) ,
" {err} "
) ;
}
2026-09-15 03:20:42 +00:00
#[ tokio::test ]
async fn restart_and_update_need_docker ( ) {
if docker ( [ " info " ] ) . await . is_ok ( ) {
return ;
}
let hub = BoxHub ::new ( ) ;
for err in [
hub . restart ( ) . await . unwrap_err ( ) . to_string ( ) ,
hub . update ( ) . await . unwrap_err ( ) . to_string ( ) ,
] {
assert! ( err . contains ( " Docker " ) , " {err} " ) ;
}
}
#[ test ]
fn ready_payload_names_the_box ( ) {
let payload = BoxHub ::ready_payload ( ) ;
assert_eq! ( payload [ " ready " ] , true ) ;
assert_eq! ( payload [ " workspace " ] , " /workspace " ) ;
assert_eq! ( payload [ " revision " ] , BOX_REVISION ) ;
assert! ( payload [ " viewer_url " ] . as_str ( ) . unwrap ( ) . contains ( " 6080 " ) ) ;
}
2026-09-14 09:08:35 +00:00
}