From de19f1839d288f606b99a2162c236033b5da9445 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:57:00 -0300 Subject: [PATCH 1/3] feat(browser): show sites a common Edge Some sites refuse to sign in to a browser that tells them a program drives it. The bot's browser now keeps navigator.webdriver false (--disable-blink-features=AutomationControlled) and overrides the user agent with its userAgentData metadata, read once from chrome://version, so the brands are no longer empty. No script is injected into pages; outerWidth stays 0. Spec 21.2, 21.3 and 19 record what was measured. --- crates/botloftd/src/browser/agent.rs | 110 +++++++++++++++++++++++++ crates/botloftd/src/browser/events.rs | 5 +- crates/botloftd/src/browser/launch.rs | 3 + crates/botloftd/src/browser/mod.rs | 1 + crates/botloftd/src/browser/session.rs | 13 ++- crates/botloftd/tests/browser.rs | 15 ++++ crates/botloftd/tests/common/site.rs | 11 ++- docs/spec.md | 5 +- 8 files changed, 147 insertions(+), 16 deletions(-) create mode 100644 crates/botloftd/src/browser/agent.rs diff --git a/crates/botloftd/src/browser/agent.rs b/crates/botloftd/src/browser/agent.rs new file mode 100644 index 00000000..b78e5801 --- /dev/null +++ b/crates/botloftd/src/browser/agent.rs @@ -0,0 +1,110 @@ +//! What the browser tells sites it is (spec 21.3): the user agent of the +//! Edge it runs without the word `Headless`, with the brands and platform a +//! site reads from `navigator.userAgentData`. Overriding the user agent +//! alone would leave those empty, which no common browser does. + +use std::time::Duration; + +use serde_json::{Value, json}; +use tokio::sync::mpsc; +use tracing::debug; + +use super::BrowserError; +use super::cdp::{Cdp, CdpEvent}; + +/// A page of the browser's own that has `navigator.userAgentData`, which +/// only secure pages have (`about:blank` does not). +const SECURE_PAGE: &str = "chrome://version"; +/// What `navigator.userAgentData` says, once the page has it. +const READ_METADATA: &str = "navigator.userAgentData ? navigator.userAgentData\ + .getHighEntropyValues(['architecture', 'bitness', 'fullVersionList', 'model', \ + 'platformVersion', 'wow64']) : null"; +/// Tries while the page loads, 50 ms apart. +const TRIES: usize = 40; + +/// The params of `Emulation.setUserAgentOverride` for every tab. Run before +/// anything else reads `events`: the page it reads from opens and closes +/// here, and its events are dropped. +pub async fn read( + cdp: &Cdp, + events: &mut mpsc::UnboundedReceiver, +) -> Result { + let version = cdp.call(None, "Browser.getVersion", json!({})).await?; + let user_agent = version["userAgent"] + .as_str() + .unwrap_or_default() + .replace("HeadlessChrome", "Chrome"); + let mut params = json!({ "userAgent": user_agent }); + match metadata(cdp).await { + Ok(Some(metadata)) => params["userAgentMetadata"] = metadata, + Ok(None) => debug!("browser: the user agent data did not come"), + Err(err) => debug!("browser: reading the user agent data: {err}"), + } + while events.try_recv().is_ok() {} + Ok(params) +} + +/// The brands and platform a secure page reads, in the shape +/// `Emulation.UserAgentMetadata` takes. +async fn metadata(cdp: &Cdp) -> Result, BrowserError> { + let created = cdp + .call( + None, + "Target.createTarget", + json!({ "url": SECURE_PAGE, "background": true }), + ) + .await?; + let target = created["targetId"].clone(); + let attached = cdp + .call( + None, + "Target.attachToTarget", + json!({ "targetId": target, "flatten": true }), + ) + .await; + let read = match attached { + Ok(attached) => { + let session = attached["sessionId"].as_str().unwrap_or_default(); + let read = evaluate(cdp, session).await; + let params = json!({ "sessionId": session }); + let _ = cdp.call(None, "Target.detachFromTarget", params).await; + read + } + Err(err) => Err(err.into()), + }; + let _ = cdp + .call(None, "Target.closeTarget", json!({ "targetId": target })) + .await; + // It closes a moment after the answer: still there, the tabs the daemon + // takes next would count it. + for _ in 0..TRIES { + let targets = cdp.call(None, "Target.getTargets", json!({})).await?; + let open = targets["targetInfos"] + .as_array() + .is_some_and(|all| all.iter().any(|info| info["targetId"] == target)); + if !open { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + read +} + +async fn evaluate(cdp: &Cdp, session: &str) -> Result, BrowserError> { + let params = + json!({ "expression": READ_METADATA, "awaitPromise": true, "returnByValue": true }); + for _ in 0..TRIES { + // The page may still be blank, or its context gone as it loads. + if let Ok(mut read) = cdp + .call(Some(session), "Runtime.evaluate", params.clone()) + .await + { + let value = read["result"]["value"].take(); + if value.get("brands").is_some() { + return Ok(Some(value)); + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Ok(None) +} diff --git a/crates/botloftd/src/browser/events.rs b/crates/botloftd/src/browser/events.rs index aed1bf40..dfbc0912 100644 --- a/crates/botloftd/src/browser/events.rs +++ b/crates/botloftd/src/browser/events.rs @@ -57,10 +57,7 @@ impl Session { json!({ "enabled": true }), ), ("Emulation.setDeviceMetricsOverride", viewport.metrics()), - ( - "Emulation.setUserAgentOverride", - json!({ "userAgent": self.user_agent }), - ), + ("Emulation.setUserAgentOverride", self.user_agent.clone()), ]; // A tab opened by a page waits for us, and some of these only // answer once it runs: send them all, the go-ahead last, then wait. diff --git a/crates/botloftd/src/browser/launch.rs b/crates/botloftd/src/browser/launch.rs index 2d25347c..12333265 100644 --- a/crates/botloftd/src/browser/launch.rs +++ b/crates/botloftd/src/browser/launch.rs @@ -65,6 +65,9 @@ pub async fn launch( // open their own tabs in the bot's browser. "--disable-extensions", "--disable-sync", + // `navigator.webdriver` stays `false`, as in a browser someone + // uses: some sites refuse to sign in to one a program drives. + "--disable-blink-features=AutomationControlled", ]) .arg(format!("--user-data-dir={}", profile.display())) .arg("about:blank") diff --git a/crates/botloftd/src/browser/mod.rs b/crates/botloftd/src/browser/mod.rs index 38a9187d..0e4903bd 100644 --- a/crates/botloftd/src/browser/mod.rs +++ b/crates/botloftd/src/browser/mod.rs @@ -3,6 +3,7 @@ //! first browser tool, closes when nobody uses or watches it, and sends //! live frames to the app connection that watches it. +mod agent; mod call; mod cdp; mod events; diff --git a/crates/botloftd/src/browser/session.rs b/crates/botloftd/src/browser/session.rs index 5f0366ff..a9361337 100644 --- a/crates/botloftd/src/browser/session.rs +++ b/crates/botloftd/src/browser/session.rs @@ -13,11 +13,11 @@ use serde_json::json; use tokio::sync::{Notify, watch}; use tracing::debug; -use super::BrowserError; use super::cdp::Cdp; use super::events::pump; use super::launch::{self, BrowserProcess}; use super::viewport::{MAX_HEIGHT, Viewport, WIDTH}; +use super::{BrowserError, agent}; /// Most tabs a browser keeps; past this, the one active longest ago closes. pub(super) const MAX_TABS: usize = 6; @@ -125,7 +125,8 @@ pub struct Session { /// The next element ref, shared by every page of this browser. pub next_ref: AtomicU64, process: Mutex>, - pub(super) user_agent: String, + /// The params of `Emulation.setUserAgentOverride` (`agent.rs`). + pub(super) user_agent: serde_json::Value, /// One change of what the app wants at a time (`watch.rs`). pub(super) syncing: tokio::sync::Mutex<()>, } @@ -141,12 +142,8 @@ impl Session { hooks: Hooks, ) -> Result, BrowserError> { let (process, url) = launch::launch(program, profile).await?; - let (cdp, events) = Cdp::connect(&url).await?; - let version = cdp.call(None, "Browser.getVersion", json!({})).await?; - let user_agent = version["userAgent"] - .as_str() - .unwrap_or_default() - .replace("HeadlessChrome", "Chrome"); + let (cdp, mut events) = Cdp::connect(&url).await?; + let user_agent = agent::read(&cdp, &mut events).await?; let session = Arc::new(Self { cdp, tabs: Mutex::new(Tabs { diff --git a/crates/botloftd/tests/browser.rs b/crates/botloftd/tests/browser.rs index a04195ca..182f3035 100644 --- a/crates/botloftd/tests/browser.rs +++ b/crates/botloftd/tests/browser.rs @@ -102,6 +102,21 @@ async fn words_made_of_one_element_per_letter_keep_their_spaces() { assert!(page.contains("Two words"), "{page}"); } +#[tokio::test(flavor = "multi_thread")] +async fn sites_see_a_common_browser() { + let Some(mut b) = setup().await else { return }; + let opening = call( + &b.mcp, + "browser_open", + json!({ "url": format!("{}/signals", b.site) }), + ); + answer_site(&mut b.app, true, None).await; + let page = opening.await.expect("task").expect("page"); + assert!(page.contains("Webdriver: false"), "{page}"); + assert!(page.contains("Brands: Chromium"), "{page}"); + assert!(!page.contains("Headless"), "{page}"); +} + #[tokio::test(flavor = "multi_thread")] async fn a_site_the_owner_denies_stays_closed() { let Some(mut b) = setup().await else { return }; diff --git a/crates/botloftd/tests/common/site.rs b/crates/botloftd/tests/common/site.rs index d663d1bf..043f9e6b 100644 --- a/crates/botloftd/tests/common/site.rs +++ b/crates/botloftd/tests/common/site.rs @@ -2,8 +2,8 @@ //! it leads to, a link that opens a new tab, a button that shows a dialog, //! a sign-in page with its fields at fixed points, for the owner's hands, //! a page that says how big its window is, one that counts its loads, one -//! that counts the times it went out of sight and one with each letter in -//! its own element. +//! that counts the times it went out of sight, one with each letter in its +//! own element and one that shows what tells a site a program drives it. use std::collections::HashMap; use std::net::SocketAddr; @@ -50,6 +50,12 @@ const SIGHT: &str = "Sight

