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/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/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 2d25347c..3ca09da4 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") @@ -115,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 38a9187d..18ff7868 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; @@ -22,6 +23,7 @@ mod sweep; mod titles; mod viewport; mod watch; +mod window; use std::collections::HashMap; use std::io; @@ -31,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}; @@ -46,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; @@ -113,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>>; @@ -163,6 +168,7 @@ impl Browsers { used: Instant::now(), held: tokio::sync::watch::channel(None).0, asking: None, + window: None, }) } @@ -203,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 { @@ -214,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 { @@ -239,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/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/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/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..055d9cf6 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1059,6 +1059,8 @@ 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 | +| 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 | @@ -1242,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 é @@ -1255,7 +1257,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 +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 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, 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. @@ -1345,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). @@ -1417,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 | |---|---|---| @@ -1427,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