diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc20efc1..d4635735 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,17 +31,40 @@ jobs: - name: Generated TypeScript bindings are up to date run: git diff --exit-code -- app/src/lib/protocol.gen.ts - rust-ubuntu: - name: Rust core + store (Ubuntu) - runs-on: ubuntu-latest + # The daemon and its crates off Windows (spec 18, item 8). Plain `cargo` + # builds the default members only: the app crates need the desktop + # libraries of each system and join later. + rust-unix: + name: Rust (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: Ubuntu + os: ubuntu-latest + - name: macOS + os: macos-latest + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable with: components: clippy - uses: Swatinem/rust-cache@v2 - - run: cargo clippy -p botloft-core -p botloft-store --all-targets -- -D warnings - - run: cargo test -p botloft-core -p botloft-store + - run: cargo clippy --all-targets -- -D warnings + # The runner image ships Edge and Chrome with sandbox helpers that are + # not setuid root, as their packages would leave them, so they refuse + # to start. The browser tests need a working sandbox. + - name: Browser sandbox helpers as their packages set them + if: runner.os == 'Linux' + run: | + for helper in /opt/microsoft/msedge/msedge-sandbox /opt/google/chrome/chrome-sandbox; do + if [ -f "$helper" ]; then + sudo chown root:root "$helper" + sudo chmod 4755 "$helper" + fi + done + - run: cargo test app: name: App (Windows) diff --git a/Cargo.lock b/Cargo.lock index 6045c4ef..8deb0059 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -483,6 +483,7 @@ dependencies = [ "getrandom 0.4.3", "hex", "jiff", + "libc", "serde", "serde_json", "sha2 0.11.0", diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml index 055cd827..8fd1be16 100644 --- a/crates/botloftd/Cargo.toml +++ b/crates/botloftd/Cargo.toml @@ -40,6 +40,9 @@ windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security" # Named by the code that `windows::core::implement` writes. windows-core = "0.62.2" +[target."cfg(unix)".dependencies] +libc = "0.2.189" + [target."cfg(windows)".build-dependencies] embed-resource = "3.0.11" diff --git a/crates/botloftd/build.rs b/crates/botloftd/build.rs index 9c7f8d7b..e9006863 100644 --- a/crates/botloftd/build.rs +++ b/crates/botloftd/build.rs @@ -36,7 +36,18 @@ fn main() { let out = PathBuf::from(env::var_os("OUT_DIR").expect("cargo sets OUT_DIR")).join("botloftd.rc"); fs::write(&out, script).expect("cannot write the resource script"); - if let Err(err) = embed_resource::compile(&out, embed_resource::NONE).manifest_required() { + embed(&out); +} + +/// `embed-resource` is a build dependency on Windows hosts only. +#[cfg(windows)] +fn embed(script: &std::path::Path) { + if let Err(err) = embed_resource::compile(script, embed_resource::NONE).manifest_required() { panic!("cannot embed the resources: {err}"); } } + +#[cfg(not(windows))] +fn embed(_script: &std::path::Path) { + panic!("the Windows daemon is built on Windows, where its resources can be embedded"); +} diff --git a/crates/botloftd/src/autostart/binary.rs b/crates/botloftd/src/autostart/binary.rs index cb21780e..bb3e899c 100644 --- a/crates/botloftd/src/autostart/binary.rs +++ b/crates/botloftd/src/autostart/binary.rs @@ -109,6 +109,13 @@ mod tests { names } + /// The old copy and the installed binary, in the order `names` gives. + fn old_and_installed() -> Vec { + let mut names = vec!["botloftd.1.old".to_owned(), EXE_NAME.to_owned()]; + names.sort(); + names + } + #[test] fn copies_once_and_replaces_only_a_different_binary() { let dir = tempfile::tempdir().expect("tempdir"); @@ -129,13 +136,13 @@ mod tests { let second = install(&source, &bin).expect("update"); assert!(second.changed); assert_eq!(fs::read(&second.path).expect("read"), b"v2"); - assert_eq!(names(&bin), ["botloftd.1.old".to_owned(), EXE_NAME.into()]); + assert_eq!(names(&bin), old_and_installed()); fs::write(&source, b"v3").expect("write"); install(&source, &bin).expect("update"); assert_eq!( names(&bin), - ["botloftd.1.old".to_owned(), EXE_NAME.into()], + old_and_installed(), "the v1 copy went away and v2 moved aside" ); diff --git a/crates/botloftd/src/browser/call.rs b/crates/botloftd/src/browser/call.rs index 61092e45..96a8b58d 100644 --- a/crates/botloftd/src/browser/call.rs +++ b/crates/botloftd/src/browser/call.rs @@ -11,7 +11,7 @@ use tokio::sync::OwnedMutexGuard; use tracing::debug; use super::session::{Hooks, PageInfo, Session}; -use super::{BrowserError, Browsers, launch, lock, update, watch}; +use super::{BrowserError, Browsers, lock, program, update, watch}; pub struct Call<'a> { pub(super) browsers: &'a Browsers, @@ -39,7 +39,7 @@ impl Call<'_> { state.error = None; }); let start = browsers.starts.fetch_add(1, Ordering::Relaxed); - let started = match launch::find(&browsers.settings.path) { + let started = match program::find(&browsers.settings.path) { None => Err(BrowserError::NotFound), Some(program) => { let profile = browsers.profiles.join(bot.as_str()); diff --git a/crates/botloftd/src/browser/input.rs b/crates/botloftd/src/browser/input.rs index b892c9c2..4151711a 100644 --- a/crates/botloftd/src/browser/input.rs +++ b/crates/botloftd/src/browser/input.rs @@ -170,16 +170,52 @@ fn press(key: &str, code: &str, modifiers: u32) -> Vec<(&'static str, Value)> { down["text"] = json!(text); down["unmodifiedText"] = json!(text); } + if let Some(command) = editing_command(key, modifiers, cfg!(target_os = "macos")) { + down["commands"] = json!([command]); + } vec![ ("Input.dispatchKeyEvent", down), ("Input.dispatchKeyEvent", event("keyUp")), ] } +/// On macOS the page runs no Cmd shortcut from the virtual key, so the +/// editing command goes with the key; elsewhere Ctrl ones run by themselves. +fn editing_command(key: &str, modifiers: u32, mac: bool) -> Option<&'static str> { + let others = modifier::CTRL | modifier::ALT; + if !mac || modifiers & modifier::META == 0 || modifiers & others != 0 { + return None; + } + let shift = modifiers & modifier::SHIFT != 0; + match (key.to_ascii_lowercase().as_str(), shift) { + ("a", false) => Some("selectAll"), + ("c", false) => Some("copy"), + ("x", false) => Some("cut"), + ("v", false) => Some("paste"), + ("z", false) => Some("undo"), + ("z", true) => Some("redo"), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn cmd_shortcuts_carry_their_command_on_macos_only() { + let cmd = modifier::META; + assert_eq!(editing_command("a", cmd, true), Some("selectAll")); + assert_eq!( + editing_command("Z", cmd | modifier::SHIFT, true), + Some("redo") + ); + assert_eq!(editing_command("a", cmd, false), None); + assert_eq!(editing_command("a", modifier::CTRL, true), None); + assert_eq!(editing_command("a", cmd | modifier::ALT, true), None); + assert_eq!(editing_command("b", cmd, true), None); + } + fn key(key: &str, code: &str, modifiers: u32) -> Vec { calls(&BrowserInput::Key { key: key.to_owned(), diff --git a/crates/botloftd/src/browser/launch.rs b/crates/botloftd/src/browser/launch.rs index 147b5090..d21be3b2 100644 --- a/crates/botloftd/src/browser/launch.rs +++ b/crates/botloftd/src/browser/launch.rs @@ -1,9 +1,10 @@ -//! Starting a bot's browser (spec 21.2): find Edge, run it without a window -//! in a job of its own, and read where its DevTools listen. +//! Starting a bot's browser (spec 21.2): run it without a window in a job +//! of its own, and read where its DevTools listen. -use std::io; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::io::{self, BufRead as _, BufReader}; +use std::path::Path; +use std::process::{Child, ChildStderr, Command, Stdio}; +use std::sync::{Arc, Mutex, mpsc}; use std::time::{Duration, Instant}; use tracing::debug; @@ -11,9 +12,12 @@ use tracing::debug; use super::BrowserError; use crate::platform::{self, ProcessJob}; -/// How long Edge may take to write `DevToolsActivePort`. -const START_TIMEOUT: Duration = Duration::from_secs(10); +/// How long the browser may take to write `DevToolsActivePort`. Usually +/// under a second; Edge's first start on a new Linux machine took over 20. +const START_TIMEOUT: Duration = Duration::from_secs(30); const PORT_FILE: &str = "DevToolsActivePort"; +/// Longest stderr line passed on as the reason a browser stopped. +const REASON_MAX: usize = 300; /// The browser's process tree. Dropping it kills every process in it. pub struct BrowserProcess { @@ -35,26 +39,6 @@ impl Drop for BrowserProcess { } } -/// The browser to run: `configured`, or the Edge that comes with Windows. -pub fn find(configured: &str) -> Option { - let configured = configured.trim(); - if !configured.is_empty() { - let path = PathBuf::from(configured); - return path.is_file().then_some(path); - } - ["ProgramFiles(x86)", "ProgramFiles", "LOCALAPPDATA"] - .iter() - .filter_map(std::env::var_os) - .map(|base| { - PathBuf::from(base) - .join("Microsoft") - .join("Edge") - .join("Application") - .join("msedge.exe") - }) - .find(|path| path.is_file()) -} - /// Starts `program` with the profile in `profile` and returns it with the /// DevTools WebSocket address. pub async fn launch( @@ -88,20 +72,16 @@ pub async fn launch( .envs(env.iter().map(|(name, value)| (name, value))) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt as _; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - command.creation_flags(CREATE_NO_WINDOW); - } + .stderr(Stdio::piped()); + ProcessJob::prepare(&mut command); let job = ProcessJob::new()?; - let child = command.spawn()?; - #[cfg(windows)] - { - use std::os::windows::io::AsRawHandle as _; - job.assign(child.as_raw_handle())?; + let mut child = command.spawn()?; + if let Err(err) = job.assign(&child) { + let _ = child.kill(); + let _ = child.wait(); + return Err(err.into()); } + let said = child.stderr.take().map(StartLog::read); let mut process = BrowserProcess { child, job }; debug!(pid = process.child.id(), "browser: started"); @@ -114,19 +94,94 @@ pub async fn launch( return Ok((process, url)); } if process.child.try_wait()?.is_some() { - return Err(BrowserError::Start( - "the browser closed right after starting".to_owned(), - )); + // Its helpers may still hold stderr open until the group dies. + process.kill(); + if let Some(said) = &said { + let _ = said.ended.recv_timeout(Duration::from_secs(1)); + } + return Err(BrowserError::Start(format!( + "the browser closed right after starting{}", + StartLog::reason(said.as_ref()) + ))); } if started.elapsed() > START_TIMEOUT { - return Err(BrowserError::Start( - "the browser did not start in time".to_owned(), - )); + return Err(BrowserError::Start(format!( + "the browser did not start in time{}", + StartLog::reason(said.as_ref()) + ))); } tokio::time::sleep(Duration::from_millis(50)).await; } } +/// What the browser writes to stderr, kept as the line that best says why +/// it failed to start (a missing sandbox on Linux, say). Nothing of it goes +/// to the log. +struct StartLog { + lines: Arc>, + /// Hears when stderr ends. + ended: mpsc::Receiver<()>, +} + +#[derive(Default)] +struct Said { + best: Option, + last: Option, +} + +impl StartLog { + fn read(stderr: ChildStderr) -> Self { + let lines = Arc::new(Mutex::new(Said::default())); + let (end, ended) = mpsc::channel(); + let shared = Arc::clone(&lines); + let spawned = std::thread::Builder::new() + .name("browser-stderr".into()) + .spawn(move || { + for line in BufReader::new(stderr).lines() { + let Ok(line) = line else { break }; + let text: String = log_text(&line).chars().take(REASON_MAX).collect(); + if text.is_empty() { + continue; + } + let lower = line.to_ascii_lowercase(); + let mut said = shared.lock().unwrap_or_else(|p| p.into_inner()); + let sandbox = lower.contains("sandbox") || lower.contains("namespace"); + if sandbox || (said.best.is_none() && lower.contains(":fatal:")) { + said.best = Some(text.clone()); + } + said.last = Some(text); + } + let _ = end.send(()); + }); + if spawned.is_err() { + debug!("browser: cannot read its stderr"); + } + Self { lines, ended } + } + + /// ` (the line)`, or nothing when the browser said nothing. + fn reason(log: Option<&Self>) -> String { + let Some(log) = log else { + return String::new(); + }; + let said = log.lines.lock().unwrap_or_else(|p| p.into_inner()); + said.best + .as_ref() + .or(said.last.as_ref()) + .map(|line| format!(" ({line})")) + .unwrap_or_default() + } +} + +/// A Chromium log line without its `[pid:tid:time:LEVEL:file.cc:123]` prefix. +fn log_text(line: &str) -> String { + let text = match line.strip_prefix('[') { + Some(rest) => rest.split_once("] ").map_or(line, |(_, text)| text), + None => line, + }; + text.trim().to_owned() +} + /// `DevToolsActivePort` holds the port and the browser's WebSocket path, one /// per line. The file may be read half-written. fn devtools_url(text: &str) -> Option { @@ -153,7 +208,13 @@ mod tests { } #[test] - fn a_configured_browser_must_exist() { - assert_eq!(find(r"C:\nowhere\msedge.exe"), None); + fn a_log_line_loses_its_prefix() { + assert_eq!( + log_text( + "[11:11:1001/173604.487235:ERROR:zygote_host_impl_linux.cc:129] No usable sandbox!" + ), + "No usable sandbox!" + ); + assert_eq!(log_text(" plain words "), "plain words"); } } diff --git a/crates/botloftd/src/browser/mod.rs b/crates/botloftd/src/browser/mod.rs index f6eb3d1d..38a9187d 100644 --- a/crates/botloftd/src/browser/mod.rs +++ b/crates/botloftd/src/browser/mod.rs @@ -12,6 +12,7 @@ mod keys; mod launch; mod moves; mod page; +mod program; mod read; mod rest; mod session; @@ -38,8 +39,8 @@ use tracing::{debug, warn}; pub use self::call::Call; pub use self::hands::{Asking, Hands, InputError, OWNER_WAIT, TakeError}; pub use self::keys::{Key, find as find_key, names as key_names}; -pub use self::launch::find as find_program; pub use self::page::{Aim, Done, Scroll}; +pub use self::program::find as find_program; pub use self::read::{READ_MAX, Reading}; pub use self::session::Session; pub use self::sweep::{Want, run}; @@ -51,7 +52,7 @@ use crate::state::Event; #[derive(Debug, thiserror::Error)] pub enum BrowserError { - #[error("Microsoft Edge was not found on this computer")] + #[error("{}", program::NOT_FOUND)] NotFound, #[error("the browser could not start: {0}")] Start(String), diff --git a/crates/botloftd/src/browser/program.rs b/crates/botloftd/src/browser/program.rs new file mode 100644 index 00000000..d81fcd26 --- /dev/null +++ b/crates/botloftd/src/browser/program.rs @@ -0,0 +1,103 @@ +//! Which browser a bot runs (spec 21.2): the one in the config, else the +//! Edge that comes with Windows, or the first Edge, Chrome or Chromium +//! installed on macOS (Chrome or Chromium first on Linux). + +use std::path::PathBuf; + +/// What is missing when no browser is found. +#[cfg(windows)] +pub const NOT_FOUND: &str = "Microsoft Edge was not found on this computer"; +#[cfg(not(windows))] +pub const NOT_FOUND: &str = + "no Microsoft Edge, Google Chrome or Chromium was found on this computer"; + +/// The browser to run: `configured`, or one installed on the computer. +pub fn find(configured: &str) -> Option { + let configured = configured.trim(); + if !configured.is_empty() { + let path = PathBuf::from(configured); + return path.is_file().then_some(path); + } + installed().into_iter().find(|path| path.is_file()) +} + +#[cfg(windows)] +fn installed() -> Vec { + ["ProgramFiles(x86)", "ProgramFiles", "LOCALAPPDATA"] + .iter() + .filter_map(std::env::var_os) + .map(|base| { + PathBuf::from(base) + .join("Microsoft") + .join("Edge") + .join("Application") + .join("msedge.exe") + }) + .collect() +} + +/// The app bundles in `/Applications`, then in the owner's own. +#[cfg(target_os = "macos")] +fn installed() -> Vec { + const APPS: &[&str] = &[ + "Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "Google Chrome.app/Contents/MacOS/Google Chrome", + "Chromium.app/Contents/MacOS/Chromium", + ]; + let folders = [ + Some(PathBuf::from("/Applications")), + dirs::home_dir().map(|home| home.join("Applications")), + ]; + folders + .into_iter() + .flatten() + .flat_map(|folder| APPS.iter().map(move |app| folder.join(app))) + .collect() +} + +/// The commands the Chrome, Chromium and Edge packages install, on the +/// owner's `PATH`. Edge comes last: it is the rarer one on Linux, and on +/// the Ubuntu runner its first start with each new profile took seconds +/// where Chrome took under one. +#[cfg(all(unix, not(target_os = "macos")))] +fn installed() -> Vec { + const COMMANDS: &[&str] = &[ + "google-chrome-stable", + "google-chrome", + "chromium", + "chromium-browser", + "microsoft-edge-stable", + "microsoft-edge", + ]; + let path = crate::platform::user_environment() + .ok() + .and_then(|vars| vars.into_iter().find(|(name, _)| name == "PATH")) + .map(|(_, value)| value) + .or_else(|| std::env::var_os("PATH")) + .unwrap_or_default(); + let dirs: Vec = std::env::split_paths(&path).collect(); + COMMANDS + .iter() + .flat_map(|command| dirs.iter().map(move |dir| dir.join(command))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_configured_browser_must_exist() { + assert_eq!(find(r"C:\nowhere\msedge.exe"), None); + assert_eq!(find("/nowhere/chromium"), None); + } + + #[test] + fn a_configured_browser_that_exists_is_used() { + let dir = tempfile::tempdir().expect("tempdir"); + let program = dir.path().join("browser"); + std::fs::write(&program, b"").expect("write"); + let configured = format!(" {} ", program.display()); + assert_eq!(find(&configured), Some(program)); + } +} diff --git a/crates/botloftd/src/browser/sites.rs b/crates/botloftd/src/browser/sites.rs index c3f87f4d..c0b3d00f 100644 --- a/crates/botloftd/src/browser/sites.rs +++ b/crates/botloftd/src/browser/sites.rs @@ -126,11 +126,17 @@ pub fn file_allowed(url: &str, folders: &[PathBuf]) -> bool { is_file(url) && inside(&file_path(url), folders).is_some() } -/// The path of a `file:` URL: `file:///C:/a%20b/x.html?q` is `C:/a b/x.html`. +/// The path of a `file:` URL: `file:///C:/a%20b/x.html?q` is `C:/a b/x.html` +/// on Windows, and `file:///home/ana/x.html` is `/home/ana/x.html` elsewhere. fn file_path(url: &str) -> PathBuf { let rest = url.get(5..).unwrap_or_default(); let rest = rest.split(['?', '#']).next().unwrap_or_default(); - PathBuf::from(decode(rest.trim_start_matches('/'))) + let path = decode(rest.trim_start_matches('/')); + if cfg!(windows) { + PathBuf::from(path) + } else { + PathBuf::from(format!("/{path}")) + } } fn file(path: &Path, base: &Path, folders: &[PathBuf]) -> Result { @@ -164,13 +170,17 @@ fn inside(path: &Path, folders: &[PathBuf]) -> Option { .then_some(real) } -/// `C:\a b\x.html` as `file:///C:/a%20b/x.html`. +/// `C:\a b\x.html` as `file:///C:/a%20b/x.html`, and `/home/ana/x.html` as +/// `file:///home/ana/x.html`. fn file_url(path: &Path) -> String { let text = path.to_string_lossy(); - let text = text - .strip_prefix(r"\\?\") - .unwrap_or(&text) - .replace('\\', "/"); + let text = if cfg!(windows) { + text.strip_prefix(r"\\?\") + .unwrap_or(&text) + .replace('\\', "/") + } else { + text.trim_start_matches('/').to_owned() + }; let mut url = String::from("file:///"); for byte in text.bytes() { match byte { diff --git a/crates/botloftd/src/config.rs b/crates/botloftd/src/config.rs index e91bd931..146561d2 100644 --- a/crates/botloftd/src/config.rs +++ b/crates/botloftd/src/config.rs @@ -218,7 +218,7 @@ mod tests { #[test] fn keys_override_defaults() { let config = Config::parse( - "port = 45999\nworkspaces_root = 'D:\\\\bots'\n[courier]\nmax_attempts = 3\n", + "port = 45999\nworkspaces_root = 'D:\\bots'\n[courier]\nmax_attempts = 3\n", ) .expect("parse"); assert_eq!(config.port, 45999); diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index fe7bd35b..a058f83b 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -1,10 +1,17 @@ -//! Unix fallbacks, so the daemon builds and its tests run off Windows. +//! Linux and macOS (spec 14.1). Starting with the system comes later; the +//! rest gives bots what they get on Windows. + +mod account; +mod env; +mod job; -use std::ffi::OsString; use std::io; use std::os::unix::fs::PermissionsExt; use std::path::Path; +pub use self::account::owner_name; +pub use self::env::user_environment; +pub use self::job::ProcessJob; use super::{TaskDefinition, TaskInfo}; /// `0700` for folders, `0600` for files. @@ -30,20 +37,6 @@ pub async fn shutdown_signal() { } } -/// No job objects here; the runtime kills the child directly. -#[derive(Debug)] -pub struct ProcessJob; - -impl ProcessJob { - pub fn new() -> io::Result { - Ok(Self) - } - - pub fn terminate(&self) -> io::Result<()> { - Ok(()) - } -} - /// Nothing keeps a Unix machine awake yet. #[derive(Debug)] pub struct KeepAwake; @@ -58,21 +51,6 @@ impl KeepAwake { } } -/// The daemon's environment without Claude Code session variables. -pub fn user_environment() -> io::Result> { - Ok(std::env::vars_os() - .filter(|(name, _)| { - let name = name.to_string_lossy(); - name != "CLAUDECODE" && !name.starts_with("CLAUDE_CODE_") - }) - .collect()) -} - -/// The login name; Unix has no display name to ask for. -pub fn owner_name() -> String { - std::env::var("USER").unwrap_or_default() -} - /// Nothing to leave: Unix never opens a console for the daemon. pub fn leave_own_console() {} @@ -87,8 +65,9 @@ pub fn register_task(_name: &str, _task: &TaskDefinition) -> io::Result<()> { Err(no_tasks()) } +/// Nothing can be installed here yet, so there is never a task to find. pub fn find_task(_name: &str) -> io::Result> { - Err(no_tasks()) + Ok(None) } pub fn run_task(_name: &str) -> io::Result<()> { @@ -100,7 +79,7 @@ pub fn stop_task(_name: &str) -> io::Result<()> { } pub fn delete_task(_name: &str) -> io::Result { - Err(no_tasks()) + Ok(false) } /// There is no Recycle Bin off Windows: the folder stays. diff --git a/crates/botloftd/src/platform/unix/account.rs b/crates/botloftd/src/platform/unix/account.rs new file mode 100644 index 00000000..de49992e --- /dev/null +++ b/crates/botloftd/src/platform/unix/account.rs @@ -0,0 +1,92 @@ +//! The owner's account as the system knows it (`getpwuid_r`): the name the +//! app shows and the login shell the bots' environment comes from. + +use std::ffi::{CStr, c_char}; +use std::path::PathBuf; + +/// Longest buffer tried for one account entry. +const BUFFER_MAX: usize = 1 << 20; + +#[derive(Debug, Default)] +pub(super) struct Account { + pub login: String, + /// The first field of the comment ("Ana Lima,,,"), when there is one. + pub full_name: String, + pub shell: Option, +} + +pub(super) fn current() -> Option { + // SAFETY: no arguments, never fails. + let uid = unsafe { libc::getuid() }; + let mut buf = vec![0u8; 4096]; + // SAFETY: plain data; `getpwuid_r` fills it in. + let mut entry: libc::passwd = unsafe { std::mem::zeroed() }; + let mut found: *mut libc::passwd = std::ptr::null_mut(); + loop { + // SAFETY: `entry`, `buf` and `found` outlive the call, and `buf.len()` + // is the size of `buf`. + let code = unsafe { + libc::getpwuid_r( + uid, + &raw mut entry, + buf.as_mut_ptr().cast(), + buf.len(), + &raw mut found, + ) + }; + if code == libc::ERANGE && buf.len() < BUFFER_MAX { + buf.resize(buf.len() * 2, 0); + continue; + } + if code != 0 || found.is_null() { + return None; + } + break; + } + let full_name = text(entry.pw_gecos) + .split(',') + .next() + .unwrap_or_default() + .trim() + .to_owned(); + let shell = Some(text(entry.pw_shell)) + .filter(|shell| !shell.is_empty()) + .map(PathBuf::from); + Some(Account { + login: text(entry.pw_name), + full_name, + shell, + }) +} + +/// A string of the entry, which points into the buffer still alive. +fn text(ptr: *const c_char) -> String { + if ptr.is_null() { + return String::new(); + } + // SAFETY: a non-null field of a filled entry is a NUL-terminated string. + unsafe { CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned() +} + +/// The account's full name ("Ana Lima"), else the login name. `USER` and +/// the home folder cover a uid the system has no entry for (containers). +pub fn owner_name() -> String { + let account = current().unwrap_or_default(); + [account.full_name, account.login] + .into_iter() + .chain( + ["USER", "LOGNAME"] + .iter() + .filter_map(std::env::var_os) + .map(|name| name.to_string_lossy().into_owned()), + ) + .chain(dirs::home_dir().and_then(|home| { + home.file_name() + .map(|name| name.to_string_lossy().into_owned()) + })) + .map(|name| name.trim().to_owned()) + .find(|name| !name.is_empty()) + .unwrap_or_default() +} diff --git a/crates/botloftd/src/platform/unix/env.rs b/crates/botloftd/src/platform/unix/env.rs new file mode 100644 index 00000000..bfacaa2c --- /dev/null +++ b/crates/botloftd/src/platform/unix/env.rs @@ -0,0 +1,147 @@ +//! The environment a new login of the owner would get (spec 14.1): what +//! their login shell sets up, so bots find what the owner's terminal finds +//! (`~/.local/bin`, Homebrew) even when the system started the daemon with +//! a bare `PATH`. + +use std::ffi::OsString; +use std::io::Read as _; +use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Mutex; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use super::ProcessJob; + +/// A login shell that takes longer is stuck (waiting for input, say). +const SHELL_TIMEOUT: Duration = Duration::from_secs(10); +/// How long one reading of the shell's environment is reused. Shorter than +/// the 30 s in which Botloft notices Claude Code installed (15.1). +const FRESH_FOR: Duration = Duration::from_secs(20); +/// Printed before the variables, after anything the profile prints. +const MARKER: &[u8] = b"BOTLOFT_ENVIRONMENT\0"; +/// Variables the shell sets for itself. +const SHELL_OWN: &[&str] = &["_", "SHLVL", "PWD", "OLDPWD"]; + +type Vars = Vec<(OsString, OsString)>; + +static CACHE: Mutex> = Mutex::new(None); + +/// The login shell's environment, else the daemon's, without Claude Code +/// session variables either way. +pub fn user_environment() -> std::io::Result { + let mut cache = CACHE.lock().unwrap_or_else(|p| p.into_inner()); + let vars = match cache.as_ref() { + Some((read, vars)) if read.elapsed() < FRESH_FOR => vars.clone(), + _ => { + let vars = login_environment().unwrap_or_else(|| std::env::vars_os().collect()); + *cache = Some((Instant::now(), vars.clone())); + vars + } + }; + Ok(vars + .into_iter() + .filter(|(name, _)| !is_claude_session(&name.to_string_lossy())) + .collect()) +} + +fn is_claude_session(name: &str) -> bool { + name == "CLAUDECODE" || name.starts_with("CLAUDE_CODE_") +} + +fn login_shell() -> PathBuf { + std::env::var_os("SHELL") + .filter(|shell| !shell.is_empty()) + .map(PathBuf::from) + .or_else(|| super::account::current().and_then(|account| account.shell)) + .unwrap_or_else(|| PathBuf::from("/bin/sh")) +} + +/// Runs the login shell without a terminal and reads `env -0`. `None` when +/// the shell fails, takes too long or prints no variables. +fn login_environment() -> Option { + let mut command = Command::new(login_shell()); + command + .args([ + "-l", + "-c", + r#"printf '%s\0' BOTLOFT_ENVIRONMENT; exec env -0"#, + ]) + .env_clear() + .envs(std::env::vars_os().filter(|(name, _)| !is_claude_session(&name.to_string_lossy()))) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + ProcessJob::prepare(&mut command); + let job = ProcessJob::new().ok()?; + let mut child = command.spawn().ok()?; + job.assign(&child).ok()?; + let mut stdout = child.stdout.take()?; + let (done, output) = mpsc::channel(); + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = stdout.read_to_end(&mut bytes); + let _ = done.send(bytes); + }); + let bytes = output.recv_timeout(SHELL_TIMEOUT).ok(); + let _ = job.terminate(); + let status = child.wait().ok()?; + let vars = parse(&bytes?)?; + status.success().then_some(vars) +} + +/// The `NAME=value` entries after the marker. +fn parse(output: &[u8]) -> Option { + let start = output + .windows(MARKER.len()) + .position(|window| window == MARKER)? + + MARKER.len(); + let vars: Vars = output[start..] + .split(|&byte| byte == 0) + .filter_map(|entry| { + let eq = entry.iter().position(|&byte| byte == b'=')?; + let name = &entry[..eq]; + (!name.is_empty() && !SHELL_OWN.iter().any(|own| own.as_bytes() == name)).then(|| { + ( + OsString::from_vec(name.to_vec()), + OsString::from_vec(entry[eq + 1..].to_vec()), + ) + }) + }) + .collect(); + vars.iter() + .any(|(name, _)| name.as_bytes() == b"PATH") + .then_some(vars) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_variables_after_what_the_profile_prints() { + let output = b"Welcome!\nBOTLOFT_ENVIRONMENT\0PATH=/a:/b\0SHLVL=2\0NOTE=two\nlines\0"; + let vars = parse(output).expect("vars"); + assert_eq!( + vars, + [ + (OsString::from("PATH"), OsString::from("/a:/b")), + (OsString::from("NOTE"), OsString::from("two\nlines")), + ] + ); + } + + #[test] + fn without_the_marker_or_a_path_there_is_nothing() { + assert!(parse(b"PATH=/a\0").is_none()); + assert!(parse(b"BOTLOFT_ENVIRONMENT\0HOME=/h\0").is_none()); + } + + #[test] + fn the_login_shell_gives_a_path() { + let vars = login_environment().expect("the login shell's environment"); + assert!(vars.iter().any(|(name, _)| name == "PATH")); + assert!(!vars.iter().any(|(name, _)| name == "SHLVL")); + } +} diff --git a/crates/botloftd/src/platform/unix/job.rs b/crates/botloftd/src/platform/unix/job.rs new file mode 100644 index 00000000..a8c75612 --- /dev/null +++ b/crates/botloftd/src/platform/unix/job.rs @@ -0,0 +1,107 @@ +//! A process group per bot process, in place of the Job Object (spec 14.1): +//! the bot leads a group of its own, everything it starts joins it, and +//! stopping the bot kills the whole group. + +use std::io; +use std::os::unix::process::CommandExt as _; +use std::process::{Child, Command}; +use std::sync::Mutex; + +#[derive(Debug, Default)] +pub struct ProcessJob { + /// The group's id, which is its leader's pid; `None` until a process + /// joins and after the group is gone. + group: Mutex>, +} + +impl ProcessJob { + pub fn new() -> io::Result { + Ok(Self::default()) + } + + /// Makes `command` start a process group of its own. + pub fn prepare(command: &mut Command) { + command.process_group(0); + } + + /// Takes the group that `child`, started from a prepared command, leads. + pub fn assign(&self, child: &Child) -> io::Result<()> { + let pid = libc::pid_t::try_from(child.id()).map_err(io::Error::other)?; + *self.lock() = Some(pid); + Ok(()) + } + + /// Kills every process in the group. + pub fn terminate(&self) -> io::Result<()> { + let mut group = self.lock(); + let Some(id) = *group else { + return Ok(()); + }; + // SAFETY: `kill` takes no pointers; a negative pid names the group. + if unsafe { libc::kill(-id, libc::SIGKILL) } == 0 { + return Ok(()); + } + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ESRCH) { + // Nobody is left. Forgetting the id keeps a later call from + // reaching a new group that happens to get the same number. + *group = None; + return Ok(()); + } + Err(err) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Option> { + self.group + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +/// Like a closed Job Object handle: whatever is left of the group dies. +impl Drop for ProcessJob { + fn drop(&mut self) { + let _ = self.terminate(); + } +} + +#[cfg(test)] +mod tests { + use std::io::{BufRead, BufReader}; + use std::process::Stdio; + use std::time::{Duration, Instant}; + + use super::*; + + fn alive(pid: libc::pid_t) -> bool { + // SAFETY: signal 0 only checks that the process exists. + unsafe { libc::kill(pid, 0) == 0 } + } + + #[test] + fn terminating_kills_what_the_process_started() { + let job = ProcessJob::new().expect("job"); + let mut command = Command::new("sh"); + command + .args(["-c", "sleep 30 & echo $!; wait"]) + .stdout(Stdio::piped()); + ProcessJob::prepare(&mut command); + let mut child = command.spawn().expect("spawn"); + job.assign(&child).expect("assign"); + let mut line = String::new(); + BufReader::new(child.stdout.take().expect("stdout")) + .read_line(&mut line) + .expect("read"); + let started: libc::pid_t = line.trim().parse().expect("pid"); + assert!(alive(started)); + + job.terminate().expect("terminate"); + child.wait().expect("wait"); + let deadline = Instant::now() + Duration::from_secs(5); + while alive(started) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(!alive(started), "the process the child started is gone"); + job.terminate().expect("terminating again is fine"); + } +} diff --git a/crates/botloftd/src/platform/windows/job.rs b/crates/botloftd/src/platform/windows/job.rs index c71d1ec2..d8c69244 100644 --- a/crates/botloftd/src/platform/windows/job.rs +++ b/crates/botloftd/src/platform/windows/job.rs @@ -3,7 +3,9 @@ //! the daemon stops the bot, and also when the daemon itself dies. use std::io; -use std::os::windows::io::RawHandle; +use std::os::windows::io::AsRawHandle as _; +use std::os::windows::process::CommandExt as _; +use std::process::{Child, Command}; use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::System::JobObjects::{ @@ -40,10 +42,16 @@ impl ProcessJob { Ok(job) } + /// Makes `command` start without a console window (spec 7.3). + pub fn prepare(command: &mut Command) { + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + /// Puts a process (and whatever it starts from now on) in the job. - pub fn assign(&self, process: RawHandle) -> io::Result<()> { - // SAFETY: `process` is a live process handle owned by the caller. - unsafe { AssignProcessToJobObject(self.0, HANDLE(process)) }?; + pub fn assign(&self, child: &Child) -> io::Result<()> { + // SAFETY: `child` owns a live process handle. + unsafe { AssignProcessToJobObject(self.0, HANDLE(child.as_raw_handle())) }?; Ok(()) } diff --git a/crates/botloftd/src/runtime/claude.rs b/crates/botloftd/src/runtime/claude.rs index 08ec832a..a8461826 100644 --- a/crates/botloftd/src/runtime/claude.rs +++ b/crates/botloftd/src/runtime/claude.rs @@ -50,7 +50,10 @@ pub fn locate(configured: &str, env: &[(OsString, OsString)]) -> Result = path_entries(&path_var.to_string_lossy()) + .chain(usual_places(env)) + .collect(); + for dir in dirs { for name in CANDIDATES { let candidate = dir.join(name); if candidate.is_file() { @@ -72,6 +75,25 @@ fn path_entries(path: &str) -> impl Iterator + '_ { .map(PathBuf::from) } +/// Where installers put Claude Code off Windows, for a profile that never +/// added it to `PATH`: the native installer, the older local install and +/// Homebrew. +fn usual_places(env: &[(OsString, OsString)]) -> Vec { + if cfg!(windows) { + return Vec::new(); + } + let home = env + .iter() + .find(|(name, _)| name == "HOME") + .map(|(_, value)| PathBuf::from(value)); + let mut places: Vec = home + .iter() + .flat_map(|home| [home.join(".local/bin"), home.join(".claude/local")]) + .collect(); + places.extend(["/opt/homebrew/bin", "/usr/local/bin"].map(PathBuf::from)); + places +} + #[cfg(windows)] const CANDIDATES: &[&str] = &["claude.exe", "claude.cmd"]; #[cfg(not(windows))] @@ -260,4 +282,18 @@ mod tests { assert_eq!(locate("", &env).expect("found"), exe); assert!(matches!(locate("", &[]), Err(ClaudeError::NotFound))); } + + #[cfg(unix)] + #[test] + fn finds_the_native_install_off_path() { + let home = tempfile::tempdir().expect("tempdir"); + let bin = home.path().join(".local/bin"); + std::fs::create_dir_all(&bin).expect("bin"); + std::fs::write(bin.join("claude"), b"").expect("fake claude"); + let env = vec![ + (OsString::from("PATH"), OsString::from("/nowhere")), + (OsString::from("HOME"), home.path().as_os_str().to_owned()), + ]; + assert_eq!(locate("", &env).expect("found"), bin.join("claude")); + } } diff --git a/crates/botloftd/src/runtime/pipe.rs b/crates/botloftd/src/runtime/pipe.rs index 67ffd4d0..f51dc254 100644 --- a/crates/botloftd/src/runtime/pipe.rs +++ b/crates/botloftd/src/runtime/pipe.rs @@ -1,7 +1,8 @@ //! Processes with stdin, stdout and stderr in pipes (spec 7.4). Each one //! gets a thread to read stdout, one to write stdin (writing never blocks -//! the caller), one to drain stderr and one to wait for the exit. On -//! Windows the process joins its own Job Object and has no console. +//! the caller), one to drain stderr and one to wait for the exit. The +//! process joins a job of its own: a Job Object with no console on Windows, +//! a process group elsewhere (spec 14.1). use std::io::{self, BufRead, BufReader, Read, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; @@ -45,22 +46,13 @@ impl Runtime for PipeRuntime { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt as _; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - command.creation_flags(CREATE_NO_WINDOW); - } + ProcessJob::prepare(&mut command); let job = Arc::new(ProcessJob::new()?); let mut child = command.spawn()?; - #[cfg(windows)] - { - use std::os::windows::io::AsRawHandle as _; - if let Err(err) = job.assign(child.as_raw_handle()) { - let _ = child.kill(); - return Err(err); - } + if let Err(err) = job.assign(&child) { + let _ = child.kill(); + return Err(err); } let pid = Some(child.id()); let stdout = child diff --git a/crates/botloftd/src/tools/browser_reply.rs b/crates/botloftd/src/tools/browser_reply.rs index e39ee9c0..d2fcf6b8 100644 --- a/crates/botloftd/src/tools/browser_reply.rs +++ b/crates/botloftd/src/tools/browser_reply.rs @@ -99,8 +99,8 @@ pub(super) fn describe(err: &BrowserError) -> String { pub(super) fn unavailable(err: &BrowserError) -> String { format!( - "Your browser could not start: {err}. Tell the owner; Botloft uses Microsoft Edge, or \ - another Chromium browser set in its config." + "Your browser could not start: {err}. Tell the owner; Botloft uses Microsoft Edge \ + (Google Chrome or Chromium off Windows), or another Chromium browser set in its config." ) } diff --git a/crates/botloftd/tests/browser.rs b/crates/botloftd/tests/browser.rs index 6ebd3a7e..9b37d21a 100644 --- a/crates/botloftd/tests/browser.rs +++ b/crates/botloftd/tests/browser.rs @@ -185,7 +185,7 @@ async fn new_tabs_and_dialogs_reach_the_bot_and_the_owner_watches() { let open = b.app.call("browser.list", Value::Null).await.expect("list"); // The new tab is the active one, after the tab that opened it. let tabs = open[0]["tabs"].as_array().expect("tabs"); - assert_eq!(tabs.len(), 2); + assert_eq!(tabs.len(), 2, "{tabs:?}"); assert_eq!(tabs[0]["active"], false); assert_eq!(tabs[1]["active"], true); assert!(tabs[1]["url"].as_str().expect("url").contains("/done")); diff --git a/crates/botloftd/tests/browser_hands.rs b/crates/botloftd/tests/browser_hands.rs index e8aeb8bb..5be6bc9f 100644 --- a/crates/botloftd/tests/browser_hands.rs +++ b/crates/botloftd/tests/browser_hands.rs @@ -134,8 +134,10 @@ async fn the_bot_waits_while_the_owner_has_its_browser() { click(&mut b.app, &bot, 200.0, 115.0).await; press(&mut b.app, &bot, "abc", 0).await; - // Ctrl+A selects the field's text, so the next keys replace it. - press(&mut b.app, &bot, "a", 2).await; + // Ctrl+A (Cmd+A on a Mac) selects the field's text, so the next keys + // replace it. + let command = if cfg!(target_os = "macos") { 4 } else { 2 }; + press(&mut b.app, &bot, "a", command).await; press(&mut b.app, &bot, "xyz", 0).await; let looking = call(&b.mcp, "browser_look", json!({})); diff --git a/crates/botloftd/tests/courier.rs b/crates/botloftd/tests/courier.rs index 819e646d..0c33f2ce 100644 --- a/crates/botloftd/tests/courier.rs +++ b/crates/botloftd/tests/courier.rs @@ -188,15 +188,19 @@ async fn a_message_the_bot_began_is_not_sent_again() { assert!(second.input_lines().is_empty()); } -#[tokio::test] +// Two workers: the held spawn blocks the supervisor's. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn archiving_a_bot_drops_what_is_still_queued_for_it() { let t = TestDaemon::start_supervised().await; + // The bot is still starting, so nothing can take the message yet. + let (_began, release) = t.runtime.hold_next_spawn(); let mut app = t.session().await; let bot = crew_and_bot(&mut app).await; send(&mut app, &bot, "never read").await; app.call("bots.archive", json!({ "botId": bot["id"] })) .await .expect("archive"); + release.send(()).expect("release"); t.clock.advance(Duration::from_secs(5)); let dead = delivery(&mut app, "dead").await; assert_eq!(dead["lastError"], "the bot was archived"); diff --git a/docs/spec.md b/docs/spec.md index 733a0b19..92480239 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -224,7 +224,7 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - Mudanças em nome, papel ou instruções regravam as regras na hora, mas o bot só as lê no próximo start; o daemon não reinicia o bot sozinho. - `auth_error` não entra em loop de restart: fica parado até o owner pedir `bots.restart` ou até o daemon ver o Claude Code conectado de novo. - **Login do Claude Code:** o daemon roda `claude auth status` (JSON com `loggedIn` e, conectado, `email`, `subscriptionType` e `orgName`; sai com 0 conectado e 1 desconectado, seção 19) no ambiente do usuário, sem janela: quando acha o Claude Code, a cada 30 s enquanto desconectado ou desconhecido, na hora quando um bot cai em `auth_error` e quando o app pede (`system.refresh`). Conectado, não confere de novo sozinho. Quando o resultado passa de desconectado para conectado, os bots em `auth_error` sobem de novo sem o dono pedir. Um erro de conta com o login válido (`billing_error`, `account_on_hold`, organização bloqueada) não muda o resultado e por isso não vira loop. O resultado sai em `system.status.claudeSignedIn`, e a conta (e-mail, plano, organização) em `system.status.account.claude`. O login em si é feito pelo app (15.2). -- Cada processo de bot entra num **Job Object** com `KILL_ON_JOB_CLOSE`. Se o daemon morrer, a árvore de processos dos bots morre junto e não sobra `claude.exe` órfão. +- Cada processo de bot entra num **Job Object** com `KILL_ON_JOB_CLOSE`. Se o daemon morrer, a árvore de processos dos bots morre junto e não sobra `claude.exe` órfão. Fora do Windows, um grupo de processos (14.1). ### 7.4 Spawn @@ -697,6 +697,19 @@ Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_time | Instância única | lock exclusivo em `\botloftd.lock` (`File::try_lock`), solto pelo sistema quando o processo termina, mesmo em crash; se já estiver preso, sair com erro claro. É um lock por pasta de dados, e não um mutex de nome fixo, para o daemon de dev (`BOTLOFT_HOME`) rodar ao lado do instalado | | Porta ocupada | se 45710 estiver em uso por outro processo, sair com erro (sem porta alternativa no MVP) | +### 14.1 Linux e macOS + +Em fatias (18, item 8). O que já vale fora do Windows: + +| Tema | Solução | +|---|---| +| Processos dos bots | Cada processo de bot e de navegador lidera um **grupo de processos** próprio (`process_group(0)`), e tudo o que ele inicia entra no grupo. Parar o bot manda `SIGKILL` ao grupo inteiro (`kill(-pgid)`), como `TerminateJobObject`. Um grupo que já sumiu (`ESRCH`) é esquecido, para um número reaproveitado não atingir outro grupo. Um processo que cria sessão própria (`setsid`) sai do grupo. **Falta:** quando o daemon morre, o grupo não morre junto como com `KILL_ON_JOB_CLOSE`. O `claude` sai sozinho quando o stdin fecha (7.4), mas um navegador continua; a fatia 3 resolve isso (o `systemd --user` mata o cgroup da unit; no macOS, uma varredura ao subir) | +| Ambiente do bot | O que o **shell de login** do dono monta: `$SHELL` (senão o shell da conta, `getpwuid_r`, senão `/bin/sh`) com `-l -c`, sem terminal, imprime uma marca e `env -0`. Assim o bot acha o que o terminal do dono acha (`~/.local/bin`, Homebrew), mesmo com o daemon iniciado pelo sistema com um `PATH` mínimo. A marca separa o que o perfil imprime; `_`, `SHLVL`, `PWD` e `OLDPWD` ficam de fora. Mais de 10 s, saída sem `PATH` ou erro: vale o ambiente do daemon. A leitura vale por 20 s, menos que os 30 s em que o Botloft percebe o Claude Code instalado (15.1). As variáveis de sessão do Claude Code saem nos dois casos | +| Claude Code | O `claude` no `PATH` desse ambiente, senão em `~/.local/bin` (instalador nativo), `~/.claude/local`, `/opt/homebrew/bin` e `/usr/local/bin` | +| Nome do dono | O nome completo da conta (primeiro campo do GECOS), senão o login; `USER`, `LOGNAME` e o nome da pasta pessoal cobrem um uid sem entrada (contêineres) | +| Navegador | 21.2 | +| Iniciar com o sistema, não suspender, Lixeira | Ainda não: procurar a tarefa responde "não instalada", e o resto avisa que só existe no Windows | + ## 15. App desktop ### 15.1 Estrutura @@ -925,7 +938,7 @@ Esta seção entra no PR do I1, junto com o código. - TS: `tsc --noEmit`, `biome check`, `vitest run`. - Limite **flexível de 300 linhas** por arquivo; passou disso, dividir por responsabilidade. - Toda lógica de supervisor, courier e chat testada com `FakeRuntime` (sem Claude real). O `FakeRuntime` fala `stream-json` de verdade: recebe as linhas do stdin e o teste escreve os eventos do stdout. -- CI: GitHub Actions em `windows-latest` (principal) e `ubuntu-latest` para `botloft-core` e `botloft-store`. +- CI: GitHub Actions em `windows-latest` (principal, workspace inteiro e app) e em `ubuntu-latest` e `macos-latest` para os membros padrão (`botloft-core`, `botloft-store`, `botloftd`), com clippy e testes. Os testes do navegador rodam com o Edge ou o Chrome da imagem. No Ubuntu, a imagem traz os auxiliares de sandbox deles sem setuid, e eles se recusam a abrir; o CI os deixa como os pacotes deixariam (`root`, `4755`). Os crates do app entram nesses runners quando o app rodar neles (18, item 8). ## 17. Marcos do MVP @@ -950,7 +963,7 @@ M2 a M4 foram entregues com ConPTY, terminal com replay, inbox por named pipe e 5. Histórico de versões das instruções do bot. 6. Acesso remoto com token por dispositivo (Tailscale). 7. Sinais entre bots disparando rotinas. -8. Suporte Linux/macOS. +8. Suporte Linux/macOS. Em fatias: (1) CI com Ubuntu e macOS para o daemon; (2) runtime do bot (grupo de processos, ambiente, `claude`, navegador); (3) iniciar com o sistema (`systemd --user`, LaunchAgent); (4) app; (5) empacotamento e release. Feitas: 1 e 2 (14.1). 9. Navegador dos bots, com o dono assistindo ao vivo. Desenho na seção 21. ## 19. Pontos a verificar na versão alvo do Claude Code @@ -1172,9 +1185,9 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg ### 21.2 Processo - Sobe na primeira tool `browser_*` do bot e fica aberto enquanto ele o usa. Um processo por bot. -- Comando: `msedge.exe --headless=new --remote-debugging-port=0 --user-data-dir=\browsers\ --no-first-run --no-default-browser-check --mute-audio --disable-extensions --disable-sync about:blank`. Sem `--disable-extensions`, um perfil novo recebe as extensões instaladas para todo o computador, e elas abrem abas próprias no navegador do bot (visto com o Edge 154). Com a porta 0, o Edge escolhe uma porta livre em 127.0.0.1 e a escreve com o caminho do WebSocket em `DevToolsActivePort`, na pasta do perfil (visto com o Edge 154); o daemon apaga o arquivo antigo antes de subir e espera o novo por até 10 s. +- Comando: `msedge.exe --headless=new --remote-debugging-port=0 --user-data-dir=\browsers\ --no-first-run --no-default-browser-check --mute-audio --disable-extensions --disable-sync about:blank`. Sem `--disable-extensions`, um perfil novo recebe as extensões instaladas para todo o computador, e elas abrem abas próprias no navegador do bot (visto com o Edge 154). Com a porta 0, o Edge escolhe uma porta livre em 127.0.0.1 e a escreve com o caminho do WebSocket em `DevToolsActivePort`, na pasta do perfil (visto com o Edge 154); o daemon apaga o arquivo antigo antes de subir e espera o novo por até 30 s. Costuma levar menos de 1 s, mas a primeira abertura do Edge no runner Ubuntu do CI passou de 20 s. Se o navegador fecha ou não fica pronto, o erro traz a linha do stderr dele que melhor explica (uma falha de sandbox ou de namespace, senão a primeira fatal, senão a última); o stderr não vai para o log. - Como o `claude.exe`: sem janela (`CREATE_NO_WINDOW`), num Job Object próprio com `KILL_ON_JOB_CLOSE` (morre junto com o daemon) e com o ambiente padrão do usuário (7.4). -- Achar o Edge: `[browser] path`, senão `msedge.exe` em `%ProgramFiles(x86)%` e `%ProgramFiles%` (`Microsoft\Edge\Application`) e em `%LOCALAPPDATA%`. Sem navegador, a tool responde ao bot que ele não está disponível, e o painel diz o mesmo. +- Achar o Edge: `[browser] path`, senão `msedge.exe` em `%ProgramFiles(x86)%` e `%ProgramFiles%` (`Microsoft\Edge\Application`) e em `%LOCALAPPDATA%`. No macOS, Edge, Chrome ou Chromium em `/Applications` e em `~/Applications`; no Linux, o primeiro de `google-chrome-stable`, `google-chrome`, `chromium`, `chromium-browser`, `microsoft-edge-stable` e `microsoft-edge` no `PATH` do ambiente do dono (14.1). O Edge vem por último no Linux: é o menos comum lá, e no runner Ubuntu do CI ele levou segundos para abrir a primeira aba de cada perfil novo (passando dos 5 s de espera da primeira aba), onde o Chrome levou menos de 1. Fora do Windows, o grupo de processos faz o papel do Job Object (14.1). Sem navegador, a tool responde ao bot que ele não está disponível, e o painel diz o mesmo. - **Perfil:** `\browsers\\`, fora da pasta do bot e em `%LOCALAPPDATA%` (5): cookies e sessões são do navegador, não dos arquivos que o bot faz. O perfil fica entre aberturas, então um login feito continua valendo. Arquivar ou excluir o bot fecha o navegador e apaga o perfil. - **Descansar:** um navegador aberto que ninguém usa não deve gastar o computador. Assim que o turno do bot termina (o estado dele deixa de ser `busy` ou `needs_approval`, 7.1), o daemon põe o navegador para descansar: tira um quadro da aba ativa, para o painel ter o que mostrar, e minimiza a janela do Edge (`Browser.setWindowBounds` com `windowState: "minimized"`; a janela já não aparecia, mas para o Edge ela existe). As páginas passam a valer como as de uma janela minimizada: `visibilityState` vira `hidden`, os timers caem para um por segundo ou menos, animações e quadros param. Medido com o Edge 154, uma página que gastava 90% de um núcleo fica abaixo de 1% (19). Nada se perde: abas, formulários e sessões continuam como estavam. Não descansa no meio de uma tool nem nas mãos do dono (21.10). - **Acordar:** na hora, com a janela de volta a `normal`, na próxima tool `browser_*` do bot, quando o dono assume o controle e quando ele recarrega a página. Depois de algo que o dono fez com o bot fora de um turno, o navegador volta a descansar sozinho: 20 s sem uso, conferido a cada 30 s. @@ -1300,7 +1313,7 @@ O dono pode usar o navegador do bot com as próprias mãos: clicar, arrastar, ro - **Assumir:** "Assumir o controle", no painel (21.8), com o navegador aberto. Quem controla é a conexão que assiste o bot (21.7); parar de assistir (fechar o painel, trocar de bot, fechar o app, cair a conexão) devolve o controle na hora, para o bot nunca ficar preso. Cada navegador tem um só controlador: `browser.take` de outra conexão enquanto um controla é `conflict`. - **O bot espera:** enquanto o dono controla, cada tool `browser_*` do bot espera ele devolver, até 10 minutos. Passou disso, a tool volta com erro: o dono está usando o navegador. `browser_ask_owner` não espera, porque é ela que chama o dono. O bot continua `busy` nessa espera, e o painel diz que ele espera. - **Devolver:** "Pronto, devolver para " no painel. Fechar o navegador (bot pausado, `browser_close`, processo que caiu) também devolve. -- **O que passa:** cliques, arrasto e roda do mouse (`Input.dispatchMouseEvent`), teclas (`Input.dispatchKeyEvent`) e texto colado ou composto (acento, IME: `Input.insertText`), sempre na aba ativa e na ordem em que o dono os fez. A conexão entrega cada evento a uma fila do navegador, que os manda um a um. Uma tecla com texto desce com o texto; Ctrl ou Meta com uma letra desce sem texto e com a tecla virtual do Windows, e o próprio Chromium faz os atalhos de edição (Ctrl+A, Ctrl+Z, Ctrl+Backspace). AltGr (Ctrl+Alt) conta como texto. Colar usa a área de transferência do dono, lida pelo app, nunca a do navegador do bot. +- **O que passa:** cliques, arrasto e roda do mouse (`Input.dispatchMouseEvent`), teclas (`Input.dispatchKeyEvent`) e texto colado ou composto (acento, IME: `Input.insertText`), sempre na aba ativa e na ordem em que o dono os fez. A conexão entrega cada evento a uma fila do navegador, que os manda um a um. Uma tecla com texto desce com o texto; Ctrl ou Meta com uma letra desce sem texto e com a tecla virtual do Windows, e o próprio Chromium faz os atalhos de edição (Ctrl+A, Ctrl+Z, Ctrl+Backspace). No macOS ele não faz os de Cmd a partir da tecla (visto no runner macOS do CI): Cmd+A, C, X, V, Z e Shift+Cmd+Z descem com o comando de edição em `commands` (`selectAll`, `copy`, `cut`, `paste`, `undo`, `redo`). AltGr (Ctrl+Alt) conta como texto. Colar usa a área de transferência do dono, lida pelo app, nunca a do navegador do bot. - **Pontos** em pixels da página, como `browser.action`; fora do tamanho que ela tem agora (21.3) são recusados. - **Privacidade:** o que o dono digita e cola não vai para o log, nem para `browser.action`, nem para o bot. O log registra só que um evento chegou, em `debug`. Depois, o bot lê a página como sempre (21.6), com a senha só como `(password)`. - **Sites:** o que o dono abre com as próprias mãos não passa pela permissão de sites, e também não libera o site para o bot: a próxima tool do bot numa página de outro site pergunta como sempre (21.5).