Hidden if (document.hidden) { hidden += 1; document.getElementById('hidden').textContent = \ 'Hidden ' + hidden + ' times'; } });"; +/// What sites look at to tell a browser a program drives it. +const SIGNALS: &str = "Signals

"; + async fn account(Form(form): Form>) -> Html { let user = form.get("user").cloned().unwrap_or_default(); let length = form @@ -93,6 +99,7 @@ pub async fn serve() -> SocketAddr { .route("/size", get(|| async { Html(SIZE) })) .route("/sight", get(|| async { Html(SIGHT) })) .route("/letters", get(|| async { Html(LETTERS) })) + .route("/signals", get(|| async { Html(SIGNALS) })) .route("/account", post(account)); let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); diff --git a/docs/spec.md b/docs/spec.md index 16a7ff44..cd45d088 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1059,6 +1059,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Revisão do MCP que o Claude Code usa | 10 | Especificação MCP 2026-07-28 (sem `initialize`) e versões antigas. **Visto com 2.1.284**: manda `server/discover` com os headers de 2026-07-28 e, se falhar, `initialize` com 2025-11-25; o mesmo num servidor stdio | feito (M3) | | Sintaxe de caminho Windows em permission rules | 7.5 | Confirmado (`permissions`) e **testado com 2.1.283** (interativo) e **2.1.284** (`-p --setting-sources project,local`): `Read(//c/.../**)` em `deny` bloqueou a leitura com "File is in a directory that is denied by your permission settings", sem perguntar, e o `result` listou a negação em `permission_denials` | feito (M4.1) | | Edge sem janela controlado por CDP | 21.2, 21.3 | **Visto com o Edge 154** (Windows 11): `--headless=new --remote-debugging-port=0` escreve `DevToolsActivePort` em ~400 ms; `Target.setAutoAttach` no alvo do navegador prende a aba que já existe e cada popup (`openerId`, esperando o depurador); `Emulation.setDeviceMetricsOverride` deixa a tela em 1280 × 800, e chamado de novo com a página aberta muda o `innerHeight` dela na hora; `Page.startScreencast` manda quadros JPEG com o tamanho da tela, também depois dessa mudança (com `maxHeight` no limite de 21.3), mas o quadro do tamanho novo se perde quando há quadros anteriores ainda sem `screencastFrameAck`, e uma página parada não manda outro: o daemon espera esses serem confirmados e recomeça o screencast (`stopScreencast`, `startScreencast`), que então manda um quadro; clique por `Input.dispatchMouseEvent` segue links; `alert` chega em `Page.javascriptDialogOpening`. `Target.targetInfoChanged` chega com o endereço novo da aba, mas com o endereço no lugar do título, e não vem de novo quando a página ganha o título. Ler o título com `Target.getTargetInfo` depois do `Page.loadEventFired` falhou de vez em quando nos testes (ficava o endereço); o `document.title` lido na página, num mundo isolado, não falhou. `Target.createTarget` com `about:blank` abre uma aba que chega por `attachedToTarget` como as outras; `Target.activateTarget` numa aba que ficou atrás a traz de volta, e o screencast dela volta a mandar quadros. Um Edge parado numa página que se mexe (um timer ocupado e uma animação) gastou 84% de um núcleo sem ninguém assistindo e 126% com o screencast, em ~450 MB e 7 a 10 processos. `Browser.setWindowBounds` com `windowState: "minimized"` na janela do `Browser.getWindowForTarget` deixou a página `hidden`, com o timer a menos de um por segundo e o processador abaixo de 1%; `normal` a trouxe de volta inteira, e `Page.captureScreenshot` funcionou em seguida. `Page.setWebLifecycleState` com `frozen` também parou a página, mas `active` a deixou `hidden` e lenta, e um `Page.bringToFront` depois disso travou o `Runtime.evaluate`: não serve. `Emulation.setCPUThrottlingRate` gastou mais, não menos. O user agent traz `HeadlessChrome`. Num popup esperando o depurador, `Page.enable` não responde até `Runtime.runIfWaitingForDebugger`; um mundo isolado com o mesmo nome (`Page.createIsolatedWorld`) volta o mesmo contexto até a página navegar, não enxerga os globais da página e lê `iframe` do mesmo site | feito | +| O que um site vê do Edge sem janela | 21.3 | **Visto com o Edge 154** (Windows 11), com uma página que mostra os sinais: sem nada, `navigator.webdriver` é `true`, o user agent traz `HeadlessChrome` e `outerWidth` e `outerHeight` são 0. `Emulation.setUserAgentOverride` só com `userAgent` deixa `navigator.userAgentData.brands` vazio; com `userAgentMetadata` (o que `getHighEntropyValues` devolve, com os mesmos nomes), as marcas voltam. `about:blank` não tem `navigator.userAgentData` (não é página segura); `chrome://version` tem. `--user-agent` na linha de comando mantém as marcas, mas esvazia `fullVersionList`, `architecture` e `platformVersion`. `--disable-blink-features=AutomationControlled` deixa `navigator.webdriver` `false` mesmo com `--remote-debugging-port`. `--window-size` deixa a janela com o tamanho pedido (`Browser.getWindowForTarget`), mas `outerWidth` continua 0. O login do Google recusa o navegador ("Esse navegador ou app pode não ser seguro"), também com o dono no controle | feito | | Imagem no resultado de uma tool MCP | 21.4 | Resultado com `{"type": "image", "data", "mimeType"}` em `content` (especificação MCP). **Testado com 2.1.284** (Haiku): pedido só pelas cores do logo no screenshot, o bot respondeu certo, e as cores não estavam no texto da página: o modelo recebe a imagem | feito | | Tools do navegador com o Claude Code real | 21 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): o bot carregou `browser_open`, `browser_click`, `browser_look` e `browser_screenshot` pelo `ToolSearch`; o pedido de example.com apareceu no chat e, permitido, ele abriu a página e clicou no link, que levou ao iana.org; o `browser_screenshot` lá pediu o iana.org, e o segundo screenshot não pediu de novo. O painel mostrou a página e o cursor ao vivo. Pausar o bot fechou o navegador (os processos do Edge sumiram). O log de `debug` só teve nomes de tools, métodos e ids | feito | | Rascunho de tela a partir do `Write` | 22.3 | **Visto com 2.1.284**: com `--include-partial-messages`, o `Write` chega em `stream_event` (`content_block_start` com `tool_use` e `name: "Write"`, depois `input_json_delta`; 379 pedaços para um arquivo de 3 KB). **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): pedida uma landing page, a área de design abriu sozinha e a página se montou versão a versão (umas 70 de 8 KB) antes do OK para gravar; permitido, a tela passou a vir do disco. Numa segunda página, negar o `Write` tirou a tela da área de design. O log de `debug` só teve status e ids. O painel do navegador do app de desktop do Claude bloqueia `iframe` para 127.0.0.1 (`ERR_BLOCKED_BY_CLIENT`), então a prévia foi vista num Edge sem janela | feito | @@ -1255,7 +1256,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é 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. +- 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 --disable-blink-features=AutomationControlled 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 `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. @@ -1267,7 +1268,7 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg ### 21.3 Página - **Abas:** o daemon se prende a toda página nova do navegador (`Target.setAutoAttach` no alvo do navegador, com `flatten` e `waitForDebuggerOnStart`) e a configura antes de ela rodar. Os comandos de preparo saem juntos com `Runtime.runIfWaitingForDebugger`, sem esperar resposta um a um: numa aba aberta pela página, `Page.enable` só responde depois que ela roda (visto com o Edge 154). O navegador só conta como aberto, e uma aba nova do dono só recebe um endereço, depois desse preparo: uma página aberta antes carregaria sem o daemon ver os eventos dela. Uma aba aberta pela página (`target=_blank`, `window.open`) vira a aba ativa, como para quem usa o navegador; quando ela fecha, a que era ativa antes dela volta (`Target.activateTarget`, para o navegador mostrar a mesma que o daemon). No máximo 6 abas: acima disso, fecha a que foi ativa há mais tempo. As tools agem sempre na ativa, e o bot não tem tool para trocar de aba; o dono, no controle, troca de aba, abre uma nova e vai a um endereço (21.10). O título de cada aba, para a lista que o dono vê (21.7), é o `document.title`, lido no mundo isolado (21.6) a cada carregamento e a cada mudança de endereço dentro da página: o Edge sem janela avisa quando a aba ganha um endereço, mas nunca o título dela (19). Até a página carregar, a aba fica sem título; um título que a página troca depois disso só aparece no carregamento seguinte. -- **Tela:** 1280 px de largura, escala 1 (`Emulation.setDeviceMetricsOverride`), e 800 px de altura enquanto ninguém assiste. Com o painel aberto, a altura acompanha o espaço que o painel tem para a página (`browser.resize`, 21.7): a mesma proporção desse espaço, entre 600 e 2000 px, para a tela ao vivo ocupar o painel em vez de deixar um vazio embaixo. A largura não muda, para os sites mostrarem sempre a versão de computador: um painel estreito encolhe a imagem, não a página. O tamanho vale para todas as abas, e volta a 1280 × 800 quando o último que assistia para. O user agent é o do Edge sem a palavra `Headless`, para os sites mostrarem a página comum; nada mais é disfarçado (`navigator.webdriver` continua `true`). +- **Tela:** 1280 px de largura, escala 1 (`Emulation.setDeviceMetricsOverride`), e 800 px de altura enquanto ninguém assiste. Com o painel aberto, a altura acompanha o espaço que o painel tem para a página (`browser.resize`, 21.7): a mesma proporção desse espaço, entre 600 e 2000 px, para a tela ao vivo ocupar o painel em vez de deixar um vazio embaixo. A largura não muda, para os sites mostrarem sempre a versão de computador: um painel estreito encolhe a imagem, não a página. O tamanho vale para todas as abas, e volta a 1280 × 800 quando o último que assistia para. O que a página vê do navegador é o de um Edge comum, porque alguns sites recusam o login num navegador que um programa controla (19): o user agent é o do Edge sem a palavra `Headless`, com as marcas e a plataforma de `navigator.userAgentData` (`userAgentMetadata`, lidas uma vez ao abrir, numa página `chrome://version` que fecha em seguida: `about:blank` não tem `userAgentData`), e `navigator.webdriver` é `false` (`--disable-blink-features=AutomationControlled`). O disfarce para aí: nada de script injetado na página, e `outerWidth` e `outerHeight` continuam 0. O login de quem olha mais que isso, como o do Google, continua recusado. - **Diálogos** (`alert`, `confirm`, `prompt`, "sair da página?"): aceitos na hora; a resposta seguinte da tool diz ao bot o texto que apareceu. - **Escolher arquivo:** o pedido da página é interceptado e cancelado, e o bot lê que enviar arquivos pelo navegador ainda não é possível. - **Downloads:** vão para `\downloads\` (`Browser.setDownloadBehavior`) e aparecem no painel de arquivos (8.4); a tool diz quando um download termina. From c05d7b4d214741168ca014cd9d94262f0abfed4b Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:10:31 -0300 Subject: [PATCH 2/3] feat(browser): sign in in a real window Google and other sites refuse to sign in to the bot's browser even with the owner in control, because it is still a headless Edge driven over DevTools. The new browser.window request closes it and opens the bot's profile in a common Edge window with no DevTools; the owner signs in there and closes it. The bot's tools wait meanwhile, an open request for help counts as done when the window closes, and the next tool reopens the headless browser on the same profile, logins included. The panel offers "Sign in in a window" next to "Take control". Spec 21.11 (new), 21.7, 21.3 and 19. --- app/src/features/browser/BrowserPanel.tsx | 12 +- app/src/features/browser/HandsBars.tsx | 38 ++-- app/src/features/browser/WindowBar.tsx | 44 ++++ app/src/features/browser/useHands.ts | 14 +- app/src/features/browser/window.test.tsx | 64 ++++++ app/src/i18n/en/browser.ts | 9 + app/src/i18n/es/browser.ts | 9 + app/src/i18n/pt-BR/browser.ts | 9 + app/src/lib/fakeBrowser.ts | 30 ++- app/src/lib/protocol.gen.ts | 11 +- app/src/store/app.ts | 4 +- crates/botloft-core/src/protocol/browser.rs | 5 + .../src/protocol/browser_input.rs | 4 +- .../src/protocol/export_browser.rs | 1 + crates/botloft-core/src/protocol/mod.rs | 1 + crates/botloftd/src/browser/hands.rs | 18 +- crates/botloftd/src/browser/launch.rs | 40 ++++ crates/botloftd/src/browser/mod.rs | 33 ++- crates/botloftd/src/browser/window.rs | 196 ++++++++++++++++++ crates/botloftd/src/rpc/mod.rs | 4 +- crates/botloftd/src/rpc/watching.rs | 63 ++++-- crates/botloftd/src/tools/browser.rs | 5 +- crates/botloftd/src/tools/browser_catalog.rs | 3 +- docs/spec.md | 23 +- 24 files changed, 581 insertions(+), 59 deletions(-) create mode 100644 app/src/features/browser/WindowBar.tsx create mode 100644 app/src/features/browser/window.test.tsx create mode 100644 crates/botloftd/src/browser/window.rs diff --git a/app/src/features/browser/BrowserPanel.tsx b/app/src/features/browser/BrowserPanel.tsx index 0d21a5c8..ef041089 100644 --- a/app/src/features/browser/BrowserPanel.tsx +++ b/app/src/features/browser/BrowserPanel.tsx @@ -1,7 +1,8 @@ // The bot's own browser beside its chat (spec 21.8): its tabs, the address // and the page it is on, live, with its cursor and a line about what it // just did. Watching starts when the panel opens and stops when it closes. -// The owner can take it into their own hands and give it back (spec 21.10). +// The owner can take it into their own hands and give it back (spec 21.10), +// or open it in a window of its own to sign in (spec 21.11). import { Globe, LoaderCircle, Maximize2, Minimize2, Moon, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -23,6 +24,7 @@ import { TabStrip } from "./TabStrip"; import { useBrowserView } from "./useBrowserView"; import { useHands } from "./useHands"; import { useFitPage, useRoom } from "./useRoom"; +import { WindowBar } from "./WindowBar"; /** Around the page in the panel, in px. */ const PAD = 12; @@ -51,6 +53,7 @@ export function BrowserPanel({ const hands = useHands(bot, state); const [focused, setFocused] = useState(false); const ask = state?.ask ?? null; + const windowed = state?.window === true; // The page takes the shape of the room the panel has for it (spec 21.3). // Notices that come and go are not counted: they never resize the page. const [inside, body] = useRoom(); @@ -118,6 +121,7 @@ export function BrowserPanel({ className="flex min-h-0 flex-1 flex-col overflow-y-auto" style={{ padding: PAD }} > + {windowed && } {status === "failed" ? ( {t.failedBody(SYSTEM)} @@ -131,7 +135,7 @@ export function BrowserPanel({ )} ) : status === "closed" && !frame ? ( - + !windowed && ) : ( <> {live && ask && !hands.held && } @@ -167,8 +171,8 @@ export function BrowserPanel({ {(status !== "open" || !frame) && ( )} diff --git a/app/src/features/browser/HandsBars.tsx b/app/src/features/browser/HandsBars.tsx index 0d1071b4..768342f2 100644 --- a/app/src/features/browser/HandsBars.tsx +++ b/app/src/features/browser/HandsBars.tsx @@ -1,6 +1,7 @@ // The owner's controls in the browser panel (spec 21.10): taking the // browser, the bot's request for a hand, and the bar that says the owner -// has it, with the button that gives it back. +// has it, with the button that gives it back. Each offers a window of its +// own too, for sites that refuse to sign in here (spec 21.11). import { Check, Hand, Keyboard, MousePointerClick } from "lucide-react"; import { useT } from "../../i18n"; @@ -8,6 +9,7 @@ import type { Bot } from "../../lib/protocol.gen"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; import type { Hands } from "./useHands"; +import { WindowButton } from "./WindowBar"; /** The bot asked for a hand and the owner has not taken the browser yet. */ export function AskCallout({ bot, task, hands }: { bot: Bot; task: string; hands: Hands }) { @@ -16,16 +18,18 @@ export function AskCallout({ bot, task, hands }: { bot: Bot; task: string; hands

