From 7eedbf66b435e6468fa66254f98b2ef8e7f83dd7 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:45:30 -0300 Subject: [PATCH 01/12] fix(daemon): the build script compiles on hosts other than Windows embed-resource is a build dependency on Windows hosts only, so build.rs must not name it elsewhere. --- crates/botloftd/build.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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"); +} From b954e37d75ce9e16472b24838c43b6cc3582e8cf Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:45:30 -0300 Subject: [PATCH 02/12] fix(daemon): settings, owner name and file pages work off Windows - With no scheduled task off Windows, finding one answers 'not installed' instead of failing, so saving the settings works. - The owner name falls back to LOGNAME and the home folder name. - file: URLs keep the leading slash of POSIX paths, so bots can open pages from their folders. --- crates/botloftd/src/browser/sites.rs | 24 +++++++++++++++++------- crates/botloftd/src/platform/unix.rs | 17 +++++++++++++---- 2 files changed, 30 insertions(+), 11 deletions(-) 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/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index fe7bd35b..c428d4fa 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -68,9 +68,17 @@ pub fn user_environment() -> io::Result> { .collect()) } -/// The login name; Unix has no display name to ask for. +/// The login name; Unix has no display name to ask for. `USER` is missing +/// in some service and container environments, and the home folder is +/// named after the login there too. pub fn owner_name() -> String { - std::env::var("USER").unwrap_or_default() + ["USER", "LOGNAME"] + .iter() + .filter_map(std::env::var_os) + .chain(dirs::home_dir().and_then(|home| home.file_name().map(ToOwned::to_owned))) + .map(|name| name.to_string_lossy().trim().to_owned()) + .find(|name| !name.is_empty()) + .unwrap_or_default() } /// Nothing to leave: Unix never opens a console for the daemon. @@ -87,8 +95,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 +109,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. From 3a0a088c83c3143ad08e202b1e0b708e3acc93e2 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:45:30 -0300 Subject: [PATCH 03/12] test(daemon): tests that hold on every system - The config test's TOML literal had one backslash too many; Windows paths hid it by merging the separators. - The installed binary has no .exe off Windows, so the expected names are sorted like the listing. - Archiving a queued message holds the bot's start, so a fast start can no longer take the message first. --- crates/botloftd/src/autostart/binary.rs | 11 +++++++++-- crates/botloftd/src/config.rs | 2 +- crates/botloftd/tests/courier.rs | 6 +++++- 3 files changed, 15 insertions(+), 4 deletions(-) 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/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/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"); From 56340140d98855a430fbaae56b746de57c735e08 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:45:31 -0300 Subject: [PATCH 04/12] ci: the daemon builds and passes its tests on Ubuntu and macOS First slice of Linux/macOS support (spec 18, item 8). The Ubuntu job covered only core and store; it now runs clippy and the tests of the default members, with macOS alongside. The app crates join when the app runs there. --- .github/workflows/ci.yml | 21 ++++++++++++++++----- docs/spec.md | 4 ++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc20efc1..37541fe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,17 +31,28 @@ 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 + - run: cargo test app: name: App (Windows) diff --git a/docs/spec.md b/docs/spec.md index 733a0b19..7e13c3f5 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -925,7 +925,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 crates do app entram nesses runners quando o app rodar neles (18, item 8). ## 17. Marcos do MVP @@ -950,7 +950,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. Feita: 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 From eb6418a086fba70f62af255f9739338653428641 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:40:51 -0300 Subject: [PATCH 05/12] feat(runtime): Claude Code found where installers put it off Windows After PATH, look in ~/.local/bin (native installer), ~/.claude/local, /opt/homebrew/bin and /usr/local/bin, for a profile that never added it to PATH. --- crates/botloftd/src/runtime/claude.rs | 38 ++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) 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")); + } } From ba92d71d7d35ebc1130fe1f9721407601760351b Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:40:52 -0300 Subject: [PATCH 06/12] feat(platform): bots run on Linux and macOS like on Windows - A process group per bot and per browser takes the place of the Job Object: stopping kills the whole group. The job interface now prepares the command and takes the child, so the runtime and the browser have no Windows blocks left. - Bots get the environment of the owner's login shell (read once per 20 s), so they find what the owner's terminal finds even when the system starts the daemon with a bare PATH. - The owner's full name comes from the account entry. - The browser is the first Edge, Chrome or Chromium installed. Spec 14.1 describes it, and what is still missing: the group does not die with the daemon until slice 3. --- Cargo.lock | 1 + crates/botloftd/Cargo.toml | 3 + crates/botloftd/src/browser/call.rs | 4 +- crates/botloftd/src/browser/launch.rs | 48 ++---- crates/botloftd/src/browser/mod.rs | 5 +- crates/botloftd/src/browser/program.rs | 101 +++++++++++++ crates/botloftd/src/platform/unix.rs | 48 ++---- crates/botloftd/src/platform/unix/account.rs | 92 ++++++++++++ crates/botloftd/src/platform/unix/env.rs | 147 +++++++++++++++++++ crates/botloftd/src/platform/unix/job.rs | 107 ++++++++++++++ crates/botloftd/src/platform/windows/job.rs | 16 +- crates/botloftd/src/runtime/pipe.rs | 22 +-- crates/botloftd/src/tools/browser_reply.rs | 4 +- docs/spec.md | 19 ++- 14 files changed, 511 insertions(+), 106 deletions(-) create mode 100644 crates/botloftd/src/browser/program.rs create mode 100644 crates/botloftd/src/platform/unix/account.rs create mode 100644 crates/botloftd/src/platform/unix/env.rs create mode 100644 crates/botloftd/src/platform/unix/job.rs 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/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/launch.rs b/crates/botloftd/src/browser/launch.rs index 147b5090..6a6fb26e 100644 --- a/crates/botloftd/src/browser/launch.rs +++ b/crates/botloftd/src/browser/launch.rs @@ -1,8 +1,8 @@ -//! 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::path::Path; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; @@ -35,26 +35,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( @@ -89,18 +69,13 @@ pub async fn launch( .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); - } + 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 mut process = BrowserProcess { child, job }; debug!(pid = process.child.id(), "browser: started"); @@ -151,9 +126,4 @@ mod tests { assert_eq!(devtools_url("0\n/devtools/browser/x"), None); assert_eq!(devtools_url(""), None); } - - #[test] - fn a_configured_browser_must_exist() { - assert_eq!(find(r"C:\nowhere\msedge.exe"), None); - } } 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..bea5765b --- /dev/null +++ b/crates/botloftd/src/browser/program.rs @@ -0,0 +1,101 @@ +//! 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 Linux and macOS. + +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 Edge, Chrome and Chromium packages install, on the +/// owner's `PATH`. +#[cfg(all(unix, not(target_os = "macos")))] +fn installed() -> Vec { + const COMMANDS: &[&str] = &[ + "microsoft-edge-stable", + "microsoft-edge", + "google-chrome-stable", + "google-chrome", + "chromium", + "chromium-browser", + ]; + 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/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index c428d4fa..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,29 +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. `USER` is missing -/// in some service and container environments, and the home folder is -/// named after the login there too. -pub fn owner_name() -> String { - ["USER", "LOGNAME"] - .iter() - .filter_map(std::env::var_os) - .chain(dirs::home_dir().and_then(|home| home.file_name().map(ToOwned::to_owned))) - .map(|name| name.to_string_lossy().trim().to_owned()) - .find(|name| !name.is_empty()) - .unwrap_or_default() -} - /// Nothing to leave: Unix never opens a console for the daemon. pub fn leave_own_console() {} 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/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/docs/spec.md b/docs/spec.md index 7e13c3f5..fa3ca1a2 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 @@ -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. 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. Feita: 1. +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 @@ -1174,7 +1187,7 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg - 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. - 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 `microsoft-edge-stable`, `microsoft-edge`, `google-chrome-stable`, `google-chrome`, `chromium` e `chromium-browser` no `PATH` do ambiente do dono (14.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. From 47b4919c5b59b9746cff31d3018153e047e02ffc Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:49:20 -0300 Subject: [PATCH 07/12] feat(browser): say why a browser closed right after starting The browser's stderr is read until it ends, and the line that best says why it stopped (a sandbox or namespace failure, else the first fatal one) goes into the error the bot and the owner see. Nothing of it is logged. --- crates/botloftd/src/browser/launch.rs | 75 ++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/crates/botloftd/src/browser/launch.rs b/crates/botloftd/src/browser/launch.rs index 6a6fb26e..04a6a250 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): run it without a window in a job //! of its own, and read where its DevTools listen. -use std::io; +use std::io::{self, BufRead as _, BufReader}; use std::path::Path; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, ChildStderr, Command, Stdio}; +use std::sync::mpsc; use std::time::{Duration, Instant}; use tracing::debug; @@ -14,6 +15,8 @@ use crate::platform::{self, ProcessJob}; /// How long Edge may take to write `DevToolsActivePort`. const START_TIMEOUT: Duration = Duration::from_secs(10); 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 { @@ -68,7 +71,7 @@ pub async fn launch( .envs(env.iter().map(|(name, value)| (name, value))) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()); + .stderr(Stdio::piped()); ProcessJob::prepare(&mut command); let job = ProcessJob::new()?; let mut child = command.spawn()?; @@ -77,6 +80,7 @@ pub async fn launch( let _ = child.wait(); return Err(err.into()); } + let why = child.stderr.take().map(why_it_stopped); let mut process = BrowserProcess { child, job }; debug!(pid = process.child.id(), "browser: started"); @@ -89,9 +93,15 @@ 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(); + let reason = why + .and_then(|why| why.recv_timeout(Duration::from_secs(1)).ok()) + .map(|line| format!(" ({line})")) + .unwrap_or_default(); + return Err(BrowserError::Start(format!( + "the browser closed right after starting{reason}" + ))); } if started.elapsed() > START_TIMEOUT { return Err(BrowserError::Start( @@ -102,6 +112,48 @@ pub async fn launch( } } +/// Reads the browser's stderr to its end and sends the line that best says +/// why it stopped, for a browser that closes right after starting (a +/// missing sandbox on Linux, say). Nothing of it goes to the log. +fn why_it_stopped(stderr: ChildStderr) -> mpsc::Receiver { + let (send, receive) = mpsc::channel(); + let spawned = std::thread::Builder::new() + .name("browser-stderr".into()) + .spawn(move || { + let mut best: Option = None; + let mut last: Option = None; + for line in BufReader::new(stderr).lines() { + let Ok(line) = line else { break }; + let text = log_text(&line); + if text.is_empty() { + continue; + } + let lower = line.to_ascii_lowercase(); + let sandbox = lower.contains("sandbox") || lower.contains("namespace"); + if sandbox || (best.is_none() && lower.contains(":fatal:")) { + best = Some(text.clone()); + } + last = Some(text); + } + if let Some(line) = best.or(last) { + let _ = send.send(line.chars().take(REASON_MAX).collect()); + } + }); + if spawned.is_err() { + debug!("browser: cannot read its stderr"); + } + receive +} + +/// 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 { @@ -126,4 +178,15 @@ mod tests { assert_eq!(devtools_url("0\n/devtools/browser/x"), None); assert_eq!(devtools_url(""), None); } + + #[test] + 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"); + } } From 18f756ede346bd3cc07c634e73b0b231ce04bfb4 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:03:50 -0300 Subject: [PATCH 08/12] fix(browser): Cmd shortcuts on macOS, and a working sandbox on the Ubuntu runner - On macOS the page runs no Cmd shortcut from the virtual key, so Cmd+A, C, X, V, Z and Shift+Cmd+Z the owner presses go with their editing command. The macOS runner showed it: Cmd+A did not select the field. - The Ubuntu runner image ships Edge and Chrome with sandbox helpers that are not setuid root, so they refused to start; CI sets them the way their packages do. --- .github/workflows/ci.yml | 12 +++++++++ crates/botloftd/src/browser/input.rs | 36 ++++++++++++++++++++++++++ crates/botloftd/tests/browser_hands.rs | 6 +++-- docs/spec.md | 4 +-- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37541fe7..d4635735 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,18 @@ jobs: components: clippy - uses: Swatinem/rust-cache@v2 - 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: 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/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/docs/spec.md b/docs/spec.md index fa3ca1a2..41775fae 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -938,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, 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 crates do app entram nesses runners quando o app rodar neles (18, item 8). +- 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 @@ -1313,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). From 2326114e90f2ac706f93a7f128baf4e9322059da Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:25:59 -0300 Subject: [PATCH 09/12] feat(browser): say what the browser printed when it does not start in time The browser's stderr is kept as the line that best explains a failure for as long as it runs, so a browser that hangs at start says why too. The tabs test prints the list when it is not the one expected. --- crates/botloftd/src/browser/launch.rs | 105 ++++++++++++++++---------- crates/botloftd/tests/browser.rs | 2 +- 2 files changed, 67 insertions(+), 40 deletions(-) diff --git a/crates/botloftd/src/browser/launch.rs b/crates/botloftd/src/browser/launch.rs index 04a6a250..acf3d241 100644 --- a/crates/botloftd/src/browser/launch.rs +++ b/crates/botloftd/src/browser/launch.rs @@ -4,7 +4,7 @@ use std::io::{self, BufRead as _, BufReader}; use std::path::Path; use std::process::{Child, ChildStderr, Command, Stdio}; -use std::sync::mpsc; +use std::sync::{Arc, Mutex, mpsc}; use std::time::{Duration, Instant}; use tracing::debug; @@ -80,7 +80,7 @@ pub async fn launch( let _ = child.wait(); return Err(err.into()); } - let why = child.stderr.take().map(why_it_stopped); + let said = child.stderr.take().map(StartLog::read); let mut process = BrowserProcess { child, job }; debug!(pid = process.child.id(), "browser: started"); @@ -95,54 +95,81 @@ pub async fn launch( if process.child.try_wait()?.is_some() { // Its helpers may still hold stderr open until the group dies. process.kill(); - let reason = why - .and_then(|why| why.recv_timeout(Duration::from_secs(1)).ok()) - .map(|line| format!(" ({line})")) - .unwrap_or_default(); + 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{reason}" + "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; } } -/// Reads the browser's stderr to its end and sends the line that best says -/// why it stopped, for a browser that closes right after starting (a -/// missing sandbox on Linux, say). Nothing of it goes to the log. -fn why_it_stopped(stderr: ChildStderr) -> mpsc::Receiver { - let (send, receive) = mpsc::channel(); - let spawned = std::thread::Builder::new() - .name("browser-stderr".into()) - .spawn(move || { - let mut best: Option = None; - let mut last: Option = None; - for line in BufReader::new(stderr).lines() { - let Ok(line) = line else { break }; - let text = log_text(&line); - if text.is_empty() { - continue; - } - let lower = line.to_ascii_lowercase(); - let sandbox = lower.contains("sandbox") || lower.contains("namespace"); - if sandbox || (best.is_none() && lower.contains(":fatal:")) { - best = Some(text.clone()); +/// 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); } - last = Some(text); - } - if let Some(line) = best.or(last) { - let _ = send.send(line.chars().take(REASON_MAX).collect()); - } - }); - if spawned.is_err() { - debug!("browser: cannot read its stderr"); + 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() } - receive } /// A Chromium log line without its `[pid:tid:time:LEVEL:file.cc:123]` prefix. 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")); From 015d7092b9a0b7198844e54e9640092fccd39237 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:29:12 -0300 Subject: [PATCH 10/12] ci: diagnostic step for the browsers of the Ubuntu runner (temporary) --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4635735..8abfec8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,22 @@ jobs: sudo chmod 4755 "$helper" fi done + - name: Browser start (diagnostic) + if: runner.os == 'Linux' + continue-on-error: true + run: | + ls -l /usr/bin/microsoft-edge* /usr/bin/google-chrome* /opt/microsoft/msedge/msedge-sandbox /opt/google/chrome/chrome-sandbox || true + sysctl kernel.apparmor_restrict_unprivileged_userns kernel.unprivileged_userns_clone || true + for browser in microsoft-edge-stable google-chrome-stable; do + echo "== $browser" + dir=$(mktemp -d) + start=$(date +%s) + timeout 20 "$browser" --headless=new --remote-debugging-port=0 --no-first-run --no-default-browser-check --disable-extensions --user-data-dir="$dir" about:blank > "$dir.out" 2>&1 & + for i in $(seq 1 40); do [ -f "$dir/DevToolsActivePort" ] && break; sleep 0.5; done + echo "after $(( $(date +%s) - start )) s:"; cat "$dir/DevToolsActivePort" 2>/dev/null || echo "no port file" + wait + head -20 "$dir.out" + done - run: cargo test app: From 39dd330a178a8da52817e5275db0defaa10aed62 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:37:17 -0300 Subject: [PATCH 11/12] fix(browser): wait up to 30 s for the browser to start Edge's first start on a new Linux machine (the Ubuntu runner) took over 20 s, past the 10 s the daemon waited; a start that works takes under a second either way. CI starts Edge once before the tests instead of the diagnostic step. --- .github/workflows/ci.yml | 22 ++++++++-------------- crates/botloftd/src/browser/launch.rs | 5 +++-- docs/spec.md | 2 +- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8abfec8b..cab28a80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,22 +64,16 @@ jobs: sudo chmod 4755 "$helper" fi done - - name: Browser start (diagnostic) + # Edge's first start on a fresh runner took over 20 s, and the next + # ones under one. Starting it once keeps the tests off that wait. + - name: Start Edge once if: runner.os == 'Linux' - continue-on-error: true run: | - ls -l /usr/bin/microsoft-edge* /usr/bin/google-chrome* /opt/microsoft/msedge/msedge-sandbox /opt/google/chrome/chrome-sandbox || true - sysctl kernel.apparmor_restrict_unprivileged_userns kernel.unprivileged_userns_clone || true - for browser in microsoft-edge-stable google-chrome-stable; do - echo "== $browser" - dir=$(mktemp -d) - start=$(date +%s) - timeout 20 "$browser" --headless=new --remote-debugging-port=0 --no-first-run --no-default-browser-check --disable-extensions --user-data-dir="$dir" about:blank > "$dir.out" 2>&1 & - for i in $(seq 1 40); do [ -f "$dir/DevToolsActivePort" ] && break; sleep 0.5; done - echo "after $(( $(date +%s) - start )) s:"; cat "$dir/DevToolsActivePort" 2>/dev/null || echo "no port file" - wait - head -20 "$dir.out" - done + dir=$(mktemp -d) + microsoft-edge-stable --headless=new --remote-debugging-port=0 --no-first-run \ + --user-data-dir="$dir" about:blank > /dev/null 2>&1 & + for i in $(seq 1 120); do [ -f "$dir/DevToolsActivePort" ] && break; sleep 0.5; done + kill $! || true - run: cargo test app: diff --git a/crates/botloftd/src/browser/launch.rs b/crates/botloftd/src/browser/launch.rs index acf3d241..d21be3b2 100644 --- a/crates/botloftd/src/browser/launch.rs +++ b/crates/botloftd/src/browser/launch.rs @@ -12,8 +12,9 @@ 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; diff --git a/docs/spec.md b/docs/spec.md index 41775fae..3dbb10ca 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1185,7 +1185,7 @@ 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 num Linux novo (o 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%`. No macOS, Edge, Chrome ou Chromium em `/Applications` e em `~/Applications`; no Linux, o primeiro de `microsoft-edge-stable`, `microsoft-edge`, `google-chrome-stable`, `google-chrome`, `chromium` e `chromium-browser` no `PATH` do ambiente do dono (14.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. From 0f7e205350b032f3e279ebd4f799e6cf524f6be7 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:44:14 -0300 Subject: [PATCH 12/12] fix(browser): Chrome or Chromium before Edge on Linux Edge is the rarer one on Linux, and on the Ubuntu runner it took seconds to open the first tab of each new profile, past the 5 s the daemon waits, where Chrome took under one. CI no longer starts Edge before the tests. --- .github/workflows/ci.yml | 10 ---------- crates/botloftd/src/browser/program.rs | 12 +++++++----- docs/spec.md | 4 ++-- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cab28a80..d4635735 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,16 +64,6 @@ jobs: sudo chmod 4755 "$helper" fi done - # Edge's first start on a fresh runner took over 20 s, and the next - # ones under one. Starting it once keeps the tests off that wait. - - name: Start Edge once - if: runner.os == 'Linux' - run: | - dir=$(mktemp -d) - microsoft-edge-stable --headless=new --remote-debugging-port=0 --no-first-run \ - --user-data-dir="$dir" about:blank > /dev/null 2>&1 & - for i in $(seq 1 120); do [ -f "$dir/DevToolsActivePort" ] && break; sleep 0.5; done - kill $! || true - run: cargo test app: diff --git a/crates/botloftd/src/browser/program.rs b/crates/botloftd/src/browser/program.rs index bea5765b..d81fcd26 100644 --- a/crates/botloftd/src/browser/program.rs +++ b/crates/botloftd/src/browser/program.rs @@ -1,6 +1,6 @@ //! 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 Linux and macOS. +//! installed on macOS (Chrome or Chromium first on Linux). use std::path::PathBuf; @@ -55,17 +55,19 @@ fn installed() -> Vec { .collect() } -/// The commands the Edge, Chrome and Chromium packages install, on the -/// owner's `PATH`. +/// 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] = &[ - "microsoft-edge-stable", - "microsoft-edge", "google-chrome-stable", "google-chrome", "chromium", "chromium-browser", + "microsoft-edge-stable", + "microsoft-edge", ]; let path = crate::platform::user_environment() .ok() diff --git a/docs/spec.md b/docs/spec.md index 3dbb10ca..92480239 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1185,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é 30 s. Costuma levar menos de 1 s, mas a primeira abertura do Edge num Linux novo (o 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. +- 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%`. No macOS, Edge, Chrome ou Chromium em `/Applications` e em `~/Applications`; no Linux, o primeiro de `microsoft-edge-stable`, `microsoft-edge`, `google-chrome-stable`, `google-chrome`, `chromium` e `chromium-browser` no `PATH` do ambiente do dono (14.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. +- 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.