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..b1248a57 100644 --- a/crates/botloftd/tests/browser.rs +++ b/crates/botloftd/tests/browser.rs @@ -102,6 +102,27 @@ 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}"); + // 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}"); +} + #[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.