{task}

- +
+ + +
); @@ -73,6 +77,7 @@ export function HeldBar({ > {t.hands.giveBack(bot.name)} + {focused ? t.hands.typing : t.hands.clickToType} @@ -84,9 +89,9 @@ export function HeldBar({ /** Under the live screen: the owner may take the browser at any time. */ export function TakeBar({ bot, hands }: { bot: Bot; hands: Hands }) { - const t = useT().browser.hands; + const t = useT().browser; return ( -
+
-

{t.takeWhy(bot.name)}

+ +

+ {t.hands.takeWhy(bot.name)} {t.window.why(bot.name)} +

); } diff --git a/app/src/features/browser/WindowBar.tsx b/app/src/features/browser/WindowBar.tsx new file mode 100644 index 00000000..9ba726f0 --- /dev/null +++ b/app/src/features/browser/WindowBar.tsx @@ -0,0 +1,44 @@ +// The bot's browser in a window of its own (spec 21.11): the button that +// opens it, for sites that refuse to sign in to a browser a program drives, +// and the bar that says it is open while the bot waits. + +import { AppWindow } from "lucide-react"; +import { useT } from "../../i18n"; +import type { Bot } from "../../lib/protocol.gen"; +import { Button } from "../../ui/Button"; +import type { Hands } from "./useHands"; + +/** Opens the bot's browser in a window of its own. */ +export function WindowButton({ hands }: { hands: Hands }) { + const t = useT().browser.window; + return ( + + ); +} + +/** The browser is open in a window of its own. */ +export function WindowBar({ bot }: { bot: Bot }) { + const t = useT().browser.window; + return ( +
+
+ +
+

{t.title}

+

{t.body(bot.name)}

+
+
+
+ ); +} diff --git a/app/src/features/browser/useHands.ts b/app/src/features/browser/useHands.ts index 28150f12..295c52ed 100644 --- a/app/src/features/browser/useHands.ts +++ b/app/src/features/browser/useHands.ts @@ -1,5 +1,6 @@ // The owner's hands in a bot's browser (spec 21.10): taking it, giving it -// back, sending what they do on the page, and moving between its tabs. +// back, sending what they do on the page, moving between its tabs, and +// opening it in a window of its own (spec 21.11). // Events go out in order and without waiting for each other: the daemon // queues them for the page. @@ -15,6 +16,8 @@ export interface Hands { busy: boolean; take(): Promise; release(): Promise; + /** Opens the browser in a window of its own, to sign in. */ + window(): Promise; send(input: BrowserInput): void; /** Opens a blank tab; `true` once the daemon took the request. */ newTab(): Promise; @@ -46,6 +49,14 @@ export function useHands(bot: Pick, state: BrowserState | null): Hand setBusy(false); }, [api, botId, putBrowser, t.hands.giveBackFailed]); + const openWindow = useCallback(async () => { + setBusy(true); + await attempt(t.window.openFailed, async () => { + putBrowser(await api.call("browser.window", { botId })); + }); + setBusy(false); + }, [api, botId, putBrowser, t.window.openFailed]); + const send = useCallback( (input: BrowserInput) => { // One lost event is not worth a message; the page shows what arrived. @@ -73,6 +84,7 @@ export function useHands(bot: Pick, state: BrowserState | null): Hand busy, take, release, + window: openWindow, send, newTab, switchTab, diff --git a/app/src/features/browser/window.test.tsx b/app/src/features/browser/window.test.tsx new file mode 100644 index 00000000..c63955e6 --- /dev/null +++ b/app/src/features/browser/window.test.tsx @@ -0,0 +1,64 @@ +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp } from "../../test/app"; + +afterEach(cleanup); + +/** A crew "Ops" with @scout, its browser open on a sign-in page, watched. */ +async function openScout() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "busy"); + fake.browser.open(scout.id, "https://accounts.google.com/", "Sign in"); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + fireEvent.click(screen.getByRole("button", { name: /^Show browser/ })); + await waitFor(() => expect(fake.browser.watching).toBe(scout.id)); + act(() => { + fake.browser.frame(scout.id, "AAAA"); + }); + return { fake, scout }; +} + +const panel = () => screen.getByRole("complementary", { name: "Scout's browser" }); + +describe("the browser in a window of its own", () => { + test("opens for signing in, says the bot waits, and goes back when the window closes", async () => { + const { fake, scout } = await openScout(); + expect(within(panel()).getByText(/like Google/)).toBeDefined(); + fireEvent.click(within(panel()).getByRole("button", { name: "Sign in in a window" })); + + expect(await within(panel()).findAllByText("Open in a window")).not.toHaveLength(0); + expect(within(panel()).getByText(/Scout waits meanwhile and keeps the login/)).toBeDefined(); + expect( + fake.calls.filter((call) => call.method === "browser.window").map((call) => call.params), + ).toEqual([{ botId: scout.id }]); + expect(within(panel()).queryByRole("button", { name: "Take control" })).toBeNull(); + + act(() => { + fake.browser.closeWindow(scout.id); + }); + await waitFor(() => expect(within(panel()).queryByText(/keeps the login/)).toBeNull()); + }); + + test("is offered when the bot asks for a hand", async () => { + const { fake, scout } = await openScout(); + act(() => { + fake.browser.ask(scout.id, "Sign in to your Google account"); + }); + const asking = await within(panel()).findByText("Sign in to your Google account"); + expect(asking).toBeDefined(); + fireEvent.click(within(panel()).getByRole("button", { name: "Sign in in a window" })); + expect(await within(panel()).findByText(/keeps the login/)).toBeDefined(); + + // Closing the window is the owner being done. + act(() => { + fake.browser.closeWindow(scout.id); + }); + expect(await screen.findByText("You did it: Sign in to your Google account")).toBeDefined(); + }); +}); diff --git a/app/src/i18n/en/browser.ts b/app/src/i18n/en/browser.ts index d28e93f0..33688120 100644 --- a/app/src/i18n/en/browser.ts +++ b/app/src/i18n/en/browser.ts @@ -79,6 +79,15 @@ export const browser = { typing: "What you type goes to the page", screen: (bot: string) => `${bot}'s browser, in your hands`, }, + window: { + open: "Sign in in a window", + why: (bot: string) => + `For sites that refuse to sign in here, like Google: ${bot}'s browser opens in a window of its own, and the login stays for ${bot}.`, + openFailed: "Could not open the window", + title: "Open in a window", + body: (bot: string) => + `Sign in in the window that opened, then close it. ${bot} waits meanwhile and keeps the login.`, + }, help: { needs: (bot: string) => `${bot} needs you in the browser`, asks: (bot: string, task: string) => `${bot} asks: ${task}`, diff --git a/app/src/i18n/es/browser.ts b/app/src/i18n/es/browser.ts index 9467b04b..cca1a261 100644 --- a/app/src/i18n/es/browser.ts +++ b/app/src/i18n/es/browser.ts @@ -79,6 +79,15 @@ export const browser: Messages["browser"] = { typing: "Lo que escribes va a la página", screen: (bot) => `Navegador de ${bot}, en tus manos`, }, + window: { + open: "Iniciar sesión en una ventana", + why: (bot) => + `Para sitios que rechazan el inicio de sesión aquí, como Google: el navegador de ${bot} se abre en una ventana propia y la sesión queda para ${bot}.`, + openFailed: "No se pudo abrir la ventana", + title: "Abierto en una ventana", + body: (bot) => + `Inicia sesión en la ventana que se abrió y luego ciérrala. ${bot} espera mientras tanto y conserva la sesión.`, + }, help: { needs: (bot) => `${bot} te necesita en el navegador`, asks: (bot, task) => `${bot} pide: ${task}`, diff --git a/app/src/i18n/pt-BR/browser.ts b/app/src/i18n/pt-BR/browser.ts index 1c8a23cd..fe7ef25c 100644 --- a/app/src/i18n/pt-BR/browser.ts +++ b/app/src/i18n/pt-BR/browser.ts @@ -81,6 +81,15 @@ export const browser: Messages["browser"] = { typing: "O que você digita vai para a página", screen: (bot) => `Navegador de ${bot}, nas suas mãos`, }, + window: { + open: "Entrar numa janela", + why: (bot) => + `Para sites que recusam o login aqui, como o Google: o navegador de ${bot} abre numa janela própria, e o login fica com ${bot}.`, + openFailed: "Não foi possível abrir a janela", + title: "Aberto numa janela", + body: (bot) => + `Entre na conta na janela que abriu e depois feche-a. ${bot} espera enquanto isso e fica com o login.`, + }, help: { needs: (bot) => `${bot} precisa de você no navegador`, asks: (bot, task) => `${bot} pede: ${task}`, diff --git a/app/src/lib/fakeBrowser.ts b/app/src/lib/fakeBrowser.ts index 6e23e376..6b525daa 100644 --- a/app/src/lib/fakeBrowser.ts +++ b/app/src/lib/fakeBrowser.ts @@ -1,7 +1,7 @@ // The fake daemon's browsers (spec 21.7): each bot's state and tabs, the // live frames of the one being watched and what the bot does, for the // browser panel, the size the panel gives the page (spec 21.3), and the -// owner's hands in it (spec 21.10). +// owner's hands in it (spec 21.10) or in a window of its own (spec 21.11). import type { FakeBotloft, Handlers } from "./fake"; import { fitting, PAGE, type PageSize, typed } from "./fakePages"; @@ -52,6 +52,7 @@ export class FakeBrowser { control: "bot", resting: false, ask: null, + window: false, updatedAt: this.fake.now, } ); @@ -125,6 +126,15 @@ export class FakeBrowser { this.set(botId, { ask: null, control: "bot" }); } + /** The owner closed the window of the bot's browser: it is the bot's again. */ + closeWindow(botId: BotId): void { + const approvalId = this.asks.get(botId); + if (approvalId) { + void this.fake.call("approvals.answer", { approvalId, allow: true }); + } + this.set(botId, { window: false, control: "bot" }); + } + /** The size of the bot's page now. */ size(botId: BotId): PageSize { return this.sizes.get(botId) ?? PAGE; @@ -174,7 +184,8 @@ export class FakeBrowser { /** The watched browser leaves the owner's hands, if it was in them. */ private release(): BrowserState | null { const botId = this.watching; - if (botId === null || this.state(botId).control !== "owner") { + const state = botId === null ? null : this.state(botId); + if (botId === null || state?.control !== "owner" || state.window) { return null; } return this.set(botId, { control: "bot" }); @@ -210,9 +221,11 @@ export class FakeBrowser { | "browser.newTab" | "browser.switchTab" | "browser.open" + | "browser.window" > { return { - "browser.list": () => [...this.states.values()].filter((state) => state.status !== "closed"), + "browser.list": () => + [...this.states.values()].filter((state) => state.status !== "closed" || state.window), "browser.watch": ({ botId }) => { this.fake.bot(botId, false); this.unwatch(); @@ -296,6 +309,17 @@ export class FakeBrowser { this.repaint(botId); return null; }, + "browser.window": ({ botId }) => { + if (this.watching !== botId) { + throw conflict("watch this browser before opening it in a window"); + } + if (this.state(botId).window) { + throw conflict("the browser is already open in a window"); + } + this.frames.delete(botId); + const away = { status: "closed", control: "owner", resting: false, window: true } as const; + return this.setTabs(botId, [], away); + }, }; } } diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 0efc3d80..ed9f4261 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1212,6 +1212,12 @@ resting: boolean, * What the bot asked the owner to do in it, while it waits. */ ask: string | null, +/** + * The owner has it open in a window of its own, to sign in where a + * site refuses a browser a program drives (spec 21.11). It is + * `closed` meanwhile, and the bot's tools wait. + */ +window: boolean, /** * Unix time in milliseconds. */ @@ -1282,8 +1288,8 @@ buttons: number, clicks: number, modifiers: number, } | { "kind": "wheel", x: number, y: number, dx: number, dy: number, modifiers: number, } | { "kind": "key", key: string, code: string, modifiers: number, } | { "kind": "text", text: string, }; /** - * `browser.take`, `browser.release`, `browser.reload` and - * `browser.newTab`. + * `browser.take`, `browser.release`, `browser.reload`, `browser.newTab` + * and `browser.window`. */ export type BrowserControlParams = { botId: BotId, }; @@ -1417,6 +1423,7 @@ export interface RpcMethods { "browser.resize": { params: BrowserResizeParams; result: null }; "browser.take": { params: BrowserControlParams; result: BrowserState }; "browser.release": { params: BrowserControlParams; result: BrowserState }; + "browser.window": { params: BrowserControlParams; result: BrowserState }; "browser.input": { params: BrowserInputParams; result: null }; "browser.reload": { params: BrowserControlParams; result: null }; "browser.newTab": { params: BrowserControlParams; result: null }; diff --git a/app/src/store/app.ts b/app/src/store/app.ts index 488ff366..d9611f26 100644 --- a/app/src/store/app.ts +++ b/app/src/store/app.ts @@ -342,7 +342,9 @@ export function applyEvent(state: AppState, event: ServerEvent): Partial { const { [browser.botId]: _, ...rest } = state.browsers; return { - browsers: browser.status === "closed" ? rest : { ...rest, [browser.botId]: browser }, + // Closed is no browser, unless it is open in a window of its own. + browsers: + browser.status === "closed" && !browser.window ? rest : { ...rest, [browser.botId]: browser }, }; } diff --git a/crates/botloft-core/src/protocol/browser.rs b/crates/botloft-core/src/protocol/browser.rs index 90be0617..48a6aff3 100644 --- a/crates/botloft-core/src/protocol/browser.rs +++ b/crates/botloft-core/src/protocol/browser.rs @@ -55,6 +55,10 @@ pub struct BrowserState { pub resting: bool, /// What the bot asked the owner to do in it, while it waits. pub ask: Option, + /// The owner has it open in a window of its own, to sign in where a + /// site refuses a browser a program drives (spec 21.11). It is + /// `closed` meanwhile, and the bot's tools wait. + pub window: bool, /// Unix time in milliseconds. pub updated_at: i64, } @@ -72,6 +76,7 @@ impl BrowserState { control: BrowserControl::Bot, resting: false, ask: None, + window: false, updated_at: now, } } diff --git a/crates/botloft-core/src/protocol/browser_input.rs b/crates/botloft-core/src/protocol/browser_input.rs index 0677aaf2..72a2a2a2 100644 --- a/crates/botloft-core/src/protocol/browser_input.rs +++ b/crates/botloft-core/src/protocol/browser_input.rs @@ -91,8 +91,8 @@ impl std::fmt::Debug for BrowserInput { } } -/// `browser.take`, `browser.release`, `browser.reload` and -/// `browser.newTab`. +/// `browser.take`, `browser.release`, `browser.reload`, `browser.newTab` +/// and `browser.window`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(test, derive(ts_rs::TS))] diff --git a/crates/botloft-core/src/protocol/export_browser.rs b/crates/botloft-core/src/protocol/export_browser.rs index e0dfcb00..d6c4fa92 100644 --- a/crates/botloft-core/src/protocol/export_browser.rs +++ b/crates/botloft-core/src/protocol/export_browser.rs @@ -50,6 +50,7 @@ pub(super) fn methods(out: &mut Out) { let state = out.name::(); out.method(method::BROWSER_TAKE, &control, &state); out.method(method::BROWSER_RELEASE, &control, &state); + out.method(method::BROWSER_WINDOW, &control, &state); out.method( method::BROWSER_INPUT, &out.name::(), diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index f1704b97..f07a28bc 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -143,6 +143,7 @@ pub mod method { pub const BROWSER_NEW_TAB: &str = "browser.newTab"; pub const BROWSER_SWITCH_TAB: &str = "browser.switchTab"; pub const BROWSER_OPEN: &str = "browser.open"; + pub const BROWSER_WINDOW: &str = "browser.window"; pub const SCREENS_LIST: &str = "screens.list"; pub const QUESTIONS_LIST: &str = "questions.list"; pub const QUESTIONS_ANSWER: &str = "questions.answer"; diff --git a/crates/botloftd/src/browser/hands.rs b/crates/botloftd/src/browser/hands.rs index e32c5b77..ea64e2c7 100644 --- a/crates/botloftd/src/browser/hands.rs +++ b/crates/botloftd/src/browser/hands.rs @@ -154,14 +154,22 @@ impl Asking<'_> { impl Drop for Asking<'_> { /// Answered or not, the browser goes back to the bot. + /// A window of its own stays the owner's until they close it. fn drop(&mut self) { - if let Some(slot) = lock(&self.browsers.slots).get_mut(&self.bot) { - slot.asking = None; - slot.held.send_replace(None); - } + let window = lock(&self.browsers.slots) + .get_mut(&self.bot) + .is_some_and(|slot| { + slot.asking = None; + if slot.window.is_none() { + slot.held.send_replace(None); + } + slot.window.is_some() + }); self.browsers.set_state(&self.bot, |state| { state.ask = None; - state.control = BrowserControl::Bot; + if !window { + state.control = BrowserControl::Bot; + } }); } } diff --git a/crates/botloftd/src/browser/launch.rs b/crates/botloftd/src/browser/launch.rs index 12333265..3ca09da4 100644 --- a/crates/botloftd/src/browser/launch.rs +++ b/crates/botloftd/src/browser/launch.rs @@ -118,6 +118,46 @@ pub async fn launch( } } +/// Starts `program` with a window of its own and no DevTools on the +/// profile in `profile`, at `url` (spec 21.11). The process is the +/// browser: it ends when the owner closes the window. +pub fn window( + program: &Path, + profile: &Path, + url: Option<&str>, +) -> Result<(Child, ProcessJob), BrowserError> { + std::fs::create_dir_all(profile)?; + let env = platform::user_environment()?; + let mut command = Command::new(program); + command + .args([ + "--no-first-run", + "--no-default-browser-check", + "--disable-extensions", + "--disable-sync", + // The browser without a window was killed, not closed. + "--hide-crash-restore-bubble", + ]) + .arg(format!("--user-data-dir={}", profile.display())) + .arg(url.unwrap_or("about:blank")) + .env_clear() + .envs(env.iter().map(|(name, value)| (name, value))) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + ProcessJob::prepare(&mut command); + let job = ProcessJob::new()?; + let mut child = command.spawn()?; + if let Err(err) = job.assign(&child) { + let _ = child.kill(); + let _ = child.wait(); + return Err(err.into()); + } + job.track(&command); + debug!(pid = child.id(), "browser: started in a window"); + Ok((child, job)) +} + /// 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. diff --git a/crates/botloftd/src/browser/mod.rs b/crates/botloftd/src/browser/mod.rs index 0e4903bd..18ff7868 100644 --- a/crates/botloftd/src/browser/mod.rs +++ b/crates/botloftd/src/browser/mod.rs @@ -23,6 +23,7 @@ mod sweep; mod titles; mod viewport; mod watch; +mod window; use std::collections::HashMap; use std::io; @@ -32,7 +33,7 @@ use std::time::{Duration, Instant}; use botloft_core::ids::{ApprovalId, BotId}; use botloft_core::protocol::{ - BrowserAction, BrowserFrame, BrowserState, BrowserStatus, BrowserView, + BrowserAction, BrowserControl, BrowserFrame, BrowserState, BrowserStatus, BrowserView, }; use tokio::sync::broadcast; use tracing::{debug, warn}; @@ -47,6 +48,7 @@ pub use self::session::Session; pub use self::sweep::{Want, run}; pub use self::viewport::Viewport; pub use self::watch::Watching; +pub use self::window::WindowError; use crate::clock::Clock; use crate::config::Config; use crate::state::Event; @@ -114,6 +116,8 @@ struct Slot { held: Held, /// The bot's open request for the owner's help. asking: Option, + /// The owner has the profile open in a window of its own. + window: Option, } type Slots = Arc>>; @@ -164,6 +168,7 @@ impl Browsers { used: Instant::now(), held: tokio::sync::watch::channel(None).0, asking: None, + window: None, }) } @@ -204,10 +209,13 @@ impl Browsers { } /// Closes the bot's browser; the profile stays. The owner's hands let - /// go of it too. + /// go of it too, but not a window of its own they have open: the bot + /// keeps waiting for that one (spec 21.11). pub fn close(&self, bot: &BotId) { let session = lock(&self.slots).get_mut(bot).and_then(|slot| { - slot.held.send_replace(None); + if slot.window.is_none() { + slot.held.send_replace(None); + } slot.session.take() }); if let Some((_, session)) = session { @@ -215,12 +223,24 @@ impl Browsers { session.close(); } self.set_state(bot, |state| { + let window = state.window; *state = BrowserState::closed(state.bot_id.clone(), state.updated_at); + if window { + state.window = true; + state.control = BrowserControl::Owner; + } }); } - /// Closes the browser and deletes its profile: the bot was archived. + /// Closes the browser, a window of it too, and deletes its profile: + /// the bot was archived. pub fn forget(&self, bot: &BotId) { + if let Some(window) = lock(&self.slots) + .get_mut(bot) + .and_then(|slot| slot.window.take()) + { + window.kill(); + } self.close(bot); let profile = self.profiles.join(bot.as_str()); let Ok(runtime) = tokio::runtime::Handle::try_current() else { @@ -240,12 +260,13 @@ impl Browsers { }); } - /// The browsers that are not closed (`browser.list`). + /// The browsers that are not closed, or open in a window of their own + /// (`browser.list`). pub fn list(&self) -> Vec { lock(&self.slots) .values() .map(|slot| slot.state.clone()) - .filter(|state| state.status != BrowserStatus::Closed) + .filter(|state| state.status != BrowserStatus::Closed || state.window) .collect() } diff --git a/crates/botloftd/src/browser/window.rs b/crates/botloftd/src/browser/window.rs new file mode 100644 index 00000000..f096f06c --- /dev/null +++ b/crates/botloftd/src/browser/window.rs @@ -0,0 +1,196 @@ +//! The bot's browser in a window of its own (spec 21.11): to sign in where +//! a site refuses a browser a program drives, the owner opens the bot's +//! profile in a common browser window, with no DevTools. The bot's tools +//! wait until the owner closes it; the logins stay in the profile. + +use std::process::Child; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use botloft_core::ids::BotId; +use botloft_core::protocol::{BrowserControl, BrowserState}; +use tokio::sync::{broadcast, oneshot}; +use tracing::{debug, warn}; + +use super::{BrowserError, Browsers, Slots, launch, lock, program, sites, update}; +use crate::clock::Clock; +use crate::platform::ProcessJob; +use crate::state::Event; + +/// The window the owner has open, and their hold on the browser meanwhile. +pub(super) struct Window { + pub(super) hold: u64, + job: ProcessJob, +} + +impl Window { + pub(super) fn kill(&self) { + let _ = self.job.terminate(); + } +} + +#[derive(Debug, thiserror::Error)] +pub enum WindowError { + #[error("the browser is already open in a window")] + Open, + #[error(transparent)] + Browser(#[from] BrowserError), +} + +impl Browsers { + /// Opens the bot's browser in a window of its own, on the page the bot + /// was on. The browser without a window closes first: one profile, one + /// browser. The receiver hears when the owner closes the window. + pub fn open_window(&self, bot: &BotId) -> Result, WindowError> { + let program = program::find(&self.settings.path).ok_or(BrowserError::NotFound)?; + let hold = self.holds.fetch_add(1, Ordering::Relaxed); + let (session, url) = { + let mut slots = lock(&self.slots); + let slot = self.slot(&mut slots, bot); + if slot.window.is_some() { + return Err(WindowError::Open); + } + // The bot's tools wait from now on, as for the owner's hands. + slot.held.send_replace(Some(hold)); + let url = slot + .state + .url + .clone() + .filter(|url| sites::site_of(url).is_some()); + (slot.session.take(), url) + }; + if let Some((_, session)) = session { + // Gone before the window starts: Edge hands a profile in use to + // the browser that has it, and the new one ends at once. + session.close(); + } + let profile = self.profiles.join(bot.as_str()); + let started = launch::window(&program, &profile, url.as_deref()); + let (child, job) = match started { + Ok(started) => started, + Err(err) => { + if let Some(slot) = lock(&self.slots).get_mut(bot) + && *slot.held.borrow() == Some(hold) + { + slot.held.send_replace(None); + } + self.set_state(bot, |state| closed(state, false)); + return Err(err.into()); + } + }; + if let Some(slot) = lock(&self.slots).get_mut(bot) { + slot.window = Some(Window { hold, job }); + } + self.set_state(bot, |state| closed(state, true)); + debug!(bot = %bot, "browser: open in a window"); + Ok(wait( + child, + Arc::clone(&self.slots), + self.events.clone(), + Arc::clone(&self.clock), + bot.clone(), + hold, + )) + } +} + +/// No browser the daemon drives; the bot's request for help stays. +fn closed(state: &mut BrowserState, window: bool) { + let ask = state.ask.take(); + *state = BrowserState::closed(state.bot_id.clone(), state.updated_at); + state.ask = ask; + state.window = window; + if window { + state.control = BrowserControl::Owner; + } +} + +/// Waits for the window to close, then gives the browser back to the bot. +fn wait( + mut child: Child, + slots: Slots, + events: broadcast::Sender, + clock: Arc, + bot: BotId, + hold: u64, +) -> oneshot::Receiver<()> { + let (closed, heard) = oneshot::channel(); + let spawned = std::thread::Builder::new() + .name("browser-window".into()) + .spawn(move || { + if let Err(err) = child.wait() { + warn!("browser: waiting for the window: {err}"); + } + let mine = lock(&slots).get_mut(&bot).is_some_and(|slot| { + let mine = slot.window.as_ref().is_some_and(|open| open.hold == hold); + if mine { + slot.window = None; + if *slot.held.borrow() == Some(hold) { + slot.held.send_replace(None); + } + } + mine + }); + if mine { + update(&slots, &events, clock.as_ref(), &bot, |state| { + state.window = false; + state.control = BrowserControl::Bot; + }); + debug!(bot = %bot, "browser: the window closed"); + } + let _ = closed.send(()); + }); + if spawned.is_err() { + warn!("browser: cannot wait for the window"); + } + heard +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::browser::BrowserSettings; + use crate::clock::SystemClock; + + /// A program that ends at once stands for a window the owner closed: + /// this test binary, which refuses the browser's flags. + fn browsers(home: &std::path::Path) -> Browsers { + let program = std::env::current_exe().expect("test binary"); + let settings = BrowserSettings { + path: program.display().to_string(), + ..BrowserSettings::default() + }; + let (events, _) = broadcast::channel(16); + Browsers::new(settings, home, events, Arc::new(SystemClock)) + } + + #[tokio::test] + async fn the_bot_waits_for_the_window_and_gets_the_browser_back() { + let home = tempfile::tempdir().expect("tempdir"); + let browsers = browsers(home.path()); + let bot = BotId::generate(); + let closed = browsers.open_window(&bot).expect("window"); + closed.await.expect("closed"); + let state = browsers.view(&bot).state; + assert!(!state.window); + assert_eq!(state.control, BrowserControl::Bot); + assert!(browsers.wait_for_owner(&bot, Duration::from_secs(1)).await); + // The window is gone: another one may open. + let again = browsers.open_window(&bot).expect("again"); + again.await.expect("closed again"); + } + + #[test] + fn closing_keeps_what_the_bot_asked() { + let mut state = BrowserState::closed(BotId::generate(), 0); + state.ask = Some("Sign in".to_owned()); + state.url = Some("https://example.com/".to_owned()); + closed(&mut state, true); + assert!(state.window); + assert_eq!(state.control, BrowserControl::Owner); + assert_eq!(state.ask.as_deref(), Some("Sign in")); + assert_eq!(state.url, None); + } +} diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index a7980fb1..97e67fe1 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -195,7 +195,7 @@ enum Routed { } fn route( - daemon: &Daemon, + daemon: &Arc, text: &str, watch: &mut Watch, frames: &watch::Sender>, @@ -224,6 +224,8 @@ fn route( | method::BROWSER_NEW_TAB | method::BROWSER_SWITCH_TAB | method::BROWSER_OPEN => watch.request(daemon, &request.method, request.params), + // A window of its own outlives the request (spec 21.11). + method::BROWSER_WINDOW => watch.window(daemon, request.params), name if ASIDE.contains(&name) => return Routed::Aside(request), _ => return Routed::InOrder(request), }; diff --git a/crates/botloftd/src/rpc/watching.rs b/crates/botloftd/src/rpc/watching.rs index b6da53ad..0e2f9c5c 100644 --- a/crates/botloftd/src/rpc/watching.rs +++ b/crates/botloftd/src/rpc/watching.rs @@ -2,7 +2,9 @@ //! and `browser.unwatch`, the frames that follow, and the size its panel //! gives the page. The same connection reloads the page, takes the browser //! into the owner's hands, moves between its tabs and gives it back (spec -//! 21.10). +//! 21.10), or opens it in a window of its own (spec 21.11). + +use std::sync::Arc; use botloft_core::ids::BotId; use botloft_core::protocol::{ @@ -111,21 +113,43 @@ impl Watch { let bot = ¶ms.bot_id; if self.hands.as_ref().is_some_and(|hands| hands.bot == *bot) { self.hands = None; - if let Some(approval_id) = daemon.browsers.take_ask(bot) { - let done = ApprovalsAnswerParams { - approval_id, - allow: true, - note: None, - input: None, - always: None, - }; - // It may have been answered in the chat a moment before. - let _ = approvals::answer(daemon, done); - } + helped(daemon, bot); } to_value(&daemon.browsers.view(bot).state) } + /// Opens the browser in a window of its own; the owner's hands give + /// way to it. Closing the window is giving the browser back. + pub(super) fn window( + &mut self, + daemon: &Arc, + params: Option, + ) -> Result { + let params: BrowserControlParams = parse(params)?; + let bot = params.bot_id; + if !self.watches(&bot) { + return Err(ApiError::Conflict( + "watch this browser before opening it in a window".to_owned(), + ) + .into()); + } + if self.hands.as_ref().is_some_and(|hands| hands.bot == bot) { + self.hands = None; + } + let closed = daemon + .browsers + .open_window(&bot) + .map_err(|err| ApiError::Conflict(err.to_string()))?; + let state = daemon.browsers.view(&bot).state; + let daemon = Arc::clone(daemon); + tokio::spawn(async move { + if closed.await.is_ok() { + helped(&daemon, &bot); + } + }); + to_value(&state) + } + /// The owner's hold on the bot's browser, if this connection has it. fn hands(&self, bot: &BotId) -> Result<&Hands, RpcError> { self.hands @@ -179,6 +203,21 @@ impl Watch { } } +/// The owner gave the browser back: an open request for help is done. +fn helped(daemon: &Daemon, bot: &BotId) { + if let Some(approval_id) = daemon.browsers.take_ask(bot) { + let done = ApprovalsAnswerParams { + approval_id, + allow: true, + note: None, + input: None, + always: None, + }; + // It may have been answered in the chat a moment before. + let _ = approvals::answer(daemon, done); + } +} + /// What the owner did is on its way to the browser, or why it is not. fn made(queued: Result<(), InputError>) -> Result { queued.map_err(|err| match err { diff --git a/crates/botloftd/src/tools/browser.rs b/crates/botloftd/src/tools/browser.rs index db56d8a7..ff7d01c4 100644 --- a/crates/botloftd/src/tools/browser.rs +++ b/crates/botloftd/src/tools/browser.rs @@ -87,8 +87,9 @@ async fn run( // What they did last reaches the browser before the bot reads it. daemon.browsers.wait_for_owner(id, OWNER_WAIT).await; let session = call.running().ok_or( - "The owner is done, but your browser closed meanwhile. Open the page again with \ - browser_open.", + "The owner is done, but your browser closed meanwhile: they may have used it in a \ + window of its own. Open the page again with browser_open; the logins they made \ + stay.", )?; let done = "The owner is done and gave your browser back. This is the page now."; let mut notes = vec![done.to_owned()]; diff --git a/crates/botloftd/src/tools/browser_catalog.rs b/crates/botloftd/src/tools/browser_catalog.rs index d84fbf27..3c5edd9b 100644 --- a/crates/botloftd/src/tools/browser_catalog.rs +++ b/crates/botloftd/src/tools/browser_catalog.rs @@ -157,7 +157,8 @@ pub(super) fn tools() -> Vec { until they are done: signing in to their account, solving a captcha, typing a \ code sent to their phone, anything only they should or can do. Open the page \ first; they take over your browser, and it comes back to you with the page as \ - they left it. \ + they left it. To sign in where a site refuses your browser, they may open it \ + in a window of its own; then it comes back closed, with their logins kept. \ Never ask for passwords, codes or card numbers in the chat: ask with this tool, \ so the owner types them where you never see them.", "inputSchema": { diff --git a/docs/spec.md b/docs/spec.md index cd45d088..055d9cf6 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1060,6 +1060,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Sintaxe de caminho Windows em permission rules | 7.5 | Confirmado (`permissions`) e **testado com 2.1.283** (interativo) e **2.1.284** (`-p --setting-sources project,local`): `Read(//c/.../**)` em `deny` bloqueou a leitura com "File is in a directory that is denied by your permission settings", sem perguntar, e o `result` listou a negação em `permission_denials` | feito (M4.1) | | Edge sem janela controlado por CDP | 21.2, 21.3 | **Visto com o Edge 154** (Windows 11): `--headless=new --remote-debugging-port=0` escreve `DevToolsActivePort` em ~400 ms; `Target.setAutoAttach` no alvo do navegador prende a aba que já existe e cada popup (`openerId`, esperando o depurador); `Emulation.setDeviceMetricsOverride` deixa a tela em 1280 × 800, e chamado de novo com a página aberta muda o `innerHeight` dela na hora; `Page.startScreencast` manda quadros JPEG com o tamanho da tela, também depois dessa mudança (com `maxHeight` no limite de 21.3), mas o quadro do tamanho novo se perde quando há quadros anteriores ainda sem `screencastFrameAck`, e uma página parada não manda outro: o daemon espera esses serem confirmados e recomeça o screencast (`stopScreencast`, `startScreencast`), que então manda um quadro; clique por `Input.dispatchMouseEvent` segue links; `alert` chega em `Page.javascriptDialogOpening`. `Target.targetInfoChanged` chega com o endereço novo da aba, mas com o endereço no lugar do título, e não vem de novo quando a página ganha o título. Ler o título com `Target.getTargetInfo` depois do `Page.loadEventFired` falhou de vez em quando nos testes (ficava o endereço); o `document.title` lido na página, num mundo isolado, não falhou. `Target.createTarget` com `about:blank` abre uma aba que chega por `attachedToTarget` como as outras; `Target.activateTarget` numa aba que ficou atrás a traz de volta, e o screencast dela volta a mandar quadros. Um Edge parado numa página que se mexe (um timer ocupado e uma animação) gastou 84% de um núcleo sem ninguém assistindo e 126% com o screencast, em ~450 MB e 7 a 10 processos. `Browser.setWindowBounds` com `windowState: "minimized"` na janela do `Browser.getWindowForTarget` deixou a página `hidden`, com o timer a menos de um por segundo e o processador abaixo de 1%; `normal` a trouxe de volta inteira, e `Page.captureScreenshot` funcionou em seguida. `Page.setWebLifecycleState` com `frozen` também parou a página, mas `active` a deixou `hidden` e lenta, e um `Page.bringToFront` depois disso travou o `Runtime.evaluate`: não serve. `Emulation.setCPUThrottlingRate` gastou mais, não menos. O user agent traz `HeadlessChrome`. Num popup esperando o depurador, `Page.enable` não responde até `Runtime.runIfWaitingForDebugger`; um mundo isolado com o mesmo nome (`Page.createIsolatedWorld`) volta o mesmo contexto até a página navegar, não enxerga os globais da página e lê `iframe` do mesmo site | feito | | O que um site vê do Edge sem janela | 21.3 | **Visto com o Edge 154** (Windows 11), com uma página que mostra os sinais: sem nada, `navigator.webdriver` é `true`, o user agent traz `HeadlessChrome` e `outerWidth` e `outerHeight` são 0. `Emulation.setUserAgentOverride` só com `userAgent` deixa `navigator.userAgentData.brands` vazio; com `userAgentMetadata` (o que `getHighEntropyValues` devolve, com os mesmos nomes), as marcas voltam. `about:blank` não tem `navigator.userAgentData` (não é página segura); `chrome://version` tem. `--user-agent` na linha de comando mantém as marcas, mas esvazia `fullVersionList`, `architecture` e `platformVersion`. `--disable-blink-features=AutomationControlled` deixa `navigator.webdriver` `false` mesmo com `--remote-debugging-port`. `--window-size` deixa a janela com o tamanho pedido (`Browser.getWindowForTarget`), mas `outerWidth` continua 0. O login do Google recusa o navegador ("Esse navegador ou app pode não ser seguro"), também com o dono no controle | feito | +| Login numa janela de verdade | 21.11 | **Visto com o Edge 154** (Windows 11): `msedge.exe` com `--user-data-dir` e sem `--remote-debugging-port` fica vivo como o próprio navegador e sai alguns segundos depois de a janela fechar. Um cookie gravado nessa janela aparece no Edge sem janela aberto depois no mesmo perfil. **A verificar** com o Claude Code real: o dono entra numa conta do Google pela janela, fecha, e o bot abre o Gmail já dentro da conta; o bot que pediu ajuda lê que o navegador fechou e abre a página de novo | manual (PR) | | Imagem no resultado de uma tool MCP | 21.4 | Resultado com `{"type": "image", "data", "mimeType"}` em `content` (especificação MCP). **Testado com 2.1.284** (Haiku): pedido só pelas cores do logo no screenshot, o bot respondeu certo, e as cores não estavam no texto da página: o modelo recebe a imagem | feito | | Tools do navegador com o Claude Code real | 21 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): o bot carregou `browser_open`, `browser_click`, `browser_look` e `browser_screenshot` pelo `ToolSearch`; o pedido de example.com apareceu no chat e, permitido, ele abriu a página e clicou no link, que levou ao iana.org; o `browser_screenshot` lá pediu o iana.org, e o segundo screenshot não pediu de novo. O painel mostrou a página e o cursor ao vivo. Pausar o bot fechou o navegador (os processos do Edge sumiram). O log de `debug` só teve nomes de tools, métodos e ids | feito | | Rascunho de tela a partir do `Write` | 22.3 | **Visto com 2.1.284**: com `--include-partial-messages`, o `Write` chega em `stream_event` (`content_block_start` com `tool_use` e `name: "Write"`, depois `input_json_delta`; 379 pedaços para um arquivo de 3 KB). **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): pedida uma landing page, a área de design abriu sozinha e a página se montou versão a versão (umas 70 de 8 KB) antes do OK para gravar; permitido, a tela passou a vir do disco. Numa segunda página, negar o `Write` tirou a tela da área de design. O log de `debug` só teve status e ids. O painel do navegador do app de desktop do Claude bloqueia `iframe` para 127.0.0.1 (`ERR_BLOCKED_BY_CLIENT`), então a prévia foi vista num Edge sem janela | feito | @@ -1243,7 +1244,7 @@ Uma rotina pode esperar um **aviso** (sinal) em vez de um horário: ela roda qua ## 21. Navegador -Status: **N1, N2, N3, N3.1, N3.2 e N3.3 implementados** (21.11); N4 é a seção 22. +Status: **N1, N2, N3, N3.1, N3.2, N3.3 e N3.4 implementados** (21.12); N4 é a seção 22. ### 21.1 O que é @@ -1268,7 +1269,7 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg ### 21.3 Página - **Abas:** o daemon se prende a toda página nova do navegador (`Target.setAutoAttach` no alvo do navegador, com `flatten` e `waitForDebuggerOnStart`) e a configura antes de ela rodar. Os comandos de preparo saem juntos com `Runtime.runIfWaitingForDebugger`, sem esperar resposta um a um: numa aba aberta pela página, `Page.enable` só responde depois que ela roda (visto com o Edge 154). O navegador só conta como aberto, e uma aba nova do dono só recebe um endereço, depois desse preparo: uma página aberta antes carregaria sem o daemon ver os eventos dela. Uma aba aberta pela página (`target=_blank`, `window.open`) vira a aba ativa, como para quem usa o navegador; quando ela fecha, a que era ativa antes dela volta (`Target.activateTarget`, para o navegador mostrar a mesma que o daemon). No máximo 6 abas: acima disso, fecha a que foi ativa há mais tempo. As tools agem sempre na ativa, e o bot não tem tool para trocar de aba; o dono, no controle, troca de aba, abre uma nova e vai a um endereço (21.10). O título de cada aba, para a lista que o dono vê (21.7), é o `document.title`, lido no mundo isolado (21.6) a cada carregamento e a cada mudança de endereço dentro da página: o Edge sem janela avisa quando a aba ganha um endereço, mas nunca o título dela (19). Até a página carregar, a aba fica sem título; um título que a página troca depois disso só aparece no carregamento seguinte. -- **Tela:** 1280 px de largura, escala 1 (`Emulation.setDeviceMetricsOverride`), e 800 px de altura enquanto ninguém assiste. Com o painel aberto, a altura acompanha o espaço que o painel tem para a página (`browser.resize`, 21.7): a mesma proporção desse espaço, entre 600 e 2000 px, para a tela ao vivo ocupar o painel em vez de deixar um vazio embaixo. A largura não muda, para os sites mostrarem sempre a versão de computador: um painel estreito encolhe a imagem, não a página. O tamanho vale para todas as abas, e volta a 1280 × 800 quando o último que assistia para. O que a página vê do navegador é o de um Edge comum, porque alguns sites recusam o login num navegador que um programa controla (19): o user agent é o do Edge sem a palavra `Headless`, com as marcas e a plataforma de `navigator.userAgentData` (`userAgentMetadata`, lidas uma vez ao abrir, numa página `chrome://version` que fecha em seguida: `about:blank` não tem `userAgentData`), e `navigator.webdriver` é `false` (`--disable-blink-features=AutomationControlled`). O disfarce para aí: nada de script injetado na página, e `outerWidth` e `outerHeight` continuam 0. O login de quem olha mais que isso, como o do Google, continua recusado. +- **Tela:** 1280 px de largura, escala 1 (`Emulation.setDeviceMetricsOverride`), e 800 px de altura enquanto ninguém assiste. Com o painel aberto, a altura acompanha o espaço que o painel tem para a página (`browser.resize`, 21.7): a mesma proporção desse espaço, entre 600 e 2000 px, para a tela ao vivo ocupar o painel em vez de deixar um vazio embaixo. A largura não muda, para os sites mostrarem sempre a versão de computador: um painel estreito encolhe a imagem, não a página. O tamanho vale para todas as abas, e volta a 1280 × 800 quando o último que assistia para. O que a página vê do navegador é o de um Edge comum, porque alguns sites recusam o login num navegador que um programa controla (19): o user agent é o do Edge sem a palavra `Headless`, com as marcas e a plataforma de `navigator.userAgentData` (`userAgentMetadata`, lidas uma vez ao abrir, numa página `chrome://version` que fecha em seguida: `about:blank` não tem `userAgentData`), e `navigator.webdriver` é `false` (`--disable-blink-features=AutomationControlled`). O disfarce para aí: nada de script injetado na página, e `outerWidth` e `outerHeight` continuam 0. O login de quem olha mais que isso, como o do Google, se faz numa janela de verdade (21.11). - **Diálogos** (`alert`, `confirm`, `prompt`, "sair da página?"): aceitos na hora; a resposta seguinte da tool diz ao bot o texto que apareceu. - **Escolher arquivo:** o pedido da página é interceptado e cancelado, e o bot lê que enviar arquivos pelo navegador ainda não é possível. - **Downloads:** vão para `\downloads\` (`Browser.setDownloadBehavior`) e aparecem no painel de arquivos (8.4); a tool diz quando um download termina. @@ -1346,9 +1347,10 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] | `browser.newTab` | `botId` | `null`: abre uma aba em branco, que vira a ativa. Só da conexão que controla | | `browser.switchTab` | `botId`, `tabId` | `null`: essa aba vira a ativa. Só da conexão que controla; aba que não existe mais é `not_found` | | `browser.open` | `botId`, `url` | `null`: leva a aba ativa a um endereço `http` ou `https`; sem esquema, vale `https://`. Só da conexão que controla; o que não é endereço da web é `validation` | +| `browser.window` | `botId` | `BrowserState`: abre o navegador numa janela própria, para o dono entrar numa conta (21.11). Da conexão que assiste esse bot; tira o controle dela, se o tinha. Já aberto numa janela, ou sem navegador no computador, é `conflict` | - O que muda o navegador responde assim que o pedido é aceito; o resultado chega em `browser.changed` e nos quadros. -- `BrowserState`: `botId`, `status` (`closed`, `starting`, `open`, `failed`), `url`, `title` e `loading` (da aba ativa), `tabs` (as abas abertas, na ordem em que abriram: `BrowserTab {id, title, url, active}`, com `id` opaco), `error` (por que não abriu, em `failed`), `control` (`bot` ou `owner`, 21.10), `resting` (o navegador descansa, 21.2), `ask` (o que o bot pediu ao dono com `browser_ask_owner`, enquanto o pedido está aberto) e `updatedAt`. +- `BrowserState`: `botId`, `status` (`closed`, `starting`, `open`, `failed`), `url`, `title` e `loading` (da aba ativa), `tabs` (as abas abertas, na ordem em que abriram: `BrowserTab {id, title, url, active}`, com `id` opaco), `error` (por que não abriu, em `failed`), `control` (`bot` ou `owner`, 21.10), `resting` (o navegador descansa, 21.2), `ask` (o que o bot pediu ao dono com `browser_ask_owner`, enquanto o pedido está aberto), `window` (aberto numa janela própria, 21.11: o estado é `closed` e `control` é `owner`) e `updatedAt`. `browser.list` traz também os `closed` com `window`. - Notificações para todos: `browser.changed` (`BrowserState`) e `browser.action {botId, kind, x, y, label, at}`, com `kind` `open`, `click`, `type`, `select`, `press`, `scroll` ou `back`; `x` e `y` em pixels da página (o tamanho que vem em `browser.frame`) quando a ação tem um ponto; `label` é o nome do elemento, a tecla ou o site, nunca o texto digitado. Clicar, digitar e escolher avisam antes de agir: o daemon acha o elemento e o traz para a tela, manda `browser.action` com o ponto e, se alguém assiste, espera o cursor deslizar até lá no painel (560 ms, o tempo da animação mais uma folga) antes do clique de verdade; sem ninguém assistindo, não espera. As outras ações avisam depois de feitas. Assim a página muda depois que o cursor chega, e não antes. - Só para a conexão que assiste: `browser.frame {botId, data, width, height}`, um JPEG em base64 e o tamanho da página que ele mostra. Cada conexão assiste um bot por vez; `browser.watch` de outro troca, e fechar a conexão para. - Os quadros vêm do `Page.startScreencast` (JPEG, qualidade 60, do tamanho da página, 21.3), que roda só enquanto alguém assiste, e só quando a tela muda. O daemon confirma cada quadro no máximo ~15 vezes por segundo. Um app lento não acumula quadros: a conexão manda sempre o mais novo quando consegue, e quadros não contam no limite de 1024 notificações (11.1). @@ -1418,7 +1420,19 @@ O dono pode usar o navegador do bot com as próprias mãos: clicar, arrastar, ro - Com um pedido aberto, em cima da tela: " precisa de você" com a tarefa e "Assumir o controle". O botão do navegador no cabeçalho ganha o ponto de atenção. - No controle: a tela ganha a borda de destaque, e o cursor do bot some. Uma faixa diz "Você está no controle" e, se o bot espera, que ele espera, com "Pronto, devolver para ". Com mais de uma aba, diz também que o bot continua na aba que ficar aberta. As abas e o endereço passam a responder (21.8). O teclado vai para a página enquanto a tela tem o foco; sem foco, a faixa diz "Clique na tela para digitar". Esc vai para a página, não devolve. -### 21.11 Marcos +### 21.11 Entrar numa janela de verdade (N3.4) + +O login do Google, e o de outros sites que olham mais que o user agent e `navigator.webdriver` (21.3), recusa o navegador do bot ("Esse navegador ou app pode não ser seguro"), também com o dono no controle (21.10): o navegador continua sendo um Edge sem janela que o daemon controla pelo DevTools. Para esses, o dono abre o navegador do bot numa janela de verdade e entra na conta lá. + +- **Comando:** o mesmo navegador (21.2) com o mesmo perfil, com janela e sem DevTools: `msedge.exe --no-first-run --no-default-browser-check --disable-extensions --disable-sync --hide-crash-restore-bubble --user-data-dir=\browsers\ `. O endereço é o da aba ativa, se for `http` ou `https`; senão `about:blank`. Sem `--remote-debugging-port`, sem `--headless`, sem nada disfarçado: é o Edge de sempre, e quem faz o login é o dono. `--hide-crash-restore-bubble` porque o navegador sem janela foi encerrado, não fechado, e o Edge ofereceria restaurar as abas. +- **Um perfil, um navegador:** antes de abrir a janela, o daemon fecha o navegador sem janela do bot e espera o processo sair. Com o perfil ainda em uso, o Edge passaria o endereço ao navegador que o tem e sairia na hora. +- **Processo:** num Job Object próprio, com o ambiente padrão do usuário, como o navegador sem janela (21.2). O processo lançado é o próprio navegador: ele sai quando o dono fecha a janela (visto com o Edge 154, 19). O daemon espera essa saída numa thread. Se o daemon para, a janela fecha junto. Arquivar ou excluir o bot fecha a janela; pausar não fecha. +- **Enquanto a janela está aberta:** `BrowserState` fica `closed`, com `window` e `control: "owner"`. As tools do bot esperam como com o dono no controle (21.10). `browser_close` e o fechamento por falta de uso não a tocam, e quem respondia a um pedido de ajuda não devolve o navegador ao bot. +- **Ao fechar a janela:** o navegador volta ao bot, fechado; a próxima tool abre o sem janela no mesmo perfil, com os cookies do login (visto com o Edge 154: um cookie gravado na janela aparece no sem janela, 19). Um pedido de ajuda aberto (`browser_ask_owner`) recebe **Pronto**, e a tool diz ao bot que o navegador fechou e que os logins ficam; ele abre a página de novo com `browser_open`. + +**No app** (21.8): "Entrar numa janela" fica ao lado de "Assumir o controle" embaixo da tela ao vivo, no aviso de um pedido de ajuda e na faixa do dono no controle. Embaixo da tela, a explicação ganha: "Para sites que recusam o login aqui, como o Google: o navegador de abre numa janela própria, e o login fica com ." Com a janela aberta, uma faixa no painel diz "Aberto numa janela": entre na conta na janela que abriu e feche-a depois; o bot espera enquanto isso e fica com o login. A janela não fecha pelo app: fechá-la à força pode perder o login ainda não gravado. + +### 21.12 Marcos | Marco | Entrega | Pronto quando | |---|---|---| @@ -1428,6 +1442,7 @@ O dono pode usar o navegador do bot com as próprias mãos: clicar, arrastar, ro | **N3.1** Tela do tamanho do painel | `browser.resize`, a página com a altura que o painel pede, a tela ao vivo ocupando o espaço do painel, testes com o Edge real | num painel alto, a página do bot ocupa a altura toda em vez de deixar um vazio; arrastar a borda do painel muda a página junto | | **N3.2** Abas e recarregar | a lista de abas em `BrowserState`, `browser.reload`, `browser.newTab`, `browser.switchTab`, `browser.open`, o aviso ao bot quando o dono troca de aba, as abas e o botão de recarregar no painel, o endereço que o dono digita, textos nos três idiomas, testes com o Edge real | pelo app, o dono vê as abas que o bot abriu, recarrega a página, assume, abre uma aba nova num endereço, troca de aba e devolve; o bot lê que a aba mudou antes de agir | | **N3.3** Descanso | o navegador descansa quando o turno do bot termina e acorda quando é usado, `resting` em `BrowserState`, fechar por falta de uso mesmo com o painel aberto, a etiqueta "Em descanso", textos nos três idiomas, testes com o Edge real | com um bot real parado numa página que se mexe, o Edge dele não gasta processador; o bot volta a usar a página sem perder nada | +| **N3.4** Janela de verdade | `browser.window`, `window` em `BrowserState`, o Edge com janela no perfil do bot, a espera das tools e do pedido de ajuda, o botão e a faixa no painel, textos nos três idiomas | com o Claude Code real, o dono entra numa conta do Google do bot pela janela, fecha, e o bot abre o Gmail já dentro da conta | | **N4** Telas | o bot desenhando telas (HTML) que aparecem lado a lado numa área de design, atualizadas enquanto ele escreve | seção 22 | ## 22. Telas From 360a065a55b570092397c9bb352196aca4df5c7d Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:19:36 -0300 Subject: [PATCH 3/3] test(browser): accept the brands in any order Edge 153 on the CI lists them as Microsoft Edge, Not_A Brand, Chromium. --- crates/botloftd/tests/browser.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/botloftd/tests/browser.rs b/crates/botloftd/tests/browser.rs index 182f3035..b1248a57 100644 --- a/crates/botloftd/tests/browser.rs +++ b/crates/botloftd/tests/browser.rs @@ -113,7 +113,13 @@ async fn sites_see_a_common_browser() { answer_site(&mut b.app, true, None).await; let page = opening.await.expect("task").expect("page"); assert!(page.contains("Webdriver: false"), "{page}"); - assert!(page.contains("Brands: Chromium"), "{page}"); + // The order of the brands changes from one version to the next. + let brands = page + .split("Brands: ") + .nth(1) + .and_then(|rest| rest.split(';').next()) + .unwrap_or_default(); + assert!(brands.contains("Chromium"), "{page}"); assert!(!page.contains("Headless"), "{page}"); }